diff --git a/.gitignore b/.gitignore index 78436f9c..8a5e5209 100644 --- a/.gitignore +++ b/.gitignore @@ -56,3 +56,6 @@ android/app/libs/gobackend-sources.jar # Extension folder extension/ +AGENTS.md +nul +/extension diff --git a/CHANGELOG.md b/CHANGELOG.md index 6abce1b5..1eae5805 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,49 @@ # Changelog +## [3.0.1] - 2026-01-21 + +### Added + +- **Year in Album Folder Name** ([#50](https://github.com/zarzet/SpotiFLAC-Mobile/issues/50)): New album folder structure options with release year + - `Artist / [Year] Album`: Albums/Coldplay/[2005] X&Y/ + - `[Year] Album Only`: Albums/[2005] X&Y/ + - Year extracted from release date metadata + - Matches desktop SpotiFLAC folder structure + +- **Extension Album/Playlist/Artist Support**: Extensions can now return albums, playlists, and artists in search results + - Search results now properly separated into Albums, Playlists, Artists, and Songs sections + - Albums, playlists, and artists show chevron icon (navigate to detail) instead of download button + - Tap album/playlist to view track list and download + - Tap artist to view their albums/discography + - New `getAlbum()`, `getPlaylist()`, and `getArtist()` extension functions + - New `ExtensionAlbumScreen`, `ExtensionPlaylistScreen`, and `ExtensionArtistScreen` for fetching content from extensions + - YouTube Music extension updated with album/playlist/artist support + - See [Extension Development Guide](docs/EXTENSION_DEVELOPMENT.md#artist-support) for implementation details + +- **Odesli (song.link) Integration for YouTube Music Extension** + - New `enrichTrack()` function to fetch ISRC and external service links + - Uses Odesli API to convert YouTube Music tracks to Deezer/Tidal/Qobuz/Spotify + - Enables built-in service fallback for high-quality audio downloads + - Extension version updated to 1.4.0 with `api.song.link` and `odesli.io` network permissions + +### Fixed + +- Fixed PageView overscroll at edges (BouncingScrollPhysics → ClampingScrollPhysics) +- Fixed settings item highlight on swipe (highlightColor: Colors.transparent) +- Fixed extension duplicate load error (skip silently instead of throwing error) +- Fixed keyboard appearing when swiping between tabs (unfocus on page change) +- Removed "Free"/"API Key" badges from search source selector +- **Go Backend: Missing `item_type` and `album_type` fields** + - Added `ItemType` and `AlbumType` fields to `ExtTrackMetadata` struct + - Fixed `CustomSearchWithExtensionJSON` - now includes `item_type` and `album_type` in response + - Fixed `HandleURLWithExtensionJSON` - now includes `item_type` and `album_type` for tracks + - Fixed `GetAlbumWithExtensionJSON` - now includes `item_type` and `album_type` for album tracks + - Fixed `GetPlaylistWithExtensionJSON` - now includes `item_type` and `album_type` for playlist tracks +- **Album/Playlist Track Thumbnails**: Tracks inside albums/playlists now use album/playlist cover as fallback when no individual cover exists +- **YouTube Music Extension getArtist**: Fixed `getArtist()` function not being registered in extension, causing artist pages to fail with "returned null" error + +--- + ## [3.0.0] - 2026-01-14 ### 🎉 Extension System (Major Feature) @@ -45,6 +89,12 @@ SpotiFLAC 3.0 introduces a powerful extension system that allows third-party int - Based on `album_type` from Spotify/Deezer metadata - Toggle in Settings > Download > Separate Singles Folder +- **Year in Album Folder Name**: New album folder structure options with release year + - `Artist / [Year] Album`: Albums/Coldplay/[2005] X&Y/ + - `[Year] Album Only`: Albums/[2005] X&Y/ + - Year extracted from release date metadata + - Matches desktop SpotiFLAC folder structure + - **Parallel API Calls**: Download URL fetching now uses parallel requests - Tidal: All 8 APIs requested simultaneously, first success wins - Qobuz: Both APIs requested simultaneously, first success wins diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index 6a0fb6b5..5673d009 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -572,6 +572,30 @@ class MainActivity: FlutterActivity() { } result.success(response) } + "getAlbumWithExtension" -> { + val extensionId = call.argument("extension_id") ?: "" + val albumId = call.argument("album_id") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.getAlbumWithExtensionJSON(extensionId, albumId) + } + result.success(response) + } + "getPlaylistWithExtension" -> { + val extensionId = call.argument("extension_id") ?: "" + val playlistId = call.argument("playlist_id") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.getPlaylistWithExtensionJSON(extensionId, playlistId) + } + result.success(response) + } + "getArtistWithExtension" -> { + val extensionId = call.argument("extension_id") ?: "" + val artistId = call.argument("artist_id") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.getArtistWithExtensionJSON(extensionId, artistId) + } + result.success(response) + } // Extension Post-Processing API "runPostProcessing" -> { val filePath = call.argument("file_path") ?: "" diff --git a/go_backend/exports.go b/go_backend/exports.go index d9cde03b..090d09d8 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -8,6 +8,8 @@ import ( "fmt" "strings" "time" + + "github.com/dop251/goja" ) // ParseSpotifyURL parses and validates a Spotify URL @@ -150,6 +152,10 @@ type DownloadRequest struct { ItemID string `json:"item_id"` // Unique ID for progress tracking DurationMS int `json:"duration_ms"` // Expected duration in milliseconds (for verification) Source string `json:"source"` // Extension ID that provided this track (prioritize this extension) + // Enriched IDs from Odesli/song.link - used to skip search and directly fetch + TidalID string `json:"tidal_id,omitempty"` + QobuzID string `json:"qobuz_id,omitempty"` + DeezerID string `json:"deezer_id,omitempty"` } // DownloadResponse represents the result of a download @@ -1516,6 +1522,8 @@ func CustomSearchWithExtensionJSON(extensionID, query string, optionsJSON string "disc_number": track.DiscNumber, "isrc": track.ISRC, "provider_id": track.ProviderID, + "item_type": track.ItemType, // track, album, or playlist + "album_type": track.AlbumType, // album, single, ep, compilation } } @@ -1613,6 +1621,8 @@ func HandleURLWithExtensionJSON(url string) (string, error) { "disc_number": track.DiscNumber, "isrc": track.ISRC, "provider_id": track.ProviderID, + "item_type": track.ItemType, + "album_type": track.AlbumType, } } response["tracks"] = tracks @@ -1627,6 +1637,7 @@ func HandleURLWithExtensionJSON(url string) (string, error) { "cover_url": result.Album.CoverURL, "release_date": result.Album.ReleaseDate, "total_tracks": result.Album.TotalTracks, + "album_type": result.Album.AlbumType, } } @@ -1681,6 +1692,219 @@ func FindURLHandlerJSON(url string) string { return handler.extension.ID } +// GetAlbumWithExtensionJSON gets album tracks using an extension +func GetAlbumWithExtensionJSON(extensionID, albumID string) (string, error) { + manager := GetExtensionManager() + ext, err := manager.GetExtension(extensionID) + if err != nil { + return "", err + } + + if !ext.Manifest.IsMetadataProvider() { + return "", fmt.Errorf("extension '%s' is not a metadata provider", extensionID) + } + + provider := NewExtensionProviderWrapper(ext) + album, err := provider.GetAlbum(albumID) + if err != nil { + return "", err + } + + if album == nil { + return "", fmt.Errorf("album not found") + } + + // Convert tracks to map format + tracks := make([]map[string]interface{}, len(album.Tracks)) + for i, track := range album.Tracks { + // Use album cover as fallback if track doesn't have its own cover + trackCover := track.ResolvedCoverURL() + if trackCover == "" { + trackCover = album.CoverURL + } + tracks[i] = map[string]interface{}{ + "id": track.ID, + "name": track.Name, + "artists": track.Artists, + "album_name": track.AlbumName, + "album_artist": track.AlbumArtist, + "duration_ms": track.DurationMS, + "cover_url": trackCover, + "release_date": track.ReleaseDate, + "track_number": track.TrackNumber, + "disc_number": track.DiscNumber, + "isrc": track.ISRC, + "provider_id": track.ProviderID, + "item_type": track.ItemType, + "album_type": track.AlbumType, + } + } + + response := map[string]interface{}{ + "id": album.ID, + "name": album.Name, + "artists": album.Artists, + "cover_url": album.CoverURL, + "release_date": album.ReleaseDate, + "total_tracks": album.TotalTracks, + "album_type": album.AlbumType, + "tracks": tracks, + "provider_id": album.ProviderID, + } + + jsonBytes, err := json.Marshal(response) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +// GetPlaylistWithExtensionJSON gets playlist tracks using an extension +func GetPlaylistWithExtensionJSON(extensionID, playlistID string) (string, error) { + manager := GetExtensionManager() + ext, err := manager.GetExtension(extensionID) + if err != nil { + return "", err + } + + if !ext.Manifest.IsMetadataProvider() { + return "", fmt.Errorf("extension '%s' is not a metadata provider", extensionID) + } + + provider := NewExtensionProviderWrapper(ext) + + // Try getPlaylist first, fall back to getAlbum (some extensions use album for playlists) + script := fmt.Sprintf(` + (function() { + if (typeof extension !== 'undefined' && typeof extension.getPlaylist === 'function') { + return extension.getPlaylist(%q); + } + if (typeof extension !== 'undefined' && typeof extension.getAlbum === 'function') { + return extension.getAlbum(%q); + } + return null; + })() + `, playlistID, playlistID) + + result, err := RunWithTimeoutAndRecover(provider.vm, script, DefaultJSTimeout) + if err != nil { + return "", fmt.Errorf("getPlaylist failed: %w", err) + } + + if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { + return "", fmt.Errorf("playlist not found") + } + + exported := result.Export() + jsonBytes, err := json.Marshal(exported) + if err != nil { + return "", fmt.Errorf("failed to marshal result: %w", err) + } + + // Parse into album metadata (same structure) + var album ExtAlbumMetadata + if err := json.Unmarshal(jsonBytes, &album); err != nil { + return "", fmt.Errorf("failed to parse playlist: %w", err) + } + + // Convert tracks to map format + tracks := make([]map[string]interface{}, len(album.Tracks)) + for i, track := range album.Tracks { + // Use playlist cover as fallback if track doesn't have its own cover + trackCover := track.ResolvedCoverURL() + if trackCover == "" { + trackCover = album.CoverURL + } + tracks[i] = map[string]interface{}{ + "id": track.ID, + "name": track.Name, + "artists": track.Artists, + "album_name": track.AlbumName, + "album_artist": track.AlbumArtist, + "duration_ms": track.DurationMS, + "cover_url": trackCover, + "release_date": track.ReleaseDate, + "track_number": track.TrackNumber, + "disc_number": track.DiscNumber, + "isrc": track.ISRC, + "provider_id": track.ProviderID, + "item_type": track.ItemType, + "album_type": track.AlbumType, + } + } + + response := map[string]interface{}{ + "id": album.ID, + "name": album.Name, + "owner": album.Artists, + "cover_url": album.CoverURL, + "total_tracks": album.TotalTracks, + "tracks": tracks, + "provider_id": album.ProviderID, + } + + jsonBytes, err = json.Marshal(response) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +// GetArtistWithExtensionJSON gets artist info and albums using an extension +func GetArtistWithExtensionJSON(extensionID, artistID string) (string, error) { + manager := GetExtensionManager() + ext, err := manager.GetExtension(extensionID) + if err != nil { + return "", err + } + + if !ext.Manifest.IsMetadataProvider() { + return "", fmt.Errorf("extension '%s' is not a metadata provider", extensionID) + } + + provider := NewExtensionProviderWrapper(ext) + artist, err := provider.GetArtist(artistID) + if err != nil { + return "", err + } + + if artist == nil { + return "", fmt.Errorf("artist not found") + } + + // Convert albums to map format + albums := make([]map[string]interface{}, len(artist.Albums)) + for i, album := range artist.Albums { + albums[i] = map[string]interface{}{ + "id": album.ID, + "name": album.Name, + "artists": album.Artists, + "cover_url": album.CoverURL, + "release_date": album.ReleaseDate, + "total_tracks": album.TotalTracks, + "album_type": album.AlbumType, + "provider_id": album.ProviderID, + } + } + + response := map[string]interface{}{ + "id": artist.ID, + "name": artist.Name, + "cover_url": artist.ImageURL, + "albums": albums, + "provider_id": artist.ProviderID, + } + + jsonBytes, err := json.Marshal(response) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + // GetURLHandlersJSON returns all extensions that handle custom URLs func GetURLHandlersJSON() (string, error) { manager := GetExtensionManager() diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go index 578bc2bb..c2c5fcfd 100644 --- a/go_backend/extension_providers.go +++ b/go_backend/extension_providers.go @@ -29,6 +29,14 @@ type ExtTrackMetadata struct { DiscNumber int `json:"disc_number,omitempty"` ISRC string `json:"isrc,omitempty"` ProviderID string `json:"provider_id"` + ItemType string `json:"item_type,omitempty"` // track, album, or playlist - for extension search results + AlbumType string `json:"album_type,omitempty"` // album, single, ep, compilation + // Enrichment fields from Odesli/song.link + TidalID string `json:"tidal_id,omitempty"` + QobuzID string `json:"qobuz_id,omitempty"` + DeezerID string `json:"deezer_id,omitempty"` + SpotifyID string `json:"spotify_id,omitempty"` + ExternalLinks map[string]string `json:"external_links,omitempty"` // service -> URL mapping } // ResolvedCoverURL returns the cover URL, checking both CoverURL and Images fields @@ -730,6 +738,19 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro GoLog("[DownloadWithExtensionFallback] ISRC enriched: %s -> %s\n", req.ISRC, enrichedTrack.ISRC) req.ISRC = enrichedTrack.ISRC } + // Update service-specific IDs from Odesli enrichment + if enrichedTrack.TidalID != "" { + GoLog("[DownloadWithExtensionFallback] Tidal ID from Odesli: %s\n", enrichedTrack.TidalID) + req.TidalID = enrichedTrack.TidalID + } + if enrichedTrack.QobuzID != "" { + GoLog("[DownloadWithExtensionFallback] Qobuz ID from Odesli: %s\n", enrichedTrack.QobuzID) + req.QobuzID = enrichedTrack.QobuzID + } + if enrichedTrack.DeezerID != "" { + GoLog("[DownloadWithExtensionFallback] Deezer ID from Odesli: %s\n", enrichedTrack.DeezerID) + req.DeezerID = enrichedTrack.DeezerID + } // Can also update other fields if needed if enrichedTrack.Name != "" { req.TrackName = enrichedTrack.Name diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index 5ebd83df..e5c3e3b4 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -367,6 +367,35 @@ func NewQobuzDownloader() *QobuzDownloader { return globalQobuzDownloader } +// GetTrackByID fetches track info directly by Qobuz track ID +func (q *QobuzDownloader) GetTrackByID(trackID int64) (*QobuzTrack, error) { + // Qobuz API: /track/get?track_id=XXX + apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9nZXQ/dHJhY2tfaWQ9") + trackURL := fmt.Sprintf("%s%d&app_id=%s", string(apiBase), trackID, q.appID) + + req, err := http.NewRequest("GET", trackURL, nil) + if err != nil { + return nil, err + } + + resp, err := DoRequestWithUserAgent(q.client, req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("get track failed: HTTP %d", resp.StatusCode) + } + + var track QobuzTrack + if err := json.NewDecoder(resp.Body).Decode(&track); err != nil { + return nil, err + } + + return &track, nil +} + // GetAvailableAPIs returns list of available Qobuz APIs // Uses same APIs as PC version for compatibility func (q *QobuzDownloader) GetAvailableAPIs() []string { @@ -936,8 +965,23 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { var track *QobuzTrack var err error + // STRATEGY 0: Use pre-fetched Qobuz ID from Odesli enrichment (highest priority) + if req.QobuzID != "" { + GoLog("[Qobuz] Using Qobuz ID from Odesli enrichment: %s\n", req.QobuzID) + var trackID int64 + if _, parseErr := fmt.Sscanf(req.QobuzID, "%d", &trackID); parseErr == nil && trackID > 0 { + track, err = downloader.GetTrackByID(trackID) + if err != nil { + GoLog("[Qobuz] Failed to get track by Odesli ID %d: %v\n", trackID, err) + track = nil + } else if track != nil { + GoLog("[Qobuz] Successfully found track via Odesli ID: '%s' by '%s'\n", track.Title, track.Performer.Name) + } + } + } + // OPTIMIZATION: Check cache first for track ID - if req.ISRC != "" { + if track == nil && req.ISRC != "" { if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.QobuzTrackID > 0 { GoLog("[Qobuz] Cache hit! Using cached track ID: %d\n", cached.QobuzTrackID) // For Qobuz we need to search again to get full track info, but we can use the ID diff --git a/go_backend/tidal.go b/go_backend/tidal.go index a6663827..15a8f7ea 100644 --- a/go_backend/tidal.go +++ b/go_backend/tidal.go @@ -1457,8 +1457,24 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { var track *TidalTrack var err error + // STRATEGY 0: Use pre-fetched Tidal ID from Odesli enrichment (highest priority) + if req.TidalID != "" { + GoLog("[Tidal] Using Tidal ID from Odesli enrichment: %s\n", req.TidalID) + // Parse track ID (could be a number or extracted from URL) + var trackID int64 + if _, parseErr := fmt.Sscanf(req.TidalID, "%d", &trackID); parseErr == nil && trackID > 0 { + track, err = downloader.GetTrackInfoByID(trackID) + if err != nil { + GoLog("[Tidal] Failed to get track by Odesli ID %d: %v\n", trackID, err) + track = nil + } else if track != nil { + GoLog("[Tidal] Successfully found track via Odesli ID: '%s' by '%s'\n", track.Title, track.Artist.Name) + } + } + } + // OPTIMIZATION: Check cache first for track ID - if req.ISRC != "" { + if track == nil && req.ISRC != "" { if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.TidalTrackID > 0 { GoLog("[Tidal] Cache hit! Using cached track ID: %d\n", cached.TidalTrackID) track, err = downloader.GetTrackInfoByID(cached.TidalTrackID) diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index fa352449..22c9768a 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -503,6 +503,30 @@ import Gobackend // Import Go framework if let error = error { throw error } return response + case "getAlbumWithExtension": + let args = call.arguments as! [String: Any] + let extensionId = args["extension_id"] as! String + let albumId = args["album_id"] as! String + let response = GobackendGetAlbumWithExtensionJSON(extensionId, albumId, &error) + if let error = error { throw error } + return response + + case "getPlaylistWithExtension": + let args = call.arguments as! [String: Any] + let extensionId = args["extension_id"] as! String + let playlistId = args["playlist_id"] as! String + let response = GobackendGetPlaylistWithExtensionJSON(extensionId, playlistId, &error) + if let error = error { throw error } + return response + + case "getArtistWithExtension": + let args = call.arguments as! [String: Any] + let extensionId = args["extension_id"] as! String + let artistId = args["artist_id"] as! String + let response = GobackendGetArtistWithExtensionJSON(extensionId, artistId, &error) + if let error = error { throw error } + return response + // Extension Post-Processing API case "runPostProcessing": let args = call.arguments as! [String: Any] diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart index 724afbe6..5049a914 100644 --- a/lib/constants/app_info.dart +++ b/lib/constants/app_info.dart @@ -1,8 +1,8 @@ /// App version and info constants /// Update version here only - all other files will reference this class AppInfo { - static const String version = '3.0.0'; - static const String buildNumber = '57'; + static const String version = '3.0.1'; + static const String buildNumber = '58'; static const String fullVersion = '$version+$buildNumber'; diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 5462a4a3..43882872 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -28,7 +28,7 @@ class AppSettings { final bool useExtensionProviders; // Use extension providers for downloads when available final String? searchProvider; // null/empty = default (Deezer/Spotify), otherwise extension ID final bool separateSingles; // Separate singles/EPs into their own folder - final String albumFolderStructure; // artist_album or album_only + final String albumFolderStructure; // artist_album, album_only, artist_year_album, year_album final bool showExtensionStore; // Show Extension Store tab in navigation const AppSettings({ diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index 06cd85b7..47330d13 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -32,7 +32,8 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( useExtensionProviders: json['useExtensionProviders'] as bool? ?? true, searchProvider: json['searchProvider'] as String?, separateSingles: json['separateSingles'] as bool? ?? false, - albumFolderStructure: json['albumFolderStructure'] as String? ?? 'artist_album', + albumFolderStructure: + json['albumFolderStructure'] as String? ?? 'artist_album', showExtensionStore: json['showExtensionStore'] as bool? ?? true, ); diff --git a/lib/models/track.dart b/lib/models/track.dart index ac579b50..dda110b7 100644 --- a/lib/models/track.dart +++ b/lib/models/track.dart @@ -20,6 +20,7 @@ class Track { final ServiceAvailability? availability; final String? source; // Extension ID that provided this track (null for built-in sources) final String? albumType; // album, single, ep, compilation (from metadata API) + final String? itemType; // track, album, playlist - for extension search results const Track({ required this.id, @@ -37,10 +38,23 @@ class Track { this.availability, this.source, this.albumType, + this.itemType, }); /// Check if this track is a single (based on album_type metadata) bool get isSingle => albumType == 'single' || albumType == 'ep'; + + /// Check if this is an album item (not a track) + bool get isAlbumItem => itemType == 'album'; + + /// Check if this is a playlist item (not a track) + bool get isPlaylistItem => itemType == 'playlist'; + + /// Check if this is an artist item (not a track) + bool get isArtistItem => itemType == 'artist'; + + /// Check if this is a collection (album, playlist, or artist) + bool get isCollection => isAlbumItem || isPlaylistItem || isArtistItem; factory Track.fromJson(Map json) => _$TrackFromJson(json); Map toJson() => _$TrackToJson(this); diff --git a/lib/models/track.g.dart b/lib/models/track.g.dart index 0836a5b2..1d2277b7 100644 --- a/lib/models/track.g.dart +++ b/lib/models/track.g.dart @@ -26,6 +26,7 @@ Track _$TrackFromJson(Map json) => Track( ), source: json['source'] as String?, albumType: json['albumType'] as String?, + itemType: json['itemType'] as String?, ); Map _$TrackToJson(Track instance) => { @@ -44,6 +45,7 @@ Map _$TrackToJson(Track instance) => { 'availability': instance.availability, 'source': instance.source, 'albumType': instance.albumType, + 'itemType': instance.itemType, }; ServiceAvailability _$ServiceAvailabilityFromJson(Map json) => diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index ccbab4a8..6a0355af 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -688,15 +688,28 @@ class DownloadQueueNotifier extends Notifier { } else { // Albums folder structure based on setting final albumName = _sanitizeFolderName(track.albumName); + final artistName = _sanitizeFolderName(track.albumArtist ?? track.artistName); + final year = _extractYear(track.releaseDate); String albumPath; - if (albumFolderStructure == 'album_only') { - // Albums/Album structure (no artist folder) - albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$albumName'; - } else { - // Albums/Artist/Album structure (default) - final artistName = _sanitizeFolderName(track.albumArtist ?? track.artistName); - albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$artistName${Platform.pathSeparator}$albumName'; + switch (albumFolderStructure) { + case 'album_only': + // Albums/Album structure (no artist folder) + albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$albumName'; + break; + case 'artist_year_album': + // Albums/Artist/[Year] Album structure + final yearAlbum = year != null ? '[$year] $albumName' : albumName; + albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$artistName${Platform.pathSeparator}$yearAlbum'; + break; + case 'year_album': + // Albums/[Year] Album structure (no artist folder) + final yearAlbum = year != null ? '[$year] $albumName' : albumName; + albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$yearAlbum'; + break; + default: + // Albums/Artist/Album structure (default: artist_album) + albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$artistName${Platform.pathSeparator}$albumName'; } final dir = Directory(albumPath); @@ -751,6 +764,14 @@ class DownloadQueueNotifier extends Notifier { .trim(); } + /// Extract year from release date (format: "2005-06-13" or "2005") + String? _extractYear(String? releaseDate) { + if (releaseDate == null || releaseDate.isEmpty) return null; + // Handle both "2005-06-13" and "2005" formats + final match = RegExp(r'^(\d{4})').firstMatch(releaseDate); + return match?.group(1); + } + void updateSettings(AppSettings settings) { state = state.copyWith( outputDir: settings.downloadDirectory.isNotEmpty diff --git a/lib/providers/track_provider.dart b/lib/providers/track_provider.dart index 032836f1..61651414 100644 --- a/lib/providers/track_provider.dart +++ b/lib/providers/track_provider.dart @@ -82,6 +82,7 @@ class ArtistAlbum { final String? coverUrl; final String albumType; // album, single, compilation final String artists; + final String? providerId; // Extension ID if from extension const ArtistAlbum({ required this.id, @@ -91,6 +92,7 @@ class ArtistAlbum { this.coverUrl, required this.albumType, required this.artists, + this.providerId, }); } @@ -479,6 +481,23 @@ class TrackNotifier extends Notifier { void setSearchText(bool hasText) { state = state.copyWith(hasSearchText: hasText); } + + /// Set tracks from a collection (album/playlist) opened from search results + void setTracksFromCollection({ + required List tracks, + String? albumName, + String? playlistName, + String? coverUrl, + }) { + state = TrackState( + tracks: tracks, + isLoading: false, + albumName: albumName, + playlistName: playlistName, + coverUrl: coverUrl, + hasSearchText: state.hasSearchText, + ); + } Track _parseTrack(Map data) { return Track( @@ -506,13 +525,16 @@ class TrackNotifier extends Notifier { durationMs = durationValue.toInt(); } + // Get item_type - can be 'track', 'album', or 'playlist' + final itemType = data['item_type']?.toString(); + return Track( id: (data['spotify_id'] ?? data['id'] ?? '').toString(), name: (data['name'] ?? '').toString(), artistName: (data['artists'] ?? data['artist'] ?? '').toString(), albumName: (data['album_name'] ?? data['album'] ?? '').toString(), albumArtist: data['album_artist']?.toString(), - coverUrl: data['images']?.toString(), + coverUrl: (data['cover_url'] ?? data['images'])?.toString(), isrc: data['isrc']?.toString(), duration: (durationMs / 1000).round(), trackNumber: data['track_number'] as int?, @@ -520,6 +542,7 @@ class TrackNotifier extends Notifier { releaseDate: data['release_date']?.toString(), source: source ?? data['source']?.toString() ?? data['provider_id']?.toString(), albumType: data['album_type']?.toString(), + itemType: itemType, ); } diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index 5235e1dd..49696698 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -5,6 +5,7 @@ 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'; +import 'package:spotiflac_android/screens/home_tab.dart' show ExtensionAlbumScreen; /// Simple in-memory cache for artist discography class _ArtistCache { @@ -346,14 +347,29 @@ class _ArtistScreenState extends ConsumerState { 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 - ), - )); + + // Check if this album is from an extension (has providerId) + if (album.providerId != null && album.providerId!.isNotEmpty) { + // Use ExtensionAlbumScreen for extension albums + Navigator.push(context, MaterialPageRoute( + builder: (context) => ExtensionAlbumScreen( + extensionId: album.providerId!, + albumId: album.id, + albumName: album.name, + coverUrl: album.coverUrl, + ), + )); + } else { + // Use regular AlbumScreen for Spotify/Deezer albums + Navigator.push(context, MaterialPageRoute( + builder: (context) => AlbumScreen( + albumId: album.id, + albumName: album.name, + coverUrl: album.coverUrl, + // tracks: null - will be fetched in AlbumScreen + ), + )); + } } /// Build error widget with special handling for rate limit (429) diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 6950ae07..32c0cae7 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -6,6 +6,8 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:spotiflac_android/providers/track_provider.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; class HomeScreen extends ConsumerStatefulWidget { const HomeScreen({super.key}); @@ -267,6 +269,23 @@ class _HomeScreenState extends ConsumerState { Widget _buildTrackTile(int index, ColorScheme colorScheme) { final track = ref.watch(trackProvider).tracks[index]; + final isCollection = track.isCollection; + + // Determine subtitle text based on item type + String subtitleText; + if (isCollection) { + final typeLabel = track.albumType ?? (track.isPlaylistItem ? 'Playlist' : 'Album'); + final capitalizedType = typeLabel.isNotEmpty + ? '${typeLabel[0].toUpperCase()}${typeLabel.substring(1)}' + : 'Album'; + final year = track.releaseDate != null && track.releaseDate!.length >= 4 + ? track.releaseDate!.substring(0, 4) + : ''; + subtitleText = '$capitalizedType • ${track.artistName}${year.isNotEmpty ? ' • $year' : ''}'; + } else { + subtitleText = track.artistName; + } + return ListTile( leading: track.coverUrl != null ? ClipRRect( @@ -285,22 +304,87 @@ class _HomeScreenState extends ConsumerState { color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), ), - child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant), + child: Icon( + isCollection ? Icons.album : Icons.music_note, + color: colorScheme.onSurfaceVariant, + ), ), title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis), subtitle: Text( - track.artistName, + subtitleText, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant), ), - trailing: Text( - _formatDuration(track.duration), - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - onTap: () => _downloadTrack(index), + trailing: isCollection + ? Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant) + : Text( + _formatDuration(track.duration), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + onTap: () => isCollection ? _openCollection(track) : _downloadTrack(index), + ); + } + + Future _openCollection(Track track) async { + // Get the extension ID from the track source + final extensionId = track.source; + if (extensionId == null) return; + + // Fetch album/playlist tracks using the extension + try { + if (track.isAlbumItem) { + final albumData = await PlatformBridge.getAlbumWithExtension(extensionId, track.id); + if (albumData != null && mounted) { + final trackList = albumData['tracks'] as List? ?? []; + final tracks = trackList.map((t) => _parseExtensionTrack(t as Map, extensionId)).toList(); + ref.read(trackProvider.notifier).setTracksFromCollection( + tracks: tracks, + albumName: albumData['name'] as String? ?? track.name, + coverUrl: albumData['cover_url'] as String? ?? track.coverUrl, + ); + } + } else if (track.isPlaylistItem) { + final playlistData = await PlatformBridge.getPlaylistWithExtension(extensionId, track.id); + if (playlistData != null && mounted) { + final trackList = playlistData['tracks'] as List? ?? []; + final tracks = trackList.map((t) => _parseExtensionTrack(t as Map, extensionId)).toList(); + ref.read(trackProvider.notifier).setTracksFromCollection( + tracks: tracks, + playlistName: playlistData['name'] as String? ?? track.name, + coverUrl: playlistData['cover_url'] as String? ?? track.coverUrl, + ); + } + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to load: $e')), + ); + } + } + } + + Track _parseExtensionTrack(Map data, String source) { + int durationMs = 0; + final durationValue = data['duration_ms']; + if (durationValue is int) { + durationMs = durationValue; + } else if (durationValue is double) { + durationMs = durationValue.toInt(); + } + + return Track( + id: (data['id'] ?? '').toString(), + name: (data['name'] ?? '').toString(), + artistName: (data['artists'] ?? '').toString(), + albumName: (data['album_name'] ?? '').toString(), + coverUrl: (data['cover_url'] ?? data['images'])?.toString(), + duration: (durationMs / 1000).round(), + releaseDate: data['release_date']?.toString(), + source: source, ); } diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 846d583e..388f41fa 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -13,6 +13,7 @@ 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/services/csv_import_service.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/screens/playlist_screen.dart'; import 'package:spotiflac_android/models/download_item.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; @@ -636,6 +637,12 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient return [const SliverToBoxAdapter(child: SizedBox.shrink())]; } + // Separate tracks from albums/playlists/artists + final realTracks = tracks.where((t) => !t.isCollection).toList(); + final albumItems = tracks.where((t) => t.isAlbumItem).toList(); + final playlistItems = tracks.where((t) => t.isPlaylistItem).toList(); + final artistItems = tracks.where((t) => t.isArtistItem).toList(); + return [ // Error message - with special handling for rate limit (429) if (error != null) @@ -648,19 +655,17 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient if (isLoading) const SliverToBoxAdapter(child: Padding(padding: EdgeInsets.symmetric(horizontal: 16), child: LinearProgressIndicator())), - // Artist search results (horizontal scroll) + // Artist search results (horizontal scroll) - from built-in providers if (searchArtists != null && searchArtists.isNotEmpty) SliverToBoxAdapter(child: _buildArtistSearchResults(searchArtists, colorScheme)), - // Songs section header - if (tracks.isNotEmpty) + // Artists section - from extension search + if (artistItems.isNotEmpty) SliverToBoxAdapter(child: Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), - child: Text('Songs', style: Theme.of(context).textTheme.titleSmall?.copyWith(color: colorScheme.onSurfaceVariant)), + child: Text('Artists', style: Theme.of(context).textTheme.titleSmall?.copyWith(color: colorScheme.onSurfaceVariant)), )), - - // Track list in grouped card - if (tracks.isNotEmpty) + if (artistItems.isNotEmpty) SliverToBoxAdapter( child: Container( margin: const EdgeInsets.symmetric(horizontal: 16), @@ -676,13 +681,120 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient child: Column( mainAxisSize: MainAxisSize.min, children: [ - for (int i = 0; i < tracks.length; i++) + for (int i = 0; i < artistItems.length; i++) + _CollectionItemWidget( + key: ValueKey('artist-${artistItems[i].id}'), + item: artistItems[i], + showDivider: i < artistItems.length - 1, + onTap: () => _navigateToExtensionArtist(artistItems[i]), + ), + ], + ), + ), + ), + ), + + // Albums section + if (albumItems.isNotEmpty) + SliverToBoxAdapter(child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Text('Albums', style: Theme.of(context).textTheme.titleSmall?.copyWith(color: colorScheme.onSurfaceVariant)), + )), + if (albumItems.isNotEmpty) + SliverToBoxAdapter( + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: Theme.of(context).brightness == Brightness.dark + ? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), colorScheme.surface) + : colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(20), + ), + clipBehavior: Clip.antiAlias, + child: Material( + color: Colors.transparent, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (int i = 0; i < albumItems.length; i++) + _CollectionItemWidget( + key: ValueKey('album-${albumItems[i].id}'), + item: albumItems[i], + showDivider: i < albumItems.length - 1, + onTap: () => _navigateToExtensionAlbum(albumItems[i]), + ), + ], + ), + ), + ), + ), + + // Playlists section + if (playlistItems.isNotEmpty) + SliverToBoxAdapter(child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Text('Playlists', style: Theme.of(context).textTheme.titleSmall?.copyWith(color: colorScheme.onSurfaceVariant)), + )), + if (playlistItems.isNotEmpty) + SliverToBoxAdapter( + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: Theme.of(context).brightness == Brightness.dark + ? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), colorScheme.surface) + : colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(20), + ), + clipBehavior: Clip.antiAlias, + child: Material( + color: Colors.transparent, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (int i = 0; i < playlistItems.length; i++) + _CollectionItemWidget( + key: ValueKey('playlist-${playlistItems[i].id}'), + item: playlistItems[i], + showDivider: i < playlistItems.length - 1, + onTap: () => _navigateToExtensionPlaylist(playlistItems[i]), + ), + ], + ), + ), + ), + ), + + // Songs section header + if (realTracks.isNotEmpty) + SliverToBoxAdapter(child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Text('Songs', style: Theme.of(context).textTheme.titleSmall?.copyWith(color: colorScheme.onSurfaceVariant)), + )), + + // Track list in grouped card + if (realTracks.isNotEmpty) + SliverToBoxAdapter( + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: Theme.of(context).brightness == Brightness.dark + ? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), colorScheme.surface) + : colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(20), + ), + clipBehavior: Clip.antiAlias, + child: Material( + color: Colors.transparent, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (int i = 0; i < realTracks.length; i++) _TrackItemWithStatus( - key: ValueKey(tracks[i].id), - track: tracks[i], - index: i, - showDivider: i < tracks.length - 1, - onDownload: () => _downloadTrack(i), + key: ValueKey(realTracks[i].id), + track: realTracks[i], + index: tracks.indexOf(realTracks[i]), // Use original index for download + showDivider: i < realTracks.length - 1, + onDownload: () => _downloadTrack(tracks.indexOf(realTracks[i])), ), ], ), @@ -785,6 +897,72 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient )); } + void _navigateToExtensionAlbum(Track albumItem) async { + final extensionId = albumItem.source; + if (extensionId == null || extensionId.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Cannot load album: missing extension source')), + ); + return; + } + + ref.read(settingsProvider.notifier).setHasSearchedBefore(); + + // Navigate to AlbumScreen - it will fetch tracks via extension + Navigator.push(context, MaterialPageRoute( + builder: (context) => ExtensionAlbumScreen( + extensionId: extensionId, + albumId: albumItem.id, + albumName: albumItem.name, + coverUrl: albumItem.coverUrl, + ), + )); + } + + void _navigateToExtensionPlaylist(Track playlistItem) async { + final extensionId = playlistItem.source; + if (extensionId == null || extensionId.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Cannot load playlist: missing extension source')), + ); + return; + } + + ref.read(settingsProvider.notifier).setHasSearchedBefore(); + + // Navigate to ExtensionPlaylistScreen - it will fetch tracks via extension + Navigator.push(context, MaterialPageRoute( + builder: (context) => ExtensionPlaylistScreen( + extensionId: extensionId, + playlistId: playlistItem.id, + playlistName: playlistItem.name, + coverUrl: playlistItem.coverUrl, + ), + )); + } + + void _navigateToExtensionArtist(Track artistItem) { + final extensionId = artistItem.source; + if (extensionId == null || extensionId.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Cannot load artist: missing extension source')), + ); + return; + } + + ref.read(settingsProvider.notifier).setHasSearchedBefore(); + + // Navigate to ExtensionArtistScreen - it will fetch albums via extension + Navigator.push(context, MaterialPageRoute( + builder: (context) => ExtensionArtistScreen( + extensionId: extensionId, + artistId: artistItem.id, + artistName: artistItem.name, + coverUrl: artistItem.coverUrl, + ), + )); + } + /// Get search hint based on selected provider String _getSearchHint() { final settings = ref.read(settingsProvider); @@ -1109,3 +1287,498 @@ class _TrackItemWithStatus extends ConsumerWidget { } } } + +/// Widget for displaying album/playlist items in search results +class _CollectionItemWidget extends StatelessWidget { + final Track item; + final bool showDivider; + final VoidCallback onTap; + + const _CollectionItemWidget({ + super.key, + required this.item, + required this.showDivider, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final isPlaylist = item.isPlaylistItem; + final isArtist = item.isArtistItem; + + // Determine icon for placeholder + IconData placeholderIcon = Icons.album; + if (isPlaylist) placeholderIcon = Icons.playlist_play; + if (isArtist) placeholderIcon = Icons.person; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + InkWell( + onTap: onTap, + 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: [ + // Cover art (circular for artists) + ClipRRect( + borderRadius: BorderRadius.circular(isArtist ? 28 : 10), + child: item.coverUrl != null && item.coverUrl!.isNotEmpty + ? CachedNetworkImage( + imageUrl: item.coverUrl!, + width: 56, + height: 56, + fit: BoxFit.cover, + memCacheWidth: 112, + memCacheHeight: 112, + ) + : Container( + width: 56, + height: 56, + color: colorScheme.surfaceContainerHighest, + child: Icon( + placeholderIcon, + color: colorScheme.onSurfaceVariant, + ), + ), + ), + const SizedBox(width: 12), + // Info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.name, + style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + item.artistName.isNotEmpty ? item.artistName : (isPlaylist ? 'Playlist' : 'Album'), + style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + // Arrow indicator + Icon( + Icons.chevron_right, + color: colorScheme.onSurfaceVariant, + size: 24, + ), + ], + ), + ), + ), + if (showDivider) + Divider( + height: 1, + thickness: 1, + indent: 80, + endIndent: 12, + color: colorScheme.outlineVariant.withValues(alpha: 0.3), + ), + ], + ); + } +} + +/// Screen for viewing extension album with track fetching +class ExtensionAlbumScreen extends ConsumerStatefulWidget { + final String extensionId; + final String albumId; + final String albumName; + final String? coverUrl; + + const ExtensionAlbumScreen({ + super.key, + required this.extensionId, + required this.albumId, + required this.albumName, + this.coverUrl, + }); + + @override + ConsumerState createState() => _ExtensionAlbumScreenState(); +} + +class _ExtensionAlbumScreenState extends ConsumerState { + List? _tracks; + bool _isLoading = true; + String? _error; + + @override + void initState() { + super.initState(); + _fetchTracks(); + } + + Future _fetchTracks() async { + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final result = await PlatformBridge.getAlbumWithExtension( + widget.extensionId, + widget.albumId, + ); + + if (result == null) { + setState(() { + _error = 'Failed to load album'; + _isLoading = false; + }); + return; + } + + // Parse tracks from result + final trackList = result['tracks'] as List?; + if (trackList == null) { + setState(() { + _error = 'No tracks found'; + _isLoading = false; + }); + return; + } + + final tracks = trackList.map((t) => _parseTrack(t as Map)).toList(); + + setState(() { + _tracks = tracks; + _isLoading = false; + }); + } catch (e) { + setState(() { + _error = 'Error: $e'; + _isLoading = false; + }); + } + } + + Track _parseTrack(Map data) { + int durationMs = 0; + final durationValue = data['duration_ms']; + if (durationValue is int) { + durationMs = durationValue; + } else if (durationValue is double) { + durationMs = durationValue.toInt(); + } + + return Track( + id: (data['id'] ?? '').toString(), + name: (data['name'] ?? '').toString(), + artistName: (data['artists'] ?? data['artist'] ?? '').toString(), + albumName: (data['album_name'] ?? widget.albumName).toString(), + coverUrl: _resolveCoverUrl(data['cover_url']?.toString(), widget.coverUrl), + isrc: data['isrc']?.toString(), + duration: (durationMs / 1000).round(), + trackNumber: data['track_number'] as int?, + source: widget.extensionId, + ); + } + + String? _resolveCoverUrl(String? trackCover, String? albumCover) { + if (trackCover != null && trackCover.isNotEmpty) return trackCover; + return albumCover; + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return Scaffold( + appBar: AppBar(title: Text(widget.albumName)), + body: const Center(child: CircularProgressIndicator()), + ); + } + + if (_error != null) { + return Scaffold( + appBar: AppBar(title: Text(widget.albumName)), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), + const SizedBox(height: 16), + ElevatedButton(onPressed: _fetchTracks, child: const Text('Retry')), + ], + ), + ), + ); + } + + // Navigate to AlbumScreen with fetched tracks + return AlbumScreen( + albumId: widget.albumId, + albumName: widget.albumName, + coverUrl: widget.coverUrl, + tracks: _tracks, + ); + } +} + +/// Screen for viewing extension playlist with track fetching +class ExtensionPlaylistScreen extends ConsumerStatefulWidget { + final String extensionId; + final String playlistId; + final String playlistName; + final String? coverUrl; + + const ExtensionPlaylistScreen({ + super.key, + required this.extensionId, + required this.playlistId, + required this.playlistName, + this.coverUrl, + }); + + @override + ConsumerState createState() => _ExtensionPlaylistScreenState(); +} + +class _ExtensionPlaylistScreenState extends ConsumerState { + List? _tracks; + bool _isLoading = true; + String? _error; + + @override + void initState() { + super.initState(); + _fetchTracks(); + } + + Future _fetchTracks() async { + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final result = await PlatformBridge.getPlaylistWithExtension( + widget.extensionId, + widget.playlistId, + ); + + if (result == null) { + setState(() { + _error = 'Failed to load playlist'; + _isLoading = false; + }); + return; + } + + // Parse tracks from result + final trackList = result['tracks'] as List?; + if (trackList == null) { + setState(() { + _error = 'No tracks found'; + _isLoading = false; + }); + return; + } + + final tracks = trackList.map((t) => _parseTrack(t as Map)).toList(); + + setState(() { + _tracks = tracks; + _isLoading = false; + }); + } catch (e) { + setState(() { + _error = 'Error: $e'; + _isLoading = false; + }); + } + } + + Track _parseTrack(Map data) { + int durationMs = 0; + final durationValue = data['duration_ms']; + if (durationValue is int) { + durationMs = durationValue; + } else if (durationValue is double) { + durationMs = durationValue.toInt(); + } + + return Track( + id: (data['id'] ?? '').toString(), + name: (data['name'] ?? '').toString(), + artistName: (data['artists'] ?? data['artist'] ?? '').toString(), + albumName: (data['album_name'] ?? '').toString(), + coverUrl: _resolveCoverUrl(data['cover_url']?.toString(), widget.coverUrl), + isrc: data['isrc']?.toString(), + duration: (durationMs / 1000).round(), + trackNumber: data['track_number'] as int?, + source: widget.extensionId, + ); + } + + String? _resolveCoverUrl(String? trackCover, String? playlistCover) { + if (trackCover != null && trackCover.isNotEmpty) return trackCover; + return playlistCover; + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return Scaffold( + appBar: AppBar(title: Text(widget.playlistName)), + body: const Center(child: CircularProgressIndicator()), + ); + } + + if (_error != null) { + return Scaffold( + appBar: AppBar(title: Text(widget.playlistName)), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), + const SizedBox(height: 16), + ElevatedButton(onPressed: _fetchTracks, child: const Text('Retry')), + ], + ), + ), + ); + } + + // Navigate to PlaylistScreen with fetched tracks + return PlaylistScreen( + playlistName: widget.playlistName, + coverUrl: widget.coverUrl, + tracks: _tracks!, + ); + } +} + +/// Screen for viewing extension artist with album fetching +class ExtensionArtistScreen extends ConsumerStatefulWidget { + final String extensionId; + final String artistId; + final String artistName; + final String? coverUrl; + + const ExtensionArtistScreen({ + super.key, + required this.extensionId, + required this.artistId, + required this.artistName, + this.coverUrl, + }); + + @override + ConsumerState createState() => _ExtensionArtistScreenState(); +} + +class _ExtensionArtistScreenState extends ConsumerState { + List? _albums; + bool _isLoading = true; + String? _error; + + @override + void initState() { + super.initState(); + _fetchArtist(); + } + + Future _fetchArtist() async { + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final result = await PlatformBridge.getArtistWithExtension( + widget.extensionId, + widget.artistId, + ); + + if (result == null) { + setState(() { + _error = 'Failed to load artist'; + _isLoading = false; + }); + return; + } + + // Parse albums from result + final albumList = result['albums'] as List?; + if (albumList == null) { + setState(() { + _albums = []; + _isLoading = false; + }); + return; + } + + final albums = albumList.map((a) => _parseAlbum(a as Map)).toList(); + + setState(() { + _albums = albums; + _isLoading = false; + }); + } catch (e) { + setState(() { + _error = 'Error: $e'; + _isLoading = false; + }); + } + } + + ArtistAlbum _parseAlbum(Map data) { + return ArtistAlbum( + id: (data['id'] ?? '').toString(), + name: (data['name'] ?? '').toString(), + artists: (data['artists'] ?? '').toString(), + releaseDate: (data['release_date'] ?? '').toString(), + totalTracks: data['total_tracks'] as int? ?? 0, + coverUrl: data['cover_url']?.toString(), + albumType: (data['album_type'] ?? 'album').toString(), + providerId: (data['provider_id'] ?? widget.extensionId).toString(), + ); + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return Scaffold( + appBar: AppBar(title: Text(widget.artistName)), + body: const Center(child: CircularProgressIndicator()), + ); + } + + if (_error != null) { + return Scaffold( + appBar: AppBar(title: Text(widget.artistName)), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), + const SizedBox(height: 16), + ElevatedButton(onPressed: _fetchArtist, child: const Text('Retry')), + ], + ), + ), + ); + } + + // Navigate to ArtistScreen with fetched albums + return ArtistScreen( + artistId: widget.artistId, + artistName: widget.artistName, + coverUrl: widget.coverUrl, + albums: _albums, + ); + } +} diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index 7ce58d9d..5f174f59 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -200,9 +200,7 @@ class DownloadSettingsPage extends ConsumerWidget { SettingsItem( icon: Icons.folder_outlined, title: 'Album Folder Structure', - subtitle: settings.albumFolderStructure == 'album_only' - ? 'Albums/Album Name/' - : 'Albums/Artist/Album Name/', + subtitle: _getAlbumFolderStructureLabel(settings.albumFolderStructure), onTap: () => _showAlbumFolderStructurePicker( context, ref, @@ -234,6 +232,19 @@ class DownloadSettingsPage extends ConsumerWidget { ); } + String _getAlbumFolderStructureLabel(String structure) { + switch (structure) { + case 'album_only': + return 'Albums/Album Name/'; + case 'artist_year_album': + return 'Albums/Artist/[Year] Album/'; + case 'year_album': + return 'Albums/[Year] Album/'; + default: + return 'Albums/Artist/Album Name/'; + } + } + void _showAlbumFolderStructurePicker(BuildContext context, WidgetRef ref, String current) { showModalBottomSheet( context: context, @@ -251,6 +262,16 @@ class DownloadSettingsPage extends ConsumerWidget { Navigator.pop(context); }, ), + ListTile( + leading: const Icon(Icons.calendar_today_outlined), + title: const Text('Artist / [Year] Album'), + subtitle: const Text('Albums/Artist Name/[2005] Album Name/'), + trailing: current == 'artist_year_album' ? const Icon(Icons.check) : null, + onTap: () { + ref.read(settingsProvider.notifier).setAlbumFolderStructure('artist_year_album'); + Navigator.pop(context); + }, + ), ListTile( leading: const Icon(Icons.album_outlined), title: const Text('Album Only'), @@ -261,6 +282,16 @@ class DownloadSettingsPage extends ConsumerWidget { Navigator.pop(context); }, ), + ListTile( + leading: const Icon(Icons.event_outlined), + title: const Text('[Year] Album Only'), + subtitle: const Text('Albums/[2005] Album Name/'), + trailing: current == 'year_album' ? const Icon(Icons.check) : null, + onTap: () { + ref.read(settingsProvider.notifier).setAlbumFolderStructure('year_album'); + Navigator.pop(context); + }, + ), ], ), ), diff --git a/lib/screens/settings/options_settings_page.dart b/lib/screens/settings/options_settings_page.dart index 3752656b..da666cd8 100644 --- a/lib/screens/settings/options_settings_page.dart +++ b/lib/screens/settings/options_settings_page.dart @@ -907,16 +907,12 @@ class _SourceChip extends StatelessWidget { final String label; final bool isSelected; final VoidCallback? onTap; - final String? badge; - final Color? badgeColor; const _SourceChip({ required this.icon, required this.label, required this.isSelected, this.onTap, - this.badge, - this.badgeColor, }); @override @@ -962,24 +958,6 @@ class _SourceChip extends StatelessWidget { : colorScheme.onSurfaceVariant, ), ), - if (badge != null) ...[ - const SizedBox(height: 4), - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: (badgeColor ?? colorScheme.tertiary).withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(4), - ), - child: Text( - badge!, - style: TextStyle( - fontSize: 9, - fontWeight: FontWeight.w500, - color: badgeColor ?? colorScheme.tertiary, - ), - ), - ), - ], ], ), ), diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 43a21fb5..44246125 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -787,6 +787,60 @@ class PlatformBridge { return list.map((e) => e as Map).toList(); } + /// Get album tracks using an extension + static Future?> getAlbumWithExtension( + String extensionId, + String albumId, + ) async { + try { + final result = await _channel.invokeMethod('getAlbumWithExtension', { + 'extension_id': extensionId, + 'album_id': albumId, + }); + if (result == null || result == '') return null; + return jsonDecode(result as String) as Map; + } catch (e) { + _log.e('getAlbumWithExtension failed: $e'); + return null; + } + } + + /// Get playlist tracks using an extension + static Future?> getPlaylistWithExtension( + String extensionId, + String playlistId, + ) async { + try { + final result = await _channel.invokeMethod('getPlaylistWithExtension', { + 'extension_id': extensionId, + 'playlist_id': playlistId, + }); + if (result == null || result == '') return null; + return jsonDecode(result as String) as Map; + } catch (e) { + _log.e('getPlaylistWithExtension failed: $e'); + return null; + } + } + + /// Get artist info and albums using an extension + static Future?> getArtistWithExtension( + String extensionId, + String artistId, + ) async { + try { + final result = await _channel.invokeMethod('getArtistWithExtension', { + 'extension_id': extensionId, + 'artist_id': artistId, + }); + if (result == null || result == '') return null; + return jsonDecode(result as String) as Map; + } catch (e) { + _log.e('getArtistWithExtension failed: $e'); + return null; + } + } + // ==================== EXTENSION POST-PROCESSING ==================== /// Run post-processing hooks on a file diff --git a/pubspec.yaml b/pubspec.yaml index 1c4d0a3d..9900e1ce 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music publish_to: "none" -version: 3.0.0+57 +version: 3.0.1+58 environment: sdk: ^3.10.0