Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 852335f794 | |||
| b87de1f00a | |||
| 8fcb389bb2 | |||
| 08bca30fcd | |||
| a7c5afdd20 | |||
| 5eac386eba | |||
| d35d60ac7d | |||
| 7c43d4bf70 |
@@ -17,14 +17,26 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
is_prerelease: ${{ steps.version.outputs.is_prerelease }}
|
||||
steps:
|
||||
- name: Get version
|
||||
id: version
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT
|
||||
VERSION="${{ github.event.inputs.version }}"
|
||||
else
|
||||
echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||
VERSION="${GITHUB_REF#refs/tags/}"
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
# Check if version contains -preview, -beta, -rc, or -alpha (NOT -hotfix)
|
||||
VERSION_LOWER=$(echo "$VERSION" | tr '[:upper:]' '[:lower:]')
|
||||
if [[ "$VERSION_LOWER" == *"-preview"* ]] || [[ "$VERSION_LOWER" == *"-beta"* ]] || [[ "$VERSION_LOWER" == *"-rc"* ]] || [[ "$VERSION_LOWER" == *"-alpha"* ]]; then
|
||||
echo "is_prerelease=true" >> $GITHUB_OUTPUT
|
||||
echo "Detected pre-release version: $VERSION"
|
||||
else
|
||||
echo "is_prerelease=false" >> $GITHUB_OUTPUT
|
||||
echo "Detected stable version: $VERSION"
|
||||
fi
|
||||
|
||||
# Android and iOS build in PARALLEL
|
||||
@@ -316,6 +328,6 @@ jobs:
|
||||
body_path: /tmp/release_body.txt
|
||||
files: ./release/*
|
||||
draft: false
|
||||
prerelease: false
|
||||
prerelease: ${{ needs.get-version.outputs.is_prerelease == 'true' }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -1,5 +1,112 @@
|
||||
# Changelog
|
||||
|
||||
## [2.0.0] - 2026-01-03
|
||||
|
||||
### Added
|
||||
- **Artist Search Results**: Search now shows artists alongside tracks
|
||||
- Horizontal scrollable artist cards with circular avatars
|
||||
- Tap artist to view their discography
|
||||
- **Multi-Layer Caching System**: Aggressive caching to minimize API calls
|
||||
- Go backend cache: Artist (10 min), Album (10 min), Search (5 min)
|
||||
- Flutter memory cache: Instant navigation for previously viewed artists/albums
|
||||
- Duplicate search prevention: Same query won't trigger new API call
|
||||
- **Real-time Download Status**: Track items show live download progress
|
||||
- Queued: Hourglass icon
|
||||
- Downloading: Circular progress with percentage
|
||||
- Completed: Check icon
|
||||
- Works in Home search, Album, and Playlist screens
|
||||
- **Downloaded Track Indicator**: Tracks already in history show check mark
|
||||
- Lazy file verification: Only checks file existence when tapped
|
||||
- Auto-removes from history if file was deleted, allowing re-download
|
||||
- Prevents accidental duplicate downloads
|
||||
- **Pre-release Support**: GitHub Actions auto-detects preview/beta/rc/alpha tags
|
||||
- Stable users won't receive update notifications for preview versions
|
||||
|
||||
### Changed
|
||||
- **Instant Navigation UX**: Navigate to Artist/Album screens immediately
|
||||
- Header (name, cover) shows instantly from available data
|
||||
- Content (albums/tracks) loads in background inside the screen
|
||||
- Second visit to same artist/album is instant from Flutter cache
|
||||
- **Search Results UI Redesign**:
|
||||
- Removed "Download All" button from search results
|
||||
- Added "Songs" section header (matches "Artists" header style)
|
||||
- Track list now in grouped card with rounded corners (like Settings)
|
||||
- Track items with dividers and InkWell ripple effect
|
||||
- **Larger UI Elements**: Improved touch targets and visual hierarchy
|
||||
- Recent downloads: Album art 56→100px, section height 80→130px
|
||||
- Artist cards: Avatar 72→88px, container 90→100px
|
||||
- Track items: Album art 48→56px
|
||||
- **Optimized Search**: Pressing Enter with same query no longer triggers duplicate search
|
||||
- **Smoother Progress Animation**: Progress jumps to 100% after download completes
|
||||
- Embedding (cover, metadata, lyrics) happens in background without blocking UI
|
||||
- **Finalizing Status**: Shows "Finalizing" indicator while embedding metadata
|
||||
- Distinct icon (edit_note) with tertiary color
|
||||
- User knows download is complete, just processing metadata
|
||||
- **Consistent Download Button Sizes**: All download/status buttons now 44x44px
|
||||
- **Better Dynamic Color Contrast**: Improved visibility for cards and chips with dynamic color
|
||||
- Settings cards use overlay colors for better contrast
|
||||
- Theme/view mode chips have visible borders in light mode
|
||||
- **Navigation Bar Styling**: Distinct background color from content area
|
||||
- **Ask Before Download Default**: Now enabled by default for better UX
|
||||
- **Quality Picker Track Info**: Shows track name, artist, and cover in quality picker
|
||||
- Tap to expand long track titles
|
||||
- Expand icon only shows when title is truncated
|
||||
- Ripple effect follows rounded corners including drag handle
|
||||
- **Update Dialog Redesign**: Material Expressive 3 style
|
||||
- Icon header with container
|
||||
- Version chips with "Current" and "New" labels
|
||||
- Changelog in rounded card
|
||||
- Download progress with percentage indicator
|
||||
- Cleaner button layout
|
||||
|
||||
### Fixed
|
||||
- **Artist Profile Images**: Fixed artist images not showing in search results (field name mismatch)
|
||||
- **Album Card Overflow**: Fixed 5px overflow in artist discography album cards
|
||||
- **Optimized Rebuilds**: Each track item only rebuilds when its own status changes
|
||||
- Uses Riverpod `select()` for granular state watching
|
||||
- Prevents entire list rebuild on progress updates
|
||||
- **Update Notification Stuck**: Fixed notification staying at 100% after download complete
|
||||
|
||||
## [1.6.3] - 2026-01-03
|
||||
|
||||
### Added
|
||||
- **Predictive Back Navigation**: Support for Android 14+ predictive back gesture with smooth animations
|
||||
- **Separate Detail Screens**: Album, Artist, and Playlist now open as dedicated screens with Material Expressive 3 design
|
||||
- Collapsing header with cover art and gradient overlay
|
||||
- Card-based info section with rounded corners (20px radius)
|
||||
- Tonal download buttons with circular shape
|
||||
- Quality picker bottom sheet with drag handle
|
||||
- **Double-Tap to Exit**: Press back twice to exit app when at home screen (replaces exit dialog)
|
||||
|
||||
### Changed
|
||||
- **Navigation Architecture**: Refactored from state-based to screen-based navigation
|
||||
- Album/Artist/Playlist URLs navigate to dedicated screens via `Navigator.push()`
|
||||
- Enables native predictive back gesture animations
|
||||
- Search results stay on Home tab for quick downloads
|
||||
- **Simplified State Management**: Removed `previousState` chain from TrackProvider since Navigator handles back navigation
|
||||
|
||||
## [1.6.2] - 2026-01-02
|
||||
|
||||
### Added
|
||||
- **HTTPS-Only Downloads**: APK downloads and update checks now enforce HTTPS-only connections for security
|
||||
|
||||
### Changed
|
||||
- **Home Tab Rename**: Renamed "Search" tab to "Home" with home icon
|
||||
- **Branding**: Changed idle screen title from "Search Music" to "SpotiFLAC"
|
||||
- **About Page Redesign**: New Material Expressive 3 grouped layout with app header, contributors section with GitHub avatars, and organized links
|
||||
|
||||
### Fixed
|
||||
- **Play Button Flash**: Fixed play button briefly showing red error icon on app start (now uses optimistic rendering)
|
||||
|
||||
### Performance
|
||||
- **Optimized State Management**: Use `.select()` for Riverpod providers to prevent unnecessary widget rebuilds
|
||||
- **List Keys**: Added keys to all list builders for efficient list updates and reordering
|
||||
- **Request Cancellation**: Outdated API requests are ignored when new search/fetch is triggered
|
||||
- **Debounced URL Fetches**: All network requests now debounced to prevent rapid duplicate calls
|
||||
- **Bounded File Cache**: File existence cache now limited to 500 entries to prevent memory leak
|
||||
- **Timer Cleanup**: Progress polling timer properly disposed when provider is destroyed
|
||||
- **Stream Error Handling**: Share intent stream now has proper error handling
|
||||
|
||||
## [1.6.1] - 2026-01-02
|
||||
|
||||
### Added
|
||||
|
||||
@@ -11,18 +11,10 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account
|
||||
|
||||
</div>
|
||||
|
||||
> **Active Development Notice**: This app is under heavy development. New builds may be pushed multiple times daily. If frequent update notifications are annoying, tap "Don't remind" when the update dialog appears, or disable update checks in Settings.
|
||||
|
||||
### [Download](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||
|
||||
## Features
|
||||
|
||||
- Download tracks, albums, and playlists from Spotify links
|
||||
- True lossless FLAC quality from Tidal, Qobuz & Amazon Music
|
||||
- Material Expressive 3 design with dynamic colors
|
||||
- High performance rendering with Impeller (Vulkan)
|
||||
- Concurrent downloads up to 3 simultaneous
|
||||
- Real-time download progress tracking
|
||||
- Download notifications
|
||||
|
||||
## Screenshots
|
||||
|
||||
<p align="center">
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
android:usesCleartextTraffic="true">
|
||||
android:usesCleartextTraffic="true"
|
||||
android:enableOnBackInvokedCallback="true">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
|
||||
@@ -50,6 +50,15 @@ class MainActivity: FlutterActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"searchSpotifyAll" -> {
|
||||
val query = call.argument<String>("query") ?: ""
|
||||
val trackLimit = call.argument<Int>("track_limit") ?: 15
|
||||
val artistLimit = call.argument<Int>("artist_limit") ?: 3
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.searchSpotifyAll(query, trackLimit.toLong(), artistLimit.toLong())
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"checkAvailability" -> {
|
||||
val spotifyId = call.argument<String>("spotify_id") ?: ""
|
||||
val isrc = call.argument<String>("isrc") ?: ""
|
||||
|
||||
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 201 KiB After Width: | Height: | Size: 300 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 135 KiB |
@@ -0,0 +1,3 @@
|
||||
description: This file stores settings for Dart & Flutter DevTools.
|
||||
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
|
||||
extensions:
|
||||
@@ -318,6 +318,13 @@ func downloadFromAmazon(req DownloadRequest) (string, error) {
|
||||
return "", fmt.Errorf("download failed: %w", err)
|
||||
}
|
||||
|
||||
// Set progress to 100% and status to finalizing (before embedding)
|
||||
// This makes the UI show "Finalizing..." while embedding happens
|
||||
if req.ItemID != "" {
|
||||
SetItemProgress(req.ItemID, 1.0, 0, 0)
|
||||
SetItemFinalizing(req.ItemID)
|
||||
}
|
||||
|
||||
// Log track info from DoubleDouble (for debugging)
|
||||
if trackName != "" && artistName != "" {
|
||||
fmt.Printf("[Amazon] DoubleDouble returned: %s - %s\n", artistName, trackName)
|
||||
|
||||
@@ -70,6 +70,26 @@ func SearchSpotify(query string, limit int) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// SearchSpotifyAll searches for tracks and artists on Spotify
|
||||
// Returns JSON with tracks and artists arrays
|
||||
func SearchSpotifyAll(query string, trackLimit, artistLimit int) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
client := NewSpotifyMetadataClient()
|
||||
results, err := client.SearchAll(ctx, query, trackLimit, artistLimit)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(results)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// CheckAvailability checks track availability on streaming services
|
||||
// Returns JSON with availability info for Tidal, Qobuz, Amazon
|
||||
func CheckAvailability(spotifyID, isrc string) (string, error) {
|
||||
|
||||
@@ -13,6 +13,7 @@ type DownloadProgress struct {
|
||||
BytesTotal int64 `json:"bytes_total"`
|
||||
BytesReceived int64 `json:"bytes_received"`
|
||||
IsDownloading bool `json:"is_downloading"`
|
||||
Status string `json:"status"` // "downloading", "finalizing", "completed"
|
||||
}
|
||||
|
||||
// ItemProgress represents progress for a single download item
|
||||
@@ -22,6 +23,7 @@ type ItemProgress struct {
|
||||
BytesReceived int64 `json:"bytes_received"`
|
||||
Progress float64 `json:"progress"` // 0.0 to 1.0
|
||||
IsDownloading bool `json:"is_downloading"`
|
||||
Status string `json:"status"` // "downloading", "finalizing", "completed"
|
||||
}
|
||||
|
||||
// MultiProgress holds progress for multiple concurrent downloads
|
||||
@@ -82,6 +84,7 @@ func StartItemProgress(itemID string) {
|
||||
BytesReceived: 0,
|
||||
Progress: 0,
|
||||
IsDownloading: true,
|
||||
Status: "downloading",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,6 +122,46 @@ func CompleteItemProgress(itemID string) {
|
||||
}
|
||||
}
|
||||
|
||||
// SetItemProgress sets progress for an item directly (used to force 100% before embedding)
|
||||
func SetItemProgress(itemID string, progress float64, bytesReceived, bytesTotal int64) {
|
||||
multiMu.Lock()
|
||||
if item, ok := multiProgress.Items[itemID]; ok {
|
||||
item.Progress = progress
|
||||
if bytesReceived > 0 {
|
||||
item.BytesReceived = bytesReceived
|
||||
}
|
||||
if bytesTotal > 0 {
|
||||
item.BytesTotal = bytesTotal
|
||||
}
|
||||
}
|
||||
multiMu.Unlock()
|
||||
|
||||
// Also update legacy progress for backward compatibility
|
||||
progressMu.Lock()
|
||||
if progress >= 1.0 {
|
||||
currentProgress.Progress = 100.0
|
||||
} else {
|
||||
currentProgress.Progress = progress * 100.0
|
||||
}
|
||||
progressMu.Unlock()
|
||||
}
|
||||
|
||||
// SetItemFinalizing marks an item as finalizing (embedding metadata)
|
||||
func SetItemFinalizing(itemID string) {
|
||||
multiMu.Lock()
|
||||
if item, ok := multiProgress.Items[itemID]; ok {
|
||||
item.Progress = 1.0
|
||||
item.Status = "finalizing"
|
||||
}
|
||||
multiMu.Unlock()
|
||||
|
||||
// Also update legacy progress
|
||||
progressMu.Lock()
|
||||
currentProgress.Progress = 100.0
|
||||
currentProgress.Status = "finalizing"
|
||||
progressMu.Unlock()
|
||||
}
|
||||
|
||||
// RemoveItemProgress removes progress tracking for an item
|
||||
func RemoveItemProgress(itemID string) {
|
||||
multiMu.Lock()
|
||||
@@ -161,6 +204,7 @@ func SetCurrentFile(filename string) {
|
||||
currentProgress.Progress = 0
|
||||
currentProgress.CurrentFile = filename
|
||||
currentProgress.IsDownloading = true
|
||||
currentProgress.Status = "downloading"
|
||||
}
|
||||
|
||||
// ResetProgress resets the download progress
|
||||
|
||||
@@ -385,6 +385,13 @@ func downloadFromQobuz(req DownloadRequest) (string, error) {
|
||||
return "", fmt.Errorf("download failed: %w", err)
|
||||
}
|
||||
|
||||
// Set progress to 100% and status to finalizing (before embedding)
|
||||
// This makes the UI show "Finalizing..." while embedding happens
|
||||
if req.ItemID != "" {
|
||||
SetItemProgress(req.ItemID, 1.0, 0, 0)
|
||||
SetItemFinalizing(req.ItemID)
|
||||
}
|
||||
|
||||
// Embed metadata
|
||||
metadata := Metadata{
|
||||
Title: req.TrackName,
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -23,10 +24,25 @@ const (
|
||||
artistBaseURL = "https://api.spotify.com/v1/artists/%s"
|
||||
artistAlbumsURL = "https://api.spotify.com/v1/artists/%s/albums"
|
||||
searchBaseURL = "https://api.spotify.com/v1/search"
|
||||
|
||||
// Cache TTL settings
|
||||
artistCacheTTL = 10 * time.Minute
|
||||
searchCacheTTL = 5 * time.Minute
|
||||
albumCacheTTL = 10 * time.Minute
|
||||
)
|
||||
|
||||
var errInvalidSpotifyURL = errors.New("invalid or unsupported Spotify URL")
|
||||
|
||||
// cacheEntry holds cached data with expiration
|
||||
type cacheEntry struct {
|
||||
data interface{}
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
func (e *cacheEntry) isExpired() bool {
|
||||
return time.Now().After(e.expiresAt)
|
||||
}
|
||||
|
||||
// SpotifyMetadataClient handles Spotify API interactions
|
||||
type SpotifyMetadataClient struct {
|
||||
httpClient *http.Client
|
||||
@@ -34,31 +50,45 @@ type SpotifyMetadataClient struct {
|
||||
clientSecret string
|
||||
cachedToken string
|
||||
tokenExpiresAt time.Time
|
||||
tokenMu sync.Mutex // Protects token cache for concurrent access
|
||||
rng *rand.Rand
|
||||
rngMu sync.Mutex
|
||||
userAgent string
|
||||
|
||||
// Caches to reduce API calls
|
||||
artistCache map[string]*cacheEntry // key: artistID
|
||||
searchCache map[string]*cacheEntry // key: query+type
|
||||
albumCache map[string]*cacheEntry // key: albumID
|
||||
cacheMu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewSpotifyMetadataClient creates a new Spotify client
|
||||
func NewSpotifyMetadataClient() *SpotifyMetadataClient {
|
||||
src := rand.NewSource(time.Now().UnixNano())
|
||||
|
||||
// Decode credentials from base64
|
||||
clientID := ""
|
||||
if decoded, err := base64.StdEncoding.DecodeString("NWY1NzNjOTYyMDQ5NGJhZTg3ODkwYzBmMDhhNjAyOTM="); err == nil {
|
||||
clientID = string(decoded)
|
||||
// Prefer environment variables for credentials (more secure), fall back to built-in
|
||||
clientID := os.Getenv("SPOTIFY_CLIENT_ID")
|
||||
if clientID == "" {
|
||||
if decoded, err := base64.StdEncoding.DecodeString("NWY1NzNjOTYyMDQ5NGJhZTg3ODkwYzBmMDhhNjAyOTM="); err == nil {
|
||||
clientID = string(decoded)
|
||||
}
|
||||
}
|
||||
|
||||
clientSecret := ""
|
||||
if decoded, err := base64.StdEncoding.DecodeString("MjEyNDc2ZDliMGYzNDcyZWFhNzYyZDkwYjE5YjBiYTg="); err == nil {
|
||||
clientSecret = string(decoded)
|
||||
clientSecret := os.Getenv("SPOTIFY_CLIENT_SECRET")
|
||||
if clientSecret == "" {
|
||||
if decoded, err := base64.StdEncoding.DecodeString("MjEyNDc2ZDliMGYzNDcyZWFhNzYyZDkwYjE5YjBiYTg="); err == nil {
|
||||
clientSecret = string(decoded)
|
||||
}
|
||||
}
|
||||
|
||||
c := &SpotifyMetadataClient{
|
||||
httpClient: &http.Client{Timeout: 15 * time.Second},
|
||||
httpClient: NewHTTPClientWithTimeout(15 * time.Second), // Use shared transport for connection pooling
|
||||
clientID: clientID,
|
||||
clientSecret: clientSecret,
|
||||
rng: rand.New(src),
|
||||
artistCache: make(map[string]*cacheEntry),
|
||||
searchCache: make(map[string]*cacheEntry),
|
||||
albumCache: make(map[string]*cacheEntry),
|
||||
}
|
||||
c.userAgent = c.randomUserAgent()
|
||||
return c
|
||||
@@ -170,6 +200,21 @@ type SearchResult struct {
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// SearchArtistResult represents an artist in search results
|
||||
type SearchArtistResult struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Images string `json:"images"`
|
||||
Followers int `json:"followers"`
|
||||
Popularity int `json:"popularity"`
|
||||
}
|
||||
|
||||
// SearchAllResult represents combined search results for tracks and artists
|
||||
type SearchAllResult struct {
|
||||
Tracks []TrackMetadata `json:"tracks"`
|
||||
Artists []SearchArtistResult `json:"artists"`
|
||||
}
|
||||
|
||||
type spotifyURI struct {
|
||||
Type string
|
||||
ID string
|
||||
@@ -293,6 +338,98 @@ func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string,
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// SearchAll searches for tracks and artists on Spotify
|
||||
func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) {
|
||||
// Create cache key
|
||||
cacheKey := fmt.Sprintf("all:%s:%d:%d", query, trackLimit, artistLimit)
|
||||
|
||||
// Check cache first
|
||||
c.cacheMu.RLock()
|
||||
if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() {
|
||||
c.cacheMu.RUnlock()
|
||||
return entry.data.(*SearchAllResult), nil
|
||||
}
|
||||
c.cacheMu.RUnlock()
|
||||
|
||||
token, err := c.getAccessToken(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
searchURL := fmt.Sprintf("%s?q=%s&type=track,artist&limit=%d", searchBaseURL, url.QueryEscape(query), trackLimit)
|
||||
|
||||
var response struct {
|
||||
Tracks struct {
|
||||
Items []trackFull `json:"items"`
|
||||
} `json:"tracks"`
|
||||
Artists struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Images []image `json:"images"`
|
||||
Followers struct {
|
||||
Total int `json:"total"`
|
||||
} `json:"followers"`
|
||||
Popularity int `json:"popularity"`
|
||||
} `json:"items"`
|
||||
} `json:"artists"`
|
||||
}
|
||||
|
||||
if err := c.getJSON(ctx, searchURL, token, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := &SearchAllResult{
|
||||
Tracks: make([]TrackMetadata, 0, len(response.Tracks.Items)),
|
||||
Artists: make([]SearchArtistResult, 0, len(response.Artists.Items)),
|
||||
}
|
||||
|
||||
for _, track := range response.Tracks.Items {
|
||||
result.Tracks = append(result.Tracks, TrackMetadata{
|
||||
SpotifyID: track.ID,
|
||||
Artists: joinArtists(track.Artists),
|
||||
Name: track.Name,
|
||||
AlbumName: track.Album.Name,
|
||||
AlbumArtist: joinArtists(track.Album.Artists),
|
||||
DurationMS: track.DurationMS,
|
||||
Images: firstImageURL(track.Album.Images),
|
||||
ReleaseDate: track.Album.ReleaseDate,
|
||||
TrackNumber: track.TrackNumber,
|
||||
TotalTracks: track.Album.TotalTracks,
|
||||
DiscNumber: track.DiscNumber,
|
||||
ExternalURL: track.ExternalURL.Spotify,
|
||||
ISRC: track.ExternalID.ISRC,
|
||||
})
|
||||
}
|
||||
|
||||
// Limit artists to artistLimit
|
||||
artistCount := len(response.Artists.Items)
|
||||
if artistCount > artistLimit {
|
||||
artistCount = artistLimit
|
||||
}
|
||||
|
||||
for i := 0; i < artistCount; i++ {
|
||||
artist := response.Artists.Items[i]
|
||||
result.Artists = append(result.Artists, SearchArtistResult{
|
||||
ID: artist.ID,
|
||||
Name: artist.Name,
|
||||
Images: firstImageURL(artist.Images),
|
||||
Followers: artist.Followers.Total,
|
||||
Popularity: artist.Popularity,
|
||||
})
|
||||
}
|
||||
|
||||
// Store in cache
|
||||
c.cacheMu.Lock()
|
||||
c.searchCache[cacheKey] = &cacheEntry{
|
||||
data: result,
|
||||
expiresAt: time.Now().Add(searchCacheTTL),
|
||||
}
|
||||
c.cacheMu.Unlock()
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID, token string) (*TrackResponse, error) {
|
||||
var data trackFull
|
||||
if err := c.getJSON(ctx, fmt.Sprintf(trackBaseURL, trackID), token, &data); err != nil {
|
||||
@@ -319,6 +456,14 @@ func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID, token s
|
||||
}
|
||||
|
||||
func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token string) (*AlbumResponsePayload, error) {
|
||||
// Check cache first
|
||||
c.cacheMu.RLock()
|
||||
if entry, ok := c.albumCache[albumID]; ok && !entry.isExpired() {
|
||||
c.cacheMu.RUnlock()
|
||||
return entry.data.(*AlbumResponsePayload), nil
|
||||
}
|
||||
c.cacheMu.RUnlock()
|
||||
|
||||
var data struct {
|
||||
Name string `json:"name"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
@@ -374,10 +519,20 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
||||
})
|
||||
}
|
||||
|
||||
return &AlbumResponsePayload{
|
||||
result := &AlbumResponsePayload{
|
||||
AlbumInfo: info,
|
||||
TrackList: tracks,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Store in cache
|
||||
c.cacheMu.Lock()
|
||||
c.albumCache[albumID] = &cacheEntry{
|
||||
data: result,
|
||||
expiresAt: time.Now().Add(albumCacheTTL),
|
||||
}
|
||||
c.cacheMu.Unlock()
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, token string) (*PlaylistResponsePayload, error) {
|
||||
@@ -436,6 +591,14 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
|
||||
}
|
||||
|
||||
func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token string) (*ArtistResponsePayload, error) {
|
||||
// Check cache first
|
||||
c.cacheMu.RLock()
|
||||
if entry, ok := c.artistCache[artistID]; ok && !entry.isExpired() {
|
||||
c.cacheMu.RUnlock()
|
||||
return entry.data.(*ArtistResponsePayload), nil
|
||||
}
|
||||
c.cacheMu.RUnlock()
|
||||
|
||||
// Fetch artist info
|
||||
var artistData struct {
|
||||
ID string `json:"id"`
|
||||
@@ -511,10 +674,20 @@ func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token
|
||||
}
|
||||
}
|
||||
|
||||
return &ArtistResponsePayload{
|
||||
result := &ArtistResponsePayload{
|
||||
ArtistInfo: artistInfo,
|
||||
Albums: albums,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Store in cache
|
||||
c.cacheMu.Lock()
|
||||
c.artistCache[artistID] = &cacheEntry{
|
||||
data: result,
|
||||
expiresAt: time.Now().Add(artistCacheTTL),
|
||||
}
|
||||
c.cacheMu.Unlock()
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *SpotifyMetadataClient) fetchTrackISRC(ctx context.Context, trackID, token string) string {
|
||||
|
||||
@@ -905,6 +905,13 @@ func downloadFromTidal(req DownloadRequest) (string, error) {
|
||||
return "", fmt.Errorf("download failed: %w", err)
|
||||
}
|
||||
|
||||
// Set progress to 100% and status to finalizing (before embedding)
|
||||
// This makes the UI show "Finalizing..." while embedding happens
|
||||
if req.ItemID != "" {
|
||||
SetItemProgress(req.ItemID, 1.0, 0, 0)
|
||||
SetItemFinalizing(req.ItemID)
|
||||
}
|
||||
|
||||
// Check if file was saved as M4A (DASH stream) instead of FLAC
|
||||
// downloadFromManifest saves DASH streams as .m4a
|
||||
actualOutputPath := outputPath
|
||||
|
||||
@@ -66,6 +66,15 @@ import Gobackend // Import Go framework
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "searchSpotifyAll":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let query = args["query"] as! String
|
||||
let trackLimit = args["track_limit"] as? Int ?? 15
|
||||
let artistLimit = args["artist_limit"] as? Int ?? 3
|
||||
let response = GobackendSearchSpotifyAll(query, Int(trackLimit), Int(artistLimit), &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "checkAvailability":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let spotifyId = args["spotify_id"] as! String
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/// App version and info constants
|
||||
/// Update version here only - all other files will reference this
|
||||
class AppInfo {
|
||||
static const String version = '1.6.1';
|
||||
static const String buildNumber = '26';
|
||||
static const String version = '2.0.0';
|
||||
static const String buildNumber = '30';
|
||||
static const String fullVersion = '$version+$buildNumber';
|
||||
|
||||
|
||||
static const String appName = 'SpotiFLAC';
|
||||
static const String copyright = '© 2026 SpotiFLAC';
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ part 'download_item.g.dart';
|
||||
enum DownloadStatus {
|
||||
queued,
|
||||
downloading,
|
||||
finalizing, // Embedding metadata, cover, lyrics
|
||||
completed,
|
||||
failed,
|
||||
skipped,
|
||||
|
||||
@@ -36,6 +36,7 @@ Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
|
||||
const _$DownloadStatusEnumMap = {
|
||||
DownloadStatus.queued: 'queued',
|
||||
DownloadStatus.downloading: 'downloading',
|
||||
DownloadStatus.finalizing: 'finalizing',
|
||||
DownloadStatus.completed: 'completed',
|
||||
DownloadStatus.failed: 'failed',
|
||||
DownloadStatus.skipped: 'skipped',
|
||||
|
||||
@@ -35,7 +35,7 @@ class AppSettings {
|
||||
this.folderOrganization = 'none', // Default: no folder organization
|
||||
this.convertLyricsToRomaji = false, // Default: keep original Japanese
|
||||
this.historyViewMode = 'grid', // Default: grid view
|
||||
this.askQualityBeforeDownload = false, // Default: use preset quality
|
||||
this.askQualityBeforeDownload = true, // Default: ask quality before download
|
||||
});
|
||||
|
||||
AppSettings copyWith({
|
||||
|
||||
@@ -21,7 +21,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
folderOrganization: json['folderOrganization'] as String? ?? 'none',
|
||||
convertLyricsToRomaji: json['convertLyricsToRomaji'] as bool? ?? false,
|
||||
historyViewMode: json['historyViewMode'] as String? ?? 'grid',
|
||||
askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? false,
|
||||
askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||
|
||||
@@ -99,8 +99,16 @@ class DownloadHistoryItem {
|
||||
// Download History State
|
||||
class DownloadHistoryState {
|
||||
final List<DownloadHistoryItem> items;
|
||||
final Set<String> _downloadedSpotifyIds; // Cache for O(1) lookup
|
||||
|
||||
const DownloadHistoryState({this.items = const []});
|
||||
DownloadHistoryState({this.items = const []})
|
||||
: _downloadedSpotifyIds = items
|
||||
.where((item) => item.spotifyId != null && item.spotifyId!.isNotEmpty)
|
||||
.map((item) => item.spotifyId!)
|
||||
.toSet();
|
||||
|
||||
/// Check if a track has been downloaded (by Spotify ID)
|
||||
bool isDownloaded(String spotifyId) => _downloadedSpotifyIds.contains(spotifyId);
|
||||
|
||||
DownloadHistoryState copyWith({List<DownloadHistoryItem>? items}) {
|
||||
return DownloadHistoryState(items: items ?? this.items);
|
||||
@@ -116,7 +124,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
DownloadHistoryState build() {
|
||||
// Load history from storage on init
|
||||
_loadFromStorageSync();
|
||||
return const DownloadHistoryState();
|
||||
return DownloadHistoryState();
|
||||
}
|
||||
|
||||
/// Synchronously schedule load - ensures it runs before any UI renders
|
||||
@@ -173,8 +181,22 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
_saveToStorage();
|
||||
}
|
||||
|
||||
/// Remove item from history by Spotify ID
|
||||
void removeBySpotifyId(String spotifyId) {
|
||||
state = state.copyWith(
|
||||
items: state.items.where((item) => item.spotifyId != spotifyId).toList(),
|
||||
);
|
||||
_saveToStorage();
|
||||
_historyLog.d('Removed item with spotifyId: $spotifyId');
|
||||
}
|
||||
|
||||
/// Get history item by Spotify ID
|
||||
DownloadHistoryItem? getBySpotifyId(String spotifyId) {
|
||||
return state.items.where((item) => item.spotifyId == spotifyId).firstOrNull;
|
||||
}
|
||||
|
||||
void clearHistory() {
|
||||
state = const DownloadHistoryState();
|
||||
state = DownloadHistoryState();
|
||||
_saveToStorage();
|
||||
}
|
||||
}
|
||||
@@ -249,6 +271,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
|
||||
@override
|
||||
DownloadQueueState build() {
|
||||
// Cleanup timer when provider is disposed
|
||||
ref.onDispose(() {
|
||||
_progressTimer?.cancel();
|
||||
_progressTimer = null;
|
||||
});
|
||||
|
||||
// Initialize output directory and load persisted queue asynchronously
|
||||
Future.microtask(() async {
|
||||
await _initOutputDir();
|
||||
@@ -334,6 +362,22 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
final bytesReceived = progress['bytes_received'] as int? ?? 0;
|
||||
final bytesTotal = progress['bytes_total'] as int? ?? 0;
|
||||
final isDownloading = progress['is_downloading'] as bool? ?? false;
|
||||
final status = progress['status'] as String? ?? 'downloading';
|
||||
|
||||
// Check if status is "finalizing" (embedding metadata)
|
||||
if (status == 'finalizing') {
|
||||
updateItemStatus(itemId, DownloadStatus.finalizing, progress: 1.0);
|
||||
|
||||
// Update notification to show finalizing
|
||||
final currentItem = state.items.where((i) => i.id == itemId).firstOrNull;
|
||||
if (currentItem != null) {
|
||||
_notificationService.showDownloadFinalizing(
|
||||
trackName: currentItem.track.name,
|
||||
artistName: currentItem.track.artistName,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDownloading && bytesTotal > 0) {
|
||||
final percentage = bytesReceived / bytesTotal;
|
||||
@@ -386,6 +430,22 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
final bytesReceived = itemProgress['bytes_received'] as int? ?? 0;
|
||||
final bytesTotal = itemProgress['bytes_total'] as int? ?? 0;
|
||||
final isDownloading = itemProgress['is_downloading'] as bool? ?? false;
|
||||
final status = itemProgress['status'] as String? ?? 'downloading';
|
||||
|
||||
// Check if status is "finalizing" (embedding metadata)
|
||||
if (status == 'finalizing') {
|
||||
updateItemStatus(itemId, DownloadStatus.finalizing, progress: 1.0);
|
||||
|
||||
// Update notification to show finalizing
|
||||
final currentItem = state.items.where((i) => i.id == itemId).firstOrNull;
|
||||
if (currentItem != null) {
|
||||
_notificationService.showDownloadFinalizing(
|
||||
trackName: currentItem.track.name,
|
||||
artistName: currentItem.track.artistName,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isDownloading && bytesTotal > 0) {
|
||||
final percentage = bytesReceived / bytesTotal;
|
||||
@@ -406,7 +466,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
final bytesTotal = firstProgress['bytes_total'] as int? ?? 0;
|
||||
|
||||
// Find the item to get track info
|
||||
final downloadingItems = state.items.where((i) => i.status == DownloadStatus.downloading).toList();
|
||||
final downloadingItems = state.items.where((i) => i.status == DownloadStatus.downloading || i.status == DownloadStatus.finalizing).toList();
|
||||
if (downloadingItems.isNotEmpty) {
|
||||
_notificationService.showDownloadProgress(
|
||||
trackName: '${downloadingItems.length} downloads',
|
||||
|
||||
@@ -6,54 +6,59 @@ class TrackState {
|
||||
final List<Track> tracks;
|
||||
final bool isLoading;
|
||||
final String? error;
|
||||
final String? albumId;
|
||||
final String? albumName;
|
||||
final String? playlistName;
|
||||
final String? artistId;
|
||||
final String? artistName;
|
||||
final String? coverUrl;
|
||||
final List<ArtistAlbum>? artistAlbums; // For artist page
|
||||
final TrackState? previousState; // For back navigation
|
||||
final List<SearchArtist>? searchArtists; // For search results
|
||||
final bool hasSearchText; // For back button handling
|
||||
|
||||
const TrackState({
|
||||
this.tracks = const [],
|
||||
this.isLoading = false,
|
||||
this.error,
|
||||
this.albumId,
|
||||
this.albumName,
|
||||
this.playlistName,
|
||||
this.artistId,
|
||||
this.artistName,
|
||||
this.coverUrl,
|
||||
this.artistAlbums,
|
||||
this.previousState,
|
||||
this.searchArtists,
|
||||
this.hasSearchText = false,
|
||||
});
|
||||
|
||||
bool get canGoBack => previousState != null;
|
||||
|
||||
bool get hasContent => tracks.isNotEmpty || artistAlbums != null;
|
||||
bool get hasContent => tracks.isNotEmpty || artistAlbums != null || (searchArtists != null && searchArtists!.isNotEmpty);
|
||||
|
||||
TrackState copyWith({
|
||||
List<Track>? tracks,
|
||||
bool? isLoading,
|
||||
String? error,
|
||||
String? albumId,
|
||||
String? albumName,
|
||||
String? playlistName,
|
||||
String? artistId,
|
||||
String? artistName,
|
||||
String? coverUrl,
|
||||
List<ArtistAlbum>? artistAlbums,
|
||||
TrackState? previousState,
|
||||
bool clearPreviousState = false,
|
||||
List<SearchArtist>? searchArtists,
|
||||
bool? hasSearchText,
|
||||
}) {
|
||||
return TrackState(
|
||||
tracks: tracks ?? this.tracks,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
error: error,
|
||||
albumId: albumId ?? this.albumId,
|
||||
albumName: albumName ?? this.albumName,
|
||||
playlistName: playlistName ?? this.playlistName,
|
||||
artistId: artistId ?? this.artistId,
|
||||
artistName: artistName ?? this.artistName,
|
||||
coverUrl: coverUrl ?? this.coverUrl,
|
||||
artistAlbums: artistAlbums ?? this.artistAlbums,
|
||||
previousState: clearPreviousState ? null : (previousState ?? this.previousState),
|
||||
searchArtists: searchArtists ?? this.searchArtists,
|
||||
hasSearchText: hasSearchText ?? this.hasSearchText,
|
||||
);
|
||||
}
|
||||
@@ -80,31 +85,49 @@ class ArtistAlbum {
|
||||
});
|
||||
}
|
||||
|
||||
/// Represents an artist in search results
|
||||
class SearchArtist {
|
||||
final String id;
|
||||
final String name;
|
||||
final String? imageUrl;
|
||||
final int followers;
|
||||
final int popularity;
|
||||
|
||||
const SearchArtist({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.imageUrl,
|
||||
required this.followers,
|
||||
required this.popularity,
|
||||
});
|
||||
}
|
||||
|
||||
class TrackNotifier extends Notifier<TrackState> {
|
||||
/// Request ID to track and cancel outdated requests
|
||||
int _currentRequestId = 0;
|
||||
|
||||
@override
|
||||
TrackState build() {
|
||||
return const TrackState();
|
||||
}
|
||||
|
||||
Future<void> fetchFromUrl(String url) async {
|
||||
// Save current state for back navigation (only if we have content or it's empty)
|
||||
final savedState = state.hasContent ? TrackState(
|
||||
tracks: state.tracks,
|
||||
albumName: state.albumName,
|
||||
playlistName: state.playlistName,
|
||||
artistName: state.artistName,
|
||||
coverUrl: state.coverUrl,
|
||||
artistAlbums: state.artistAlbums,
|
||||
previousState: state.previousState,
|
||||
) : const TrackState(); // Empty state for back to home
|
||||
/// Check if request is still valid (not cancelled by newer request)
|
||||
bool _isRequestValid(int requestId) => requestId == _currentRequestId;
|
||||
|
||||
state = TrackState(isLoading: true, previousState: savedState);
|
||||
Future<void> fetchFromUrl(String url) async {
|
||||
// Increment request ID to cancel any pending requests
|
||||
final requestId = ++_currentRequestId;
|
||||
|
||||
state = const TrackState(isLoading: true);
|
||||
|
||||
try {
|
||||
final parsed = await PlatformBridge.parseSpotifyUrl(url);
|
||||
if (!_isRequestValid(requestId)) return; // Request cancelled
|
||||
|
||||
final type = parsed['type'] as String;
|
||||
|
||||
final metadata = await PlatformBridge.getSpotifyMetadata(url);
|
||||
if (!_isRequestValid(requestId)) return; // Request cancelled
|
||||
|
||||
if (type == 'track') {
|
||||
final trackData = metadata['track'] as Map<String, dynamic>;
|
||||
@@ -113,7 +136,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
tracks: [track],
|
||||
isLoading: false,
|
||||
coverUrl: track.coverUrl,
|
||||
previousState: savedState,
|
||||
);
|
||||
} else if (type == 'album') {
|
||||
final albumInfo = metadata['album_info'] as Map<String, dynamic>;
|
||||
@@ -122,9 +144,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
state = TrackState(
|
||||
tracks: tracks,
|
||||
isLoading: false,
|
||||
albumId: parsed['id'] as String?,
|
||||
albumName: albumInfo['name'] as String?,
|
||||
coverUrl: albumInfo['images'] as String?,
|
||||
previousState: savedState,
|
||||
);
|
||||
} else if (type == 'playlist') {
|
||||
final playlistInfo = metadata['playlist_info'] as Map<String, dynamic>;
|
||||
@@ -136,7 +158,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
isLoading: false,
|
||||
playlistName: owner?['name'] as String?,
|
||||
coverUrl: owner?['images'] as String?,
|
||||
previousState: savedState,
|
||||
);
|
||||
} else if (type == 'artist') {
|
||||
final artistInfo = metadata['artist_info'] as Map<String, dynamic>;
|
||||
@@ -145,42 +166,42 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
state = TrackState(
|
||||
tracks: [], // No tracks for artist view
|
||||
isLoading: false,
|
||||
artistId: artistInfo['id'] as String?,
|
||||
artistName: artistInfo['name'] as String?,
|
||||
coverUrl: artistInfo['images'] as String?,
|
||||
artistAlbums: albums,
|
||||
previousState: savedState,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
state = TrackState(isLoading: false, error: e.toString(), previousState: savedState);
|
||||
if (!_isRequestValid(requestId)) return; // Request cancelled
|
||||
state = TrackState(isLoading: false, error: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> search(String query) async {
|
||||
// Save current state for back navigation
|
||||
final savedState = state.hasContent ? TrackState(
|
||||
tracks: state.tracks,
|
||||
albumName: state.albumName,
|
||||
playlistName: state.playlistName,
|
||||
artistName: state.artistName,
|
||||
coverUrl: state.coverUrl,
|
||||
artistAlbums: state.artistAlbums,
|
||||
previousState: state.previousState,
|
||||
) : const TrackState();
|
||||
// Increment request ID to cancel any pending requests
|
||||
final requestId = ++_currentRequestId;
|
||||
|
||||
state = TrackState(isLoading: true, previousState: savedState);
|
||||
state = const TrackState(isLoading: true);
|
||||
|
||||
try {
|
||||
final results = await PlatformBridge.searchSpotify(query, limit: 20);
|
||||
final results = await PlatformBridge.searchSpotifyAll(query, trackLimit: 20, artistLimit: 5);
|
||||
if (!_isRequestValid(requestId)) return; // Request cancelled
|
||||
|
||||
final trackList = results['tracks'] as List<dynamic>? ?? [];
|
||||
final artistList = results['artists'] as List<dynamic>? ?? [];
|
||||
|
||||
final tracks = trackList.map((t) => _parseSearchTrack(t as Map<String, dynamic>)).toList();
|
||||
final artists = artistList.map((a) => _parseSearchArtist(a as Map<String, dynamic>)).toList();
|
||||
|
||||
state = TrackState(
|
||||
tracks: tracks,
|
||||
searchArtists: artists,
|
||||
isLoading: false,
|
||||
previousState: savedState,
|
||||
);
|
||||
} catch (e) {
|
||||
state = TrackState(isLoading: false, error: e.toString(), previousState: savedState);
|
||||
if (!_isRequestValid(requestId)) return; // Request cancelled
|
||||
state = TrackState(isLoading: false, error: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,54 +252,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
state = state.copyWith(hasSearchText: hasText);
|
||||
}
|
||||
|
||||
/// Go back to previous state (if available)
|
||||
bool goBack() {
|
||||
if (state.previousState != null) {
|
||||
state = state.previousState!;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Fetch album from artist view - saves current artist state for back navigation
|
||||
Future<void> fetchAlbumFromArtist(String albumId) async {
|
||||
// Save current artist state before fetching album
|
||||
final savedState = TrackState(
|
||||
artistName: state.artistName,
|
||||
coverUrl: state.coverUrl,
|
||||
artistAlbums: state.artistAlbums,
|
||||
previousState: state.previousState, // Keep the chain
|
||||
);
|
||||
|
||||
state = TrackState(
|
||||
isLoading: true,
|
||||
previousState: savedState,
|
||||
);
|
||||
|
||||
try {
|
||||
final url = 'https://open.spotify.com/album/$albumId';
|
||||
final metadata = await PlatformBridge.getSpotifyMetadata(url);
|
||||
|
||||
final albumInfo = metadata['album_info'] as Map<String, dynamic>;
|
||||
final trackList = metadata['track_list'] as List<dynamic>;
|
||||
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
||||
|
||||
state = TrackState(
|
||||
tracks: tracks,
|
||||
isLoading: false,
|
||||
albumName: albumInfo['name'] as String?,
|
||||
coverUrl: albumInfo['images'] as String?,
|
||||
previousState: savedState,
|
||||
);
|
||||
} catch (e) {
|
||||
state = TrackState(
|
||||
isLoading: false,
|
||||
error: e.toString(),
|
||||
previousState: savedState,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Track _parseTrack(Map<String, dynamic> data) {
|
||||
return Track(
|
||||
id: data['spotify_id'] as String? ?? '',
|
||||
@@ -322,6 +295,16 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
artists: data['artists'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
SearchArtist _parseSearchArtist(Map<String, dynamic> data) {
|
||||
return SearchArtist(
|
||||
id: data['id'] as String? ?? '',
|
||||
name: data['name'] as String? ?? '',
|
||||
imageUrl: data['images'] as String?,
|
||||
followers: data['followers'] as int? ?? 0,
|
||||
popularity: data['popularity'] as int? ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final trackProvider = NotifierProvider<TrackNotifier, TrackState>(
|
||||
|
||||
@@ -0,0 +1,591 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/models/download_item.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
|
||||
/// Simple in-memory cache for album tracks
|
||||
class _AlbumCache {
|
||||
static final Map<String, _CacheEntry> _cache = {};
|
||||
static const Duration _ttl = Duration(minutes: 10);
|
||||
|
||||
static List<Track>? get(String albumId) {
|
||||
final entry = _cache[albumId];
|
||||
if (entry == null) return null;
|
||||
if (DateTime.now().isAfter(entry.expiresAt)) {
|
||||
_cache.remove(albumId);
|
||||
return null;
|
||||
}
|
||||
return entry.tracks;
|
||||
}
|
||||
|
||||
static void set(String albumId, List<Track> tracks) {
|
||||
_cache[albumId] = _CacheEntry(tracks, DateTime.now().add(_ttl));
|
||||
}
|
||||
}
|
||||
|
||||
class _CacheEntry {
|
||||
final List<Track> tracks;
|
||||
final DateTime expiresAt;
|
||||
_CacheEntry(this.tracks, this.expiresAt);
|
||||
}
|
||||
|
||||
/// Album detail screen with Material Expressive 3 design
|
||||
class AlbumScreen extends ConsumerStatefulWidget {
|
||||
final String albumId;
|
||||
final String albumName;
|
||||
final String? coverUrl;
|
||||
final List<Track>? tracks; // Optional - will fetch if null
|
||||
|
||||
const AlbumScreen({
|
||||
super.key,
|
||||
required this.albumId,
|
||||
required this.albumName,
|
||||
this.coverUrl,
|
||||
this.tracks,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<AlbumScreen> createState() => _AlbumScreenState();
|
||||
}
|
||||
|
||||
class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
List<Track>? _tracks;
|
||||
bool _isLoading = false;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Priority: widget.tracks > cache > fetch
|
||||
_tracks = widget.tracks ?? _AlbumCache.get(widget.albumId);
|
||||
if (_tracks == null) {
|
||||
_fetchTracks();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _fetchTracks() async {
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
final url = 'https://open.spotify.com/album/${widget.albumId}';
|
||||
final metadata = await PlatformBridge.getSpotifyMetadata(url);
|
||||
final trackList = metadata['track_list'] as List<dynamic>;
|
||||
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
||||
|
||||
// Store in cache
|
||||
_AlbumCache.set(widget.albumId, tracks);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_tracks = tracks;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_error = e.toString();
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Track _parseTrack(Map<String, dynamic> data) {
|
||||
return Track(
|
||||
id: data['spotify_id'] as String? ?? '',
|
||||
name: data['name'] as String? ?? '',
|
||||
artistName: data['artists'] as String? ?? '',
|
||||
albumName: data['album_name'] as String? ?? '',
|
||||
albumArtist: data['album_artist'] as String?,
|
||||
coverUrl: data['images'] as String?,
|
||||
isrc: data['isrc'] as String?,
|
||||
duration: data['duration_ms'] as int? ?? 0,
|
||||
trackNumber: data['track_number'] as int?,
|
||||
discNumber: data['disc_number'] as int?,
|
||||
releaseDate: data['release_date'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final tracks = _tracks ?? [];
|
||||
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
_buildAppBar(context, colorScheme),
|
||||
_buildInfoCard(context, colorScheme),
|
||||
if (_isLoading)
|
||||
const SliverToBoxAdapter(child: Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)),
|
||||
if (_error != null)
|
||||
SliverToBoxAdapter(child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(_error!, style: TextStyle(color: colorScheme.error)),
|
||||
)),
|
||||
if (!_isLoading && _error == null && tracks.isNotEmpty) ...[
|
||||
_buildTrackListHeader(context, colorScheme),
|
||||
_buildTrackList(context, colorScheme, tracks),
|
||||
],
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
|
||||
return SliverAppBar(
|
||||
expandedHeight: 280,
|
||||
pinned: true,
|
||||
stretch: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
background: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (widget.coverUrl != null)
|
||||
CachedNetworkImage(
|
||||
imageUrl: widget.coverUrl!,
|
||||
fit: BoxFit.cover,
|
||||
color: Colors.black.withValues(alpha: 0.5),
|
||||
colorBlendMode: BlendMode.darken,
|
||||
memCacheWidth: 600,
|
||||
),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.transparent,
|
||||
colorScheme.surface.withValues(alpha: 0.8),
|
||||
colorScheme.surface,
|
||||
],
|
||||
stops: const [0.0, 0.7, 1.0],
|
||||
),
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 60),
|
||||
child: Container(
|
||||
width: 140,
|
||||
height: 140,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.3),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: widget.coverUrl != null
|
||||
? CachedNetworkImage(imageUrl: widget.coverUrl!, fit: BoxFit.cover, memCacheWidth: 280)
|
||||
: Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(Icons.album, size: 48, color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
|
||||
),
|
||||
leading: IconButton(
|
||||
icon: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(color: colorScheme.surface.withValues(alpha: 0.8), shape: BoxShape.circle),
|
||||
child: Icon(Icons.arrow_back, color: colorScheme.onSurface),
|
||||
),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
|
||||
final tracks = _tracks ?? [];
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Card(
|
||||
elevation: 0,
|
||||
color: colorScheme.surfaceContainerLow,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.albumName,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (tracks.isNotEmpty)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(color: colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(20)),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.music_note, size: 14, color: colorScheme.onSecondaryContainer),
|
||||
const SizedBox(width: 4),
|
||||
Text('${tracks.length} tracks', style: TextStyle(color: colorScheme.onSecondaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (tracks.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: () => _downloadAll(context),
|
||||
icon: const Icon(Icons.download),
|
||||
label: Text('Download All (${tracks.length})'),
|
||||
style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(52), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16))),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTrackListHeader(BuildContext context, ColorScheme colorScheme) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text('Tracks', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTrackList(BuildContext context, ColorScheme colorScheme, List<Track> tracks) {
|
||||
return SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
final track = tracks[index];
|
||||
return KeyedSubtree(
|
||||
key: ValueKey(track.id),
|
||||
child: _AlbumTrackItem(
|
||||
track: track,
|
||||
onDownload: () => _downloadTrack(context, track),
|
||||
),
|
||||
);
|
||||
},
|
||||
childCount: tracks.length,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _downloadTrack(BuildContext context, Track track) {
|
||||
final settings = ref.read(settingsProvider);
|
||||
if (settings.askQualityBeforeDownload) {
|
||||
_showQualityPicker(context, (quality) {
|
||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService, qualityOverride: quality);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||
}, trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl);
|
||||
} else {
|
||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||
}
|
||||
}
|
||||
|
||||
void _downloadAll(BuildContext context) {
|
||||
final tracks = _tracks;
|
||||
if (tracks == null || tracks.isEmpty) return;
|
||||
final settings = ref.read(settingsProvider);
|
||||
if (settings.askQualityBeforeDownload) {
|
||||
_showQualityPicker(context, (quality) {
|
||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService, qualityOverride: quality);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
|
||||
}, trackName: '${tracks.length} tracks', artistName: widget.albumName);
|
||||
} else {
|
||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
|
||||
}
|
||||
}
|
||||
|
||||
void _showQualityPicker(BuildContext context, void Function(String quality) onSelect, {String? trackName, String? artistName, String? coverUrl}) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
|
||||
builder: (context) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (trackName != null) ...[
|
||||
_TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl),
|
||||
Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
|
||||
] else ...[
|
||||
const SizedBox(height: 8),
|
||||
Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))),
|
||||
],
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
||||
child: Text('Select Quality', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
|
||||
),
|
||||
_QualityOption(title: 'FLAC Lossless', subtitle: '16-bit / 44.1kHz', icon: Icons.music_note, onTap: () { Navigator.pop(context); onSelect('LOSSLESS'); }),
|
||||
_QualityOption(title: 'Hi-Res FLAC', subtitle: '24-bit / up to 96kHz', icon: Icons.high_quality, onTap: () { Navigator.pop(context); onSelect('HI_RES'); }),
|
||||
_QualityOption(title: 'Hi-Res FLAC Max', subtitle: '24-bit / up to 192kHz', icon: Icons.hd, onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS'); }),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _QualityOption extends StatelessWidget {
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final IconData icon;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _QualityOption({required this.title, required this.subtitle, required this.icon, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
|
||||
leading: Container(padding: const EdgeInsets.all(10), decoration: BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(12)), child: Icon(icon, color: colorScheme.onPrimaryContainer, size: 20)),
|
||||
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
subtitle: Text(subtitle, style: TextStyle(color: colorScheme.onSurfaceVariant)),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TrackInfoHeader extends StatefulWidget {
|
||||
final String trackName;
|
||||
final String? artistName;
|
||||
final String? coverUrl;
|
||||
const _TrackInfoHeader({required this.trackName, this.artistName, this.coverUrl});
|
||||
|
||||
@override
|
||||
State<_TrackInfoHeader> createState() => _TrackInfoHeaderState();
|
||||
}
|
||||
|
||||
class _TrackInfoHeaderState extends State<_TrackInfoHeader> {
|
||||
bool _expanded = false;
|
||||
bool _isOverflowing = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: _isOverflowing ? () => setState(() => _expanded = !_expanded) : null,
|
||||
borderRadius: const BorderRadius.only(topLeft: Radius.circular(28), topRight: Radius.circular(28)),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2))),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: widget.coverUrl != null
|
||||
? Image.network(widget.coverUrl!, width: 56, height: 56, fit: BoxFit.cover,
|
||||
errorBuilder: (_, e, s) => Container(width: 56, height: 56, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)))
|
||||
: Container(width: 56, height: 56, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final titleStyle = Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600);
|
||||
final titleSpan = TextSpan(text: widget.trackName, style: titleStyle);
|
||||
final titlePainter = TextPainter(text: titleSpan, maxLines: 1, textDirection: TextDirection.ltr)..layout(maxWidth: constraints.maxWidth);
|
||||
final titleOverflows = titlePainter.didExceedMaxLines;
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && _isOverflowing != titleOverflows) {
|
||||
setState(() => _isOverflowing = titleOverflows);
|
||||
}
|
||||
});
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.trackName,
|
||||
style: titleStyle,
|
||||
maxLines: _expanded ? 10 : 1,
|
||||
overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis,
|
||||
),
|
||||
if (widget.artistName != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
widget.artistName!,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
maxLines: _expanded ? 3 : 1,
|
||||
overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (_isOverflowing || _expanded)
|
||||
Icon(_expanded ? Icons.expand_less : Icons.expand_more, color: colorScheme.onSurfaceVariant, size: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Separate Consumer widget for each track - only rebuilds when this specific track's status changes
|
||||
class _AlbumTrackItem extends ConsumerWidget {
|
||||
final Track track;
|
||||
final VoidCallback onDownload;
|
||||
|
||||
const _AlbumTrackItem({required this.track, required this.onDownload});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
// Only watch the specific item for this track
|
||||
final queueItem = ref.watch(downloadQueueProvider.select((state) {
|
||||
return state.items.where((item) => item.track.id == track.id).firstOrNull;
|
||||
}));
|
||||
|
||||
// Check if track is in history (already downloaded before)
|
||||
final isInHistory = ref.watch(downloadHistoryProvider.select((state) {
|
||||
return state.isDownloaded(track.id);
|
||||
}));
|
||||
|
||||
final isQueued = queueItem != null;
|
||||
final isDownloading = queueItem?.status == DownloadStatus.downloading;
|
||||
final isFinalizing = queueItem?.status == DownloadStatus.finalizing;
|
||||
final isCompleted = queueItem?.status == DownloadStatus.completed;
|
||||
final progress = queueItem?.progress ?? 0.0;
|
||||
|
||||
// Show as downloaded if in queue completed OR in history
|
||||
final showAsDownloaded = isCompleted || (!isQueued && isInHistory);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Card(
|
||||
elevation: 0,
|
||||
color: Colors.transparent,
|
||||
margin: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: ListTile(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
leading: track.coverUrl != null
|
||||
? ClipRRect(borderRadius: BorderRadius.circular(8), child: CachedNetworkImage(imageUrl: track.coverUrl!, width: 48, height: 48, fit: BoxFit.cover, memCacheWidth: 96))
|
||||
: Container(width: 48, height: 48, decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8)), child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)),
|
||||
title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500)),
|
||||
subtitle: Text(track.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant)),
|
||||
trailing: _buildDownloadButton(context, ref, colorScheme, isQueued: isQueued, isDownloading: isDownloading, isFinalizing: isFinalizing, showAsDownloaded: showAsDownloaded, isInHistory: isInHistory, progress: progress),
|
||||
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleTap(BuildContext context, WidgetRef ref, {required bool isQueued, required bool isInHistory}) async {
|
||||
if (isQueued) return;
|
||||
|
||||
if (isInHistory) {
|
||||
final historyItem = ref.read(downloadHistoryProvider.notifier).getBySpotifyId(track.id);
|
||||
if (historyItem != null) {
|
||||
final fileExists = await File(historyItem.filePath).exists();
|
||||
if (fileExists) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('"${track.name}" already downloaded')));
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
ref.read(downloadHistoryProvider.notifier).removeBySpotifyId(track.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onDownload();
|
||||
}
|
||||
|
||||
Widget _buildDownloadButton(BuildContext context, WidgetRef ref, ColorScheme colorScheme, {
|
||||
required bool isQueued,
|
||||
required bool isDownloading,
|
||||
required bool isFinalizing,
|
||||
required bool showAsDownloaded,
|
||||
required bool isInHistory,
|
||||
required double progress,
|
||||
}) {
|
||||
const double size = 44.0;
|
||||
const double iconSize = 20.0;
|
||||
|
||||
if (showAsDownloaded) {
|
||||
return GestureDetector(
|
||||
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory),
|
||||
child: Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.primaryContainer, shape: BoxShape.circle), child: Icon(Icons.check, color: colorScheme.onPrimaryContainer, size: iconSize)),
|
||||
);
|
||||
} else if (isFinalizing) {
|
||||
return SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(strokeWidth: 3, color: colorScheme.tertiary, backgroundColor: colorScheme.surfaceContainerHighest),
|
||||
Icon(Icons.edit_note, color: colorScheme.tertiary, size: 16),
|
||||
],
|
||||
),
|
||||
);
|
||||
} else if (isDownloading) {
|
||||
return SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(value: progress > 0 ? progress : null, strokeWidth: 3, color: colorScheme.primary, backgroundColor: colorScheme.surfaceContainerHighest),
|
||||
if (progress > 0) Text('${(progress * 100).toInt()}', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: colorScheme.primary)),
|
||||
],
|
||||
),
|
||||
);
|
||||
} else if (isQueued) {
|
||||
return Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, shape: BoxShape.circle), child: Icon(Icons.hourglass_empty, color: colorScheme.onSurfaceVariant, size: iconSize));
|
||||
} else {
|
||||
return GestureDetector(
|
||||
onTap: onDownload,
|
||||
child: Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.secondaryContainer, shape: BoxShape.circle), child: Icon(Icons.download, color: colorScheme.onSecondaryContainer, size: iconSize)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:spotiflac_android/providers/track_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/screens/album_screen.dart';
|
||||
|
||||
/// Simple in-memory cache for artist discography
|
||||
class _ArtistCache {
|
||||
static final Map<String, _CacheEntry> _cache = {};
|
||||
static const Duration _ttl = Duration(minutes: 10);
|
||||
|
||||
static List<ArtistAlbum>? get(String artistId) {
|
||||
final entry = _cache[artistId];
|
||||
if (entry == null) return null;
|
||||
if (DateTime.now().isAfter(entry.expiresAt)) {
|
||||
_cache.remove(artistId);
|
||||
return null;
|
||||
}
|
||||
return entry.albums;
|
||||
}
|
||||
|
||||
static void set(String artistId, List<ArtistAlbum> albums) {
|
||||
_cache[artistId] = _CacheEntry(albums, DateTime.now().add(_ttl));
|
||||
}
|
||||
}
|
||||
|
||||
class _CacheEntry {
|
||||
final List<ArtistAlbum> albums;
|
||||
final DateTime expiresAt;
|
||||
_CacheEntry(this.albums, this.expiresAt);
|
||||
}
|
||||
|
||||
/// Artist screen with Material Expressive 3 design - shows discography
|
||||
class ArtistScreen extends ConsumerStatefulWidget {
|
||||
final String artistId;
|
||||
final String artistName;
|
||||
final String? coverUrl;
|
||||
final List<ArtistAlbum>? albums; // Optional - will fetch if null
|
||||
|
||||
const ArtistScreen({
|
||||
super.key,
|
||||
required this.artistId,
|
||||
required this.artistName,
|
||||
this.coverUrl,
|
||||
this.albums,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ArtistScreen> createState() => _ArtistScreenState();
|
||||
}
|
||||
|
||||
class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
bool _isLoadingDiscography = false;
|
||||
List<ArtistAlbum>? _albums;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Priority: widget.albums > cache > fetch
|
||||
_albums = widget.albums ?? _ArtistCache.get(widget.artistId);
|
||||
if (_albums == null) {
|
||||
_fetchDiscography();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _fetchDiscography() async {
|
||||
setState(() => _isLoadingDiscography = true);
|
||||
try {
|
||||
final url = 'https://open.spotify.com/artist/${widget.artistId}';
|
||||
final metadata = await PlatformBridge.getSpotifyMetadata(url);
|
||||
final albumsList = metadata['albums'] as List<dynamic>;
|
||||
final albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
|
||||
|
||||
// Store in cache
|
||||
_ArtistCache.set(widget.artistId, albums);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_albums = albums;
|
||||
_isLoadingDiscography = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_error = e.toString();
|
||||
_isLoadingDiscography = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ArtistAlbum _parseArtistAlbum(Map<String, dynamic> data) {
|
||||
return ArtistAlbum(
|
||||
id: data['id'] as String? ?? '',
|
||||
name: data['name'] as String? ?? '',
|
||||
releaseDate: data['release_date'] as String? ?? '',
|
||||
totalTracks: data['total_tracks'] as int? ?? 0,
|
||||
coverUrl: data['images'] as String?,
|
||||
albumType: data['album_type'] as String? ?? 'album',
|
||||
artists: data['artists'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final albums = _albums ?? [];
|
||||
final albumsOnly = albums.where((a) => a.albumType == 'album').toList();
|
||||
final singles = albums.where((a) => a.albumType == 'single').toList();
|
||||
final compilations = albums.where((a) => a.albumType == 'compilation').toList();
|
||||
|
||||
return Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
CustomScrollView(
|
||||
slivers: [
|
||||
_buildAppBar(context, colorScheme),
|
||||
_buildInfoCard(context, colorScheme),
|
||||
if (_isLoadingDiscography)
|
||||
const SliverToBoxAdapter(child: Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)),
|
||||
if (_error != null)
|
||||
SliverToBoxAdapter(child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(_error!, style: TextStyle(color: colorScheme.error)),
|
||||
)),
|
||||
if (!_isLoadingDiscography && _error == null) ...[
|
||||
if (albumsOnly.isNotEmpty) SliverToBoxAdapter(child: _buildAlbumSection('Albums', albumsOnly, colorScheme)),
|
||||
if (singles.isNotEmpty) SliverToBoxAdapter(child: _buildAlbumSection('Singles & EPs', singles, colorScheme)),
|
||||
if (compilations.isNotEmpty) SliverToBoxAdapter(child: _buildAlbumSection('Compilations', compilations, colorScheme)),
|
||||
],
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
|
||||
return SliverAppBar(
|
||||
expandedHeight: 280,
|
||||
pinned: true,
|
||||
stretch: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
background: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (widget.coverUrl != null)
|
||||
CachedNetworkImage(imageUrl: widget.coverUrl!, fit: BoxFit.cover, color: Colors.black.withValues(alpha: 0.5), colorBlendMode: BlendMode.darken, memCacheWidth: 600),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Colors.transparent, colorScheme.surface.withValues(alpha: 0.8), colorScheme.surface],
|
||||
stops: const [0.0, 0.7, 1.0],
|
||||
),
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 60),
|
||||
child: Container(
|
||||
width: 140,
|
||||
height: 140,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 20, offset: const Offset(0, 10))],
|
||||
),
|
||||
child: ClipOval(
|
||||
child: widget.coverUrl != null
|
||||
? CachedNetworkImage(imageUrl: widget.coverUrl!, fit: BoxFit.cover, memCacheWidth: 280)
|
||||
: Container(color: colorScheme.surfaceContainerHighest, child: Icon(Icons.person, size: 48, color: colorScheme.onSurfaceVariant)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
|
||||
),
|
||||
leading: IconButton(
|
||||
icon: Container(padding: const EdgeInsets.all(8), decoration: BoxDecoration(color: colorScheme.surface.withValues(alpha: 0.8), shape: BoxShape.circle), child: Icon(Icons.arrow_back, color: colorScheme.onSurface)),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Card(
|
||||
elevation: 0,
|
||||
color: colorScheme.surfaceContainerLow,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(widget.artistName, style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface)),
|
||||
const SizedBox(height: 8),
|
||||
if (_albums != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(20)),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.album, size: 14, color: colorScheme.onPrimaryContainer),
|
||||
const SizedBox(width: 4),
|
||||
Text('${_albums!.length} releases', style: TextStyle(color: colorScheme.onPrimaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAlbumSection(String title, List<ArtistAlbum> albums, ColorScheme colorScheme) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.album, size: 20, color: colorScheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text('$title (${albums.length})', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.primary)),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 210,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
itemCount: albums.length,
|
||||
itemBuilder: (context, index) {
|
||||
final album = albums[index];
|
||||
return KeyedSubtree(key: ValueKey(album.id), child: _buildAlbumCard(album, colorScheme));
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAlbumCard(ArtistAlbum album, ColorScheme colorScheme) {
|
||||
return GestureDetector(
|
||||
onTap: () => _navigateToAlbum(album),
|
||||
child: Container(
|
||||
width: 140,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 6),
|
||||
child: Card(
|
||||
elevation: 0,
|
||||
color: colorScheme.surfaceContainerLow,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: album.coverUrl != null
|
||||
? CachedNetworkImage(imageUrl: album.coverUrl!, width: 124, height: 124, fit: BoxFit.cover, memCacheWidth: 248)
|
||||
: Container(width: 124, height: 124, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.album, color: colorScheme.onSurfaceVariant, size: 40)),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(album.name, style: Theme.of(context).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600), maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'${album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate} • ${album.totalTracks} tracks',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant, fontSize: 11),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _navigateToAlbum(ArtistAlbum album) {
|
||||
// Navigate immediately with data from artist discography, fetch tracks in AlbumScreen
|
||||
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
||||
Navigator.push(context, MaterialPageRoute(
|
||||
builder: (context) => AlbumScreen(
|
||||
albumId: album.id,
|
||||
albumName: album.name,
|
||||
coverUrl: album.coverUrl,
|
||||
// tracks: null - will be fetched in AlbumScreen
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
late PageController _pageController;
|
||||
bool _hasCheckedUpdate = false;
|
||||
StreamSubscription<String>? _shareSubscription;
|
||||
DateTime? _lastBackPress; // For double-tap to exit
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -47,11 +48,17 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
_handleSharedUrl(pendingUrl);
|
||||
}
|
||||
|
||||
// Listen for future shared URLs
|
||||
_shareSubscription = ShareIntentService().sharedUrlStream.listen((url) {
|
||||
_log.d('Received shared URL from stream: $url');
|
||||
_handleSharedUrl(url);
|
||||
});
|
||||
// Listen for future shared URLs with error handling
|
||||
_shareSubscription = ShareIntentService().sharedUrlStream.listen(
|
||||
(url) {
|
||||
_log.d('Received shared URL from stream: $url');
|
||||
_handleSharedUrl(url);
|
||||
},
|
||||
onError: (error) {
|
||||
_log.e('Share stream error: $error');
|
||||
},
|
||||
cancelOnError: false,
|
||||
);
|
||||
}
|
||||
|
||||
void _handleSharedUrl(String url) {
|
||||
@@ -114,65 +121,66 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _showExitDialog() async {
|
||||
return await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Exit App'),
|
||||
content: const Text('Are you sure you want to exit?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('No'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Yes'),
|
||||
),
|
||||
],
|
||||
),
|
||||
) ?? false;
|
||||
/// Handle back press with double-tap to exit
|
||||
void _handleBackPress() {
|
||||
final trackState = ref.read(trackProvider);
|
||||
|
||||
// If on Home tab and has text in search bar or has content (but not loading), clear it
|
||||
if (_currentIndex == 0 && !trackState.isLoading && (trackState.hasSearchText || trackState.hasContent)) {
|
||||
ref.read(trackProvider.notifier).clear();
|
||||
return;
|
||||
}
|
||||
|
||||
// If not on Home tab, go to Home tab first
|
||||
if (_currentIndex != 0) {
|
||||
_onNavTap(0);
|
||||
return;
|
||||
}
|
||||
|
||||
// If loading, ignore back press
|
||||
if (trackState.isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Double-tap to exit
|
||||
final now = DateTime.now();
|
||||
if (_lastBackPress != null && now.difference(_lastBackPress!) < const Duration(seconds: 2)) {
|
||||
SystemNavigator.pop();
|
||||
} else {
|
||||
_lastBackPress = now;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Press back again to exit'),
|
||||
duration: Duration(seconds: 2),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final queueState = ref.watch(downloadQueueProvider.select((s) => s.queuedCount));
|
||||
final trackState = ref.watch(trackProvider);
|
||||
|
||||
// Determine if we can pop (for predictive back animation)
|
||||
// canPop is true when we're at root with no content - enables predictive back gesture
|
||||
final canPop = _currentIndex == 0 &&
|
||||
!trackState.hasSearchText &&
|
||||
!trackState.hasContent &&
|
||||
!trackState.isLoading;
|
||||
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
canPop: canPop,
|
||||
onPopInvokedWithResult: (didPop, result) async {
|
||||
if (didPop) return;
|
||||
|
||||
// If on Search tab and can go back in track history, go back
|
||||
if (_currentIndex == 0 && trackState.canGoBack) {
|
||||
ref.read(trackProvider.notifier).goBack();
|
||||
if (didPop) {
|
||||
// System handled the pop - this means predictive back completed
|
||||
// We need to handle double-tap to exit here
|
||||
return;
|
||||
}
|
||||
|
||||
// If on Search tab and has text in search bar or has content (but not loading), clear it
|
||||
// Don't clear while loading - this prevents clearing during share intent processing
|
||||
if (_currentIndex == 0 && !trackState.isLoading && (trackState.hasSearchText || trackState.hasContent)) {
|
||||
ref.read(trackProvider.notifier).clear();
|
||||
return;
|
||||
}
|
||||
|
||||
// If not on Search tab, go to Search tab first
|
||||
if (_currentIndex != 0) {
|
||||
_onNavTap(0);
|
||||
return;
|
||||
}
|
||||
|
||||
// If loading, ignore back press
|
||||
if (trackState.isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Already at root, show exit dialog
|
||||
final shouldPop = await _showExitDialog();
|
||||
if (shouldPop && context.mounted) {
|
||||
SystemNavigator.pop();
|
||||
}
|
||||
// Handle back press manually when canPop is false
|
||||
_handleBackPress();
|
||||
},
|
||||
child: Scaffold(
|
||||
body: PageView(
|
||||
@@ -189,11 +197,14 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
selectedIndex: _currentIndex,
|
||||
onDestinationSelected: _onNavTap,
|
||||
animationDuration: const Duration(milliseconds: 200),
|
||||
backgroundColor: Theme.of(context).brightness == Brightness.dark
|
||||
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), Theme.of(context).colorScheme.surface)
|
||||
: Color.alphaBlend(Colors.black.withValues(alpha: 0.03), Theme.of(context).colorScheme.surface),
|
||||
destinations: [
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.search_outlined),
|
||||
selectedIcon: Icon(Icons.search),
|
||||
label: 'Search',
|
||||
icon: Icon(Icons.home_outlined),
|
||||
selectedIcon: Icon(Icons.home),
|
||||
label: 'Home',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Badge(
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/models/download_item.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
|
||||
/// Playlist detail screen with Material Expressive 3 design
|
||||
class PlaylistScreen extends ConsumerWidget {
|
||||
final String playlistName;
|
||||
final String? coverUrl;
|
||||
final List<Track> tracks;
|
||||
|
||||
const PlaylistScreen({
|
||||
super.key,
|
||||
required this.playlistName,
|
||||
this.coverUrl,
|
||||
required this.tracks,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
_buildAppBar(context, colorScheme),
|
||||
_buildInfoCard(context, ref, colorScheme),
|
||||
_buildTrackListHeader(context, colorScheme),
|
||||
_buildTrackList(context, ref, colorScheme),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
|
||||
return SliverAppBar(
|
||||
expandedHeight: 280,
|
||||
pinned: true,
|
||||
stretch: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
background: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (coverUrl != null)
|
||||
CachedNetworkImage(imageUrl: coverUrl!, fit: BoxFit.cover, color: Colors.black.withValues(alpha: 0.5), colorBlendMode: BlendMode.darken, memCacheWidth: 600),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Colors.transparent, colorScheme.surface.withValues(alpha: 0.8), colorScheme.surface],
|
||||
stops: const [0.0, 0.7, 1.0],
|
||||
),
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 60),
|
||||
child: Container(
|
||||
width: 140,
|
||||
height: 140,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 20, offset: const Offset(0, 10))],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: coverUrl != null
|
||||
? CachedNetworkImage(imageUrl: coverUrl!, fit: BoxFit.cover, memCacheWidth: 280)
|
||||
: Container(color: colorScheme.surfaceContainerHighest, child: Icon(Icons.playlist_play, size: 48, color: colorScheme.onSurfaceVariant)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
|
||||
),
|
||||
leading: IconButton(
|
||||
icon: Container(padding: const EdgeInsets.all(8), decoration: BoxDecoration(color: colorScheme.surface.withValues(alpha: 0.8), shape: BoxShape.circle), child: Icon(Icons.arrow_back, color: colorScheme.onSurface)),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoCard(BuildContext context, WidgetRef ref, ColorScheme colorScheme) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Card(
|
||||
elevation: 0,
|
||||
color: colorScheme.surfaceContainerLow,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(playlistName, style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface)),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(color: colorScheme.tertiaryContainer, borderRadius: BorderRadius.circular(20)),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.playlist_play, size: 14, color: colorScheme.onTertiaryContainer),
|
||||
const SizedBox(width: 4),
|
||||
Text('${tracks.length} tracks', style: TextStyle(color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: () => _downloadAll(context, ref),
|
||||
icon: const Icon(Icons.download),
|
||||
label: Text('Download All (${tracks.length})'),
|
||||
style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(52), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16))),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTrackListHeader(BuildContext context, ColorScheme colorScheme) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text('Tracks', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTrackList(BuildContext context, WidgetRef ref, ColorScheme colorScheme) {
|
||||
return SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
final track = tracks[index];
|
||||
return KeyedSubtree(
|
||||
key: ValueKey(track.id),
|
||||
child: _PlaylistTrackItem(
|
||||
track: track,
|
||||
onDownload: () => _downloadTrack(context, ref, track),
|
||||
),
|
||||
);
|
||||
},
|
||||
childCount: tracks.length,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _downloadTrack(BuildContext context, WidgetRef ref, Track track) {
|
||||
final settings = ref.read(settingsProvider);
|
||||
if (settings.askQualityBeforeDownload) {
|
||||
_showQualityPicker(context, (quality) {
|
||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService, qualityOverride: quality);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||
}, trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl);
|
||||
} else {
|
||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||
}
|
||||
}
|
||||
|
||||
void _downloadAll(BuildContext context, WidgetRef ref) {
|
||||
if (tracks.isEmpty) return;
|
||||
final settings = ref.read(settingsProvider);
|
||||
if (settings.askQualityBeforeDownload) {
|
||||
_showQualityPicker(context, (quality) {
|
||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService, qualityOverride: quality);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
|
||||
}, trackName: '${tracks.length} tracks', artistName: playlistName);
|
||||
} else {
|
||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
|
||||
}
|
||||
}
|
||||
|
||||
void _showQualityPicker(BuildContext context, void Function(String quality) onSelect, {String? trackName, String? artistName, String? coverUrl}) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
|
||||
builder: (context) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (trackName != null) ...[
|
||||
_TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl),
|
||||
Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
|
||||
] else ...[
|
||||
const SizedBox(height: 8),
|
||||
Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))),
|
||||
],
|
||||
Padding(padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), child: Text('Select Quality', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold))),
|
||||
_QualityOption(title: 'FLAC Lossless', subtitle: '16-bit / 44.1kHz', icon: Icons.music_note, onTap: () { Navigator.pop(context); onSelect('LOSSLESS'); }),
|
||||
_QualityOption(title: 'Hi-Res FLAC', subtitle: '24-bit / up to 96kHz', icon: Icons.high_quality, onTap: () { Navigator.pop(context); onSelect('HI_RES'); }),
|
||||
_QualityOption(title: 'Hi-Res FLAC Max', subtitle: '24-bit / up to 192kHz', icon: Icons.hd, onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS'); }),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _QualityOption extends StatelessWidget {
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final IconData icon;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _QualityOption({required this.title, required this.subtitle, required this.icon, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
|
||||
leading: Container(padding: const EdgeInsets.all(10), decoration: BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(12)), child: Icon(icon, color: colorScheme.onPrimaryContainer, size: 20)),
|
||||
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
subtitle: Text(subtitle, style: TextStyle(color: colorScheme.onSurfaceVariant)),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TrackInfoHeader extends StatefulWidget {
|
||||
final String trackName;
|
||||
final String? artistName;
|
||||
final String? coverUrl;
|
||||
const _TrackInfoHeader({required this.trackName, this.artistName, this.coverUrl});
|
||||
|
||||
@override
|
||||
State<_TrackInfoHeader> createState() => _TrackInfoHeaderState();
|
||||
}
|
||||
|
||||
class _TrackInfoHeaderState extends State<_TrackInfoHeader> {
|
||||
bool _expanded = false;
|
||||
bool _isOverflowing = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: _isOverflowing ? () => setState(() => _expanded = !_expanded) : null,
|
||||
borderRadius: const BorderRadius.only(topLeft: Radius.circular(28), topRight: Radius.circular(28)),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2))),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: widget.coverUrl != null
|
||||
? Image.network(widget.coverUrl!, width: 56, height: 56, fit: BoxFit.cover,
|
||||
errorBuilder: (_, e, s) => Container(width: 56, height: 56, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)))
|
||||
: Container(width: 56, height: 56, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final titleStyle = Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600);
|
||||
final titleSpan = TextSpan(text: widget.trackName, style: titleStyle);
|
||||
final titlePainter = TextPainter(text: titleSpan, maxLines: 1, textDirection: TextDirection.ltr)..layout(maxWidth: constraints.maxWidth);
|
||||
final titleOverflows = titlePainter.didExceedMaxLines;
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && _isOverflowing != titleOverflows) {
|
||||
setState(() => _isOverflowing = titleOverflows);
|
||||
}
|
||||
});
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.trackName,
|
||||
style: titleStyle,
|
||||
maxLines: _expanded ? 10 : 1,
|
||||
overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis,
|
||||
),
|
||||
if (widget.artistName != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
widget.artistName!,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
maxLines: _expanded ? 3 : 1,
|
||||
overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (_isOverflowing || _expanded)
|
||||
Icon(_expanded ? Icons.expand_less : Icons.expand_more, color: colorScheme.onSurfaceVariant, size: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Separate Consumer widget for each track - only rebuilds when this specific track's status changes
|
||||
class _PlaylistTrackItem extends ConsumerWidget {
|
||||
final Track track;
|
||||
final VoidCallback onDownload;
|
||||
|
||||
const _PlaylistTrackItem({required this.track, required this.onDownload});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
// Only watch the specific item for this track
|
||||
final queueItem = ref.watch(downloadQueueProvider.select((state) {
|
||||
return state.items.where((item) => item.track.id == track.id).firstOrNull;
|
||||
}));
|
||||
|
||||
// Check if track is in history (already downloaded before)
|
||||
final isInHistory = ref.watch(downloadHistoryProvider.select((state) {
|
||||
return state.isDownloaded(track.id);
|
||||
}));
|
||||
|
||||
final isQueued = queueItem != null;
|
||||
final isDownloading = queueItem?.status == DownloadStatus.downloading;
|
||||
final isFinalizing = queueItem?.status == DownloadStatus.finalizing;
|
||||
final isCompleted = queueItem?.status == DownloadStatus.completed;
|
||||
final progress = queueItem?.progress ?? 0.0;
|
||||
|
||||
// Show as downloaded if in queue completed OR in history
|
||||
final showAsDownloaded = isCompleted || (!isQueued && isInHistory);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Card(
|
||||
elevation: 0,
|
||||
color: Colors.transparent,
|
||||
margin: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: ListTile(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
leading: track.coverUrl != null
|
||||
? ClipRRect(borderRadius: BorderRadius.circular(8), child: CachedNetworkImage(imageUrl: track.coverUrl!, width: 48, height: 48, fit: BoxFit.cover, memCacheWidth: 96))
|
||||
: Container(width: 48, height: 48, decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8)), child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)),
|
||||
title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500)),
|
||||
subtitle: Text(track.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant)),
|
||||
trailing: _buildDownloadButton(context, ref, colorScheme, isQueued: isQueued, isDownloading: isDownloading, isFinalizing: isFinalizing, showAsDownloaded: showAsDownloaded, isInHistory: isInHistory, progress: progress),
|
||||
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleTap(BuildContext context, WidgetRef ref, {required bool isQueued, required bool isInHistory}) async {
|
||||
if (isQueued) return;
|
||||
|
||||
if (isInHistory) {
|
||||
final historyItem = ref.read(downloadHistoryProvider.notifier).getBySpotifyId(track.id);
|
||||
if (historyItem != null) {
|
||||
final fileExists = await File(historyItem.filePath).exists();
|
||||
if (fileExists) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('"${track.name}" already downloaded')));
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
ref.read(downloadHistoryProvider.notifier).removeBySpotifyId(track.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onDownload();
|
||||
}
|
||||
|
||||
Widget _buildDownloadButton(BuildContext context, WidgetRef ref, ColorScheme colorScheme, {
|
||||
required bool isQueued,
|
||||
required bool isDownloading,
|
||||
required bool isFinalizing,
|
||||
required bool showAsDownloaded,
|
||||
required bool isInHistory,
|
||||
required double progress,
|
||||
}) {
|
||||
const double size = 44.0;
|
||||
const double iconSize = 20.0;
|
||||
|
||||
if (showAsDownloaded) {
|
||||
return GestureDetector(
|
||||
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory),
|
||||
child: Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.primaryContainer, shape: BoxShape.circle), child: Icon(Icons.check, color: colorScheme.onPrimaryContainer, size: iconSize)),
|
||||
);
|
||||
} else if (isFinalizing) {
|
||||
return SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(strokeWidth: 3, color: colorScheme.tertiary, backgroundColor: colorScheme.surfaceContainerHighest),
|
||||
Icon(Icons.edit_note, color: colorScheme.tertiary, size: 16),
|
||||
],
|
||||
),
|
||||
);
|
||||
} else if (isDownloading) {
|
||||
return SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(value: progress > 0 ? progress : null, strokeWidth: 3, color: colorScheme.primary, backgroundColor: colorScheme.surfaceContainerHighest),
|
||||
if (progress > 0) Text('${(progress * 100).toInt()}', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: colorScheme.primary)),
|
||||
],
|
||||
),
|
||||
);
|
||||
} else if (isQueued) {
|
||||
return Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, shape: BoxShape.circle), child: Icon(Icons.hourglass_empty, color: colorScheme.onSurfaceVariant, size: iconSize));
|
||||
} else {
|
||||
return GestureDetector(
|
||||
onTap: onDownload,
|
||||
child: Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.secondaryContainer, shape: BoxShape.circle), child: Icon(Icons.download, color: colorScheme.onSecondaryContainer, size: iconSize)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -144,6 +144,18 @@ class QueueScreen extends ConsumerWidget {
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
);
|
||||
case DownloadStatus.finalizing:
|
||||
return SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(strokeWidth: 2, color: colorScheme.tertiary),
|
||||
Icon(Icons.edit_note, color: colorScheme.tertiary, size: 12),
|
||||
],
|
||||
),
|
||||
);
|
||||
case DownloadStatus.completed:
|
||||
return Icon(Icons.check_circle, color: colorScheme.primary);
|
||||
case DownloadStatus.failed:
|
||||
|
||||
@@ -16,20 +16,41 @@ class QueueTab extends ConsumerStatefulWidget {
|
||||
|
||||
class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
final Map<String, bool> _fileExistsCache = {};
|
||||
final Set<String> _pendingChecks = {}; // Track pending async checks
|
||||
static const int _maxCacheSize = 500; // Limit cache size to prevent memory leak
|
||||
|
||||
/// Check if file exists - returns true optimistically while checking
|
||||
/// This prevents the "red flash" on app start
|
||||
bool _checkFileExists(String? filePath) {
|
||||
if (filePath == null) return false;
|
||||
|
||||
// If already cached, return cached value
|
||||
if (_fileExistsCache.containsKey(filePath)) {
|
||||
return _fileExistsCache[filePath]!;
|
||||
}
|
||||
|
||||
// If check is pending, return true optimistically (assume file exists)
|
||||
if (_pendingChecks.contains(filePath)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Limit cache size - remove oldest entry if full
|
||||
if (_fileExistsCache.length >= _maxCacheSize) {
|
||||
_fileExistsCache.remove(_fileExistsCache.keys.first);
|
||||
}
|
||||
|
||||
// Mark as pending and start async check
|
||||
_pendingChecks.add(filePath);
|
||||
Future.microtask(() async {
|
||||
final exists = await File(filePath).exists();
|
||||
_pendingChecks.remove(filePath);
|
||||
if (mounted && _fileExistsCache[filePath] != exists) {
|
||||
setState(() => _fileExistsCache[filePath] = exists);
|
||||
}
|
||||
});
|
||||
_fileExistsCache[filePath] = false;
|
||||
return false;
|
||||
|
||||
// Return true optimistically while checking
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> _openFile(String filePath) async {
|
||||
@@ -69,8 +90,13 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final queueState = ref.watch(downloadQueueProvider);
|
||||
final historyState = ref.watch(downloadHistoryProvider);
|
||||
// Use select() to only rebuild when specific fields change
|
||||
final queueItems = ref.watch(downloadQueueProvider.select((s) => s.items));
|
||||
final isProcessing = ref.watch(downloadQueueProvider.select((s) => s.isProcessing));
|
||||
final isPaused = ref.watch(downloadQueueProvider.select((s) => s.isPaused));
|
||||
final queuedCount = ref.watch(downloadQueueProvider.select((s) => s.queuedCount));
|
||||
final completedCount = ref.watch(downloadQueueProvider.select((s) => s.completedCount));
|
||||
final historyItems = ref.watch(downloadHistoryProvider.select((s) => s.items));
|
||||
final historyViewMode = ref.watch(settingsProvider.select((s) => s.historyViewMode));
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
@@ -100,7 +126,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
),
|
||||
|
||||
// Pause/Resume controls - only show when multiple items or paused
|
||||
if ((queueState.isProcessing || queueState.queuedCount > 0) && (queueState.items.length > 1 || queueState.isPaused))
|
||||
if ((isProcessing || queuedCount > 0) && (queueItems.length > 1 || isPaused))
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
@@ -113,14 +139,14 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: queueState.isPaused
|
||||
color: isPaused
|
||||
? colorScheme.errorContainer
|
||||
: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(
|
||||
queueState.isPaused ? Icons.pause : Icons.downloading,
|
||||
color: queueState.isPaused
|
||||
isPaused ? Icons.pause : Icons.downloading,
|
||||
color: isPaused
|
||||
? colorScheme.onErrorContainer
|
||||
: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
@@ -129,9 +155,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
// Status text - simplified
|
||||
Expanded(
|
||||
child: Text(
|
||||
queueState.isPaused
|
||||
isPaused
|
||||
? 'Paused'
|
||||
: '${queueState.completedCount}/${queueState.items.length}',
|
||||
: '$completedCount/${queueItems.length}',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
@@ -140,7 +166,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
// Pause/Resume button
|
||||
FilledButton.tonal(
|
||||
onPressed: () => ref.read(downloadQueueProvider.notifier).togglePause(),
|
||||
child: Text(queueState.isPaused ? 'Resume' : 'Pause'),
|
||||
child: Text(isPaused ? 'Resume' : 'Pause'),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -150,34 +176,40 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
),
|
||||
|
||||
// Queue header
|
||||
if (queueState.items.isNotEmpty)
|
||||
if (queueItems.isNotEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Text('Downloading (${queueState.items.length})',
|
||||
child: Text('Downloading (${queueItems.length})',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
|
||||
),
|
||||
),
|
||||
|
||||
// Queue list
|
||||
if (queueState.items.isNotEmpty)
|
||||
// Queue list with keys for efficient updates
|
||||
if (queueItems.isNotEmpty)
|
||||
SliverList(delegate: SliverChildBuilderDelegate(
|
||||
(context, index) => _buildQueueItem(context, queueState.items[index], colorScheme),
|
||||
childCount: queueState.items.length,
|
||||
(context, index) {
|
||||
final item = queueItems[index];
|
||||
return KeyedSubtree(
|
||||
key: ValueKey(item.id),
|
||||
child: _buildQueueItem(context, item, colorScheme),
|
||||
);
|
||||
},
|
||||
childCount: queueItems.length,
|
||||
)),
|
||||
|
||||
// History section header - show count only
|
||||
if (historyState.items.isNotEmpty && queueState.items.isEmpty)
|
||||
if (historyItems.isNotEmpty && queueItems.isEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Text('${historyState.items.length} ${historyState.items.length == 1 ? 'track' : 'tracks'}',
|
||||
child: Text('${historyItems.length} ${historyItems.length == 1 ? 'track' : 'tracks'}',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||
),
|
||||
),
|
||||
|
||||
// History section header when queue has items (show "Downloaded" label)
|
||||
if (historyState.items.isNotEmpty && queueState.items.isNotEmpty)
|
||||
if (historyItems.isNotEmpty && queueItems.isNotEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
@@ -186,8 +218,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
),
|
||||
),
|
||||
|
||||
// History - Grid or List based on setting
|
||||
if (historyState.items.isNotEmpty)
|
||||
// History - Grid or List based on setting (with keys)
|
||||
if (historyItems.isNotEmpty)
|
||||
historyViewMode == 'grid'
|
||||
? SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
@@ -199,18 +231,30 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
childAspectRatio: 0.75,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) => _buildHistoryGridItem(context, historyState.items[index], colorScheme),
|
||||
childCount: historyState.items.length,
|
||||
(context, index) {
|
||||
final item = historyItems[index];
|
||||
return KeyedSubtree(
|
||||
key: ValueKey(item.id),
|
||||
child: _buildHistoryGridItem(context, item, colorScheme),
|
||||
);
|
||||
},
|
||||
childCount: historyItems.length,
|
||||
),
|
||||
),
|
||||
)
|
||||
: SliverList(delegate: SliverChildBuilderDelegate(
|
||||
(context, index) => _buildHistoryItem(context, historyState.items[index], colorScheme),
|
||||
childCount: historyState.items.length,
|
||||
(context, index) {
|
||||
final item = historyItems[index];
|
||||
return KeyedSubtree(
|
||||
key: ValueKey(item.id),
|
||||
child: _buildHistoryItem(context, item, colorScheme),
|
||||
);
|
||||
},
|
||||
childCount: historyItems.length,
|
||||
)),
|
||||
|
||||
// Empty state when both queue and history are empty
|
||||
if (queueState.items.isEmpty && historyState.items.isEmpty)
|
||||
if (queueItems.isEmpty && historyItems.isEmpty)
|
||||
SliverFillRemaining(hasScrollBody: false, child: _buildEmptyState(context, colorScheme))
|
||||
else
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 16)),
|
||||
@@ -380,6 +424,25 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
],
|
||||
);
|
||||
|
||||
case DownloadStatus.finalizing:
|
||||
// Finalizing: Show spinner with edit icon (embedding metadata)
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(strokeWidth: 3, color: colorScheme.tertiary),
|
||||
Icon(Icons.edit_note, color: colorScheme.tertiary, size: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
case DownloadStatus.completed:
|
||||
// Completed: Show play button and check icon
|
||||
final fileExists = _checkFileExists(item.filePath);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:spotiflac_android/constants/app_info.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
|
||||
class AboutPage extends StatelessWidget {
|
||||
const AboutPage({super.key});
|
||||
@@ -21,7 +23,10 @@ class AboutPage extends StatelessWidget {
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
flexibleSpace: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxHeight = 120 + topPadding;
|
||||
@@ -35,10 +40,13 @@ class AboutPage extends StatelessWidget {
|
||||
child: Container(
|
||||
alignment: Alignment.bottomLeft,
|
||||
padding: EdgeInsets.only(
|
||||
// When collapsed (expandRatio=0): left=56 to align with back button
|
||||
// When expanded (expandRatio=1): left=24 for normal padding
|
||||
left: Tween<double>(begin: 56, end: 24).evaluate(animation),
|
||||
bottom: Tween<double>(begin: 12, end: 16).evaluate(animation),
|
||||
bottom: Tween<double>(begin: 16, end: 16).evaluate(animation),
|
||||
),
|
||||
child: Text('About',
|
||||
child: Text(
|
||||
'About',
|
||||
style: TextStyle(
|
||||
fontSize: Tween<double>(begin: 20, end: 28).evaluate(animation),
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -52,73 +60,90 @@ class AboutPage extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
|
||||
// App info card
|
||||
// App header card with logo and description
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Card(
|
||||
elevation: 0,
|
||||
color: colorScheme.surfaceContainerHigh,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(children: [
|
||||
Container(
|
||||
width: 56, height: 56,
|
||||
decoration: BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(16)),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Image.asset('assets/images/logo.png', fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => Icon(Icons.music_note, size: 32, color: colorScheme.onPrimaryContainer)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(AppInfo.appName, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(color: colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(12)),
|
||||
child: Text('v${AppInfo.version}', style: Theme.of(context).textTheme.labelMedium?.copyWith(color: colorScheme.onSecondaryContainer)),
|
||||
),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
),
|
||||
child: _AppHeaderCard(),
|
||||
),
|
||||
),
|
||||
|
||||
// GitHub section
|
||||
SliverToBoxAdapter(child: _SectionHeader(title: 'GitHub')),
|
||||
SliverList(delegate: SliverChildListDelegate([
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: Icon(Icons.phone_android, color: colorScheme.onSurfaceVariant),
|
||||
title: Text('${AppInfo.appName} Mobile'),
|
||||
subtitle: Text('github.com/${AppInfo.githubRepo}'),
|
||||
trailing: const Icon(Icons.open_in_new, size: 20),
|
||||
onTap: () => _launchUrl(AppInfo.githubUrl),
|
||||
),
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: Icon(Icons.computer, color: colorScheme.onSurfaceVariant),
|
||||
title: Text('Original ${AppInfo.appName}'),
|
||||
subtitle: Text('github.com/${AppInfo.originalAuthor}/SpotiFLAC'),
|
||||
trailing: const Icon(Icons.open_in_new, size: 20),
|
||||
onTap: () => _launchUrl(AppInfo.originalGithubUrl),
|
||||
),
|
||||
])),
|
||||
|
||||
// Credits section
|
||||
SliverToBoxAdapter(child: _SectionHeader(title: 'Credits')),
|
||||
// Contributors section
|
||||
const SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: 'Contributors'),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Column(children: [
|
||||
_CreditRow(label: 'Mobile Version', value: AppInfo.mobileAuthor),
|
||||
const SizedBox(height: 12),
|
||||
_CreditRow(label: 'Original Project', value: AppInfo.originalAuthor),
|
||||
]),
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
_ContributorItem(
|
||||
name: AppInfo.mobileAuthor,
|
||||
description: 'Mobile version developer',
|
||||
githubUsername: AppInfo.mobileAuthor,
|
||||
showDivider: true,
|
||||
),
|
||||
_ContributorItem(
|
||||
name: AppInfo.originalAuthor,
|
||||
description: 'Creator of the original SpotiFLAC',
|
||||
githubUsername: AppInfo.originalAuthor,
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Links section
|
||||
const SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: 'Links'),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
SettingsItem(
|
||||
icon: Icons.phone_android,
|
||||
title: 'Mobile source code',
|
||||
subtitle: 'github.com/${AppInfo.githubRepo}',
|
||||
onTap: () => _launchUrl(AppInfo.githubUrl),
|
||||
showDivider: true,
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.computer,
|
||||
title: 'PC source code',
|
||||
subtitle: 'github.com/${AppInfo.originalAuthor}/SpotiFLAC',
|
||||
onTap: () => _launchUrl(AppInfo.originalGithubUrl),
|
||||
showDivider: true,
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.bug_report_outlined,
|
||||
title: 'Report an issue',
|
||||
subtitle: 'Report any problems you encounter',
|
||||
onTap: () => _launchUrl('${AppInfo.githubUrl}/issues/new'),
|
||||
showDivider: true,
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.lightbulb_outline,
|
||||
title: 'Feature request',
|
||||
subtitle: 'Suggest new features for the app',
|
||||
onTap: () => _launchUrl('${AppInfo.githubUrl}/issues/new'),
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// App info section
|
||||
const SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: 'App'),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
SettingsItem(
|
||||
icon: Icons.info_outline,
|
||||
title: 'Version',
|
||||
subtitle: 'v${AppInfo.version} (build ${AppInfo.buildNumber})',
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -126,42 +151,214 @@ class AboutPage extends StatelessWidget {
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Center(child: Text(AppInfo.copyright,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant))),
|
||||
child: Center(
|
||||
child: Text(
|
||||
AppInfo.copyright,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Bottom padding
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 16)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> _launchUrl(String url) async {
|
||||
final uri = Uri.parse(url);
|
||||
// Use inAppBrowserView for reliable URL opening with app chooser
|
||||
await launchUrl(uri, mode: LaunchMode.inAppBrowserView);
|
||||
}
|
||||
}
|
||||
|
||||
class _AppHeaderCard extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
final cardColor = isDark
|
||||
? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), colorScheme.surface)
|
||||
: colorScheme.surfaceContainerHighest;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: cardColor,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
children: [
|
||||
// App logo
|
||||
Container(
|
||||
width: 88,
|
||||
height: 88,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: colorScheme.primary.withValues(alpha: 0.2),
|
||||
blurRadius: 16,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: Image.asset(
|
||||
'assets/images/logo.png',
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => Icon(
|
||||
Icons.music_note,
|
||||
size: 48,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// App name
|
||||
Text(
|
||||
AppInfo.appName,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// Version badge
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.secondaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
'v${AppInfo.version}',
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: colorScheme.onSecondaryContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Description
|
||||
Text(
|
||||
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _launchUrl(String url) async {
|
||||
final uri = Uri.parse(url);
|
||||
if (await canLaunchUrl(uri)) await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
final String title;
|
||||
const _SectionHeader({required this.title});
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||
child: Text(title, style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.w600)),
|
||||
);
|
||||
}
|
||||
class _ContributorItem extends StatelessWidget {
|
||||
final String name;
|
||||
final String description;
|
||||
final String githubUsername;
|
||||
final bool showDivider;
|
||||
|
||||
const _ContributorItem({
|
||||
required this.name,
|
||||
required this.description,
|
||||
required this.githubUsername,
|
||||
this.showDivider = false,
|
||||
});
|
||||
|
||||
class _CreditRow extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
const _CreditRow({required this.label, required this.value});
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
|
||||
Text(label, style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||
Text(value, style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600)),
|
||||
]);
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: () => _launchGitHub(githubUsername),
|
||||
splashColor: colorScheme.primary.withValues(alpha: 0.12),
|
||||
highlightColor: colorScheme.primary.withValues(alpha: 0.08),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
// GitHub Avatar
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: 'https://github.com/$githubUsername.png',
|
||||
width: 40,
|
||||
height: 40,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Icons.person,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
errorWidget: (context, url, error) => Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Icons.person,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// Name and description
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
name,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
description,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// GitHub icon
|
||||
Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showDivider)
|
||||
Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
indent: 76,
|
||||
endIndent: 20,
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _launchGitHub(String username) async {
|
||||
final uri = Uri.parse('https://github.com/$username');
|
||||
await launchUrl(uri, mode: LaunchMode.inAppBrowserView);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ class AppearanceSettingsPage extends ConsumerWidget {
|
||||
alignment: Alignment.bottomLeft,
|
||||
padding: EdgeInsets.only(
|
||||
left: Tween<double>(begin: 56, end: 24).evaluate(animation),
|
||||
bottom: Tween<double>(begin: 12, end: 16).evaluate(animation),
|
||||
bottom: Tween<double>(begin: 16, end: 16).evaluate(animation),
|
||||
),
|
||||
child: Text('Appearance',
|
||||
style: TextStyle(
|
||||
@@ -144,27 +144,38 @@ class _ThemeModeChip extends StatelessWidget {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
// Unselected chips need to be darker than the card background
|
||||
// Unselected chips need contrast with card background
|
||||
// Card uses: dark = white 8% overlay, light = surfaceContainerHighest
|
||||
// So chips use: dark = white 5% overlay (darker), light = black 5% overlay (darker than card)
|
||||
final unselectedColor = isDark
|
||||
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface)
|
||||
: colorScheme.surfaceContainerHigh;
|
||||
: Color.alphaBlend(Colors.black.withValues(alpha: 0.05), colorScheme.surfaceContainerHighest);
|
||||
|
||||
return Expanded(
|
||||
child: Material(
|
||||
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
child: Column(children: [
|
||||
Icon(icon, color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant),
|
||||
const SizedBox(height: 6),
|
||||
Text(label, style: TextStyle(fontSize: 12,
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant)),
|
||||
]),
|
||||
border: !isDark && !isSelected
|
||||
? Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5), width: 1)
|
||||
: null,
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
child: Column(children: [
|
||||
Icon(icon, color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant),
|
||||
const SizedBox(height: 6),
|
||||
Text(label, style: TextStyle(fontSize: 12,
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant)),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -251,26 +262,36 @@ class _ViewModeChip extends StatelessWidget {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
// Unselected chips need contrast with card background
|
||||
final unselectedColor = isDark
|
||||
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface)
|
||||
: colorScheme.surfaceContainerHigh;
|
||||
: Color.alphaBlend(Colors.black.withValues(alpha: 0.05), colorScheme.surfaceContainerHighest);
|
||||
|
||||
return Expanded(
|
||||
child: Material(
|
||||
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
child: Column(children: [
|
||||
Icon(icon, color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant),
|
||||
const SizedBox(height: 6),
|
||||
Text(label, style: TextStyle(fontSize: 12,
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant)),
|
||||
]),
|
||||
border: !isDark && !isSelected
|
||||
? Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5), width: 1)
|
||||
: null,
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
child: Column(children: [
|
||||
Icon(icon, color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant),
|
||||
const SizedBox(height: 6),
|
||||
Text(label, style: TextStyle(fontSize: 12,
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant)),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -39,7 +39,7 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
alignment: Alignment.bottomLeft,
|
||||
padding: EdgeInsets.only(
|
||||
left: Tween<double>(begin: 56, end: 24).evaluate(animation),
|
||||
bottom: Tween<double>(begin: 12, end: 16).evaluate(animation),
|
||||
bottom: Tween<double>(begin: 16, end: 16).evaluate(animation),
|
||||
),
|
||||
child: Text('Download',
|
||||
style: TextStyle(
|
||||
|
||||
@@ -39,7 +39,7 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||
alignment: Alignment.bottomLeft,
|
||||
padding: EdgeInsets.only(
|
||||
left: Tween<double>(begin: 56, end: 24).evaluate(animation),
|
||||
bottom: Tween<double>(begin: 12, end: 16).evaluate(animation),
|
||||
bottom: Tween<double>(begin: 16, end: 16).evaluate(animation),
|
||||
),
|
||||
child: Text('Options',
|
||||
style: TextStyle(
|
||||
|
||||
@@ -14,9 +14,18 @@ class ApkDownloader {
|
||||
required String version,
|
||||
ProgressCallback? onProgress,
|
||||
}) async {
|
||||
// Validate URL for security
|
||||
final uri = Uri.tryParse(url);
|
||||
if (uri == null || uri.scheme != 'https') {
|
||||
_log.e('Refusing to download from invalid or non-HTTPS URL');
|
||||
return null;
|
||||
}
|
||||
|
||||
final client = http.Client();
|
||||
IOSink? sink;
|
||||
|
||||
try {
|
||||
final client = http.Client();
|
||||
final request = http.Request('GET', Uri.parse(url));
|
||||
final request = http.Request('GET', uri);
|
||||
final response = await client.send(request);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
@@ -41,7 +50,7 @@ class ApkDownloader {
|
||||
await file.delete();
|
||||
}
|
||||
|
||||
final sink = file.openWrite();
|
||||
sink = file.openWrite();
|
||||
int received = 0;
|
||||
|
||||
await for (final chunk in response.stream) {
|
||||
@@ -50,14 +59,15 @@ class ApkDownloader {
|
||||
onProgress?.call(received, contentLength);
|
||||
}
|
||||
|
||||
await sink.close();
|
||||
client.close();
|
||||
|
||||
await sink.flush();
|
||||
_log.i('Downloaded to: $filePath');
|
||||
return filePath;
|
||||
} catch (e) {
|
||||
_log.e('Error: $e');
|
||||
return null;
|
||||
} finally {
|
||||
await sink?.close();
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -98,6 +98,49 @@ class NotificationService {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> showDownloadFinalizing({
|
||||
required String trackName,
|
||||
required String artistName,
|
||||
}) async {
|
||||
if (!_isInitialized) await initialize();
|
||||
|
||||
final androidDetails = AndroidNotificationDetails(
|
||||
channelId,
|
||||
channelName,
|
||||
channelDescription: channelDescription,
|
||||
importance: Importance.low,
|
||||
priority: Priority.low,
|
||||
showProgress: true,
|
||||
maxProgress: 100,
|
||||
progress: 100,
|
||||
indeterminate: false,
|
||||
ongoing: true,
|
||||
autoCancel: false,
|
||||
playSound: false,
|
||||
enableVibration: false,
|
||||
onlyAlertOnce: true,
|
||||
icon: '@mipmap/ic_launcher',
|
||||
);
|
||||
|
||||
const iosDetails = DarwinNotificationDetails(
|
||||
presentAlert: false,
|
||||
presentBadge: false,
|
||||
presentSound: false,
|
||||
);
|
||||
|
||||
final details = NotificationDetails(
|
||||
android: androidDetails,
|
||||
iOS: iosDetails,
|
||||
);
|
||||
|
||||
await _notifications.show(
|
||||
downloadProgressId,
|
||||
'Finalizing $trackName',
|
||||
'$artistName • Embedding metadata...',
|
||||
details,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> showDownloadComplete({
|
||||
required String trackName,
|
||||
required String artistName,
|
||||
|
||||
@@ -26,6 +26,16 @@ class PlatformBridge {
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Search Spotify for tracks and artists
|
||||
static Future<Map<String, dynamic>> searchSpotifyAll(String query, {int trackLimit = 15, int artistLimit = 3}) async {
|
||||
final result = await _channel.invokeMethod('searchSpotifyAll', {
|
||||
'query': query,
|
||||
'track_limit': trackLimit,
|
||||
'artist_limit': artistLimit,
|
||||
});
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Check track availability on streaming services
|
||||
static Future<Map<String, dynamic>> checkAvailability(String spotifyId, String isrc) async {
|
||||
final result = await _channel.invokeMethod('checkAvailability', {
|
||||
|
||||
@@ -92,6 +92,12 @@ class UpdateChecker {
|
||||
final name = (asset['name'] as String? ?? '').toLowerCase();
|
||||
if (name.endsWith('.apk')) {
|
||||
final downloadUrl = asset['browser_download_url'] as String?;
|
||||
// Only accept HTTPS URLs for security
|
||||
final uri = downloadUrl != null ? Uri.tryParse(downloadUrl) : null;
|
||||
if (uri == null || uri.scheme != 'https') {
|
||||
_log.w('Skipping non-HTTPS APK URL: $downloadUrl');
|
||||
continue;
|
||||
}
|
||||
if (name.contains('arm64') || name.contains('v8a')) {
|
||||
arm64Url = downloadUrl;
|
||||
} else if (name.contains('arm32') || name.contains('v7a') || name.contains('armeabi')) {
|
||||
|
||||
@@ -27,7 +27,6 @@ class DynamicColorWrapper extends ConsumerWidget {
|
||||
// Use dynamic colors from wallpaper (Android 12+)
|
||||
lightScheme = lightDynamic;
|
||||
darkScheme = darkDynamic;
|
||||
debugPrint('Using dynamic color from wallpaper');
|
||||
} else {
|
||||
// Fallback to seed color
|
||||
final seedColor = themeSettings.seedColor;
|
||||
@@ -39,7 +38,6 @@ class DynamicColorWrapper extends ConsumerWidget {
|
||||
seedColor: seedColor,
|
||||
brightness: Brightness.dark,
|
||||
);
|
||||
debugPrint('Using fallback seed color: ${seedColor.toARGB32().toRadixString(16)}');
|
||||
}
|
||||
|
||||
// Build themes
|
||||
|
||||
@@ -20,9 +20,10 @@ class SettingsGroup extends StatelessWidget {
|
||||
// Use a more contrasting color for cards
|
||||
// In dark mode with dynamic color, surfaceContainerHighest can be too similar to surface
|
||||
// So we add a slight white overlay to make it more visible
|
||||
// In light mode with dynamic color, we add a slight black overlay for the same reason
|
||||
final cardColor = isDark
|
||||
? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), colorScheme.surface)
|
||||
: colorScheme.surfaceContainerHighest;
|
||||
: Color.alphaBlend(Colors.black.withValues(alpha: 0.04), colorScheme.surface);
|
||||
|
||||
return Container(
|
||||
margin: margin ?? const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
|
||||
@@ -69,6 +69,9 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
||||
);
|
||||
|
||||
if (filePath != null) {
|
||||
// Cancel progress notification first
|
||||
await notificationService.cancelUpdateNotification();
|
||||
|
||||
await notificationService.showUpdateDownloadComplete(
|
||||
version: widget.updateInfo.version,
|
||||
);
|
||||
@@ -80,6 +83,9 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
||||
// Open APK for installation
|
||||
await ApkDownloader.installApk(filePath);
|
||||
} else {
|
||||
// Cancel progress notification first
|
||||
await notificationService.cancelUpdateNotification();
|
||||
|
||||
await notificationService.showUpdateDownloadFailed();
|
||||
|
||||
if (mounted) {
|
||||
@@ -98,129 +104,202 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return AlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
Icon(Icons.system_update, color: colorScheme.primary),
|
||||
const SizedBox(width: 12),
|
||||
const Text('Update Available'),
|
||||
],
|
||||
),
|
||||
content: SizedBox(
|
||||
width: double.maxFinite,
|
||||
return Dialog(
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Version info
|
||||
// Header with icon
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Icon(Icons.system_update_rounded, color: colorScheme.onPrimaryContainer, size: 28),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Update Available', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 2),
|
||||
Text('A new version is ready', style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Version badge
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: isDark
|
||||
? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), colorScheme.surface)
|
||||
: Color.alphaBlend(Colors.black.withValues(alpha: 0.04), colorScheme.surface),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'v${AppInfo.version}',
|
||||
style: TextStyle(color: colorScheme.onPrimaryContainer),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Icon(Icons.arrow_forward, size: 16, color: colorScheme.onPrimaryContainer),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'v${widget.updateInfo.version}',
|
||||
style: TextStyle(
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
_VersionChip(version: AppInfo.version, label: 'Current', colorScheme: colorScheme),
|
||||
const SizedBox(width: 12),
|
||||
Icon(Icons.arrow_forward_rounded, size: 20, color: colorScheme.primary),
|
||||
const SizedBox(width: 12),
|
||||
_VersionChip(version: widget.updateInfo.version, label: 'New', colorScheme: colorScheme, isNew: true),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Changelog header
|
||||
Text(
|
||||
'What\'s New:',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Changelog content (scrollable) - hide when downloading
|
||||
if (!_isDownloading)
|
||||
Flexible(
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxHeight: 200),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Text(
|
||||
_formatChangelog(widget.updateInfo.changelog),
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
// Download progress (when downloading)
|
||||
if (_isDownloading) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark
|
||||
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface)
|
||||
: Color.alphaBlend(Colors.black.withValues(alpha: 0.03), colorScheme.surface),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 20, height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.primary),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text('Downloading...', style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: LinearProgressIndicator(
|
||||
value: _progress,
|
||||
minHeight: 6,
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(_statusText, style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||
Text('${(_progress * 100).toInt()}%', style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.primary, fontWeight: FontWeight.w600)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
// Changelog section
|
||||
Text("What's New", style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
constraints: const BoxConstraints(maxHeight: 180),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark
|
||||
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface)
|
||||
: Color.alphaBlend(Colors.black.withValues(alpha: 0.03), colorScheme.surface),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
_formatChangelog(widget.updateInfo.changelog),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(height: 1.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Download progress
|
||||
if (_isDownloading) ...[
|
||||
const SizedBox(height: 8),
|
||||
LinearProgressIndicator(value: _progress),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_statusText,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Action buttons
|
||||
if (_isDownloading)
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
)
|
||||
else
|
||||
Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: _downloadAndInstall,
|
||||
icon: const Icon(Icons.download_rounded, size: 20),
|
||||
label: const Text('Download & Install'),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextButton(
|
||||
onPressed: () {
|
||||
widget.onDisableUpdates();
|
||||
Navigator.pop(context);
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
child: Text("Don't remind", style: TextStyle(color: colorScheme.onSurfaceVariant)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () {
|
||||
widget.onDismiss();
|
||||
Navigator.pop(context);
|
||||
},
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
child: const Text('Later'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: _isDownloading
|
||||
? [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
]
|
||||
: [
|
||||
// Don't remind again button
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
widget.onDisableUpdates();
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text(
|
||||
'Don\'t remind',
|
||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
// Later button
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
widget.onDismiss();
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Text('Later'),
|
||||
),
|
||||
// Download button
|
||||
FilledButton(
|
||||
onPressed: _downloadAndInstall,
|
||||
child: const Text('Install'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Format changelog - clean up markdown and extract relevant content
|
||||
String _formatChangelog(String changelog) {
|
||||
// Try to extract just the changelog section (between "What's New" and "Downloads" or "---")
|
||||
var content = changelog;
|
||||
|
||||
// Find content after "What's New" header
|
||||
@@ -238,19 +317,18 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
||||
// Process line by line for better formatting
|
||||
final lines = content.split('\n');
|
||||
final formattedLines = <String>[];
|
||||
String? currentSection;
|
||||
|
||||
for (var line in lines) {
|
||||
line = line.trim();
|
||||
if (line.isEmpty) continue;
|
||||
|
||||
// Check if it's a section header (### Added, ### Fixed, etc.)
|
||||
// Check if it's a section header
|
||||
final sectionMatch = RegExp(r'^#{1,3}\s*(.+)$').firstMatch(line);
|
||||
if (sectionMatch != null) {
|
||||
currentSection = sectionMatch.group(1)?.trim();
|
||||
if (currentSection != null && currentSection.isNotEmpty) {
|
||||
final section = sectionMatch.group(1)?.trim();
|
||||
if (section != null && section.isNotEmpty) {
|
||||
if (formattedLines.isNotEmpty) formattedLines.add('');
|
||||
formattedLines.add('$currentSection:');
|
||||
formattedLines.add(section);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -259,36 +337,23 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
||||
final listMatch = RegExp(r'^[-*]\s+(.+)$').firstMatch(line);
|
||||
if (listMatch != null) {
|
||||
var itemText = listMatch.group(1) ?? '';
|
||||
// Remove bold markdown
|
||||
itemText = itemText.replaceAllMapped(
|
||||
RegExp(r'\*\*([^*]+)\*\*'),
|
||||
(m) => m.group(1) ?? ''
|
||||
);
|
||||
// Remove code markdown
|
||||
itemText = itemText.replaceAllMapped(
|
||||
RegExp(r'`([^`]+)`'),
|
||||
(m) => m.group(1) ?? ''
|
||||
);
|
||||
itemText = itemText.replaceAllMapped(RegExp(r'\*\*([^*]+)\*\*'), (m) => m.group(1) ?? '');
|
||||
itemText = itemText.replaceAllMapped(RegExp(r'`([^`]+)`'), (m) => m.group(1) ?? '');
|
||||
formattedLines.add('• $itemText');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if it's a sub-item (indented list)
|
||||
// Check if it's a sub-item
|
||||
final subListMatch = RegExp(r'^\s+[-*]\s+(.+)$').firstMatch(line);
|
||||
if (subListMatch != null) {
|
||||
var itemText = subListMatch.group(1) ?? '';
|
||||
itemText = itemText.replaceAllMapped(
|
||||
RegExp(r'\*\*([^*]+)\*\*'),
|
||||
(m) => m.group(1) ?? ''
|
||||
);
|
||||
itemText = itemText.replaceAllMapped(RegExp(r'\*\*([^*]+)\*\*'), (m) => m.group(1) ?? '');
|
||||
formattedLines.add(' - $itemText');
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
var formatted = formattedLines.join('\n').trim();
|
||||
|
||||
// Limit length
|
||||
if (formatted.length > 2000) {
|
||||
formatted = '${formatted.substring(0, 2000)}...';
|
||||
}
|
||||
@@ -297,6 +362,44 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
||||
}
|
||||
}
|
||||
|
||||
class _VersionChip extends StatelessWidget {
|
||||
final String version;
|
||||
final String label;
|
||||
final ColorScheme colorScheme;
|
||||
final bool isNew;
|
||||
|
||||
const _VersionChip({
|
||||
required this.version,
|
||||
required this.label,
|
||||
required this.colorScheme,
|
||||
this.isNew = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
Text(label, style: Theme.of(context).textTheme.labelSmall?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: isNew ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
'v$version',
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color: isNew ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
|
||||
fontWeight: isNew ? FontWeight.bold : FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Show update dialog
|
||||
Future<void> showUpdateDialog(
|
||||
BuildContext context, {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: spotiflac_android
|
||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
||||
publish_to: 'none'
|
||||
version: 1.6.1+26
|
||||
version: 2.0.0+30
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.0
|
||||
|
||||