mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-15 07:30:58 +02:00
feat: quality picker with track info, update dialog redesign, finalizing notification fix
- Quality picker now shows track name, artist, and cover - Tap to expand long track titles (icon only shows when truncated) - Ripple effect follows rounded corners including drag handle - Update dialog redesigned with Material Expressive 3 style - Fixed update notification stuck at 100% after download complete - Ask before download now enabled by default - Finalizing notification for multi-progress polling
This commit is contained in:
@@ -17,14 +17,26 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
outputs:
|
outputs:
|
||||||
version: ${{ steps.version.outputs.version }}
|
version: ${{ steps.version.outputs.version }}
|
||||||
|
is_prerelease: ${{ steps.version.outputs.is_prerelease }}
|
||||||
steps:
|
steps:
|
||||||
- name: Get version
|
- name: Get version
|
||||||
id: version
|
id: version
|
||||||
run: |
|
run: |
|
||||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||||
echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT
|
VERSION="${{ github.event.inputs.version }}"
|
||||||
else
|
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
|
fi
|
||||||
|
|
||||||
# Android and iOS build in PARALLEL
|
# Android and iOS build in PARALLEL
|
||||||
@@ -316,6 +328,6 @@ jobs:
|
|||||||
body_path: /tmp/release_body.txt
|
body_path: /tmp/release_body.txt
|
||||||
files: ./release/*
|
files: ./release/*
|
||||||
draft: false
|
draft: false
|
||||||
prerelease: false
|
prerelease: ${{ needs.get-version.outputs.is_prerelease == 'true' }}
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
@@ -1,5 +1,90 @@
|
|||||||
# Changelog
|
# 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
|
## [1.6.2] - 2026-01-02
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -19,7 +19,8 @@
|
|||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:requestLegacyExternalStorage="true"
|
android:requestLegacyExternalStorage="true"
|
||||||
android:usesCleartextTraffic="true">
|
android:usesCleartextTraffic="true"
|
||||||
|
android:enableOnBackInvokedCallback="true">
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
|
|||||||
@@ -50,6 +50,15 @@ class MainActivity: FlutterActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
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" -> {
|
"checkAvailability" -> {
|
||||||
val spotifyId = call.argument<String>("spotify_id") ?: ""
|
val spotifyId = call.argument<String>("spotify_id") ?: ""
|
||||||
val isrc = call.argument<String>("isrc") ?: ""
|
val isrc = call.argument<String>("isrc") ?: ""
|
||||||
|
|||||||
@@ -318,6 +318,13 @@ func downloadFromAmazon(req DownloadRequest) (string, error) {
|
|||||||
return "", fmt.Errorf("download failed: %w", err)
|
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)
|
// Log track info from DoubleDouble (for debugging)
|
||||||
if trackName != "" && artistName != "" {
|
if trackName != "" && artistName != "" {
|
||||||
fmt.Printf("[Amazon] DoubleDouble returned: %s - %s\n", artistName, trackName)
|
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
|
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
|
// CheckAvailability checks track availability on streaming services
|
||||||
// Returns JSON with availability info for Tidal, Qobuz, Amazon
|
// Returns JSON with availability info for Tidal, Qobuz, Amazon
|
||||||
func CheckAvailability(spotifyID, isrc string) (string, error) {
|
func CheckAvailability(spotifyID, isrc string) (string, error) {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ type DownloadProgress struct {
|
|||||||
BytesTotal int64 `json:"bytes_total"`
|
BytesTotal int64 `json:"bytes_total"`
|
||||||
BytesReceived int64 `json:"bytes_received"`
|
BytesReceived int64 `json:"bytes_received"`
|
||||||
IsDownloading bool `json:"is_downloading"`
|
IsDownloading bool `json:"is_downloading"`
|
||||||
|
Status string `json:"status"` // "downloading", "finalizing", "completed"
|
||||||
}
|
}
|
||||||
|
|
||||||
// ItemProgress represents progress for a single download item
|
// ItemProgress represents progress for a single download item
|
||||||
@@ -22,6 +23,7 @@ type ItemProgress struct {
|
|||||||
BytesReceived int64 `json:"bytes_received"`
|
BytesReceived int64 `json:"bytes_received"`
|
||||||
Progress float64 `json:"progress"` // 0.0 to 1.0
|
Progress float64 `json:"progress"` // 0.0 to 1.0
|
||||||
IsDownloading bool `json:"is_downloading"`
|
IsDownloading bool `json:"is_downloading"`
|
||||||
|
Status string `json:"status"` // "downloading", "finalizing", "completed"
|
||||||
}
|
}
|
||||||
|
|
||||||
// MultiProgress holds progress for multiple concurrent downloads
|
// MultiProgress holds progress for multiple concurrent downloads
|
||||||
@@ -82,6 +84,7 @@ func StartItemProgress(itemID string) {
|
|||||||
BytesReceived: 0,
|
BytesReceived: 0,
|
||||||
Progress: 0,
|
Progress: 0,
|
||||||
IsDownloading: true,
|
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
|
// RemoveItemProgress removes progress tracking for an item
|
||||||
func RemoveItemProgress(itemID string) {
|
func RemoveItemProgress(itemID string) {
|
||||||
multiMu.Lock()
|
multiMu.Lock()
|
||||||
@@ -161,6 +204,7 @@ func SetCurrentFile(filename string) {
|
|||||||
currentProgress.Progress = 0
|
currentProgress.Progress = 0
|
||||||
currentProgress.CurrentFile = filename
|
currentProgress.CurrentFile = filename
|
||||||
currentProgress.IsDownloading = true
|
currentProgress.IsDownloading = true
|
||||||
|
currentProgress.Status = "downloading"
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResetProgress resets the download progress
|
// ResetProgress resets the download progress
|
||||||
|
|||||||
@@ -385,6 +385,13 @@ func downloadFromQobuz(req DownloadRequest) (string, error) {
|
|||||||
return "", fmt.Errorf("download failed: %w", err)
|
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
|
// Embed metadata
|
||||||
metadata := Metadata{
|
metadata := Metadata{
|
||||||
Title: req.TrackName,
|
Title: req.TrackName,
|
||||||
|
|||||||
+171
-4
@@ -24,10 +24,25 @@ const (
|
|||||||
artistBaseURL = "https://api.spotify.com/v1/artists/%s"
|
artistBaseURL = "https://api.spotify.com/v1/artists/%s"
|
||||||
artistAlbumsURL = "https://api.spotify.com/v1/artists/%s/albums"
|
artistAlbumsURL = "https://api.spotify.com/v1/artists/%s/albums"
|
||||||
searchBaseURL = "https://api.spotify.com/v1/search"
|
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")
|
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
|
// SpotifyMetadataClient handles Spotify API interactions
|
||||||
type SpotifyMetadataClient struct {
|
type SpotifyMetadataClient struct {
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
@@ -39,6 +54,12 @@ type SpotifyMetadataClient struct {
|
|||||||
rng *rand.Rand
|
rng *rand.Rand
|
||||||
rngMu sync.Mutex
|
rngMu sync.Mutex
|
||||||
userAgent string
|
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
|
// NewSpotifyMetadataClient creates a new Spotify client
|
||||||
@@ -65,6 +86,9 @@ func NewSpotifyMetadataClient() *SpotifyMetadataClient {
|
|||||||
clientID: clientID,
|
clientID: clientID,
|
||||||
clientSecret: clientSecret,
|
clientSecret: clientSecret,
|
||||||
rng: rand.New(src),
|
rng: rand.New(src),
|
||||||
|
artistCache: make(map[string]*cacheEntry),
|
||||||
|
searchCache: make(map[string]*cacheEntry),
|
||||||
|
albumCache: make(map[string]*cacheEntry),
|
||||||
}
|
}
|
||||||
c.userAgent = c.randomUserAgent()
|
c.userAgent = c.randomUserAgent()
|
||||||
return c
|
return c
|
||||||
@@ -176,6 +200,21 @@ type SearchResult struct {
|
|||||||
Total int `json:"total"`
|
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 spotifyURI struct {
|
||||||
Type string
|
Type string
|
||||||
ID string
|
ID string
|
||||||
@@ -299,6 +338,98 @@ func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string,
|
|||||||
return result, nil
|
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) {
|
func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID, token string) (*TrackResponse, error) {
|
||||||
var data trackFull
|
var data trackFull
|
||||||
if err := c.getJSON(ctx, fmt.Sprintf(trackBaseURL, trackID), token, &data); err != nil {
|
if err := c.getJSON(ctx, fmt.Sprintf(trackBaseURL, trackID), token, &data); err != nil {
|
||||||
@@ -325,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) {
|
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 {
|
var data struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
ReleaseDate string `json:"release_date"`
|
ReleaseDate string `json:"release_date"`
|
||||||
@@ -380,10 +519,20 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return &AlbumResponsePayload{
|
result := &AlbumResponsePayload{
|
||||||
AlbumInfo: info,
|
AlbumInfo: info,
|
||||||
TrackList: tracks,
|
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) {
|
func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, token string) (*PlaylistResponsePayload, error) {
|
||||||
@@ -442,6 +591,14 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token string) (*ArtistResponsePayload, error) {
|
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
|
// Fetch artist info
|
||||||
var artistData struct {
|
var artistData struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
@@ -517,10 +674,20 @@ func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return &ArtistResponsePayload{
|
result := &ArtistResponsePayload{
|
||||||
ArtistInfo: artistInfo,
|
ArtistInfo: artistInfo,
|
||||||
Albums: albums,
|
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 {
|
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)
|
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
|
// Check if file was saved as M4A (DASH stream) instead of FLAC
|
||||||
// downloadFromManifest saves DASH streams as .m4a
|
// downloadFromManifest saves DASH streams as .m4a
|
||||||
actualOutputPath := outputPath
|
actualOutputPath := outputPath
|
||||||
|
|||||||
@@ -66,6 +66,15 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
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":
|
case "checkAvailability":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let spotifyId = args["spotify_id"] as! String
|
let spotifyId = args["spotify_id"] as! String
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
/// App version and info constants
|
/// App version and info constants
|
||||||
/// Update version here only - all other files will reference this
|
/// Update version here only - all other files will reference this
|
||||||
class AppInfo {
|
class AppInfo {
|
||||||
static const String version = '1.6.2';
|
static const String version = '2.0.0';
|
||||||
static const String buildNumber = '27';
|
static const String buildNumber = '30';
|
||||||
static const String fullVersion = '$version+$buildNumber';
|
static const String fullVersion = '$version+$buildNumber';
|
||||||
|
|
||||||
|
|
||||||
static const String appName = 'SpotiFLAC';
|
static const String appName = 'SpotiFLAC';
|
||||||
static const String copyright = '© 2026 SpotiFLAC';
|
static const String copyright = '© 2026 SpotiFLAC';
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ part 'download_item.g.dart';
|
|||||||
enum DownloadStatus {
|
enum DownloadStatus {
|
||||||
queued,
|
queued,
|
||||||
downloading,
|
downloading,
|
||||||
|
finalizing, // Embedding metadata, cover, lyrics
|
||||||
completed,
|
completed,
|
||||||
failed,
|
failed,
|
||||||
skipped,
|
skipped,
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
|
|||||||
const _$DownloadStatusEnumMap = {
|
const _$DownloadStatusEnumMap = {
|
||||||
DownloadStatus.queued: 'queued',
|
DownloadStatus.queued: 'queued',
|
||||||
DownloadStatus.downloading: 'downloading',
|
DownloadStatus.downloading: 'downloading',
|
||||||
|
DownloadStatus.finalizing: 'finalizing',
|
||||||
DownloadStatus.completed: 'completed',
|
DownloadStatus.completed: 'completed',
|
||||||
DownloadStatus.failed: 'failed',
|
DownloadStatus.failed: 'failed',
|
||||||
DownloadStatus.skipped: 'skipped',
|
DownloadStatus.skipped: 'skipped',
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ class AppSettings {
|
|||||||
this.folderOrganization = 'none', // Default: no folder organization
|
this.folderOrganization = 'none', // Default: no folder organization
|
||||||
this.convertLyricsToRomaji = false, // Default: keep original Japanese
|
this.convertLyricsToRomaji = false, // Default: keep original Japanese
|
||||||
this.historyViewMode = 'grid', // Default: grid view
|
this.historyViewMode = 'grid', // Default: grid view
|
||||||
this.askQualityBeforeDownload = false, // Default: use preset quality
|
this.askQualityBeforeDownload = true, // Default: ask quality before download
|
||||||
});
|
});
|
||||||
|
|
||||||
AppSettings copyWith({
|
AppSettings copyWith({
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
|||||||
folderOrganization: json['folderOrganization'] as String? ?? 'none',
|
folderOrganization: json['folderOrganization'] as String? ?? 'none',
|
||||||
convertLyricsToRomaji: json['convertLyricsToRomaji'] as bool? ?? false,
|
convertLyricsToRomaji: json['convertLyricsToRomaji'] as bool? ?? false,
|
||||||
historyViewMode: json['historyViewMode'] as String? ?? 'grid',
|
historyViewMode: json['historyViewMode'] as String? ?? 'grid',
|
||||||
askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? false,
|
askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true,
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||||
|
|||||||
@@ -99,8 +99,16 @@ class DownloadHistoryItem {
|
|||||||
// Download History State
|
// Download History State
|
||||||
class DownloadHistoryState {
|
class DownloadHistoryState {
|
||||||
final List<DownloadHistoryItem> items;
|
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}) {
|
DownloadHistoryState copyWith({List<DownloadHistoryItem>? items}) {
|
||||||
return DownloadHistoryState(items: items ?? this.items);
|
return DownloadHistoryState(items: items ?? this.items);
|
||||||
@@ -116,7 +124,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
|||||||
DownloadHistoryState build() {
|
DownloadHistoryState build() {
|
||||||
// Load history from storage on init
|
// Load history from storage on init
|
||||||
_loadFromStorageSync();
|
_loadFromStorageSync();
|
||||||
return const DownloadHistoryState();
|
return DownloadHistoryState();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Synchronously schedule load - ensures it runs before any UI renders
|
/// Synchronously schedule load - ensures it runs before any UI renders
|
||||||
@@ -173,8 +181,22 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
|||||||
_saveToStorage();
|
_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() {
|
void clearHistory() {
|
||||||
state = const DownloadHistoryState();
|
state = DownloadHistoryState();
|
||||||
_saveToStorage();
|
_saveToStorage();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -340,6 +362,22 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
final bytesReceived = progress['bytes_received'] as int? ?? 0;
|
final bytesReceived = progress['bytes_received'] as int? ?? 0;
|
||||||
final bytesTotal = progress['bytes_total'] as int? ?? 0;
|
final bytesTotal = progress['bytes_total'] as int? ?? 0;
|
||||||
final isDownloading = progress['is_downloading'] as bool? ?? false;
|
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) {
|
if (isDownloading && bytesTotal > 0) {
|
||||||
final percentage = bytesReceived / bytesTotal;
|
final percentage = bytesReceived / bytesTotal;
|
||||||
@@ -392,6 +430,22 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
final bytesReceived = itemProgress['bytes_received'] as int? ?? 0;
|
final bytesReceived = itemProgress['bytes_received'] as int? ?? 0;
|
||||||
final bytesTotal = itemProgress['bytes_total'] as int? ?? 0;
|
final bytesTotal = itemProgress['bytes_total'] as int? ?? 0;
|
||||||
final isDownloading = itemProgress['is_downloading'] as bool? ?? false;
|
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) {
|
if (isDownloading && bytesTotal > 0) {
|
||||||
final percentage = bytesReceived / bytesTotal;
|
final percentage = bytesReceived / bytesTotal;
|
||||||
@@ -412,7 +466,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
final bytesTotal = firstProgress['bytes_total'] as int? ?? 0;
|
final bytesTotal = firstProgress['bytes_total'] as int? ?? 0;
|
||||||
|
|
||||||
// Find the item to get track info
|
// 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) {
|
if (downloadingItems.isNotEmpty) {
|
||||||
_notificationService.showDownloadProgress(
|
_notificationService.showDownloadProgress(
|
||||||
trackName: '${downloadingItems.length} downloads',
|
trackName: '${downloadingItems.length} downloads',
|
||||||
|
|||||||
@@ -6,54 +6,59 @@ class TrackState {
|
|||||||
final List<Track> tracks;
|
final List<Track> tracks;
|
||||||
final bool isLoading;
|
final bool isLoading;
|
||||||
final String? error;
|
final String? error;
|
||||||
|
final String? albumId;
|
||||||
final String? albumName;
|
final String? albumName;
|
||||||
final String? playlistName;
|
final String? playlistName;
|
||||||
|
final String? artistId;
|
||||||
final String? artistName;
|
final String? artistName;
|
||||||
final String? coverUrl;
|
final String? coverUrl;
|
||||||
final List<ArtistAlbum>? artistAlbums; // For artist page
|
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
|
final bool hasSearchText; // For back button handling
|
||||||
|
|
||||||
const TrackState({
|
const TrackState({
|
||||||
this.tracks = const [],
|
this.tracks = const [],
|
||||||
this.isLoading = false,
|
this.isLoading = false,
|
||||||
this.error,
|
this.error,
|
||||||
|
this.albumId,
|
||||||
this.albumName,
|
this.albumName,
|
||||||
this.playlistName,
|
this.playlistName,
|
||||||
|
this.artistId,
|
||||||
this.artistName,
|
this.artistName,
|
||||||
this.coverUrl,
|
this.coverUrl,
|
||||||
this.artistAlbums,
|
this.artistAlbums,
|
||||||
this.previousState,
|
this.searchArtists,
|
||||||
this.hasSearchText = false,
|
this.hasSearchText = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
bool get canGoBack => previousState != null;
|
bool get hasContent => tracks.isNotEmpty || artistAlbums != null || (searchArtists != null && searchArtists!.isNotEmpty);
|
||||||
|
|
||||||
bool get hasContent => tracks.isNotEmpty || artistAlbums != null;
|
|
||||||
|
|
||||||
TrackState copyWith({
|
TrackState copyWith({
|
||||||
List<Track>? tracks,
|
List<Track>? tracks,
|
||||||
bool? isLoading,
|
bool? isLoading,
|
||||||
String? error,
|
String? error,
|
||||||
|
String? albumId,
|
||||||
String? albumName,
|
String? albumName,
|
||||||
String? playlistName,
|
String? playlistName,
|
||||||
|
String? artistId,
|
||||||
String? artistName,
|
String? artistName,
|
||||||
String? coverUrl,
|
String? coverUrl,
|
||||||
List<ArtistAlbum>? artistAlbums,
|
List<ArtistAlbum>? artistAlbums,
|
||||||
TrackState? previousState,
|
List<SearchArtist>? searchArtists,
|
||||||
bool clearPreviousState = false,
|
|
||||||
bool? hasSearchText,
|
bool? hasSearchText,
|
||||||
}) {
|
}) {
|
||||||
return TrackState(
|
return TrackState(
|
||||||
tracks: tracks ?? this.tracks,
|
tracks: tracks ?? this.tracks,
|
||||||
isLoading: isLoading ?? this.isLoading,
|
isLoading: isLoading ?? this.isLoading,
|
||||||
error: error,
|
error: error,
|
||||||
|
albumId: albumId ?? this.albumId,
|
||||||
albumName: albumName ?? this.albumName,
|
albumName: albumName ?? this.albumName,
|
||||||
playlistName: playlistName ?? this.playlistName,
|
playlistName: playlistName ?? this.playlistName,
|
||||||
|
artistId: artistId ?? this.artistId,
|
||||||
artistName: artistName ?? this.artistName,
|
artistName: artistName ?? this.artistName,
|
||||||
coverUrl: coverUrl ?? this.coverUrl,
|
coverUrl: coverUrl ?? this.coverUrl,
|
||||||
artistAlbums: artistAlbums ?? this.artistAlbums,
|
artistAlbums: artistAlbums ?? this.artistAlbums,
|
||||||
previousState: clearPreviousState ? null : (previousState ?? this.previousState),
|
searchArtists: searchArtists ?? this.searchArtists,
|
||||||
hasSearchText: hasSearchText ?? this.hasSearchText,
|
hasSearchText: hasSearchText ?? this.hasSearchText,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -80,6 +85,23 @@ 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> {
|
class TrackNotifier extends Notifier<TrackState> {
|
||||||
/// Request ID to track and cancel outdated requests
|
/// Request ID to track and cancel outdated requests
|
||||||
int _currentRequestId = 0;
|
int _currentRequestId = 0;
|
||||||
@@ -96,18 +118,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
// Increment request ID to cancel any pending requests
|
// Increment request ID to cancel any pending requests
|
||||||
final requestId = ++_currentRequestId;
|
final requestId = ++_currentRequestId;
|
||||||
|
|
||||||
// Save current state for back navigation (only if we have content or it's empty)
|
state = const TrackState(isLoading: true);
|
||||||
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
|
|
||||||
|
|
||||||
state = TrackState(isLoading: true, previousState: savedState);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final parsed = await PlatformBridge.parseSpotifyUrl(url);
|
final parsed = await PlatformBridge.parseSpotifyUrl(url);
|
||||||
@@ -125,7 +136,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
tracks: [track],
|
tracks: [track],
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
coverUrl: track.coverUrl,
|
coverUrl: track.coverUrl,
|
||||||
previousState: savedState,
|
|
||||||
);
|
);
|
||||||
} else if (type == 'album') {
|
} else if (type == 'album') {
|
||||||
final albumInfo = metadata['album_info'] as Map<String, dynamic>;
|
final albumInfo = metadata['album_info'] as Map<String, dynamic>;
|
||||||
@@ -134,9 +144,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
state = TrackState(
|
state = TrackState(
|
||||||
tracks: tracks,
|
tracks: tracks,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
albumId: parsed['id'] as String?,
|
||||||
albumName: albumInfo['name'] as String?,
|
albumName: albumInfo['name'] as String?,
|
||||||
coverUrl: albumInfo['images'] as String?,
|
coverUrl: albumInfo['images'] as String?,
|
||||||
previousState: savedState,
|
|
||||||
);
|
);
|
||||||
} else if (type == 'playlist') {
|
} else if (type == 'playlist') {
|
||||||
final playlistInfo = metadata['playlist_info'] as Map<String, dynamic>;
|
final playlistInfo = metadata['playlist_info'] as Map<String, dynamic>;
|
||||||
@@ -148,7 +158,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
playlistName: owner?['name'] as String?,
|
playlistName: owner?['name'] as String?,
|
||||||
coverUrl: owner?['images'] as String?,
|
coverUrl: owner?['images'] as String?,
|
||||||
previousState: savedState,
|
|
||||||
);
|
);
|
||||||
} else if (type == 'artist') {
|
} else if (type == 'artist') {
|
||||||
final artistInfo = metadata['artist_info'] as Map<String, dynamic>;
|
final artistInfo = metadata['artist_info'] as Map<String, dynamic>;
|
||||||
@@ -157,15 +166,15 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
state = TrackState(
|
state = TrackState(
|
||||||
tracks: [], // No tracks for artist view
|
tracks: [], // No tracks for artist view
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
artistId: artistInfo['id'] as String?,
|
||||||
artistName: artistInfo['name'] as String?,
|
artistName: artistInfo['name'] as String?,
|
||||||
coverUrl: artistInfo['images'] as String?,
|
coverUrl: artistInfo['images'] as String?,
|
||||||
artistAlbums: albums,
|
artistAlbums: albums,
|
||||||
previousState: savedState,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!_isRequestValid(requestId)) return; // Request cancelled
|
if (!_isRequestValid(requestId)) return; // Request cancelled
|
||||||
state = TrackState(isLoading: false, error: e.toString(), previousState: savedState);
|
state = TrackState(isLoading: false, error: e.toString());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,33 +182,26 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
// Increment request ID to cancel any pending requests
|
// Increment request ID to cancel any pending requests
|
||||||
final requestId = ++_currentRequestId;
|
final requestId = ++_currentRequestId;
|
||||||
|
|
||||||
// Save current state for back navigation
|
state = const TrackState(isLoading: true);
|
||||||
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();
|
|
||||||
|
|
||||||
state = TrackState(isLoading: true, previousState: savedState);
|
|
||||||
|
|
||||||
try {
|
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
|
if (!_isRequestValid(requestId)) return; // Request cancelled
|
||||||
|
|
||||||
final trackList = results['tracks'] as List<dynamic>? ?? [];
|
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 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(
|
state = TrackState(
|
||||||
tracks: tracks,
|
tracks: tracks,
|
||||||
|
searchArtists: artists,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
previousState: savedState,
|
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!_isRequestValid(requestId)) return; // Request cancelled
|
if (!_isRequestValid(requestId)) return; // Request cancelled
|
||||||
state = TrackState(isLoading: false, error: e.toString(), previousState: savedState);
|
state = TrackState(isLoading: false, error: e.toString());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,59 +252,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
state = state.copyWith(hasSearchText: hasText);
|
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 {
|
|
||||||
// Increment request ID to cancel any pending requests
|
|
||||||
final requestId = ++_currentRequestId;
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
if (!_isRequestValid(requestId)) return; // Request cancelled
|
|
||||||
|
|
||||||
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) {
|
|
||||||
if (!_isRequestValid(requestId)) return; // Request cancelled
|
|
||||||
state = TrackState(
|
|
||||||
isLoading: false,
|
|
||||||
error: e.toString(),
|
|
||||||
previousState: savedState,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Track _parseTrack(Map<String, dynamic> data) {
|
Track _parseTrack(Map<String, dynamic> data) {
|
||||||
return Track(
|
return Track(
|
||||||
id: data['spotify_id'] as String? ?? '',
|
id: data['spotify_id'] as String? ?? '',
|
||||||
@@ -346,6 +295,16 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
artists: data['artists'] as String? ?? '',
|
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>(
|
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
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
+487
-288
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
@@ -8,6 +9,10 @@ import 'package:spotiflac_android/providers/track_provider.dart';
|
|||||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
||||||
|
import 'package:spotiflac_android/screens/album_screen.dart';
|
||||||
|
import 'package:spotiflac_android/screens/artist_screen.dart';
|
||||||
|
import 'package:spotiflac_android/screens/playlist_screen.dart';
|
||||||
|
import 'package:spotiflac_android/models/download_item.dart';
|
||||||
|
|
||||||
class HomeTab extends ConsumerStatefulWidget {
|
class HomeTab extends ConsumerStatefulWidget {
|
||||||
const HomeTab({super.key});
|
const HomeTab({super.key});
|
||||||
@@ -20,6 +25,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
Timer? _debounce;
|
Timer? _debounce;
|
||||||
bool _isTyping = false;
|
bool _isTyping = false;
|
||||||
final FocusNode _searchFocusNode = FocusNode();
|
final FocusNode _searchFocusNode = FocusNode();
|
||||||
|
String? _lastSearchQuery; // Track last searched query to avoid duplicate searches
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool get wantKeepAlive => true;
|
bool get wantKeepAlive => true;
|
||||||
@@ -89,6 +95,10 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _performSearch(String query) async {
|
Future<void> _performSearch(String query) async {
|
||||||
|
// Skip if same query already searched
|
||||||
|
if (_lastSearchQuery == query) return;
|
||||||
|
_lastSearchQuery = query;
|
||||||
|
|
||||||
await ref.read(trackProvider.notifier).search(query);
|
await ref.read(trackProvider.notifier).search(query);
|
||||||
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
||||||
}
|
}
|
||||||
@@ -109,6 +119,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
_debounce?.cancel();
|
_debounce?.cancel();
|
||||||
_urlController.clear();
|
_urlController.clear();
|
||||||
_searchFocusNode.unfocus();
|
_searchFocusNode.unfocus();
|
||||||
|
_lastSearchQuery = null; // Reset last query
|
||||||
setState(() => _isTyping = false);
|
setState(() => _isTyping = false);
|
||||||
ref.read(trackProvider.notifier).clear();
|
ref.read(trackProvider.notifier).clear();
|
||||||
}
|
}
|
||||||
@@ -118,12 +129,59 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
if (url.isEmpty) return;
|
if (url.isEmpty) return;
|
||||||
if (url.startsWith('http') || url.startsWith('spotify:')) {
|
if (url.startsWith('http') || url.startsWith('spotify:')) {
|
||||||
await ref.read(trackProvider.notifier).fetchFromUrl(url);
|
await ref.read(trackProvider.notifier).fetchFromUrl(url);
|
||||||
|
_navigateToDetailIfNeeded();
|
||||||
} else {
|
} else {
|
||||||
await ref.read(trackProvider.notifier).search(url);
|
await ref.read(trackProvider.notifier).search(url);
|
||||||
}
|
}
|
||||||
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Navigate to detail screen based on fetched content type
|
||||||
|
void _navigateToDetailIfNeeded() {
|
||||||
|
final trackState = ref.read(trackProvider);
|
||||||
|
|
||||||
|
// Navigate to Album screen
|
||||||
|
if (trackState.albumId != null && trackState.albumName != null && trackState.tracks.isNotEmpty) {
|
||||||
|
Navigator.push(context, MaterialPageRoute(builder: (context) => AlbumScreen(
|
||||||
|
albumId: trackState.albumId!,
|
||||||
|
albumName: trackState.albumName!,
|
||||||
|
coverUrl: trackState.coverUrl,
|
||||||
|
tracks: trackState.tracks,
|
||||||
|
)));
|
||||||
|
ref.read(trackProvider.notifier).clear();
|
||||||
|
_urlController.clear();
|
||||||
|
setState(() => _isTyping = false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate to Playlist screen
|
||||||
|
if (trackState.playlistName != null && trackState.tracks.isNotEmpty) {
|
||||||
|
Navigator.push(context, MaterialPageRoute(builder: (context) => PlaylistScreen(
|
||||||
|
playlistName: trackState.playlistName!,
|
||||||
|
coverUrl: trackState.coverUrl,
|
||||||
|
tracks: trackState.tracks,
|
||||||
|
)));
|
||||||
|
ref.read(trackProvider.notifier).clear();
|
||||||
|
_urlController.clear();
|
||||||
|
setState(() => _isTyping = false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate to Artist screen
|
||||||
|
if (trackState.artistId != null && trackState.artistName != null && trackState.artistAlbums != null) {
|
||||||
|
Navigator.push(context, MaterialPageRoute(builder: (context) => ArtistScreen(
|
||||||
|
artistId: trackState.artistId!,
|
||||||
|
artistName: trackState.artistName!,
|
||||||
|
coverUrl: trackState.coverUrl,
|
||||||
|
albums: trackState.artistAlbums!,
|
||||||
|
)));
|
||||||
|
ref.read(trackProvider.notifier).clear();
|
||||||
|
_urlController.clear();
|
||||||
|
setState(() => _isTyping = false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _downloadTrack(int index) {
|
void _downloadTrack(int index) {
|
||||||
final trackState = ref.read(trackProvider);
|
final trackState = ref.read(trackProvider);
|
||||||
if (index >= 0 && index < trackState.tracks.length) {
|
if (index >= 0 && index < trackState.tracks.length) {
|
||||||
@@ -134,7 +192,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
_showQualityPicker(context, (quality) {
|
_showQualityPicker(context, (quality) {
|
||||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService, qualityOverride: quality);
|
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService, qualityOverride: quality);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||||
});
|
}, trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl);
|
||||||
} else {
|
} else {
|
||||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
|
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||||
@@ -142,23 +200,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _downloadAll() {
|
void _showQualityPicker(BuildContext context, void Function(String quality) onSelect, {String? trackName, String? artistName, String? coverUrl}) {
|
||||||
final trackState = ref.read(trackProvider);
|
|
||||||
if (trackState.tracks.isEmpty) return;
|
|
||||||
final settings = ref.read(settingsProvider);
|
|
||||||
|
|
||||||
if (settings.askQualityBeforeDownload) {
|
|
||||||
_showQualityPicker(context, (quality) {
|
|
||||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(trackState.tracks, settings.defaultService, qualityOverride: quality);
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${trackState.tracks.length} tracks to queue')));
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(trackState.tracks, settings.defaultService);
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${trackState.tracks.length} tracks to queue')));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showQualityPicker(BuildContext context, void Function(String quality) onSelect) {
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -169,8 +211,15 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
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(
|
||||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
||||||
child: Text('Select Quality', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
|
child: Text('Select Quality', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
|
||||||
),
|
),
|
||||||
_QualityPickerOption(
|
_QualityPickerOption(
|
||||||
@@ -199,22 +248,24 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
super.build(context);
|
super.build(context);
|
||||||
|
|
||||||
// Listen for state changes to sync search bar
|
// Listen for state changes to sync search bar and auto-navigate
|
||||||
ref.listen<TrackState>(trackProvider, _onTrackStateChanged);
|
ref.listen<TrackState>(trackProvider, (previous, next) {
|
||||||
|
_onTrackStateChanged(previous, next);
|
||||||
|
// Auto-navigate when URL fetch completes
|
||||||
|
if (previous != null && previous.isLoading && !next.isLoading && next.error == null) {
|
||||||
|
_navigateToDetailIfNeeded();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Use select() to only rebuild when specific fields change
|
// Use select() to only rebuild when specific fields change
|
||||||
final tracks = ref.watch(trackProvider.select((s) => s.tracks));
|
final tracks = ref.watch(trackProvider.select((s) => s.tracks));
|
||||||
|
final searchArtists = ref.watch(trackProvider.select((s) => s.searchArtists));
|
||||||
final isLoading = ref.watch(trackProvider.select((s) => s.isLoading));
|
final isLoading = ref.watch(trackProvider.select((s) => s.isLoading));
|
||||||
final error = ref.watch(trackProvider.select((s) => s.error));
|
final error = ref.watch(trackProvider.select((s) => s.error));
|
||||||
final albumName = ref.watch(trackProvider.select((s) => s.albumName));
|
|
||||||
final playlistName = ref.watch(trackProvider.select((s) => s.playlistName));
|
|
||||||
final artistName = ref.watch(trackProvider.select((s) => s.artistName));
|
|
||||||
final coverUrl = ref.watch(trackProvider.select((s) => s.coverUrl));
|
|
||||||
final artistAlbums = ref.watch(trackProvider.select((s) => s.artistAlbums));
|
|
||||||
final hasSearchedBefore = ref.watch(settingsProvider.select((s) => s.hasSearchedBefore));
|
final hasSearchedBefore = ref.watch(settingsProvider.select((s) => s.hasSearchedBefore));
|
||||||
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final hasResults = _isTyping || tracks.isNotEmpty || artistAlbums != null || isLoading;
|
final hasResults = _isTyping || tracks.isNotEmpty || (searchArtists != null && searchArtists.isNotEmpty) || isLoading;
|
||||||
final screenHeight = MediaQuery.of(context).size.height;
|
final screenHeight = MediaQuery.of(context).size.height;
|
||||||
final historyItems = ref.watch(downloadHistoryProvider.select((s) => s.items));
|
final historyItems = ref.watch(downloadHistoryProvider.select((s) => s.items));
|
||||||
|
|
||||||
@@ -320,16 +371,12 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Results content - always in tree
|
// Results content - search results only (albums/artists/playlists navigate to separate screens)
|
||||||
..._buildResultsContentOptimized(
|
..._buildSearchResults(
|
||||||
tracks: tracks,
|
tracks: tracks,
|
||||||
|
searchArtists: searchArtists,
|
||||||
isLoading: isLoading,
|
isLoading: isLoading,
|
||||||
error: error,
|
error: error,
|
||||||
albumName: albumName,
|
|
||||||
playlistName: playlistName,
|
|
||||||
artistName: artistName,
|
|
||||||
coverUrl: coverUrl,
|
|
||||||
artistAlbums: artistAlbums,
|
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
hasResults: hasResults,
|
hasResults: hasResults,
|
||||||
),
|
),
|
||||||
@@ -354,7 +401,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 80,
|
height: 130,
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
itemCount: displayItems.length,
|
itemCount: displayItems.length,
|
||||||
@@ -365,32 +412,32 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () => _navigateToMetadataScreen(item),
|
onTap: () => _navigateToMetadataScreen(item),
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 60,
|
width: 100,
|
||||||
margin: const EdgeInsets.only(right: 12),
|
margin: const EdgeInsets.only(right: 12),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: item.coverUrl != null
|
child: item.coverUrl != null
|
||||||
? CachedNetworkImage(
|
? CachedNetworkImage(
|
||||||
imageUrl: item.coverUrl!,
|
imageUrl: item.coverUrl!,
|
||||||
width: 56,
|
width: 100,
|
||||||
height: 56,
|
height: 100,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
memCacheWidth: 112,
|
memCacheWidth: 200,
|
||||||
memCacheHeight: 112,
|
memCacheHeight: 200,
|
||||||
)
|
)
|
||||||
: Container(
|
: Container(
|
||||||
width: 56,
|
width: 100,
|
||||||
height: 56,
|
height: 100,
|
||||||
color: colorScheme.surfaceContainerHighest,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant, size: 24),
|
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant, size: 32),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 6),
|
||||||
Text(
|
Text(
|
||||||
item.trackName,
|
item.trackName,
|
||||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
@@ -418,20 +465,15 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Results content slivers (without app bar and search bar) - optimized version
|
// Search results slivers - only shows search results (track list)
|
||||||
List<Widget> _buildResultsContentOptimized({
|
List<Widget> _buildSearchResults({
|
||||||
required List<Track> tracks,
|
required List<Track> tracks,
|
||||||
|
required List<SearchArtist>? searchArtists,
|
||||||
required bool isLoading,
|
required bool isLoading,
|
||||||
required String? error,
|
required String? error,
|
||||||
required String? albumName,
|
|
||||||
required String? playlistName,
|
|
||||||
required String? artistName,
|
|
||||||
required String? coverUrl,
|
|
||||||
required List<ArtistAlbum>? artistAlbums,
|
|
||||||
required ColorScheme colorScheme,
|
required ColorScheme colorScheme,
|
||||||
required bool hasResults,
|
required bool hasResults,
|
||||||
}) {
|
}) {
|
||||||
// Return empty slivers when no results to keep tree structure stable
|
|
||||||
if (!hasResults) {
|
if (!hasResults) {
|
||||||
return [const SliverToBoxAdapter(child: SizedBox.shrink())];
|
return [const SliverToBoxAdapter(child: SizedBox.shrink())];
|
||||||
}
|
}
|
||||||
@@ -448,177 +490,131 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
if (isLoading)
|
if (isLoading)
|
||||||
const SliverToBoxAdapter(child: Padding(padding: EdgeInsets.symmetric(horizontal: 16), child: LinearProgressIndicator())),
|
const SliverToBoxAdapter(child: Padding(padding: EdgeInsets.symmetric(horizontal: 16), child: LinearProgressIndicator())),
|
||||||
|
|
||||||
// Album/Playlist header
|
// Artist search results (horizontal scroll)
|
||||||
if (albumName != null || playlistName != null)
|
if (searchArtists != null && searchArtists.isNotEmpty)
|
||||||
SliverToBoxAdapter(child: _buildHeaderOptimized(
|
SliverToBoxAdapter(child: _buildArtistSearchResults(searchArtists, colorScheme)),
|
||||||
albumName: albumName,
|
|
||||||
playlistName: playlistName,
|
|
||||||
coverUrl: coverUrl,
|
|
||||||
trackCount: tracks.length,
|
|
||||||
colorScheme: colorScheme,
|
|
||||||
)),
|
|
||||||
|
|
||||||
// Artist header and discography
|
// Songs section header
|
||||||
if (artistName != null && artistAlbums != null)
|
if (tracks.isNotEmpty)
|
||||||
SliverToBoxAdapter(child: _buildArtistHeaderOptimized(
|
|
||||||
artistName: artistName,
|
|
||||||
coverUrl: coverUrl,
|
|
||||||
albumCount: artistAlbums.length,
|
|
||||||
colorScheme: colorScheme,
|
|
||||||
)),
|
|
||||||
|
|
||||||
if (artistAlbums != null && artistAlbums.isNotEmpty)
|
|
||||||
SliverToBoxAdapter(child: _buildArtistDiscographyOptimized(artistAlbums, colorScheme)),
|
|
||||||
|
|
||||||
// Download All button
|
|
||||||
if (tracks.length > 1 && albumName == null && playlistName == null && artistAlbums == null)
|
|
||||||
SliverToBoxAdapter(child: Padding(
|
SliverToBoxAdapter(child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||||
child: FilledButton.icon(onPressed: _downloadAll, icon: const Icon(Icons.download),
|
child: Text('Songs', style: Theme.of(context).textTheme.titleSmall?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||||
label: Text('Download All (${tracks.length})'),
|
|
||||||
style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(48))),
|
|
||||||
)),
|
)),
|
||||||
|
|
||||||
// Track list with keys for efficient updates
|
// Track list in grouped card
|
||||||
SliverList(delegate: SliverChildBuilderDelegate(
|
if (tracks.isNotEmpty)
|
||||||
(context, index) {
|
SliverToBoxAdapter(
|
||||||
final track = tracks[index];
|
child: Container(
|
||||||
return KeyedSubtree(
|
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
key: ValueKey(track.id),
|
decoration: BoxDecoration(
|
||||||
child: _buildTrackTileOptimized(track, index, colorScheme),
|
color: Theme.of(context).brightness == Brightness.dark
|
||||||
);
|
? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), colorScheme.surface)
|
||||||
},
|
: colorScheme.surfaceContainerHighest,
|
||||||
childCount: tracks.length,
|
borderRadius: BorderRadius.circular(20),
|
||||||
)),
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
for (int i = 0; i < tracks.length; i++)
|
||||||
|
_TrackItemWithStatus(
|
||||||
|
key: ValueKey(tracks[i].id),
|
||||||
|
track: tracks[i],
|
||||||
|
index: i,
|
||||||
|
showDivider: i < tracks.length - 1,
|
||||||
|
onDownload: () => _downloadTrack(i),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
// Bottom padding
|
// Bottom padding
|
||||||
const SliverToBoxAdapter(child: SizedBox(height: 16)),
|
const SliverToBoxAdapter(child: SizedBox(height: 16)),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildHeaderOptimized({
|
Widget _buildArtistSearchResults(List<SearchArtist> artists, ColorScheme colorScheme) {
|
||||||
required String? albumName,
|
return Column(
|
||||||
required String? playlistName,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
required String? coverUrl,
|
children: [
|
||||||
required int trackCount,
|
Padding(
|
||||||
required ColorScheme colorScheme,
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||||
}) {
|
child: Text('Artists', style: Theme.of(context).textTheme.titleSmall?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||||
return Card(
|
|
||||||
margin: const EdgeInsets.all(16),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
if (coverUrl != null)
|
|
||||||
ClipRRect(borderRadius: BorderRadius.circular(12),
|
|
||||||
child: CachedNetworkImage(imageUrl: coverUrl, width: 80, height: 80, fit: BoxFit.cover,
|
|
||||||
placeholder: (_, _) => Container(width: 80, height: 80, color: colorScheme.surfaceContainerHighest))),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
||||||
Text(albumName ?? playlistName ?? '',
|
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
|
||||||
maxLines: 2, overflow: TextOverflow.ellipsis),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text('$trackCount tracks',
|
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
|
||||||
])),
|
|
||||||
FilledButton.tonal(onPressed: _downloadAll,
|
|
||||||
style: FilledButton.styleFrom(shape: const CircleBorder(), padding: const EdgeInsets.all(16)),
|
|
||||||
child: const Icon(Icons.download)),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
SizedBox(
|
||||||
|
height: 160,
|
||||||
|
child: ListView.builder(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
itemCount: artists.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final artist = artists[index];
|
||||||
|
return KeyedSubtree(
|
||||||
|
key: ValueKey(artist.id),
|
||||||
|
child: _buildArtistCard(artist, colorScheme),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildArtistHeaderOptimized({
|
Widget _buildArtistCard(SearchArtist artist, ColorScheme colorScheme) {
|
||||||
required String? artistName,
|
return GestureDetector(
|
||||||
required String? coverUrl,
|
onTap: () => _navigateToArtist(artist.id, artist.name, artist.imageUrl),
|
||||||
required int albumCount,
|
child: Container(
|
||||||
required ColorScheme colorScheme,
|
width: 110,
|
||||||
}) {
|
margin: const EdgeInsets.symmetric(horizontal: 6),
|
||||||
return Card(
|
child: Column(
|
||||||
margin: const EdgeInsets.all(16),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
children: [
|
||||||
if (coverUrl != null)
|
Container(
|
||||||
ClipRRect(
|
width: 100,
|
||||||
borderRadius: BorderRadius.circular(40),
|
height: 100,
|
||||||
child: CachedNetworkImage(
|
decoration: BoxDecoration(
|
||||||
imageUrl: coverUrl,
|
shape: BoxShape.circle,
|
||||||
width: 80,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
height: 80,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
placeholder: (_, _) => Container(
|
|
||||||
width: 80,
|
|
||||||
height: 80,
|
|
||||||
color: colorScheme.surfaceContainerHighest,
|
|
||||||
child: Icon(Icons.person, color: colorScheme.onSurfaceVariant),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
child: ClipOval(
|
||||||
Expanded(
|
child: artist.imageUrl != null
|
||||||
child: Column(
|
? CachedNetworkImage(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
imageUrl: artist.imageUrl!,
|
||||||
children: [
|
fit: BoxFit.cover,
|
||||||
Text(
|
memCacheWidth: 200,
|
||||||
artistName ?? '',
|
memCacheHeight: 200,
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
)
|
||||||
maxLines: 2,
|
: Icon(Icons.person, color: colorScheme.onSurfaceVariant, size: 44),
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
'$albumCount releases',
|
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
artist.name,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildArtistDiscographyOptimized(List<ArtistAlbum> albums, ColorScheme colorScheme) {
|
void _navigateToArtist(String artistId, String artistName, String? imageUrl) {
|
||||||
final albumsOnly = albums.where((a) => a.albumType == 'album').toList();
|
// Navigate immediately with data from search, fetch albums in ArtistScreen
|
||||||
final singles = albums.where((a) => a.albumType == 'single').toList();
|
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
||||||
final compilations = albums.where((a) => a.albumType == 'compilation').toList();
|
Navigator.push(context, MaterialPageRoute(
|
||||||
|
builder: (context) => ArtistScreen(
|
||||||
return Column(
|
artistId: artistId,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
artistName: artistName,
|
||||||
children: [
|
coverUrl: imageUrl,
|
||||||
if (albumsOnly.isNotEmpty) _buildAlbumSection('Albums', albumsOnly, colorScheme),
|
// albums: null - will be fetched in ArtistScreen
|
||||||
if (singles.isNotEmpty) _buildAlbumSection('Singles & EPs', singles, colorScheme),
|
),
|
||||||
if (compilations.isNotEmpty) _buildAlbumSection('Compilations', compilations, colorScheme),
|
));
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildTrackTileOptimized(Track track, int index, ColorScheme colorScheme) {
|
|
||||||
return ListTile(
|
|
||||||
leading: track.coverUrl != null
|
|
||||||
? ClipRRect(borderRadius: BorderRadius.circular(8),
|
|
||||||
child: CachedNetworkImage(
|
|
||||||
imageUrl: track.coverUrl!,
|
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
memCacheWidth: 96,
|
|
||||||
memCacheHeight: 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),
|
|
||||||
subtitle: Text(track.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant)),
|
|
||||||
trailing: IconButton(icon: Icon(Icons.download, color: colorScheme.primary), onPressed: () => _downloadTrack(index)),
|
|
||||||
onTap: () => _downloadTrack(index),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildSearchBar(ColorScheme colorScheme) {
|
Widget _buildSearchBar(ColorScheme colorScheme) {
|
||||||
@@ -668,93 +664,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildAlbumSection(String title, List<ArtistAlbum> albums, ColorScheme colorScheme) {
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
|
||||||
child: Text(
|
|
||||||
'$title (${albums.length})',
|
|
||||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: colorScheme.primary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(
|
|
||||||
height: 180,
|
|
||||||
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: () => _fetchAlbum(album.id),
|
|
||||||
child: Container(
|
|
||||||
width: 130,
|
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
child: album.coverUrl != null
|
|
||||||
? CachedNetworkImage(
|
|
||||||
imageUrl: album.coverUrl!,
|
|
||||||
width: 130,
|
|
||||||
height: 130,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
memCacheWidth: 260,
|
|
||||||
memCacheHeight: 260,
|
|
||||||
)
|
|
||||||
: Container(
|
|
||||||
width: 130,
|
|
||||||
height: 130,
|
|
||||||
color: colorScheme.surfaceContainerHighest,
|
|
||||||
child: Icon(Icons.album, color: colorScheme.onSurfaceVariant),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
album.name,
|
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
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 _fetchAlbum(String albumId) {
|
|
||||||
// Use fetchAlbumFromArtist to save artist state for back navigation
|
|
||||||
ref.read(trackProvider.notifier).fetchAlbumFromArtist(albumId);
|
|
||||||
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class _QualityPickerOption extends StatelessWidget {
|
class _QualityPickerOption extends StatelessWidget {
|
||||||
@@ -775,3 +684,293 @@ class _QualityPickerOption extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 item - only rebuilds when this specific track's status changes
|
||||||
|
class _TrackItemWithStatus extends ConsumerWidget {
|
||||||
|
final Track track;
|
||||||
|
final int index;
|
||||||
|
final bool showDivider;
|
||||||
|
final VoidCallback onDownload;
|
||||||
|
|
||||||
|
const _TrackItemWithStatus({
|
||||||
|
super.key,
|
||||||
|
required this.track,
|
||||||
|
required this.index,
|
||||||
|
required this.showDivider,
|
||||||
|
required this.onDownload,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
// Only watch the specific item for this track using select()
|
||||||
|
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 Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
InkWell(
|
||||||
|
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory),
|
||||||
|
splashColor: colorScheme.primary.withValues(alpha: 0.12),
|
||||||
|
highlightColor: colorScheme.primary.withValues(alpha: 0.08),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// Album art
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
child: track.coverUrl != null
|
||||||
|
? CachedNetworkImage(
|
||||||
|
imageUrl: track.coverUrl!,
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
memCacheWidth: 112,
|
||||||
|
memCacheHeight: 112,
|
||||||
|
)
|
||||||
|
: Container(
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
// Track info
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
track.name,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
track.artistName,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Download button / status indicator
|
||||||
|
_buildDownloadButton(context, ref, colorScheme, isQueued: isQueued, isDownloading: isDownloading, isFinalizing: isFinalizing, showAsDownloaded: showAsDownloaded, isInHistory: isInHistory, progress: progress),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (showDivider)
|
||||||
|
Divider(
|
||||||
|
height: 1,
|
||||||
|
thickness: 1,
|
||||||
|
indent: 80,
|
||||||
|
endIndent: 12,
|
||||||
|
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleTap(BuildContext context, WidgetRef ref, {required bool isQueued, required bool isInHistory}) async {
|
||||||
|
// If already in queue, do nothing
|
||||||
|
if (isQueued) return;
|
||||||
|
|
||||||
|
// If in history, check if file still exists
|
||||||
|
if (isInHistory) {
|
||||||
|
final historyItem = ref.read(downloadHistoryProvider.notifier).getBySpotifyId(track.id);
|
||||||
|
if (historyItem != null) {
|
||||||
|
final fileExists = await File(historyItem.filePath).exists();
|
||||||
|
if (fileExists) {
|
||||||
|
// File exists, show snackbar
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('"${track.name}" already downloaded')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
// File doesn't exist, remove from history and allow download
|
||||||
|
ref.read(downloadHistoryProvider.notifier).removeBySpotifyId(track.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proceed with download
|
||||||
|
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) {
|
||||||
|
// Show finalizing status (embedding metadata)
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+52
-47
@@ -27,6 +27,7 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
late PageController _pageController;
|
late PageController _pageController;
|
||||||
bool _hasCheckedUpdate = false;
|
bool _hasCheckedUpdate = false;
|
||||||
StreamSubscription<String>? _shareSubscription;
|
StreamSubscription<String>? _shareSubscription;
|
||||||
|
DateTime? _lastBackPress; // For double-tap to exit
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -120,24 +121,41 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> _showExitDialog() async {
|
/// Handle back press with double-tap to exit
|
||||||
return await showDialog<bool>(
|
void _handleBackPress() {
|
||||||
context: context,
|
final trackState = ref.read(trackProvider);
|
||||||
builder: (context) => AlertDialog(
|
|
||||||
title: const Text('Exit App'),
|
// If on Home tab and has text in search bar or has content (but not loading), clear it
|
||||||
content: const Text('Are you sure you want to exit?'),
|
if (_currentIndex == 0 && !trackState.isLoading && (trackState.hasSearchText || trackState.hasContent)) {
|
||||||
actions: [
|
ref.read(trackProvider.notifier).clear();
|
||||||
TextButton(
|
return;
|
||||||
onPressed: () => Navigator.pop(context, false),
|
}
|
||||||
child: const Text('No'),
|
|
||||||
),
|
// If not on Home tab, go to Home tab first
|
||||||
TextButton(
|
if (_currentIndex != 0) {
|
||||||
onPressed: () => Navigator.pop(context, true),
|
_onNavTap(0);
|
||||||
child: const Text('Yes'),
|
return;
|
||||||
),
|
}
|
||||||
],
|
|
||||||
),
|
// If loading, ignore back press
|
||||||
) ?? false;
|
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
|
@override
|
||||||
@@ -145,40 +163,24 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
final queueState = ref.watch(downloadQueueProvider.select((s) => s.queuedCount));
|
final queueState = ref.watch(downloadQueueProvider.select((s) => s.queuedCount));
|
||||||
final trackState = ref.watch(trackProvider);
|
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(
|
return PopScope(
|
||||||
canPop: false,
|
canPop: canPop,
|
||||||
onPopInvokedWithResult: (didPop, result) async {
|
onPopInvokedWithResult: (didPop, result) async {
|
||||||
if (didPop) return;
|
if (didPop) {
|
||||||
|
// System handled the pop - this means predictive back completed
|
||||||
// If on Search tab and can go back in track history, go back
|
// We need to handle double-tap to exit here
|
||||||
if (_currentIndex == 0 && trackState.canGoBack) {
|
|
||||||
ref.read(trackProvider.notifier).goBack();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If on Search tab and has text in search bar or has content (but not loading), clear it
|
// Handle back press manually when canPop is false
|
||||||
// Don't clear while loading - this prevents clearing during share intent processing
|
_handleBackPress();
|
||||||
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();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
body: PageView(
|
body: PageView(
|
||||||
@@ -195,6 +197,9 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
selectedIndex: _currentIndex,
|
selectedIndex: _currentIndex,
|
||||||
onDestinationSelected: _onNavTap,
|
onDestinationSelected: _onNavTap,
|
||||||
animationDuration: const Duration(milliseconds: 200),
|
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: [
|
destinations: [
|
||||||
const NavigationDestination(
|
const NavigationDestination(
|
||||||
icon: Icon(Icons.home_outlined),
|
icon: Icon(Icons.home_outlined),
|
||||||
|
|||||||
@@ -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,
|
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:
|
case DownloadStatus.completed:
|
||||||
return Icon(Icons.check_circle, color: colorScheme.primary);
|
return Icon(Icons.check_circle, color: colorScheme.primary);
|
||||||
case DownloadStatus.failed:
|
case DownloadStatus.failed:
|
||||||
|
|||||||
@@ -424,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:
|
case DownloadStatus.completed:
|
||||||
// Completed: Show play button and check icon
|
// Completed: Show play button and check icon
|
||||||
final fileExists = _checkFileExists(item.filePath);
|
final fileExists = _checkFileExists(item.filePath);
|
||||||
|
|||||||
@@ -144,27 +144,38 @@ class _ThemeModeChip extends StatelessWidget {
|
|||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
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
|
final unselectedColor = isDark
|
||||||
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface)
|
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface)
|
||||||
: colorScheme.surfaceContainerHigh;
|
: Color.alphaBlend(Colors.black.withValues(alpha: 0.05), colorScheme.surfaceContainerHighest);
|
||||||
|
|
||||||
return Expanded(
|
return Expanded(
|
||||||
child: Material(
|
child: Container(
|
||||||
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(12),
|
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
|
||||||
child: InkWell(
|
|
||||||
onTap: onTap,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: Padding(
|
border: !isDark && !isSelected
|
||||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
? Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5), width: 1)
|
||||||
child: Column(children: [
|
: null,
|
||||||
Icon(icon, color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant),
|
),
|
||||||
const SizedBox(height: 6),
|
child: Material(
|
||||||
Text(label, style: TextStyle(fontSize: 12,
|
color: Colors.transparent,
|
||||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
borderRadius: BorderRadius.circular(12),
|
||||||
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant)),
|
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 colorScheme = Theme.of(context).colorScheme;
|
||||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
|
// Unselected chips need contrast with card background
|
||||||
final unselectedColor = isDark
|
final unselectedColor = isDark
|
||||||
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface)
|
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface)
|
||||||
: colorScheme.surfaceContainerHigh;
|
: Color.alphaBlend(Colors.black.withValues(alpha: 0.05), colorScheme.surfaceContainerHighest);
|
||||||
|
|
||||||
return Expanded(
|
return Expanded(
|
||||||
child: Material(
|
child: Container(
|
||||||
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(12),
|
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
|
||||||
child: InkWell(
|
|
||||||
onTap: onTap,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: Padding(
|
border: !isDark && !isSelected
|
||||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
? Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5), width: 1)
|
||||||
child: Column(children: [
|
: null,
|
||||||
Icon(icon, color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant),
|
),
|
||||||
const SizedBox(height: 6),
|
child: Material(
|
||||||
Text(label, style: TextStyle(fontSize: 12,
|
color: Colors.transparent,
|
||||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
borderRadius: BorderRadius.circular(12),
|
||||||
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant)),
|
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)),
|
||||||
|
]),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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({
|
Future<void> showDownloadComplete({
|
||||||
required String trackName,
|
required String trackName,
|
||||||
required String artistName,
|
required String artistName,
|
||||||
|
|||||||
@@ -26,6 +26,16 @@ class PlatformBridge {
|
|||||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
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
|
/// Check track availability on streaming services
|
||||||
static Future<Map<String, dynamic>> checkAvailability(String spotifyId, String isrc) async {
|
static Future<Map<String, dynamic>> checkAvailability(String spotifyId, String isrc) async {
|
||||||
final result = await _channel.invokeMethod('checkAvailability', {
|
final result = await _channel.invokeMethod('checkAvailability', {
|
||||||
|
|||||||
@@ -20,9 +20,10 @@ class SettingsGroup extends StatelessWidget {
|
|||||||
// Use a more contrasting color for cards
|
// Use a more contrasting color for cards
|
||||||
// In dark mode with dynamic color, surfaceContainerHighest can be too similar to surface
|
// 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
|
// 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
|
final cardColor = isDark
|
||||||
? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), colorScheme.surface)
|
? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), colorScheme.surface)
|
||||||
: colorScheme.surfaceContainerHighest;
|
: Color.alphaBlend(Colors.black.withValues(alpha: 0.04), colorScheme.surface);
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
margin: margin ?? const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
margin: margin ?? const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||||
|
|||||||
+221
-118
@@ -69,6 +69,9 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (filePath != null) {
|
if (filePath != null) {
|
||||||
|
// Cancel progress notification first
|
||||||
|
await notificationService.cancelUpdateNotification();
|
||||||
|
|
||||||
await notificationService.showUpdateDownloadComplete(
|
await notificationService.showUpdateDownloadComplete(
|
||||||
version: widget.updateInfo.version,
|
version: widget.updateInfo.version,
|
||||||
);
|
);
|
||||||
@@ -80,6 +83,9 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
|||||||
// Open APK for installation
|
// Open APK for installation
|
||||||
await ApkDownloader.installApk(filePath);
|
await ApkDownloader.installApk(filePath);
|
||||||
} else {
|
} else {
|
||||||
|
// Cancel progress notification first
|
||||||
|
await notificationService.cancelUpdateNotification();
|
||||||
|
|
||||||
await notificationService.showUpdateDownloadFailed();
|
await notificationService.showUpdateDownloadFailed();
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -98,129 +104,202 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
return AlertDialog(
|
return Dialog(
|
||||||
title: Row(
|
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||||
children: [
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
|
||||||
Icon(Icons.system_update, color: colorScheme.primary),
|
child: Padding(
|
||||||
const SizedBox(width: 12),
|
padding: const EdgeInsets.all(24),
|
||||||
const Text('Update Available'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
content: SizedBox(
|
|
||||||
width: double.maxFinite,
|
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
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(
|
Container(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colorScheme.primaryContainer,
|
color: isDark
|
||||||
borderRadius: BorderRadius.circular(12),
|
? 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(
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
_VersionChip(version: AppInfo.version, label: 'Current', colorScheme: colorScheme),
|
||||||
'v${AppInfo.version}',
|
const SizedBox(width: 12),
|
||||||
style: TextStyle(color: colorScheme.onPrimaryContainer),
|
Icon(Icons.arrow_forward_rounded, size: 20, color: colorScheme.primary),
|
||||||
),
|
const SizedBox(width: 12),
|
||||||
const SizedBox(width: 8),
|
_VersionChip(version: widget.updateInfo.version, label: 'New', colorScheme: colorScheme, isNew: true),
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
// Changelog header
|
// Download progress (when downloading)
|
||||||
Text(
|
if (_isDownloading) ...[
|
||||||
'What\'s New:',
|
Container(
|
||||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
padding: const EdgeInsets.all(16),
|
||||||
fontWeight: FontWeight.bold,
|
decoration: BoxDecoration(
|
||||||
),
|
color: isDark
|
||||||
),
|
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface)
|
||||||
const SizedBox(height: 8),
|
: Color.alphaBlend(Colors.black.withValues(alpha: 0.03), colorScheme.surface),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
// Changelog content (scrollable) - hide when downloading
|
),
|
||||||
if (!_isDownloading)
|
child: Column(
|
||||||
Flexible(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
child: Container(
|
children: [
|
||||||
constraints: const BoxConstraints(maxHeight: 200),
|
Row(
|
||||||
decoration: BoxDecoration(
|
children: [
|
||||||
color: colorScheme.surfaceContainerHighest,
|
SizedBox(
|
||||||
borderRadius: BorderRadius.circular(8),
|
width: 20, height: 20,
|
||||||
),
|
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.primary),
|
||||||
child: SingleChildScrollView(
|
),
|
||||||
padding: const EdgeInsets.all(12),
|
const SizedBox(width: 12),
|
||||||
child: Text(
|
Text('Downloading...', style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)),
|
||||||
_formatChangelog(widget.updateInfo.changelog),
|
],
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
|
||||||
),
|
),
|
||||||
|
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
|
/// Format changelog - clean up markdown and extract relevant content
|
||||||
String _formatChangelog(String changelog) {
|
String _formatChangelog(String changelog) {
|
||||||
// Try to extract just the changelog section (between "What's New" and "Downloads" or "---")
|
|
||||||
var content = changelog;
|
var content = changelog;
|
||||||
|
|
||||||
// Find content after "What's New" header
|
// Find content after "What's New" header
|
||||||
@@ -238,19 +317,18 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
|||||||
// Process line by line for better formatting
|
// Process line by line for better formatting
|
||||||
final lines = content.split('\n');
|
final lines = content.split('\n');
|
||||||
final formattedLines = <String>[];
|
final formattedLines = <String>[];
|
||||||
String? currentSection;
|
|
||||||
|
|
||||||
for (var line in lines) {
|
for (var line in lines) {
|
||||||
line = line.trim();
|
line = line.trim();
|
||||||
if (line.isEmpty) continue;
|
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);
|
final sectionMatch = RegExp(r'^#{1,3}\s*(.+)$').firstMatch(line);
|
||||||
if (sectionMatch != null) {
|
if (sectionMatch != null) {
|
||||||
currentSection = sectionMatch.group(1)?.trim();
|
final section = sectionMatch.group(1)?.trim();
|
||||||
if (currentSection != null && currentSection.isNotEmpty) {
|
if (section != null && section.isNotEmpty) {
|
||||||
if (formattedLines.isNotEmpty) formattedLines.add('');
|
if (formattedLines.isNotEmpty) formattedLines.add('');
|
||||||
formattedLines.add('$currentSection:');
|
formattedLines.add(section);
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -259,36 +337,23 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
|||||||
final listMatch = RegExp(r'^[-*]\s+(.+)$').firstMatch(line);
|
final listMatch = RegExp(r'^[-*]\s+(.+)$').firstMatch(line);
|
||||||
if (listMatch != null) {
|
if (listMatch != null) {
|
||||||
var itemText = listMatch.group(1) ?? '';
|
var itemText = listMatch.group(1) ?? '';
|
||||||
// Remove bold markdown
|
itemText = itemText.replaceAllMapped(RegExp(r'\*\*([^*]+)\*\*'), (m) => m.group(1) ?? '');
|
||||||
itemText = itemText.replaceAllMapped(
|
itemText = itemText.replaceAllMapped(RegExp(r'`([^`]+)`'), (m) => m.group(1) ?? '');
|
||||||
RegExp(r'\*\*([^*]+)\*\*'),
|
|
||||||
(m) => m.group(1) ?? ''
|
|
||||||
);
|
|
||||||
// Remove code markdown
|
|
||||||
itemText = itemText.replaceAllMapped(
|
|
||||||
RegExp(r'`([^`]+)`'),
|
|
||||||
(m) => m.group(1) ?? ''
|
|
||||||
);
|
|
||||||
formattedLines.add('• $itemText');
|
formattedLines.add('• $itemText');
|
||||||
continue;
|
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);
|
final subListMatch = RegExp(r'^\s+[-*]\s+(.+)$').firstMatch(line);
|
||||||
if (subListMatch != null) {
|
if (subListMatch != null) {
|
||||||
var itemText = subListMatch.group(1) ?? '';
|
var itemText = subListMatch.group(1) ?? '';
|
||||||
itemText = itemText.replaceAllMapped(
|
itemText = itemText.replaceAllMapped(RegExp(r'\*\*([^*]+)\*\*'), (m) => m.group(1) ?? '');
|
||||||
RegExp(r'\*\*([^*]+)\*\*'),
|
|
||||||
(m) => m.group(1) ?? ''
|
|
||||||
);
|
|
||||||
formattedLines.add(' - $itemText');
|
formattedLines.add(' - $itemText');
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var formatted = formattedLines.join('\n').trim();
|
var formatted = formattedLines.join('\n').trim();
|
||||||
|
|
||||||
// Limit length
|
|
||||||
if (formatted.length > 2000) {
|
if (formatted.length > 2000) {
|
||||||
formatted = '${formatted.substring(0, 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
|
/// Show update dialog
|
||||||
Future<void> showUpdateDialog(
|
Future<void> showUpdateDialog(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
name: spotiflac_android
|
name: spotiflac_android
|
||||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 1.6.2+27
|
version: 2.0.0-preview1+29
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.10.0
|
sdk: ^3.10.0
|
||||||
|
|||||||
Reference in New Issue
Block a user