diff --git a/CHANGELOG.md b/CHANGELOG.md index f149e1db..439699d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,26 @@ # Changelog -## [3.7.0] - 2026-02-18 +## [3.7.0] - 2026-02-19 ### Added +- **Library Tab Redesign**: Wishlist, Loved, and individual Playlist collections now appear as unified list/grid items in the "All" tab alongside tracks, replacing the old "My Folders" horizontal card section +- **Drag-and-Drop Track Categorization**: Long-press-drag tracks onto playlist items to add them to that playlist; when multiple tracks are selected and one is dragged, all selected tracks are added to the target playlist + - Drag feedback widget displays multi-select count badge +- **Playlist Multi-Select Deletion**: Long-press playlists to enter selection mode, select multiple playlists, and batch-delete all selected at once via a dedicated selection bottom bar +- **Track Categorization System**: Tracks added to any playlist are automatically hidden from the main tracks list; removing a track from a playlist or deleting the playlist makes the track reappear — no actual file deletion ever occurs +- **Create Playlist Button**: New "+" `TextButton.icon` in Library tab header with dynamic theme colors, replacing the old "Select" button +- **Track Options Bottom Sheet**: Rewrote `TrackCollectionQuickActions` from inline action buttons to a single styled bottom sheet with track header (cover, title, artist), divider, and option tiles matching `DownloadServicePicker` visual style +- **Library Tracks Folder SliverAppBar**: Wishlist, Loved, and Playlist detail screens now feature a collapsible SliverAppBar with cover art (45% viewport height, parallax, gradient overlay), mode-specific icons (bookmark/heart/queue_music), title, and track count badge +- **Custom Playlist Cover Images**: Users can set custom cover images for playlists via long-press menu or camera icon in SliverAppBar + - Covers stored locally in app support directory with priority: custom cover > first track URL > icon fallback + - Cover options bottom sheet with change/remove actions + - Playlist list screen shows cover thumbnails +- **Long-Press Context Menus**: Track tiles in library folders and playlist list items now use long-press for styled bottom sheet context menus instead of trailing icon buttons, matching platform conventions +- **Wishlist Quick Download**: Tapping a track in Wishlist opens quality picker (respects "Ask quality before download" setting) and starts download +- **Playlist Track Playback**: Tapping a downloaded track in a Playlist opens it in the device's external music player via `openFile()` with file existence check +- **Collapsible AppBar on Playlist List Screen**: Playlist list screen now uses a collapsible SliverAppBar matching Settings sub-page style (animated title size 20→28px, animated left padding 56→24px) for visual consistency +- **`UnifiedLibraryItem.collectionKey` Getter**: Efficient playlist membership checking without constructing a full `Track` object - **Multi-select Share**: Share multiple downloaded/local tracks at once from the selection bottom bar - Supports SAF content URIs via native `ACTION_SEND_MULTIPLE` intent - Supports regular file paths via SharePlus @@ -15,9 +32,17 @@ - Available in Downloaded Album, Local Album, and Queue tab screens - **Native `shareMultipleContentUris`**: New Android `ACTION_SEND_MULTIPLE` handler in `MainActivity` for sharing multiple SAF content URIs - **Localization**: Added selection share/convert strings to all 13 supported locales (`selectionShareCount`, `selectionShareNoFiles`, `selectionConvertCount`, `selectionConvertNoConvertible`, `selectionBatchConvertConfirmTitle`, `selectionBatchConvertConfirmMessage`, `selectionBatchConvertProgress`, `selectionBatchConvertSuccess`) +- **Localization**: Added library collection l10n keys (`trackOptionAddToLoved`, `trackOptionRemoveFromLoved`, `trackOptionAddToWishlist`, `trackOptionRemoveFromWishlist`, `libraryTracksUnit`, `collectionPlaylistChangeCover`, `collectionPlaylistRemoveCover`) +- **Global Network Compatibility Mode**: New Download settings toggle to help restricted/ISP-filtered networks + - Applies to backend API requests (not SongLink-only) + - Enables HTTP scheme fallback and optional insecure TLS behavior in one switch + - Synced end-to-end across Flutter settings, platform channel (Android/iOS), and Go backend ### Changed +- **Removed "My Folders" Section**: Horizontal card section removed from Library tab header; collections are now inline items in the unified main list/grid +- **Playlist Subtitle Simplified**: Playlist items now show "N tracks" instead of "Playlist • N tracks" +- **Pinned App Bar on All Detail Screens**: `SliverAppBar` changed from `pinned: false` to `pinned: true` in 6 detail screens (album, downloaded album, local album, playlist, track metadata, library tracks folder) so the app bar stays visible when scrolling - **Local Album Multi-select Action Updated**: Replaced batch `Share` action with batch `Re-enrich` - Local album selection bar now uses `Re-enrich` + `Convert` actions - Added batch re-enrich processing for local tracks (FLAC native path and MP3/Opus FFmpeg path, including SAF write-back flow) @@ -25,6 +50,26 @@ - **Queue Multi-select Local Action Updated**: Queue selection bar now switches the first action to `Re-enrich` when selected items are local-only - If selection contains downloaded or mixed items, action remains `Share` - Local-only selection now supports batch re-enrich with the same native/FFmpeg + SAF flow and auto-refreshes local library metadata after completion +- **SongLink Network Option Scope Expanded**: The previous SongLink compatibility path now routes through global network compatibility controls so all supported backend API clients can benefit under problematic networks +- **Removed Per-Track Action Buttons**: Album, playlist, home, artist, and search screens no longer show individual download/add buttons on each track tile; all actions accessed via `TrackCollectionQuickActions` bottom sheet +- **Loved SliverAppBar Always Shows Heart Icon**: Loved tracks folder always displays the heart icon as cover, never uses first track's cover art (like Spotify's Liked Songs) +- **Wishlist Long-Press Menu Conditional Actions**: "Add to Playlist" option only appears when the track is already downloaded +- **Loved Track Tap Disabled**: Tapping a track in the Loved folder performs no action (long-press for options only) +- **Removed Duplicate Create Playlist Button**: Removed `+` IconButton from playlist list screen AppBar since the FAB already serves the same purpose +- **`coverImagePath` Field on `UserPlaylistCollection`**: Model now supports nullable custom cover path with `copyWith` using `String? Function()?` pattern for explicit null assignment + +### Fixed + +- **Local Cover Path Handling**: All cover image renderers (Library tab, playlist detail screen hero cover, per-track tiles, options bottom sheet) now detect whether `coverUrl` is a URL or local file path and use `Image.file` for local paths instead of `CachedNetworkImage` +- **Empty Playlists Now Clickable**: Empty playlist items in Library tab can now be tapped to navigate to their detail screen +- **RenderFlex Overflow**: Fixed overflow in unified library item Row layout when track metadata text was too long +- **SAF FD Permission Denied on Tidal Downloads**: Fixed `failed to create file: open /proc/self/fd/*: permission denied` on some devices/providers + - Android SAF bridge now hands off detached raw FD (`output_fd`) to Go instead of forcing procfs path reopen + - Go output writer includes safer procfs fallback behavior for providers that reject truncate semantics +- **Batch Convert Lyrics Embedding Gap**: Batch convert in Downloaded Album, Local Album, and Queue now preserves/adds lyrics consistently like single convert + - Reuses embedded lyrics when available + - Falls back to sidecar `.lrc` when present + - Falls back to online lyrics fetch and injects into conversion metadata when embedding is enabled --- 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 83caa3be..bd3014c5 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -666,11 +666,13 @@ class MainActivity: FlutterFragmentActivity() { val pfd = contentResolver.openFileDescriptor(document.uri, "rw") ?: return errorJson("Failed to open SAF file") + var detachedFd: Int? = null try { - // Keep SAF PFD ownership in Kotlin and pass only procfs path to Go. - // Go re-opens this procfs FD path for writing to avoid raw FD ownership handoff. - req.put("output_path", "/proc/self/fd/${pfd.fd}") - req.put("output_fd", 0) + // Prefer handing off a detached FD directly to Go. + // Some devices/providers reject re-opening /proc/self/fd/* with permission denied. + detachedFd = pfd.detachFd() + req.put("output_path", "") + req.put("output_fd", detachedFd) req.put("output_ext", outputExt) val response = downloader(req.toString()) val respObj = JSONObject(response) @@ -685,9 +687,13 @@ class MainActivity: FlutterFragmentActivity() { document.delete() return errorJson("SAF download failed: ${e.message}") } finally { - try { - pfd.close() - } catch (_: Exception) {} + // If detachFd() failed before handoff, close original ParcelFileDescriptor. + // Otherwise Go owns the detached raw FD and is responsible for closing it. + if (detachedFd == null) { + try { + pfd.close() + } catch (_: Exception) {} + } } } @@ -1354,6 +1360,14 @@ class MainActivity: FlutterFragmentActivity() { } result.success(null) } + "setNetworkCompatibilityOptions", "setSongLinkNetworkOptions" -> { + val allowHttp = call.argument("allow_http") ?: false + val insecureTls = call.argument("insecure_tls") ?: false + withContext(Dispatchers.IO) { + Gobackend.setNetworkCompatibilityOptions(allowHttp, insecureTls) + } + result.success(null) + } "checkDuplicate" -> { val outputDir = call.argument("output_dir") ?: "" val isrc = call.argument("isrc") ?: "" diff --git a/go_backend/exports.go b/go_backend/exports.go index 9da92ca4..268184b3 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -140,6 +140,12 @@ func CheckAvailability(spotifyID, isrc string) (string, error) { return string(jsonBytes), nil } +// SetSongLinkNetworkOptions is kept for backward compatibility. +// It now applies global network compatibility options for all backend API requests. +func SetSongLinkNetworkOptions(allowHTTP, insecureTLS bool) { + SetNetworkCompatibilityOptions(allowHTTP, insecureTLS) +} + type DownloadRequest struct { ISRC string `json:"isrc"` Service string `json:"service"` diff --git a/go_backend/extension_runtime.go b/go_backend/extension_runtime.go index 5ee1455e..9f87b39d 100644 --- a/go_backend/extension_runtime.go +++ b/go_backend/extension_runtime.go @@ -102,33 +102,31 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime { vm: ext.VM, } - client := &http.Client{ - Timeout: 30 * time.Second, - Jar: jar, - CheckRedirect: func(req *http.Request, via []*http.Request) error { - if req.URL.Scheme != "https" { - GoLog("[Extension:%s] Redirect blocked: non-https scheme '%s'\n", ext.ID, req.URL.Scheme) - return fmt.Errorf("redirect blocked: only https is allowed") - } + client := NewHTTPClientWithTimeout(30 * time.Second) + client.Jar = jar + client.CheckRedirect = func(req *http.Request, via []*http.Request) error { + if req.URL.Scheme != "https" { + GoLog("[Extension:%s] Redirect blocked: non-https scheme '%s'\n", ext.ID, req.URL.Scheme) + return fmt.Errorf("redirect blocked: only https is allowed") + } - domain := req.URL.Hostname() - if domain == "" { - GoLog("[Extension:%s] Redirect blocked: missing hostname\n", ext.ID) - return fmt.Errorf("redirect blocked: hostname is required") - } - if !ext.Manifest.IsDomainAllowed(domain) { - GoLog("[Extension:%s] Redirect blocked: domain '%s' not in allowed list\n", ext.ID, domain) - return &RedirectBlockedError{Domain: domain} - } - if isPrivateIP(domain) { - GoLog("[Extension:%s] Redirect blocked: private IP '%s'\n", ext.ID, domain) - return &RedirectBlockedError{Domain: domain, IsPrivate: true} - } - if len(via) >= 10 { - return http.ErrUseLastResponse - } - return nil - }, + domain := req.URL.Hostname() + if domain == "" { + GoLog("[Extension:%s] Redirect blocked: missing hostname\n", ext.ID) + return fmt.Errorf("redirect blocked: hostname is required") + } + if !ext.Manifest.IsDomainAllowed(domain) { + GoLog("[Extension:%s] Redirect blocked: domain '%s' not in allowed list\n", ext.ID, domain) + return &RedirectBlockedError{Domain: domain} + } + if isPrivateIP(domain) { + GoLog("[Extension:%s] Redirect blocked: private IP '%s'\n", ext.ID, domain) + return &RedirectBlockedError{Domain: domain, IsPrivate: true} + } + if len(via) >= 10 { + return http.ErrUseLastResponse + } + return nil } runtime.httpClient = client diff --git a/go_backend/extension_store.go b/go_backend/extension_store.go index 27c14dec..6195907a 100644 --- a/go_backend/extension_store.go +++ b/go_backend/extension_store.go @@ -218,7 +218,7 @@ func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error LogInfo("ExtensionStore", "Fetching registry from %s", s.registryURL) - client := &http.Client{Timeout: 30 * time.Second} + client := NewHTTPClientWithTimeout(30 * time.Second) resp, err := client.Get(s.registryURL) if err != nil { if s.cache != nil { @@ -310,7 +310,7 @@ func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string) LogInfo("ExtensionStore", "Downloading %s from %s", ext.getDisplayName(), ext.getDownloadURL()) - client := &http.Client{Timeout: 5 * time.Minute} + client := NewHTTPClientWithTimeout(5 * time.Minute) resp, err := client.Get(ext.getDownloadURL()) if err != nil { return fmt.Errorf("failed to download: %w", err) diff --git a/go_backend/httputil.go b/go_backend/httputil.go index d6033243..991904d0 100644 --- a/go_backend/httputil.go +++ b/go_backend/httputil.go @@ -11,6 +11,7 @@ import ( "net/url" "strconv" "strings" + "sync" "syscall" "time" ) @@ -37,6 +38,16 @@ const ( Second = time.Second ) +type NetworkCompatibilityOptions struct { + AllowHTTP bool + InsecureTLS bool +} + +var ( + networkCompatibilityMu sync.RWMutex + networkCompatibilityOptions NetworkCompatibilityOptions +) + var sharedTransport = &http.Transport{ DialContext: (&net.Dialer{ Timeout: 30 * time.Second, @@ -77,18 +88,18 @@ var metadataTransport = &http.Transport{ } var sharedClient = &http.Client{ - Transport: sharedTransport, + Transport: newCompatibilityTransport(sharedTransport), Timeout: DefaultTimeout, } var downloadClient = &http.Client{ - Transport: sharedTransport, + Transport: newCompatibilityTransport(sharedTransport), Timeout: DownloadTimeout, } func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client { return &http.Client{ - Transport: sharedTransport, + Transport: newCompatibilityTransport(sharedTransport), Timeout: timeout, } } @@ -97,7 +108,7 @@ func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client { // Use this for API calls that should not be affected by download traffic. func NewMetadataHTTPClient(timeout time.Duration) *http.Client { return &http.Client{ - Transport: metadataTransport, + Transport: newCompatibilityTransport(metadataTransport), Timeout: timeout, } } @@ -115,12 +126,78 @@ func CloseIdleConnections() { metadataTransport.CloseIdleConnections() } +func SetNetworkCompatibilityOptions(allowHTTP, insecureTLS bool) { + networkCompatibilityMu.Lock() + networkCompatibilityOptions = NetworkCompatibilityOptions{ + AllowHTTP: allowHTTP, + InsecureTLS: insecureTLS, + } + networkCompatibilityMu.Unlock() + + applyTLSCompatibility(sharedTransport, insecureTLS) + applyTLSCompatibility(metadataTransport, insecureTLS) + CloseIdleConnections() + + GoLog("[HTTP] Network compatibility options updated: allow_http=%v insecure_tls=%v\n", allowHTTP, insecureTLS) +} + +func GetNetworkCompatibilityOptions() NetworkCompatibilityOptions { + networkCompatibilityMu.RLock() + defer networkCompatibilityMu.RUnlock() + return networkCompatibilityOptions +} + +func applyTLSCompatibility(transport *http.Transport, insecureTLS bool) { + if insecureTLS { + cfg := &tls.Config{InsecureSkipVerify: true} + if transport.TLSClientConfig != nil { + cfg = transport.TLSClientConfig.Clone() + cfg.InsecureSkipVerify = true + } + transport.TLSClientConfig = cfg + return + } + + transport.TLSClientConfig = nil +} + +type compatibilityTransport struct { + base http.RoundTripper +} + +func newCompatibilityTransport(base http.RoundTripper) http.RoundTripper { + return &compatibilityTransport{base: base} +} + +func (t *compatibilityTransport) RoundTrip(req *http.Request) (*http.Response, error) { + reqCompat := applyCompatibilityToRequest(req) + return t.base.RoundTrip(reqCompat) +} + +func applyCompatibilityToRequest(req *http.Request) *http.Request { + if req == nil || req.URL == nil { + return req + } + + opts := GetNetworkCompatibilityOptions() + if !opts.AllowHTTP || req.URL.Scheme != "https" { + return req + } + + reqCopy := req.Clone(req.Context()) + urlCopy := *req.URL + urlCopy.Scheme = "http" + reqCopy.URL = &urlCopy + return reqCopy +} + // Also checks for ISP blocking on errors func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Response, error) { req.Header.Set("User-Agent", getRandomUserAgent()) - resp, err := client.Do(req) + reqToSend := applyCompatibilityToRequest(req) + resp, err := client.Do(reqToSend) if err != nil { - CheckAndLogISPBlocking(err, req.URL.String(), "HTTP") + CheckAndLogISPBlocking(err, reqToSend.URL.String(), "HTTP") } return resp, err } @@ -145,18 +222,18 @@ func DefaultRetryConfig() RetryConfig { func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConfig) (*http.Response, error) { var lastErr error delay := config.InitialDelay - requestURL := req.URL.String() for attempt := 0; attempt <= config.MaxRetries; attempt++ { reqCopy := req.Clone(req.Context()) reqCopy.Header.Set("User-Agent", getRandomUserAgent()) + reqCopy = applyCompatibilityToRequest(reqCopy) resp, err := client.Do(reqCopy) if err != nil { lastErr = err - if CheckAndLogISPBlocking(err, requestURL, "HTTP") { - return nil, WrapErrorWithISPCheck(err, requestURL, "HTTP") + if CheckAndLogISPBlocking(err, reqCopy.URL.String(), "HTTP") { + return nil, WrapErrorWithISPCheck(err, reqCopy.URL.String(), "HTTP") } if attempt < config.MaxRetries { diff --git a/go_backend/output_fd.go b/go_backend/output_fd.go index 248e28fd..fed09bd5 100644 --- a/go_backend/output_fd.go +++ b/go_backend/output_fd.go @@ -18,7 +18,15 @@ func openOutputForWrite(outputPath string, outputFD int) (*os.File, error) { path := strings.TrimSpace(outputPath) if strings.HasPrefix(path, "/proc/self/fd/") { // Re-open procfs fd path instead of taking ownership of raw detached fd. - return os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0) + // Some SAF providers reject O_TRUNC on these descriptors with EACCES/EPERM. + file, err := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0) + if err == nil { + return file, nil + } + if strings.Contains(strings.ToLower(err.Error()), "permission denied") { + return os.OpenFile(path, os.O_WRONLY, 0) + } + return nil, err } return os.Create(outputPath) diff --git a/go_backend/songlink.go b/go_backend/songlink.go index ed9346bd..ecc9e35a 100644 --- a/go_backend/songlink.go +++ b/go_backend/songlink.go @@ -1,7 +1,6 @@ package gobackend import ( - "encoding/base64" "encoding/json" "fmt" "net/http" @@ -46,14 +45,39 @@ func NewSongLinkClient() *SongLinkClient { return globalSongLinkClient } +func songLinkBaseURL() string { + opts := GetNetworkCompatibilityOptions() + if opts.AllowHTTP { + return "http://api.song.link/v1-alpha.1/links" + } + return "https://api.song.link/v1-alpha.1/links" +} + +func buildSongLinkURLFromTarget(targetURL string, userCountry string) string { + apiURL := fmt.Sprintf("%s?url=%s", songLinkBaseURL(), url.QueryEscape(targetURL)) + if userCountry != "" { + apiURL = fmt.Sprintf("%s&userCountry=%s", apiURL, url.QueryEscape(userCountry)) + } + return apiURL +} + +func buildSongLinkURLByPlatform(platform, entityType, entityID, userCountry string) string { + apiURL := fmt.Sprintf("%s?platform=%s&type=%s&id=%s", + songLinkBaseURL(), + url.QueryEscape(platform), + url.QueryEscape(entityType), + url.QueryEscape(entityID)) + if userCountry != "" { + apiURL = fmt.Sprintf("%s&userCountry=%s", apiURL, url.QueryEscape(userCountry)) + } + return apiURL +} + func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) { songLinkRateLimiter.WaitForSlot() - spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==") - spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID) - - apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==") - apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL)) + spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID) + apiURL := buildSongLinkURLFromTarget(spotifyURL, "") req, err := http.NewRequest("GET", apiURL, nil) if err != nil { @@ -351,11 +375,8 @@ type AlbumAvailability struct { func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAvailability, error) { songLinkRateLimiter.WaitForSlot() - spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL2FsYnVtLw==") - spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyAlbumID) - - apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==") - apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL)) + spotifyURL := fmt.Sprintf("https://open.spotify.com/album/%s", spotifyAlbumID) + apiURL := buildSongLinkURLFromTarget(spotifyURL, "") req, err := http.NewRequest("GET", apiURL, nil) if err != nil { @@ -440,9 +461,7 @@ func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID strin songLinkRateLimiter.WaitForSlot() deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID) - - apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==") - apiURL := fmt.Sprintf("%s%s&userCountry=US", string(apiBase), url.QueryEscape(deezerURL)) + apiURL := buildSongLinkURLFromTarget(deezerURL, "US") req, err := http.NewRequest("GET", apiURL, nil) if err != nil { @@ -546,10 +565,7 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit songLinkRateLimiter.WaitForSlot() - apiURL := fmt.Sprintf("https://api.song.link/v1-alpha.1/links?platform=%s&type=%s&id=%s&userCountry=US", - url.QueryEscape(platform), - url.QueryEscape(entityType), - url.QueryEscape(entityID)) + apiURL := buildSongLinkURLByPlatform(platform, entityType, entityID, "US") req, err := http.NewRequest("GET", apiURL, nil) if err != nil { @@ -706,8 +722,7 @@ func (s *SongLinkClient) GetYouTubeURLFromDeezer(deezerTrackID string) (string, func (s *SongLinkClient) CheckAvailabilityFromURL(inputURL string) (*TrackAvailability, error) { songLinkRateLimiter.WaitForSlot() - apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==") - apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(inputURL)) + apiURL := buildSongLinkURLFromTarget(inputURL, "") req, err := http.NewRequest("GET", apiURL, nil) if err != nil { diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 6c6a9ab6..2314d37f 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -127,6 +127,13 @@ import Gobackend // Import Go framework GobackendSetDownloadDirectory(path, &error) if let error = error { throw error } return nil + + case "setNetworkCompatibilityOptions", "setSongLinkNetworkOptions": + let args = call.arguments as! [String: Any] + let allowHTTP = args["allow_http"] as? Bool ?? false + let insecureTLS = args["insecure_tls"] as? Bool ?? false + GobackendSetNetworkCompatibilityOptions(allowHTTP, insecureTLS) + return nil case "checkDuplicate": let args = call.arguments as! [String: Any] diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 847fd92b..8333786e 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -4342,6 +4342,12 @@ abstract class AppLocalizations { /// **'{count} tracks'** String libraryTracksCount(int count); + /// Unit label for tracks count (without the number itself) + /// + /// In en, this message translates to: + /// **'{count, plural, =1{track} other{tracks}}'** + String libraryTracksUnit(int count); + /// Last scan time display /// /// In en, this message translates to: @@ -5258,6 +5264,246 @@ abstract class AppLocalizations { /// **'Conversion failed'** String get trackConvertFailed; + /// Generic action button - create + /// + /// In en, this message translates to: + /// **'Create'** + String get actionCreate; + + /// Library section title for custom folders + /// + /// In en, this message translates to: + /// **'My folders'** + String get collectionFoldersTitle; + + /// Custom folder for saved tracks to download later + /// + /// In en, this message translates to: + /// **'Wishlist'** + String get collectionWishlist; + + /// Custom folder for favorite tracks + /// + /// In en, this message translates to: + /// **'Loved'** + String get collectionLoved; + + /// Custom user playlists folder + /// + /// In en, this message translates to: + /// **'Playlists'** + String get collectionPlaylists; + + /// Single playlist label + /// + /// In en, this message translates to: + /// **'Playlist'** + String get collectionPlaylist; + + /// Action to add a track to user playlist + /// + /// In en, this message translates to: + /// **'Add to playlist'** + String get collectionAddToPlaylist; + + /// Action to create a new playlist + /// + /// In en, this message translates to: + /// **'Create playlist'** + String get collectionCreatePlaylist; + + /// Empty state title when user has no playlists + /// + /// In en, this message translates to: + /// **'No playlists yet'** + String get collectionNoPlaylistsYet; + + /// Empty state subtitle when user has no playlists + /// + /// In en, this message translates to: + /// **'Create a playlist to start categorizing tracks'** + String get collectionNoPlaylistsSubtitle; + + /// Track count label for custom playlists + /// + /// In en, this message translates to: + /// **'{count, plural, =1{1 track} other{{count} tracks}}'** + String collectionPlaylistTracks(int count); + + /// Snackbar after adding track to playlist + /// + /// In en, this message translates to: + /// **'Added to \"{playlistName}\"'** + String collectionAddedToPlaylist(String playlistName); + + /// Snackbar when track already exists in playlist + /// + /// In en, this message translates to: + /// **'Already in \"{playlistName}\"'** + String collectionAlreadyInPlaylist(String playlistName); + + /// Snackbar after creating playlist + /// + /// In en, this message translates to: + /// **'Playlist created'** + String get collectionPlaylistCreated; + + /// Hint text for playlist name input + /// + /// In en, this message translates to: + /// **'Playlist name'** + String get collectionPlaylistNameHint; + + /// Validation error for empty playlist name + /// + /// In en, this message translates to: + /// **'Playlist name is required'** + String get collectionPlaylistNameRequired; + + /// Action to rename playlist + /// + /// In en, this message translates to: + /// **'Rename playlist'** + String get collectionRenamePlaylist; + + /// Action to delete playlist + /// + /// In en, this message translates to: + /// **'Delete playlist'** + String get collectionDeletePlaylist; + + /// Confirmation message for deleting playlist + /// + /// In en, this message translates to: + /// **'Delete \"{playlistName}\" and all tracks inside it?'** + String collectionDeletePlaylistMessage(String playlistName); + + /// Snackbar after deleting playlist + /// + /// In en, this message translates to: + /// **'Playlist deleted'** + String get collectionPlaylistDeleted; + + /// Snackbar after renaming playlist + /// + /// In en, this message translates to: + /// **'Playlist renamed'** + String get collectionPlaylistRenamed; + + /// Wishlist empty state title + /// + /// In en, this message translates to: + /// **'Wishlist is empty'** + String get collectionWishlistEmptyTitle; + + /// Wishlist empty state subtitle + /// + /// In en, this message translates to: + /// **'Tap + on tracks to save what you want to download later'** + String get collectionWishlistEmptySubtitle; + + /// Loved empty state title + /// + /// In en, this message translates to: + /// **'Loved folder is empty'** + String get collectionLovedEmptyTitle; + + /// Loved empty state subtitle + /// + /// In en, this message translates to: + /// **'Tap love on tracks to keep your favorites'** + String get collectionLovedEmptySubtitle; + + /// Playlist empty state title + /// + /// In en, this message translates to: + /// **'Playlist is empty'** + String get collectionPlaylistEmptyTitle; + + /// Playlist empty state subtitle + /// + /// In en, this message translates to: + /// **'Long-press + on any track to add it here'** + String get collectionPlaylistEmptySubtitle; + + /// Tooltip for removing track from playlist + /// + /// In en, this message translates to: + /// **'Remove from playlist'** + String get collectionRemoveFromPlaylist; + + /// Tooltip for removing track from wishlist/loved folder + /// + /// In en, this message translates to: + /// **'Remove from folder'** + String get collectionRemoveFromFolder; + + /// Snackbar after removing a track from a collection + /// + /// In en, this message translates to: + /// **'\"{trackName}\" removed'** + String collectionRemoved(String trackName); + + /// Snackbar after adding track to loved folder + /// + /// In en, this message translates to: + /// **'\"{trackName}\" added to Loved'** + String collectionAddedToLoved(String trackName); + + /// Snackbar after removing track from loved folder + /// + /// In en, this message translates to: + /// **'\"{trackName}\" removed from Loved'** + String collectionRemovedFromLoved(String trackName); + + /// Snackbar after adding track to wishlist + /// + /// In en, this message translates to: + /// **'\"{trackName}\" added to Wishlist'** + String collectionAddedToWishlist(String trackName); + + /// Snackbar after removing track from wishlist + /// + /// In en, this message translates to: + /// **'\"{trackName}\" removed from Wishlist'** + String collectionRemovedFromWishlist(String trackName); + + /// Bottom sheet action label - add track to loved folder + /// + /// In en, this message translates to: + /// **'Add to Loved'** + String get trackOptionAddToLoved; + + /// Bottom sheet action label - remove track from loved folder + /// + /// In en, this message translates to: + /// **'Remove from Loved'** + String get trackOptionRemoveFromLoved; + + /// Bottom sheet action label - add track to wishlist + /// + /// In en, this message translates to: + /// **'Add to Wishlist'** + String get trackOptionAddToWishlist; + + /// Bottom sheet action label - remove track from wishlist + /// + /// In en, this message translates to: + /// **'Remove from Wishlist'** + String get trackOptionRemoveFromWishlist; + + /// Bottom sheet action to pick a custom cover image for a playlist + /// + /// In en, this message translates to: + /// **'Change cover image'** + String get collectionPlaylistChangeCover; + + /// Bottom sheet action to remove custom cover image from a playlist + /// + /// In en, this message translates to: + /// **'Remove cover image'** + String get collectionPlaylistRemoveCover; + /// Share button text with count in selection mode /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 8bfc7e07..3663483d 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -2428,6 +2428,17 @@ class AppLocalizationsDe extends AppLocalizations { return '$count tracks'; } + @override + String libraryTracksUnit(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return '$_temp0'; + } + @override String libraryLastScanned(String time) { return 'Last scanned: $time'; @@ -2988,6 +2999,154 @@ class AppLocalizationsDe extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + @override + String get actionCreate => 'Create'; + + @override + String get collectionFoldersTitle => 'My folders'; + + @override + String get collectionWishlist => 'Wishlist'; + + @override + String get collectionLoved => 'Loved'; + + @override + String get collectionPlaylists => 'Playlists'; + + @override + String get collectionPlaylist => 'Playlist'; + + @override + String get collectionAddToPlaylist => 'Add to playlist'; + + @override + String get collectionCreatePlaylist => 'Create playlist'; + + @override + String get collectionNoPlaylistsYet => 'No playlists yet'; + + @override + String get collectionNoPlaylistsSubtitle => + 'Create a playlist to start categorizing tracks'; + + @override + String collectionPlaylistTracks(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String collectionAddedToPlaylist(String playlistName) { + return 'Added to \"$playlistName\"'; + } + + @override + String collectionAlreadyInPlaylist(String playlistName) { + return 'Already in \"$playlistName\"'; + } + + @override + String get collectionPlaylistCreated => 'Playlist created'; + + @override + String get collectionPlaylistNameHint => 'Playlist name'; + + @override + String get collectionPlaylistNameRequired => 'Playlist name is required'; + + @override + String get collectionRenamePlaylist => 'Rename playlist'; + + @override + String get collectionDeletePlaylist => 'Delete playlist'; + + @override + String collectionDeletePlaylistMessage(String playlistName) { + return 'Delete \"$playlistName\" and all tracks inside it?'; + } + + @override + String get collectionPlaylistDeleted => 'Playlist deleted'; + + @override + String get collectionPlaylistRenamed => 'Playlist renamed'; + + @override + String get collectionWishlistEmptyTitle => 'Wishlist is empty'; + + @override + String get collectionWishlistEmptySubtitle => + 'Tap + on tracks to save what you want to download later'; + + @override + String get collectionLovedEmptyTitle => 'Loved folder is empty'; + + @override + String get collectionLovedEmptySubtitle => + 'Tap love on tracks to keep your favorites'; + + @override + String get collectionPlaylistEmptyTitle => 'Playlist is empty'; + + @override + String get collectionPlaylistEmptySubtitle => + 'Long-press + on any track to add it here'; + + @override + String get collectionRemoveFromPlaylist => 'Remove from playlist'; + + @override + String get collectionRemoveFromFolder => 'Remove from folder'; + + @override + String collectionRemoved(String trackName) { + return '\"$trackName\" removed'; + } + + @override + String collectionAddedToLoved(String trackName) { + return '\"$trackName\" added to Loved'; + } + + @override + String collectionRemovedFromLoved(String trackName) { + return '\"$trackName\" removed from Loved'; + } + + @override + String collectionAddedToWishlist(String trackName) { + return '\"$trackName\" added to Wishlist'; + } + + @override + String collectionRemovedFromWishlist(String trackName) { + return '\"$trackName\" removed from Wishlist'; + } + + @override + String get trackOptionAddToLoved => 'Add to Loved'; + + @override + String get trackOptionRemoveFromLoved => 'Remove from Loved'; + + @override + String get trackOptionAddToWishlist => 'Add to Wishlist'; + + @override + String get trackOptionRemoveFromWishlist => 'Remove from Wishlist'; + + @override + String get collectionPlaylistChangeCover => 'Change cover image'; + + @override + String get collectionPlaylistRemoveCover => 'Remove cover image'; + @override String selectionShareCount(int count) { String _temp0 = intl.Intl.pluralLogic( diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 91db54f6..3864a2a6 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2407,6 +2407,17 @@ class AppLocalizationsEn extends AppLocalizations { return '$count tracks'; } + @override + String libraryTracksUnit(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return '$_temp0'; + } + @override String libraryLastScanned(String time) { return 'Last scanned: $time'; @@ -2967,6 +2978,154 @@ class AppLocalizationsEn extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + @override + String get actionCreate => 'Create'; + + @override + String get collectionFoldersTitle => 'My folders'; + + @override + String get collectionWishlist => 'Wishlist'; + + @override + String get collectionLoved => 'Loved'; + + @override + String get collectionPlaylists => 'Playlists'; + + @override + String get collectionPlaylist => 'Playlist'; + + @override + String get collectionAddToPlaylist => 'Add to playlist'; + + @override + String get collectionCreatePlaylist => 'Create playlist'; + + @override + String get collectionNoPlaylistsYet => 'No playlists yet'; + + @override + String get collectionNoPlaylistsSubtitle => + 'Create a playlist to start categorizing tracks'; + + @override + String collectionPlaylistTracks(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String collectionAddedToPlaylist(String playlistName) { + return 'Added to \"$playlistName\"'; + } + + @override + String collectionAlreadyInPlaylist(String playlistName) { + return 'Already in \"$playlistName\"'; + } + + @override + String get collectionPlaylistCreated => 'Playlist created'; + + @override + String get collectionPlaylistNameHint => 'Playlist name'; + + @override + String get collectionPlaylistNameRequired => 'Playlist name is required'; + + @override + String get collectionRenamePlaylist => 'Rename playlist'; + + @override + String get collectionDeletePlaylist => 'Delete playlist'; + + @override + String collectionDeletePlaylistMessage(String playlistName) { + return 'Delete \"$playlistName\" and all tracks inside it?'; + } + + @override + String get collectionPlaylistDeleted => 'Playlist deleted'; + + @override + String get collectionPlaylistRenamed => 'Playlist renamed'; + + @override + String get collectionWishlistEmptyTitle => 'Wishlist is empty'; + + @override + String get collectionWishlistEmptySubtitle => + 'Tap + on tracks to save what you want to download later'; + + @override + String get collectionLovedEmptyTitle => 'Loved folder is empty'; + + @override + String get collectionLovedEmptySubtitle => + 'Tap love on tracks to keep your favorites'; + + @override + String get collectionPlaylistEmptyTitle => 'Playlist is empty'; + + @override + String get collectionPlaylistEmptySubtitle => + 'Long-press + on any track to add it here'; + + @override + String get collectionRemoveFromPlaylist => 'Remove from playlist'; + + @override + String get collectionRemoveFromFolder => 'Remove from folder'; + + @override + String collectionRemoved(String trackName) { + return '\"$trackName\" removed'; + } + + @override + String collectionAddedToLoved(String trackName) { + return '\"$trackName\" added to Loved'; + } + + @override + String collectionRemovedFromLoved(String trackName) { + return '\"$trackName\" removed from Loved'; + } + + @override + String collectionAddedToWishlist(String trackName) { + return '\"$trackName\" added to Wishlist'; + } + + @override + String collectionRemovedFromWishlist(String trackName) { + return '\"$trackName\" removed from Wishlist'; + } + + @override + String get trackOptionAddToLoved => 'Add to Loved'; + + @override + String get trackOptionRemoveFromLoved => 'Remove from Loved'; + + @override + String get trackOptionAddToWishlist => 'Add to Wishlist'; + + @override + String get trackOptionRemoveFromWishlist => 'Remove from Wishlist'; + + @override + String get collectionPlaylistChangeCover => 'Change cover image'; + + @override + String get collectionPlaylistRemoveCover => 'Remove cover image'; + @override String selectionShareCount(int count) { String _temp0 = intl.Intl.pluralLogic( diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index cfc8e46a..4184f353 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -2407,6 +2407,17 @@ class AppLocalizationsEs extends AppLocalizations { return '$count tracks'; } + @override + String libraryTracksUnit(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return '$_temp0'; + } + @override String libraryLastScanned(String time) { return 'Last scanned: $time'; @@ -2967,6 +2978,154 @@ class AppLocalizationsEs extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + @override + String get actionCreate => 'Create'; + + @override + String get collectionFoldersTitle => 'My folders'; + + @override + String get collectionWishlist => 'Wishlist'; + + @override + String get collectionLoved => 'Loved'; + + @override + String get collectionPlaylists => 'Playlists'; + + @override + String get collectionPlaylist => 'Playlist'; + + @override + String get collectionAddToPlaylist => 'Add to playlist'; + + @override + String get collectionCreatePlaylist => 'Create playlist'; + + @override + String get collectionNoPlaylistsYet => 'No playlists yet'; + + @override + String get collectionNoPlaylistsSubtitle => + 'Create a playlist to start categorizing tracks'; + + @override + String collectionPlaylistTracks(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String collectionAddedToPlaylist(String playlistName) { + return 'Added to \"$playlistName\"'; + } + + @override + String collectionAlreadyInPlaylist(String playlistName) { + return 'Already in \"$playlistName\"'; + } + + @override + String get collectionPlaylistCreated => 'Playlist created'; + + @override + String get collectionPlaylistNameHint => 'Playlist name'; + + @override + String get collectionPlaylistNameRequired => 'Playlist name is required'; + + @override + String get collectionRenamePlaylist => 'Rename playlist'; + + @override + String get collectionDeletePlaylist => 'Delete playlist'; + + @override + String collectionDeletePlaylistMessage(String playlistName) { + return 'Delete \"$playlistName\" and all tracks inside it?'; + } + + @override + String get collectionPlaylistDeleted => 'Playlist deleted'; + + @override + String get collectionPlaylistRenamed => 'Playlist renamed'; + + @override + String get collectionWishlistEmptyTitle => 'Wishlist is empty'; + + @override + String get collectionWishlistEmptySubtitle => + 'Tap + on tracks to save what you want to download later'; + + @override + String get collectionLovedEmptyTitle => 'Loved folder is empty'; + + @override + String get collectionLovedEmptySubtitle => + 'Tap love on tracks to keep your favorites'; + + @override + String get collectionPlaylistEmptyTitle => 'Playlist is empty'; + + @override + String get collectionPlaylistEmptySubtitle => + 'Long-press + on any track to add it here'; + + @override + String get collectionRemoveFromPlaylist => 'Remove from playlist'; + + @override + String get collectionRemoveFromFolder => 'Remove from folder'; + + @override + String collectionRemoved(String trackName) { + return '\"$trackName\" removed'; + } + + @override + String collectionAddedToLoved(String trackName) { + return '\"$trackName\" added to Loved'; + } + + @override + String collectionRemovedFromLoved(String trackName) { + return '\"$trackName\" removed from Loved'; + } + + @override + String collectionAddedToWishlist(String trackName) { + return '\"$trackName\" added to Wishlist'; + } + + @override + String collectionRemovedFromWishlist(String trackName) { + return '\"$trackName\" removed from Wishlist'; + } + + @override + String get trackOptionAddToLoved => 'Add to Loved'; + + @override + String get trackOptionRemoveFromLoved => 'Remove from Loved'; + + @override + String get trackOptionAddToWishlist => 'Add to Wishlist'; + + @override + String get trackOptionRemoveFromWishlist => 'Remove from Wishlist'; + + @override + String get collectionPlaylistChangeCover => 'Change cover image'; + + @override + String get collectionPlaylistRemoveCover => 'Remove cover image'; + @override String selectionShareCount(int count) { String _temp0 = intl.Intl.pluralLogic( diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index f1354665..76b5227c 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -2413,6 +2413,17 @@ class AppLocalizationsFr extends AppLocalizations { return '$count tracks'; } + @override + String libraryTracksUnit(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return '$_temp0'; + } + @override String libraryLastScanned(String time) { return 'Last scanned: $time'; @@ -2973,6 +2984,154 @@ class AppLocalizationsFr extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + @override + String get actionCreate => 'Create'; + + @override + String get collectionFoldersTitle => 'My folders'; + + @override + String get collectionWishlist => 'Wishlist'; + + @override + String get collectionLoved => 'Loved'; + + @override + String get collectionPlaylists => 'Playlists'; + + @override + String get collectionPlaylist => 'Playlist'; + + @override + String get collectionAddToPlaylist => 'Add to playlist'; + + @override + String get collectionCreatePlaylist => 'Create playlist'; + + @override + String get collectionNoPlaylistsYet => 'No playlists yet'; + + @override + String get collectionNoPlaylistsSubtitle => + 'Create a playlist to start categorizing tracks'; + + @override + String collectionPlaylistTracks(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String collectionAddedToPlaylist(String playlistName) { + return 'Added to \"$playlistName\"'; + } + + @override + String collectionAlreadyInPlaylist(String playlistName) { + return 'Already in \"$playlistName\"'; + } + + @override + String get collectionPlaylistCreated => 'Playlist created'; + + @override + String get collectionPlaylistNameHint => 'Playlist name'; + + @override + String get collectionPlaylistNameRequired => 'Playlist name is required'; + + @override + String get collectionRenamePlaylist => 'Rename playlist'; + + @override + String get collectionDeletePlaylist => 'Delete playlist'; + + @override + String collectionDeletePlaylistMessage(String playlistName) { + return 'Delete \"$playlistName\" and all tracks inside it?'; + } + + @override + String get collectionPlaylistDeleted => 'Playlist deleted'; + + @override + String get collectionPlaylistRenamed => 'Playlist renamed'; + + @override + String get collectionWishlistEmptyTitle => 'Wishlist is empty'; + + @override + String get collectionWishlistEmptySubtitle => + 'Tap + on tracks to save what you want to download later'; + + @override + String get collectionLovedEmptyTitle => 'Loved folder is empty'; + + @override + String get collectionLovedEmptySubtitle => + 'Tap love on tracks to keep your favorites'; + + @override + String get collectionPlaylistEmptyTitle => 'Playlist is empty'; + + @override + String get collectionPlaylistEmptySubtitle => + 'Long-press + on any track to add it here'; + + @override + String get collectionRemoveFromPlaylist => 'Remove from playlist'; + + @override + String get collectionRemoveFromFolder => 'Remove from folder'; + + @override + String collectionRemoved(String trackName) { + return '\"$trackName\" removed'; + } + + @override + String collectionAddedToLoved(String trackName) { + return '\"$trackName\" added to Loved'; + } + + @override + String collectionRemovedFromLoved(String trackName) { + return '\"$trackName\" removed from Loved'; + } + + @override + String collectionAddedToWishlist(String trackName) { + return '\"$trackName\" added to Wishlist'; + } + + @override + String collectionRemovedFromWishlist(String trackName) { + return '\"$trackName\" removed from Wishlist'; + } + + @override + String get trackOptionAddToLoved => 'Add to Loved'; + + @override + String get trackOptionRemoveFromLoved => 'Remove from Loved'; + + @override + String get trackOptionAddToWishlist => 'Add to Wishlist'; + + @override + String get trackOptionRemoveFromWishlist => 'Remove from Wishlist'; + + @override + String get collectionPlaylistChangeCover => 'Change cover image'; + + @override + String get collectionPlaylistRemoveCover => 'Remove cover image'; + @override String selectionShareCount(int count) { String _temp0 = intl.Intl.pluralLogic( diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index bb4ad22a..36ae71f0 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -2407,6 +2407,17 @@ class AppLocalizationsHi extends AppLocalizations { return '$count tracks'; } + @override + String libraryTracksUnit(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return '$_temp0'; + } + @override String libraryLastScanned(String time) { return 'Last scanned: $time'; @@ -2967,6 +2978,154 @@ class AppLocalizationsHi extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + @override + String get actionCreate => 'Create'; + + @override + String get collectionFoldersTitle => 'My folders'; + + @override + String get collectionWishlist => 'Wishlist'; + + @override + String get collectionLoved => 'Loved'; + + @override + String get collectionPlaylists => 'Playlists'; + + @override + String get collectionPlaylist => 'Playlist'; + + @override + String get collectionAddToPlaylist => 'Add to playlist'; + + @override + String get collectionCreatePlaylist => 'Create playlist'; + + @override + String get collectionNoPlaylistsYet => 'No playlists yet'; + + @override + String get collectionNoPlaylistsSubtitle => + 'Create a playlist to start categorizing tracks'; + + @override + String collectionPlaylistTracks(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String collectionAddedToPlaylist(String playlistName) { + return 'Added to \"$playlistName\"'; + } + + @override + String collectionAlreadyInPlaylist(String playlistName) { + return 'Already in \"$playlistName\"'; + } + + @override + String get collectionPlaylistCreated => 'Playlist created'; + + @override + String get collectionPlaylistNameHint => 'Playlist name'; + + @override + String get collectionPlaylistNameRequired => 'Playlist name is required'; + + @override + String get collectionRenamePlaylist => 'Rename playlist'; + + @override + String get collectionDeletePlaylist => 'Delete playlist'; + + @override + String collectionDeletePlaylistMessage(String playlistName) { + return 'Delete \"$playlistName\" and all tracks inside it?'; + } + + @override + String get collectionPlaylistDeleted => 'Playlist deleted'; + + @override + String get collectionPlaylistRenamed => 'Playlist renamed'; + + @override + String get collectionWishlistEmptyTitle => 'Wishlist is empty'; + + @override + String get collectionWishlistEmptySubtitle => + 'Tap + on tracks to save what you want to download later'; + + @override + String get collectionLovedEmptyTitle => 'Loved folder is empty'; + + @override + String get collectionLovedEmptySubtitle => + 'Tap love on tracks to keep your favorites'; + + @override + String get collectionPlaylistEmptyTitle => 'Playlist is empty'; + + @override + String get collectionPlaylistEmptySubtitle => + 'Long-press + on any track to add it here'; + + @override + String get collectionRemoveFromPlaylist => 'Remove from playlist'; + + @override + String get collectionRemoveFromFolder => 'Remove from folder'; + + @override + String collectionRemoved(String trackName) { + return '\"$trackName\" removed'; + } + + @override + String collectionAddedToLoved(String trackName) { + return '\"$trackName\" added to Loved'; + } + + @override + String collectionRemovedFromLoved(String trackName) { + return '\"$trackName\" removed from Loved'; + } + + @override + String collectionAddedToWishlist(String trackName) { + return '\"$trackName\" added to Wishlist'; + } + + @override + String collectionRemovedFromWishlist(String trackName) { + return '\"$trackName\" removed from Wishlist'; + } + + @override + String get trackOptionAddToLoved => 'Add to Loved'; + + @override + String get trackOptionRemoveFromLoved => 'Remove from Loved'; + + @override + String get trackOptionAddToWishlist => 'Add to Wishlist'; + + @override + String get trackOptionRemoveFromWishlist => 'Remove from Wishlist'; + + @override + String get collectionPlaylistChangeCover => 'Change cover image'; + + @override + String get collectionPlaylistRemoveCover => 'Remove cover image'; + @override String selectionShareCount(int count) { String _temp0 = intl.Intl.pluralLogic( diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 561eff10..a6820bb7 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -2420,6 +2420,17 @@ class AppLocalizationsId extends AppLocalizations { return '$count tracks'; } + @override + String libraryTracksUnit(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'trek', + one: 'trek', + ); + return '$_temp0'; + } + @override String libraryLastScanned(String time) { return 'Last scanned: $time'; @@ -2980,6 +2991,154 @@ class AppLocalizationsId extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + @override + String get actionCreate => 'Buat'; + + @override + String get collectionFoldersTitle => 'Folder saya'; + + @override + String get collectionWishlist => 'Wishlist'; + + @override + String get collectionLoved => 'Loved'; + + @override + String get collectionPlaylists => 'Playlist'; + + @override + String get collectionPlaylist => 'Playlist'; + + @override + String get collectionAddToPlaylist => 'Tambahkan ke playlist'; + + @override + String get collectionCreatePlaylist => 'Buat playlist'; + + @override + String get collectionNoPlaylistsYet => 'Belum ada playlist'; + + @override + String get collectionNoPlaylistsSubtitle => + 'Buat playlist untuk mulai mengategorikan lagu'; + + @override + String collectionPlaylistTracks(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count lagu', + one: '1 lagu', + ); + return '$_temp0'; + } + + @override + String collectionAddedToPlaylist(String playlistName) { + return 'Ditambahkan ke \"$playlistName\"'; + } + + @override + String collectionAlreadyInPlaylist(String playlistName) { + return 'Sudah ada di \"$playlistName\"'; + } + + @override + String get collectionPlaylistCreated => 'Playlist berhasil dibuat'; + + @override + String get collectionPlaylistNameHint => 'Nama playlist'; + + @override + String get collectionPlaylistNameRequired => 'Nama playlist wajib diisi'; + + @override + String get collectionRenamePlaylist => 'Ubah nama playlist'; + + @override + String get collectionDeletePlaylist => 'Hapus playlist'; + + @override + String collectionDeletePlaylistMessage(String playlistName) { + return 'Hapus \"$playlistName\" beserta semua lagunya?'; + } + + @override + String get collectionPlaylistDeleted => 'Playlist dihapus'; + + @override + String get collectionPlaylistRenamed => 'Nama playlist diperbarui'; + + @override + String get collectionWishlistEmptyTitle => 'Wishlist masih kosong'; + + @override + String get collectionWishlistEmptySubtitle => + 'Tap + di lagu untuk menyimpan yang ingin diunduh nanti'; + + @override + String get collectionLovedEmptyTitle => 'Folder Loved masih kosong'; + + @override + String get collectionLovedEmptySubtitle => + 'Tap love di lagu untuk menyimpan favoritmu'; + + @override + String get collectionPlaylistEmptyTitle => 'Playlist masih kosong'; + + @override + String get collectionPlaylistEmptySubtitle => + 'Tekan lama tombol + pada lagu untuk menambahkannya ke sini'; + + @override + String get collectionRemoveFromPlaylist => 'Hapus dari playlist'; + + @override + String get collectionRemoveFromFolder => 'Hapus dari folder'; + + @override + String collectionRemoved(String trackName) { + return '\"$trackName\" dihapus'; + } + + @override + String collectionAddedToLoved(String trackName) { + return '\"$trackName\" ditambahkan ke Loved'; + } + + @override + String collectionRemovedFromLoved(String trackName) { + return '\"$trackName\" dihapus dari Loved'; + } + + @override + String collectionAddedToWishlist(String trackName) { + return '\"$trackName\" ditambahkan ke Wishlist'; + } + + @override + String collectionRemovedFromWishlist(String trackName) { + return '\"$trackName\" dihapus dari Wishlist'; + } + + @override + String get trackOptionAddToLoved => 'Tambahkan ke Loved'; + + @override + String get trackOptionRemoveFromLoved => 'Hapus dari Loved'; + + @override + String get trackOptionAddToWishlist => 'Tambahkan ke Wishlist'; + + @override + String get trackOptionRemoveFromWishlist => 'Hapus dari Wishlist'; + + @override + String get collectionPlaylistChangeCover => 'Ubah gambar sampul'; + + @override + String get collectionPlaylistRemoveCover => 'Hapus gambar sampul'; + @override String selectionShareCount(int count) { String _temp0 = intl.Intl.pluralLogic( diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 2e806040..2e64efd3 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -2393,6 +2393,17 @@ class AppLocalizationsJa extends AppLocalizations { return '$count tracks'; } + @override + String libraryTracksUnit(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return '$_temp0'; + } + @override String libraryLastScanned(String time) { return 'Last scanned: $time'; @@ -2953,6 +2964,154 @@ class AppLocalizationsJa extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + @override + String get actionCreate => 'Create'; + + @override + String get collectionFoldersTitle => 'My folders'; + + @override + String get collectionWishlist => 'Wishlist'; + + @override + String get collectionLoved => 'Loved'; + + @override + String get collectionPlaylists => 'Playlists'; + + @override + String get collectionPlaylist => 'Playlist'; + + @override + String get collectionAddToPlaylist => 'Add to playlist'; + + @override + String get collectionCreatePlaylist => 'Create playlist'; + + @override + String get collectionNoPlaylistsYet => 'No playlists yet'; + + @override + String get collectionNoPlaylistsSubtitle => + 'Create a playlist to start categorizing tracks'; + + @override + String collectionPlaylistTracks(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String collectionAddedToPlaylist(String playlistName) { + return 'Added to \"$playlistName\"'; + } + + @override + String collectionAlreadyInPlaylist(String playlistName) { + return 'Already in \"$playlistName\"'; + } + + @override + String get collectionPlaylistCreated => 'Playlist created'; + + @override + String get collectionPlaylistNameHint => 'Playlist name'; + + @override + String get collectionPlaylistNameRequired => 'Playlist name is required'; + + @override + String get collectionRenamePlaylist => 'Rename playlist'; + + @override + String get collectionDeletePlaylist => 'Delete playlist'; + + @override + String collectionDeletePlaylistMessage(String playlistName) { + return 'Delete \"$playlistName\" and all tracks inside it?'; + } + + @override + String get collectionPlaylistDeleted => 'Playlist deleted'; + + @override + String get collectionPlaylistRenamed => 'Playlist renamed'; + + @override + String get collectionWishlistEmptyTitle => 'Wishlist is empty'; + + @override + String get collectionWishlistEmptySubtitle => + 'Tap + on tracks to save what you want to download later'; + + @override + String get collectionLovedEmptyTitle => 'Loved folder is empty'; + + @override + String get collectionLovedEmptySubtitle => + 'Tap love on tracks to keep your favorites'; + + @override + String get collectionPlaylistEmptyTitle => 'Playlist is empty'; + + @override + String get collectionPlaylistEmptySubtitle => + 'Long-press + on any track to add it here'; + + @override + String get collectionRemoveFromPlaylist => 'Remove from playlist'; + + @override + String get collectionRemoveFromFolder => 'Remove from folder'; + + @override + String collectionRemoved(String trackName) { + return '\"$trackName\" removed'; + } + + @override + String collectionAddedToLoved(String trackName) { + return '\"$trackName\" added to Loved'; + } + + @override + String collectionRemovedFromLoved(String trackName) { + return '\"$trackName\" removed from Loved'; + } + + @override + String collectionAddedToWishlist(String trackName) { + return '\"$trackName\" added to Wishlist'; + } + + @override + String collectionRemovedFromWishlist(String trackName) { + return '\"$trackName\" removed from Wishlist'; + } + + @override + String get trackOptionAddToLoved => 'Add to Loved'; + + @override + String get trackOptionRemoveFromLoved => 'Remove from Loved'; + + @override + String get trackOptionAddToWishlist => 'Add to Wishlist'; + + @override + String get trackOptionRemoveFromWishlist => 'Remove from Wishlist'; + + @override + String get collectionPlaylistChangeCover => 'Change cover image'; + + @override + String get collectionPlaylistRemoveCover => 'Remove cover image'; + @override String selectionShareCount(int count) { String _temp0 = intl.Intl.pluralLogic( diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index d0909a4d..32d7f843 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -2406,6 +2406,17 @@ class AppLocalizationsKo extends AppLocalizations { return '$count tracks'; } + @override + String libraryTracksUnit(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return '$_temp0'; + } + @override String libraryLastScanned(String time) { return 'Last scanned: $time'; @@ -2966,6 +2977,154 @@ class AppLocalizationsKo extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + @override + String get actionCreate => 'Create'; + + @override + String get collectionFoldersTitle => 'My folders'; + + @override + String get collectionWishlist => 'Wishlist'; + + @override + String get collectionLoved => 'Loved'; + + @override + String get collectionPlaylists => 'Playlists'; + + @override + String get collectionPlaylist => 'Playlist'; + + @override + String get collectionAddToPlaylist => 'Add to playlist'; + + @override + String get collectionCreatePlaylist => 'Create playlist'; + + @override + String get collectionNoPlaylistsYet => 'No playlists yet'; + + @override + String get collectionNoPlaylistsSubtitle => + 'Create a playlist to start categorizing tracks'; + + @override + String collectionPlaylistTracks(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String collectionAddedToPlaylist(String playlistName) { + return 'Added to \"$playlistName\"'; + } + + @override + String collectionAlreadyInPlaylist(String playlistName) { + return 'Already in \"$playlistName\"'; + } + + @override + String get collectionPlaylistCreated => 'Playlist created'; + + @override + String get collectionPlaylistNameHint => 'Playlist name'; + + @override + String get collectionPlaylistNameRequired => 'Playlist name is required'; + + @override + String get collectionRenamePlaylist => 'Rename playlist'; + + @override + String get collectionDeletePlaylist => 'Delete playlist'; + + @override + String collectionDeletePlaylistMessage(String playlistName) { + return 'Delete \"$playlistName\" and all tracks inside it?'; + } + + @override + String get collectionPlaylistDeleted => 'Playlist deleted'; + + @override + String get collectionPlaylistRenamed => 'Playlist renamed'; + + @override + String get collectionWishlistEmptyTitle => 'Wishlist is empty'; + + @override + String get collectionWishlistEmptySubtitle => + 'Tap + on tracks to save what you want to download later'; + + @override + String get collectionLovedEmptyTitle => 'Loved folder is empty'; + + @override + String get collectionLovedEmptySubtitle => + 'Tap love on tracks to keep your favorites'; + + @override + String get collectionPlaylistEmptyTitle => 'Playlist is empty'; + + @override + String get collectionPlaylistEmptySubtitle => + 'Long-press + on any track to add it here'; + + @override + String get collectionRemoveFromPlaylist => 'Remove from playlist'; + + @override + String get collectionRemoveFromFolder => 'Remove from folder'; + + @override + String collectionRemoved(String trackName) { + return '\"$trackName\" removed'; + } + + @override + String collectionAddedToLoved(String trackName) { + return '\"$trackName\" added to Loved'; + } + + @override + String collectionRemovedFromLoved(String trackName) { + return '\"$trackName\" removed from Loved'; + } + + @override + String collectionAddedToWishlist(String trackName) { + return '\"$trackName\" added to Wishlist'; + } + + @override + String collectionRemovedFromWishlist(String trackName) { + return '\"$trackName\" removed from Wishlist'; + } + + @override + String get trackOptionAddToLoved => 'Add to Loved'; + + @override + String get trackOptionRemoveFromLoved => 'Remove from Loved'; + + @override + String get trackOptionAddToWishlist => 'Add to Wishlist'; + + @override + String get trackOptionRemoveFromWishlist => 'Remove from Wishlist'; + + @override + String get collectionPlaylistChangeCover => 'Change cover image'; + + @override + String get collectionPlaylistRemoveCover => 'Remove cover image'; + @override String selectionShareCount(int count) { String _temp0 = intl.Intl.pluralLogic( diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 6de6d29d..4f263f2d 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -2407,6 +2407,17 @@ class AppLocalizationsNl extends AppLocalizations { return '$count tracks'; } + @override + String libraryTracksUnit(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return '$_temp0'; + } + @override String libraryLastScanned(String time) { return 'Last scanned: $time'; @@ -2967,6 +2978,154 @@ class AppLocalizationsNl extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + @override + String get actionCreate => 'Create'; + + @override + String get collectionFoldersTitle => 'My folders'; + + @override + String get collectionWishlist => 'Wishlist'; + + @override + String get collectionLoved => 'Loved'; + + @override + String get collectionPlaylists => 'Playlists'; + + @override + String get collectionPlaylist => 'Playlist'; + + @override + String get collectionAddToPlaylist => 'Add to playlist'; + + @override + String get collectionCreatePlaylist => 'Create playlist'; + + @override + String get collectionNoPlaylistsYet => 'No playlists yet'; + + @override + String get collectionNoPlaylistsSubtitle => + 'Create a playlist to start categorizing tracks'; + + @override + String collectionPlaylistTracks(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String collectionAddedToPlaylist(String playlistName) { + return 'Added to \"$playlistName\"'; + } + + @override + String collectionAlreadyInPlaylist(String playlistName) { + return 'Already in \"$playlistName\"'; + } + + @override + String get collectionPlaylistCreated => 'Playlist created'; + + @override + String get collectionPlaylistNameHint => 'Playlist name'; + + @override + String get collectionPlaylistNameRequired => 'Playlist name is required'; + + @override + String get collectionRenamePlaylist => 'Rename playlist'; + + @override + String get collectionDeletePlaylist => 'Delete playlist'; + + @override + String collectionDeletePlaylistMessage(String playlistName) { + return 'Delete \"$playlistName\" and all tracks inside it?'; + } + + @override + String get collectionPlaylistDeleted => 'Playlist deleted'; + + @override + String get collectionPlaylistRenamed => 'Playlist renamed'; + + @override + String get collectionWishlistEmptyTitle => 'Wishlist is empty'; + + @override + String get collectionWishlistEmptySubtitle => + 'Tap + on tracks to save what you want to download later'; + + @override + String get collectionLovedEmptyTitle => 'Loved folder is empty'; + + @override + String get collectionLovedEmptySubtitle => + 'Tap love on tracks to keep your favorites'; + + @override + String get collectionPlaylistEmptyTitle => 'Playlist is empty'; + + @override + String get collectionPlaylistEmptySubtitle => + 'Long-press + on any track to add it here'; + + @override + String get collectionRemoveFromPlaylist => 'Remove from playlist'; + + @override + String get collectionRemoveFromFolder => 'Remove from folder'; + + @override + String collectionRemoved(String trackName) { + return '\"$trackName\" removed'; + } + + @override + String collectionAddedToLoved(String trackName) { + return '\"$trackName\" added to Loved'; + } + + @override + String collectionRemovedFromLoved(String trackName) { + return '\"$trackName\" removed from Loved'; + } + + @override + String collectionAddedToWishlist(String trackName) { + return '\"$trackName\" added to Wishlist'; + } + + @override + String collectionRemovedFromWishlist(String trackName) { + return '\"$trackName\" removed from Wishlist'; + } + + @override + String get trackOptionAddToLoved => 'Add to Loved'; + + @override + String get trackOptionRemoveFromLoved => 'Remove from Loved'; + + @override + String get trackOptionAddToWishlist => 'Add to Wishlist'; + + @override + String get trackOptionRemoveFromWishlist => 'Remove from Wishlist'; + + @override + String get collectionPlaylistChangeCover => 'Change cover image'; + + @override + String get collectionPlaylistRemoveCover => 'Remove cover image'; + @override String selectionShareCount(int count) { String _temp0 = intl.Intl.pluralLogic( diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index e39e5e64..2ab77278 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -2407,6 +2407,17 @@ class AppLocalizationsPt extends AppLocalizations { return '$count tracks'; } + @override + String libraryTracksUnit(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return '$_temp0'; + } + @override String libraryLastScanned(String time) { return 'Last scanned: $time'; @@ -2967,6 +2978,154 @@ class AppLocalizationsPt extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + @override + String get actionCreate => 'Create'; + + @override + String get collectionFoldersTitle => 'My folders'; + + @override + String get collectionWishlist => 'Wishlist'; + + @override + String get collectionLoved => 'Loved'; + + @override + String get collectionPlaylists => 'Playlists'; + + @override + String get collectionPlaylist => 'Playlist'; + + @override + String get collectionAddToPlaylist => 'Add to playlist'; + + @override + String get collectionCreatePlaylist => 'Create playlist'; + + @override + String get collectionNoPlaylistsYet => 'No playlists yet'; + + @override + String get collectionNoPlaylistsSubtitle => + 'Create a playlist to start categorizing tracks'; + + @override + String collectionPlaylistTracks(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String collectionAddedToPlaylist(String playlistName) { + return 'Added to \"$playlistName\"'; + } + + @override + String collectionAlreadyInPlaylist(String playlistName) { + return 'Already in \"$playlistName\"'; + } + + @override + String get collectionPlaylistCreated => 'Playlist created'; + + @override + String get collectionPlaylistNameHint => 'Playlist name'; + + @override + String get collectionPlaylistNameRequired => 'Playlist name is required'; + + @override + String get collectionRenamePlaylist => 'Rename playlist'; + + @override + String get collectionDeletePlaylist => 'Delete playlist'; + + @override + String collectionDeletePlaylistMessage(String playlistName) { + return 'Delete \"$playlistName\" and all tracks inside it?'; + } + + @override + String get collectionPlaylistDeleted => 'Playlist deleted'; + + @override + String get collectionPlaylistRenamed => 'Playlist renamed'; + + @override + String get collectionWishlistEmptyTitle => 'Wishlist is empty'; + + @override + String get collectionWishlistEmptySubtitle => + 'Tap + on tracks to save what you want to download later'; + + @override + String get collectionLovedEmptyTitle => 'Loved folder is empty'; + + @override + String get collectionLovedEmptySubtitle => + 'Tap love on tracks to keep your favorites'; + + @override + String get collectionPlaylistEmptyTitle => 'Playlist is empty'; + + @override + String get collectionPlaylistEmptySubtitle => + 'Long-press + on any track to add it here'; + + @override + String get collectionRemoveFromPlaylist => 'Remove from playlist'; + + @override + String get collectionRemoveFromFolder => 'Remove from folder'; + + @override + String collectionRemoved(String trackName) { + return '\"$trackName\" removed'; + } + + @override + String collectionAddedToLoved(String trackName) { + return '\"$trackName\" added to Loved'; + } + + @override + String collectionRemovedFromLoved(String trackName) { + return '\"$trackName\" removed from Loved'; + } + + @override + String collectionAddedToWishlist(String trackName) { + return '\"$trackName\" added to Wishlist'; + } + + @override + String collectionRemovedFromWishlist(String trackName) { + return '\"$trackName\" removed from Wishlist'; + } + + @override + String get trackOptionAddToLoved => 'Add to Loved'; + + @override + String get trackOptionRemoveFromLoved => 'Remove from Loved'; + + @override + String get trackOptionAddToWishlist => 'Add to Wishlist'; + + @override + String get trackOptionRemoveFromWishlist => 'Remove from Wishlist'; + + @override + String get collectionPlaylistChangeCover => 'Change cover image'; + + @override + String get collectionPlaylistRemoveCover => 'Remove cover image'; + @override String selectionShareCount(int count) { String _temp0 = intl.Intl.pluralLogic( diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 95b1295b..dbd95cc2 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -2466,6 +2466,17 @@ class AppLocalizationsRu extends AppLocalizations { return '$count $_temp0'; } + @override + String libraryTracksUnit(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return '$_temp0'; + } + @override String libraryLastScanned(String time) { return 'Последнее сканирование: $time'; @@ -3065,6 +3076,154 @@ class AppLocalizationsRu extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + @override + String get actionCreate => 'Create'; + + @override + String get collectionFoldersTitle => 'My folders'; + + @override + String get collectionWishlist => 'Wishlist'; + + @override + String get collectionLoved => 'Loved'; + + @override + String get collectionPlaylists => 'Playlists'; + + @override + String get collectionPlaylist => 'Playlist'; + + @override + String get collectionAddToPlaylist => 'Add to playlist'; + + @override + String get collectionCreatePlaylist => 'Create playlist'; + + @override + String get collectionNoPlaylistsYet => 'No playlists yet'; + + @override + String get collectionNoPlaylistsSubtitle => + 'Create a playlist to start categorizing tracks'; + + @override + String collectionPlaylistTracks(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String collectionAddedToPlaylist(String playlistName) { + return 'Added to \"$playlistName\"'; + } + + @override + String collectionAlreadyInPlaylist(String playlistName) { + return 'Already in \"$playlistName\"'; + } + + @override + String get collectionPlaylistCreated => 'Playlist created'; + + @override + String get collectionPlaylistNameHint => 'Playlist name'; + + @override + String get collectionPlaylistNameRequired => 'Playlist name is required'; + + @override + String get collectionRenamePlaylist => 'Rename playlist'; + + @override + String get collectionDeletePlaylist => 'Delete playlist'; + + @override + String collectionDeletePlaylistMessage(String playlistName) { + return 'Delete \"$playlistName\" and all tracks inside it?'; + } + + @override + String get collectionPlaylistDeleted => 'Playlist deleted'; + + @override + String get collectionPlaylistRenamed => 'Playlist renamed'; + + @override + String get collectionWishlistEmptyTitle => 'Wishlist is empty'; + + @override + String get collectionWishlistEmptySubtitle => + 'Tap + on tracks to save what you want to download later'; + + @override + String get collectionLovedEmptyTitle => 'Loved folder is empty'; + + @override + String get collectionLovedEmptySubtitle => + 'Tap love on tracks to keep your favorites'; + + @override + String get collectionPlaylistEmptyTitle => 'Playlist is empty'; + + @override + String get collectionPlaylistEmptySubtitle => + 'Long-press + on any track to add it here'; + + @override + String get collectionRemoveFromPlaylist => 'Remove from playlist'; + + @override + String get collectionRemoveFromFolder => 'Remove from folder'; + + @override + String collectionRemoved(String trackName) { + return '\"$trackName\" removed'; + } + + @override + String collectionAddedToLoved(String trackName) { + return '\"$trackName\" added to Loved'; + } + + @override + String collectionRemovedFromLoved(String trackName) { + return '\"$trackName\" removed from Loved'; + } + + @override + String collectionAddedToWishlist(String trackName) { + return '\"$trackName\" added to Wishlist'; + } + + @override + String collectionRemovedFromWishlist(String trackName) { + return '\"$trackName\" removed from Wishlist'; + } + + @override + String get trackOptionAddToLoved => 'Add to Loved'; + + @override + String get trackOptionRemoveFromLoved => 'Remove from Loved'; + + @override + String get trackOptionAddToWishlist => 'Add to Wishlist'; + + @override + String get trackOptionRemoveFromWishlist => 'Remove from Wishlist'; + + @override + String get collectionPlaylistChangeCover => 'Change cover image'; + + @override + String get collectionPlaylistRemoveCover => 'Remove cover image'; + @override String selectionShareCount(int count) { String _temp0 = intl.Intl.pluralLogic( diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index 15e12f36..dda62af2 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -2422,6 +2422,17 @@ class AppLocalizationsTr extends AppLocalizations { return '$count tracks'; } + @override + String libraryTracksUnit(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return '$_temp0'; + } + @override String libraryLastScanned(String time) { return 'Last scanned: $time'; @@ -2982,6 +2993,154 @@ class AppLocalizationsTr extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + @override + String get actionCreate => 'Create'; + + @override + String get collectionFoldersTitle => 'My folders'; + + @override + String get collectionWishlist => 'Wishlist'; + + @override + String get collectionLoved => 'Loved'; + + @override + String get collectionPlaylists => 'Playlists'; + + @override + String get collectionPlaylist => 'Playlist'; + + @override + String get collectionAddToPlaylist => 'Add to playlist'; + + @override + String get collectionCreatePlaylist => 'Create playlist'; + + @override + String get collectionNoPlaylistsYet => 'No playlists yet'; + + @override + String get collectionNoPlaylistsSubtitle => + 'Create a playlist to start categorizing tracks'; + + @override + String collectionPlaylistTracks(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String collectionAddedToPlaylist(String playlistName) { + return 'Added to \"$playlistName\"'; + } + + @override + String collectionAlreadyInPlaylist(String playlistName) { + return 'Already in \"$playlistName\"'; + } + + @override + String get collectionPlaylistCreated => 'Playlist created'; + + @override + String get collectionPlaylistNameHint => 'Playlist name'; + + @override + String get collectionPlaylistNameRequired => 'Playlist name is required'; + + @override + String get collectionRenamePlaylist => 'Rename playlist'; + + @override + String get collectionDeletePlaylist => 'Delete playlist'; + + @override + String collectionDeletePlaylistMessage(String playlistName) { + return 'Delete \"$playlistName\" and all tracks inside it?'; + } + + @override + String get collectionPlaylistDeleted => 'Playlist deleted'; + + @override + String get collectionPlaylistRenamed => 'Playlist renamed'; + + @override + String get collectionWishlistEmptyTitle => 'Wishlist is empty'; + + @override + String get collectionWishlistEmptySubtitle => + 'Tap + on tracks to save what you want to download later'; + + @override + String get collectionLovedEmptyTitle => 'Loved folder is empty'; + + @override + String get collectionLovedEmptySubtitle => + 'Tap love on tracks to keep your favorites'; + + @override + String get collectionPlaylistEmptyTitle => 'Playlist is empty'; + + @override + String get collectionPlaylistEmptySubtitle => + 'Long-press + on any track to add it here'; + + @override + String get collectionRemoveFromPlaylist => 'Remove from playlist'; + + @override + String get collectionRemoveFromFolder => 'Remove from folder'; + + @override + String collectionRemoved(String trackName) { + return '\"$trackName\" removed'; + } + + @override + String collectionAddedToLoved(String trackName) { + return '\"$trackName\" added to Loved'; + } + + @override + String collectionRemovedFromLoved(String trackName) { + return '\"$trackName\" removed from Loved'; + } + + @override + String collectionAddedToWishlist(String trackName) { + return '\"$trackName\" added to Wishlist'; + } + + @override + String collectionRemovedFromWishlist(String trackName) { + return '\"$trackName\" removed from Wishlist'; + } + + @override + String get trackOptionAddToLoved => 'Add to Loved'; + + @override + String get trackOptionRemoveFromLoved => 'Remove from Loved'; + + @override + String get trackOptionAddToWishlist => 'Add to Wishlist'; + + @override + String get trackOptionRemoveFromWishlist => 'Remove from Wishlist'; + + @override + String get collectionPlaylistChangeCover => 'Change cover image'; + + @override + String get collectionPlaylistRemoveCover => 'Remove cover image'; + @override String selectionShareCount(int count) { String _temp0 = intl.Intl.pluralLogic( diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index f88c9131..9021f5dc 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -2407,6 +2407,17 @@ class AppLocalizationsZh extends AppLocalizations { return '$count tracks'; } + @override + String libraryTracksUnit(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return '$_temp0'; + } + @override String libraryLastScanned(String time) { return 'Last scanned: $time'; @@ -2967,6 +2978,154 @@ class AppLocalizationsZh extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + @override + String get actionCreate => 'Create'; + + @override + String get collectionFoldersTitle => 'My folders'; + + @override + String get collectionWishlist => 'Wishlist'; + + @override + String get collectionLoved => 'Loved'; + + @override + String get collectionPlaylists => 'Playlists'; + + @override + String get collectionPlaylist => 'Playlist'; + + @override + String get collectionAddToPlaylist => 'Add to playlist'; + + @override + String get collectionCreatePlaylist => 'Create playlist'; + + @override + String get collectionNoPlaylistsYet => 'No playlists yet'; + + @override + String get collectionNoPlaylistsSubtitle => + 'Create a playlist to start categorizing tracks'; + + @override + String collectionPlaylistTracks(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String collectionAddedToPlaylist(String playlistName) { + return 'Added to \"$playlistName\"'; + } + + @override + String collectionAlreadyInPlaylist(String playlistName) { + return 'Already in \"$playlistName\"'; + } + + @override + String get collectionPlaylistCreated => 'Playlist created'; + + @override + String get collectionPlaylistNameHint => 'Playlist name'; + + @override + String get collectionPlaylistNameRequired => 'Playlist name is required'; + + @override + String get collectionRenamePlaylist => 'Rename playlist'; + + @override + String get collectionDeletePlaylist => 'Delete playlist'; + + @override + String collectionDeletePlaylistMessage(String playlistName) { + return 'Delete \"$playlistName\" and all tracks inside it?'; + } + + @override + String get collectionPlaylistDeleted => 'Playlist deleted'; + + @override + String get collectionPlaylistRenamed => 'Playlist renamed'; + + @override + String get collectionWishlistEmptyTitle => 'Wishlist is empty'; + + @override + String get collectionWishlistEmptySubtitle => + 'Tap + on tracks to save what you want to download later'; + + @override + String get collectionLovedEmptyTitle => 'Loved folder is empty'; + + @override + String get collectionLovedEmptySubtitle => + 'Tap love on tracks to keep your favorites'; + + @override + String get collectionPlaylistEmptyTitle => 'Playlist is empty'; + + @override + String get collectionPlaylistEmptySubtitle => + 'Long-press + on any track to add it here'; + + @override + String get collectionRemoveFromPlaylist => 'Remove from playlist'; + + @override + String get collectionRemoveFromFolder => 'Remove from folder'; + + @override + String collectionRemoved(String trackName) { + return '\"$trackName\" removed'; + } + + @override + String collectionAddedToLoved(String trackName) { + return '\"$trackName\" added to Loved'; + } + + @override + String collectionRemovedFromLoved(String trackName) { + return '\"$trackName\" removed from Loved'; + } + + @override + String collectionAddedToWishlist(String trackName) { + return '\"$trackName\" added to Wishlist'; + } + + @override + String collectionRemovedFromWishlist(String trackName) { + return '\"$trackName\" removed from Wishlist'; + } + + @override + String get trackOptionAddToLoved => 'Add to Loved'; + + @override + String get trackOptionRemoveFromLoved => 'Remove from Loved'; + + @override + String get trackOptionAddToWishlist => 'Add to Wishlist'; + + @override + String get trackOptionRemoveFromWishlist => 'Remove from Wishlist'; + + @override + String get collectionPlaylistChangeCover => 'Change cover image'; + + @override + String get collectionPlaylistRemoveCover => 'Remove cover image'; + @override String selectionShareCount(int count) { String _temp0 = intl.Intl.pluralLogic( diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 7853c4fe..1e0f3572 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -1814,6 +1814,13 @@ "count": {"type": "int"} } }, + "libraryTracksUnit": "{count, plural, =1{track} other{tracks}}", + "@libraryTracksUnit": { + "description": "Unit label for tracks count (without the number itself)", + "placeholders": { + "count": {"type": "int"} + } + }, "libraryLastScanned": "Last scanned: {time}", "@libraryLastScanned": { "description": "Last scan time display", @@ -2260,6 +2267,135 @@ "trackConvertFailed": "Conversion failed", "@trackConvertFailed": {"description": "Snackbar when conversion fails"}, + "actionCreate": "Create", + "@actionCreate": {"description": "Generic action button - create"}, + + "collectionFoldersTitle": "My folders", + "@collectionFoldersTitle": {"description": "Library section title for custom folders"}, + "collectionWishlist": "Wishlist", + "@collectionWishlist": {"description": "Custom folder for saved tracks to download later"}, + "collectionLoved": "Loved", + "@collectionLoved": {"description": "Custom folder for favorite tracks"}, + "collectionPlaylists": "Playlists", + "@collectionPlaylists": {"description": "Custom user playlists folder"}, + "collectionPlaylist": "Playlist", + "@collectionPlaylist": {"description": "Single playlist label"}, + "collectionAddToPlaylist": "Add to playlist", + "@collectionAddToPlaylist": {"description": "Action to add a track to user playlist"}, + "collectionCreatePlaylist": "Create playlist", + "@collectionCreatePlaylist": {"description": "Action to create a new playlist"}, + "collectionNoPlaylistsYet": "No playlists yet", + "@collectionNoPlaylistsYet": {"description": "Empty state title when user has no playlists"}, + "collectionNoPlaylistsSubtitle": "Create a playlist to start categorizing tracks", + "@collectionNoPlaylistsSubtitle": {"description": "Empty state subtitle when user has no playlists"}, + "collectionPlaylistTracks": "{count, plural, =1{1 track} other{{count} tracks}}", + "@collectionPlaylistTracks": { + "description": "Track count label for custom playlists", + "placeholders": { + "count": {"type": "int"} + } + }, + "collectionAddedToPlaylist": "Added to \"{playlistName}\"", + "@collectionAddedToPlaylist": { + "description": "Snackbar after adding track to playlist", + "placeholders": { + "playlistName": {"type": "String"} + } + }, + "collectionAlreadyInPlaylist": "Already in \"{playlistName}\"", + "@collectionAlreadyInPlaylist": { + "description": "Snackbar when track already exists in playlist", + "placeholders": { + "playlistName": {"type": "String"} + } + }, + "collectionPlaylistCreated": "Playlist created", + "@collectionPlaylistCreated": {"description": "Snackbar after creating playlist"}, + "collectionPlaylistNameHint": "Playlist name", + "@collectionPlaylistNameHint": {"description": "Hint text for playlist name input"}, + "collectionPlaylistNameRequired": "Playlist name is required", + "@collectionPlaylistNameRequired": {"description": "Validation error for empty playlist name"}, + "collectionRenamePlaylist": "Rename playlist", + "@collectionRenamePlaylist": {"description": "Action to rename playlist"}, + "collectionDeletePlaylist": "Delete playlist", + "@collectionDeletePlaylist": {"description": "Action to delete playlist"}, + "collectionDeletePlaylistMessage": "Delete \"{playlistName}\" and all tracks inside it?", + "@collectionDeletePlaylistMessage": { + "description": "Confirmation message for deleting playlist", + "placeholders": { + "playlistName": {"type": "String"} + } + }, + "collectionPlaylistDeleted": "Playlist deleted", + "@collectionPlaylistDeleted": {"description": "Snackbar after deleting playlist"}, + "collectionPlaylistRenamed": "Playlist renamed", + "@collectionPlaylistRenamed": {"description": "Snackbar after renaming playlist"}, + "collectionWishlistEmptyTitle": "Wishlist is empty", + "@collectionWishlistEmptyTitle": {"description": "Wishlist empty state title"}, + "collectionWishlistEmptySubtitle": "Tap + on tracks to save what you want to download later", + "@collectionWishlistEmptySubtitle": {"description": "Wishlist empty state subtitle"}, + "collectionLovedEmptyTitle": "Loved folder is empty", + "@collectionLovedEmptyTitle": {"description": "Loved empty state title"}, + "collectionLovedEmptySubtitle": "Tap love on tracks to keep your favorites", + "@collectionLovedEmptySubtitle": {"description": "Loved empty state subtitle"}, + "collectionPlaylistEmptyTitle": "Playlist is empty", + "@collectionPlaylistEmptyTitle": {"description": "Playlist empty state title"}, + "collectionPlaylistEmptySubtitle": "Long-press + on any track to add it here", + "@collectionPlaylistEmptySubtitle": {"description": "Playlist empty state subtitle"}, + "collectionRemoveFromPlaylist": "Remove from playlist", + "@collectionRemoveFromPlaylist": {"description": "Tooltip for removing track from playlist"}, + "collectionRemoveFromFolder": "Remove from folder", + "@collectionRemoveFromFolder": {"description": "Tooltip for removing track from wishlist/loved folder"}, + "collectionRemoved": "\"{trackName}\" removed", + "@collectionRemoved": { + "description": "Snackbar after removing a track from a collection", + "placeholders": { + "trackName": {"type": "String"} + } + }, + "collectionAddedToLoved": "\"{trackName}\" added to Loved", + "@collectionAddedToLoved": { + "description": "Snackbar after adding track to loved folder", + "placeholders": { + "trackName": {"type": "String"} + } + }, + "collectionRemovedFromLoved": "\"{trackName}\" removed from Loved", + "@collectionRemovedFromLoved": { + "description": "Snackbar after removing track from loved folder", + "placeholders": { + "trackName": {"type": "String"} + } + }, + "collectionAddedToWishlist": "\"{trackName}\" added to Wishlist", + "@collectionAddedToWishlist": { + "description": "Snackbar after adding track to wishlist", + "placeholders": { + "trackName": {"type": "String"} + } + }, + "collectionRemovedFromWishlist": "\"{trackName}\" removed from Wishlist", + "@collectionRemovedFromWishlist": { + "description": "Snackbar after removing track from wishlist", + "placeholders": { + "trackName": {"type": "String"} + } + }, + + "trackOptionAddToLoved": "Add to Loved", + "@trackOptionAddToLoved": {"description": "Bottom sheet action label - add track to loved folder"}, + "trackOptionRemoveFromLoved": "Remove from Loved", + "@trackOptionRemoveFromLoved": {"description": "Bottom sheet action label - remove track from loved folder"}, + "trackOptionAddToWishlist": "Add to Wishlist", + "@trackOptionAddToWishlist": {"description": "Bottom sheet action label - add track to wishlist"}, + "trackOptionRemoveFromWishlist": "Remove from Wishlist", + "@trackOptionRemoveFromWishlist": {"description": "Bottom sheet action label - remove track from wishlist"}, + + "collectionPlaylistChangeCover": "Change cover image", + "@collectionPlaylistChangeCover": {"description": "Bottom sheet action to pick a custom cover image for a playlist"}, + "collectionPlaylistRemoveCover": "Remove cover image", + "@collectionPlaylistRemoveCover": {"description": "Bottom sheet action to remove custom cover image from a playlist"}, + "selectionShareCount": "Share {count} {count, plural, =1{track} other{tracks}}", "@selectionShareCount": { "description": "Share button text with count in selection mode", diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index 8e2d9214..b74ffdb1 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -3173,15 +3173,24 @@ "@libraryAboutDescription": { "description": "Description of local library feature" }, - "libraryTracksCount": "{count} tracks", - "@libraryTracksCount": { - "description": "Track count in library", - "placeholders": { - "count": { - "type": "int" - } - } - }, + "libraryTracksCount": "{count} tracks", + "@libraryTracksCount": { + "description": "Track count in library", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "libraryTracksUnit": "{count, plural, =1{trek} other{trek}}", + "@libraryTracksUnit": { + "description": "Unit label for tracks count (without the number itself)", + "placeholders": { + "count": { + "type": "int" + } + } + }, "libraryLastScanned": "Last scanned: {time}", "@libraryLastScanned": { "description": "Last scan time display", @@ -3924,8 +3933,204 @@ } } }, - "trackConvertFailed": "Conversion failed", - "@trackConvertFailed": { - "description": "Snackbar when conversion fails" - } + "trackConvertFailed": "Conversion failed", + "@trackConvertFailed": { + "description": "Snackbar when conversion fails" + }, + + "actionCreate": "Buat", + "@actionCreate": { + "description": "Generic action button - create" + }, + "collectionFoldersTitle": "Folder saya", + "@collectionFoldersTitle": { + "description": "Library section title for custom folders" + }, + "collectionWishlist": "Wishlist", + "@collectionWishlist": { + "description": "Custom folder for saved tracks to download later" + }, + "collectionLoved": "Loved", + "@collectionLoved": { + "description": "Custom folder for favorite tracks" + }, + "collectionPlaylists": "Playlist", + "@collectionPlaylists": { + "description": "Custom user playlists folder" + }, + "collectionPlaylist": "Playlist", + "@collectionPlaylist": { + "description": "Single playlist label" + }, + "collectionAddToPlaylist": "Tambahkan ke playlist", + "@collectionAddToPlaylist": { + "description": "Action to add a track to user playlist" + }, + "collectionCreatePlaylist": "Buat playlist", + "@collectionCreatePlaylist": { + "description": "Action to create a new playlist" + }, + "collectionNoPlaylistsYet": "Belum ada playlist", + "@collectionNoPlaylistsYet": { + "description": "Empty state title when user has no playlists" + }, + "collectionNoPlaylistsSubtitle": "Buat playlist untuk mulai mengategorikan lagu", + "@collectionNoPlaylistsSubtitle": { + "description": "Empty state subtitle when user has no playlists" + }, + "collectionPlaylistTracks": "{count, plural, =1{1 lagu} other{{count} lagu}}", + "@collectionPlaylistTracks": { + "description": "Track count label for custom playlists", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "collectionAddedToPlaylist": "Ditambahkan ke \"{playlistName}\"", + "@collectionAddedToPlaylist": { + "description": "Snackbar after adding track to playlist", + "placeholders": { + "playlistName": { + "type": "String" + } + } + }, + "collectionAlreadyInPlaylist": "Sudah ada di \"{playlistName}\"", + "@collectionAlreadyInPlaylist": { + "description": "Snackbar when track already exists in playlist", + "placeholders": { + "playlistName": { + "type": "String" + } + } + }, + "collectionPlaylistCreated": "Playlist berhasil dibuat", + "@collectionPlaylistCreated": { + "description": "Snackbar after creating playlist" + }, + "collectionPlaylistNameHint": "Nama playlist", + "@collectionPlaylistNameHint": { + "description": "Hint text for playlist name input" + }, + "collectionPlaylistNameRequired": "Nama playlist wajib diisi", + "@collectionPlaylistNameRequired": { + "description": "Validation error for empty playlist name" + }, + "collectionRenamePlaylist": "Ubah nama playlist", + "@collectionRenamePlaylist": { + "description": "Action to rename playlist" + }, + "collectionDeletePlaylist": "Hapus playlist", + "@collectionDeletePlaylist": { + "description": "Action to delete playlist" + }, + "collectionDeletePlaylistMessage": "Hapus \"{playlistName}\" beserta semua lagunya?", + "@collectionDeletePlaylistMessage": { + "description": "Confirmation message for deleting playlist", + "placeholders": { + "playlistName": { + "type": "String" + } + } + }, + "collectionPlaylistDeleted": "Playlist dihapus", + "@collectionPlaylistDeleted": { + "description": "Snackbar after deleting playlist" + }, + "collectionPlaylistRenamed": "Nama playlist diperbarui", + "@collectionPlaylistRenamed": { + "description": "Snackbar after renaming playlist" + }, + "collectionWishlistEmptyTitle": "Wishlist masih kosong", + "@collectionWishlistEmptyTitle": { + "description": "Wishlist empty state title" + }, + "collectionWishlistEmptySubtitle": "Tap + di lagu untuk menyimpan yang ingin diunduh nanti", + "@collectionWishlistEmptySubtitle": { + "description": "Wishlist empty state subtitle" + }, + "collectionLovedEmptyTitle": "Folder Loved masih kosong", + "@collectionLovedEmptyTitle": { + "description": "Loved empty state title" + }, + "collectionLovedEmptySubtitle": "Tap love di lagu untuk menyimpan favoritmu", + "@collectionLovedEmptySubtitle": { + "description": "Loved empty state subtitle" + }, + "collectionPlaylistEmptyTitle": "Playlist masih kosong", + "@collectionPlaylistEmptyTitle": { + "description": "Playlist empty state title" + }, + "collectionPlaylistEmptySubtitle": "Tekan lama tombol + pada lagu untuk menambahkannya ke sini", + "@collectionPlaylistEmptySubtitle": { + "description": "Playlist empty state subtitle" + }, + "collectionRemoveFromPlaylist": "Hapus dari playlist", + "@collectionRemoveFromPlaylist": { + "description": "Tooltip for removing track from playlist" + }, + "collectionRemoveFromFolder": "Hapus dari folder", + "@collectionRemoveFromFolder": { + "description": "Tooltip for removing track from wishlist/loved folder" + }, + "collectionRemoved": "\"{trackName}\" dihapus", + "@collectionRemoved": { + "description": "Snackbar after removing a track from a collection", + "placeholders": { + "trackName": { + "type": "String" + } + } + }, + "collectionAddedToLoved": "\"{trackName}\" ditambahkan ke Loved", + "@collectionAddedToLoved": { + "description": "Snackbar after adding track to loved folder", + "placeholders": { + "trackName": { + "type": "String" + } + } + }, + "collectionRemovedFromLoved": "\"{trackName}\" dihapus dari Loved", + "@collectionRemovedFromLoved": { + "description": "Snackbar after removing track from loved folder", + "placeholders": { + "trackName": { + "type": "String" + } + } + }, + "collectionAddedToWishlist": "\"{trackName}\" ditambahkan ke Wishlist", + "@collectionAddedToWishlist": { + "description": "Snackbar after adding track to wishlist", + "placeholders": { + "trackName": { + "type": "String" + } + } + }, + "collectionRemovedFromWishlist": "\"{trackName}\" dihapus dari Wishlist", + "@collectionRemovedFromWishlist": { + "description": "Snackbar after removing track from wishlist", + "placeholders": { + "trackName": { + "type": "String" + } + } + }, + + "trackOptionAddToLoved": "Tambahkan ke Loved", + "@trackOptionAddToLoved": {"description": "Bottom sheet action label - add track to loved folder"}, + "trackOptionRemoveFromLoved": "Hapus dari Loved", + "@trackOptionRemoveFromLoved": {"description": "Bottom sheet action label - remove track from loved folder"}, + "trackOptionAddToWishlist": "Tambahkan ke Wishlist", + "@trackOptionAddToWishlist": {"description": "Bottom sheet action label - add track to wishlist"}, + "trackOptionRemoveFromWishlist": "Hapus dari Wishlist", + "@trackOptionRemoveFromWishlist": {"description": "Bottom sheet action label - remove track from wishlist"}, + + "collectionPlaylistChangeCover": "Ubah gambar sampul", + "@collectionPlaylistChangeCover": {"description": "Bottom sheet action to pick a custom cover image for a playlist"}, + "collectionPlaylistRemoveCover": "Hapus gambar sampul", + "@collectionPlaylistRemoveCover": {"description": "Bottom sheet action to remove custom cover image from a playlist"} } diff --git a/lib/models/settings.dart b/lib/models/settings.dart index dfdd3565..ba7b6f09 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -49,6 +49,8 @@ class AppSettings { autoExportFailedDownloads; // Auto export failed downloads to TXT file final String downloadNetworkMode; // 'any' = WiFi + Mobile, 'wifi_only' = WiFi only + final bool + networkCompatibilityMode; // Try HTTP + allow invalid TLS cert for API requests // Local Library Settings final bool localLibraryEnabled; // Enable local library scanning @@ -112,6 +114,7 @@ class AppSettings { this.useAllFilesAccess = false, this.autoExportFailedDownloads = false, this.downloadNetworkMode = 'any', + this.networkCompatibilityMode = false, // Local Library defaults this.localLibraryEnabled = false, this.localLibraryPath = '', @@ -173,6 +176,7 @@ class AppSettings { bool? useAllFilesAccess, bool? autoExportFailedDownloads, String? downloadNetworkMode, + bool? networkCompatibilityMode, // Local Library bool? localLibraryEnabled, String? localLibraryPath, @@ -235,6 +239,8 @@ class AppSettings { autoExportFailedDownloads: autoExportFailedDownloads ?? this.autoExportFailedDownloads, downloadNetworkMode: downloadNetworkMode ?? this.downloadNetworkMode, + networkCompatibilityMode: + networkCompatibilityMode ?? this.networkCompatibilityMode, // Local Library localLibraryEnabled: localLibraryEnabled ?? this.localLibraryEnabled, localLibraryPath: localLibraryPath ?? this.localLibraryPath, diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index 2fa2870d..04f4028f 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -50,6 +50,10 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( autoExportFailedDownloads: json['autoExportFailedDownloads'] as bool? ?? false, downloadNetworkMode: json['downloadNetworkMode'] as String? ?? 'any', + networkCompatibilityMode: + json['networkCompatibilityMode'] as bool? ?? + json['songLinkCompatibilityMode'] as bool? ?? + false, localLibraryEnabled: json['localLibraryEnabled'] as bool? ?? false, localLibraryPath: json['localLibraryPath'] as String? ?? '', localLibraryShowDuplicates: @@ -112,6 +116,7 @@ Map _$AppSettingsToJson( 'useAllFilesAccess': instance.useAllFilesAccess, 'autoExportFailedDownloads': instance.autoExportFailedDownloads, 'downloadNetworkMode': instance.downloadNetworkMode, + 'networkCompatibilityMode': instance.networkCompatibilityMode, 'localLibraryEnabled': instance.localLibraryEnabled, 'localLibraryPath': instance.localLibraryPath, 'localLibraryShowDuplicates': instance.localLibraryShowDuplicates, diff --git a/lib/providers/library_collections_provider.dart b/lib/providers/library_collections_provider.dart new file mode 100644 index 00000000..e1d7ca1f --- /dev/null +++ b/lib/providers/library_collections_provider.dart @@ -0,0 +1,490 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:spotiflac_android/models/track.dart'; + +const _collectionsStorageKey = 'library_collections_v1'; + +String trackCollectionKey(Track track) { + final isrc = track.isrc?.trim(); + if (isrc != null && isrc.isNotEmpty) { + return 'isrc:${isrc.toUpperCase()}'; + } + final source = (track.source?.trim().isNotEmpty ?? false) + ? track.source!.trim() + : 'builtin'; + return '$source:${track.id}'; +} + +class CollectionTrackEntry { + final String key; + final Track track; + final DateTime addedAt; + + const CollectionTrackEntry({ + required this.key, + required this.track, + required this.addedAt, + }); + + Map toJson() => { + 'key': key, + 'track': track.toJson(), + 'addedAt': addedAt.toIso8601String(), + }; + + factory CollectionTrackEntry.fromJson(Map json) { + final addedAtRaw = json['addedAt'] as String?; + return CollectionTrackEntry( + key: json['key'] as String, + track: Track.fromJson(Map.from(json['track'] as Map)), + addedAt: DateTime.tryParse(addedAtRaw ?? '') ?? DateTime.now(), + ); + } +} + +class UserPlaylistCollection { + final String id; + final String name; + final String? coverImagePath; + final DateTime createdAt; + final DateTime updatedAt; + final List tracks; + + const UserPlaylistCollection({ + required this.id, + required this.name, + this.coverImagePath, + required this.createdAt, + required this.updatedAt, + required this.tracks, + }); + + UserPlaylistCollection copyWith({ + String? id, + String? name, + String? Function()? coverImagePath, + DateTime? createdAt, + DateTime? updatedAt, + List? tracks, + }) { + return UserPlaylistCollection( + id: id ?? this.id, + name: name ?? this.name, + coverImagePath: + coverImagePath != null ? coverImagePath() : this.coverImagePath, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + tracks: tracks ?? this.tracks, + ); + } + + bool containsTrack(Track track) { + final key = trackCollectionKey(track); + return tracks.any((entry) => entry.key == key); + } + + Map toJson() => { + 'id': id, + 'name': name, + if (coverImagePath != null) 'coverImagePath': coverImagePath, + 'createdAt': createdAt.toIso8601String(), + 'updatedAt': updatedAt.toIso8601String(), + 'tracks': tracks.map((e) => e.toJson()).toList(), + }; + + factory UserPlaylistCollection.fromJson(Map json) { + final createdAtRaw = json['createdAt'] as String?; + final updatedAtRaw = json['updatedAt'] as String?; + final createdAt = DateTime.tryParse(createdAtRaw ?? '') ?? DateTime.now(); + final updatedAt = DateTime.tryParse(updatedAtRaw ?? '') ?? createdAt; + final tracksRaw = (json['tracks'] as List?) ?? const []; + return UserPlaylistCollection( + id: json['id'] as String, + name: json['name'] as String? ?? '', + coverImagePath: json['coverImagePath'] as String?, + createdAt: createdAt, + updatedAt: updatedAt, + tracks: tracksRaw + .whereType() + .map( + (e) => CollectionTrackEntry.fromJson(Map.from(e)), + ) + .toList(growable: false), + ); + } +} + +class LibraryCollectionsState { + final List wishlist; + final List loved; + final List playlists; + final bool isLoaded; + + const LibraryCollectionsState({ + this.wishlist = const [], + this.loved = const [], + this.playlists = const [], + this.isLoaded = false, + }); + + int get wishlistCount => wishlist.length; + int get lovedCount => loved.length; + int get playlistCount => playlists.length; + + bool isInWishlist(Track track) { + final key = trackCollectionKey(track); + return wishlist.any((entry) => entry.key == key); + } + + bool isLoved(Track track) { + final key = trackCollectionKey(track); + return loved.any((entry) => entry.key == key); + } + + UserPlaylistCollection? playlistById(String playlistId) { + for (final playlist in playlists) { + if (playlist.id == playlistId) return playlist; + } + return null; + } + + LibraryCollectionsState copyWith({ + List? wishlist, + List? loved, + List? playlists, + bool? isLoaded, + }) { + return LibraryCollectionsState( + wishlist: wishlist ?? this.wishlist, + loved: loved ?? this.loved, + playlists: playlists ?? this.playlists, + isLoaded: isLoaded ?? this.isLoaded, + ); + } + + Map toJson() => { + 'wishlist': wishlist.map((e) => e.toJson()).toList(), + 'loved': loved.map((e) => e.toJson()).toList(), + 'playlists': playlists.map((e) => e.toJson()).toList(), + }; + + factory LibraryCollectionsState.fromJson(Map json) { + final wishlistRaw = (json['wishlist'] as List?) ?? const []; + final lovedRaw = (json['loved'] as List?) ?? const []; + final playlistsRaw = (json['playlists'] as List?) ?? const []; + + return LibraryCollectionsState( + wishlist: wishlistRaw + .whereType() + .map( + (e) => CollectionTrackEntry.fromJson(Map.from(e)), + ) + .toList(growable: false), + loved: lovedRaw + .whereType() + .map( + (e) => CollectionTrackEntry.fromJson(Map.from(e)), + ) + .toList(growable: false), + playlists: playlistsRaw + .whereType() + .map( + (e) => + UserPlaylistCollection.fromJson(Map.from(e)), + ) + .toList(growable: false), + isLoaded: true, + ); + } +} + +class LibraryCollectionsNotifier extends Notifier { + final Future _prefs = SharedPreferences.getInstance(); + Future? _loadFuture; + + @override + LibraryCollectionsState build() { + _loadFuture = _load(); + return const LibraryCollectionsState(); + } + + Future _load() async { + final prefs = await _prefs; + final raw = prefs.getString(_collectionsStorageKey); + + if (raw == null || raw.isEmpty) { + state = state.copyWith(isLoaded: true); + return; + } + + try { + final parsed = jsonDecode(raw); + if (parsed is Map) { + state = LibraryCollectionsState.fromJson(parsed); + } else { + state = state.copyWith(isLoaded: true); + } + } catch (_) { + state = state.copyWith(isLoaded: true); + } + } + + Future _save() async { + final prefs = await _prefs; + await prefs.setString(_collectionsStorageKey, jsonEncode(state.toJson())); + } + + Future _ensureLoaded() async { + if (state.isLoaded) return; + await (_loadFuture ?? _load()); + } + + Future toggleWishlist(Track track) async { + await _ensureLoaded(); + final key = trackCollectionKey(track); + final index = state.wishlist.indexWhere((entry) => entry.key == key); + + if (index >= 0) { + final updated = [...state.wishlist]..removeAt(index); + state = state.copyWith(wishlist: updated); + await _save(); + return false; + } + + final entry = CollectionTrackEntry( + key: key, + track: track, + addedAt: DateTime.now(), + ); + final updated = [entry, ...state.wishlist]; + state = state.copyWith(wishlist: updated); + await _save(); + return true; + } + + Future toggleLoved(Track track) async { + await _ensureLoaded(); + final key = trackCollectionKey(track); + final index = state.loved.indexWhere((entry) => entry.key == key); + + if (index >= 0) { + final updated = [...state.loved]..removeAt(index); + state = state.copyWith(loved: updated); + await _save(); + return false; + } + + final entry = CollectionTrackEntry( + key: key, + track: track, + addedAt: DateTime.now(), + ); + final updated = [entry, ...state.loved]; + state = state.copyWith(loved: updated); + await _save(); + return true; + } + + Future removeFromWishlist(String trackKey) async { + await _ensureLoaded(); + final updated = state.wishlist + .where((entry) => entry.key != trackKey) + .toList(growable: false); + if (updated.length == state.wishlist.length) return; + state = state.copyWith(wishlist: updated); + await _save(); + } + + Future removeFromLoved(String trackKey) async { + await _ensureLoaded(); + final updated = state.loved + .where((entry) => entry.key != trackKey) + .toList(growable: false); + if (updated.length == state.loved.length) return; + state = state.copyWith(loved: updated); + await _save(); + } + + Future createPlaylist(String name) async { + await _ensureLoaded(); + final now = DateTime.now(); + final id = 'pl_${now.microsecondsSinceEpoch}'; + final trimmedName = name.trim(); + + final playlist = UserPlaylistCollection( + id: id, + name: trimmedName, + createdAt: now, + updatedAt: now, + tracks: const [], + ); + + state = state.copyWith(playlists: [playlist, ...state.playlists]); + await _save(); + return id; + } + + Future renamePlaylist(String playlistId, String newName) async { + await _ensureLoaded(); + final trimmed = newName.trim(); + if (trimmed.isEmpty) return; + + final now = DateTime.now(); + final updated = state.playlists + .map((playlist) { + if (playlist.id != playlistId) return playlist; + return playlist.copyWith(name: trimmed, updatedAt: now); + }) + .toList(growable: false); + + state = state.copyWith(playlists: updated); + await _save(); + } + + Future deletePlaylist(String playlistId) async { + await _ensureLoaded(); + final updated = state.playlists + .where((playlist) => playlist.id != playlistId) + .toList(growable: false); + if (updated.length == state.playlists.length) return; + state = state.copyWith(playlists: updated); + await _save(); + } + + Future addTrackToPlaylist(String playlistId, Track track) async { + await _ensureLoaded(); + final key = trackCollectionKey(track); + final now = DateTime.now(); + var changed = false; + + final updated = state.playlists + .map((playlist) { + if (playlist.id != playlistId) return playlist; + final alreadyInPlaylist = playlist.tracks.any( + (entry) => entry.key == key, + ); + if (alreadyInPlaylist) return playlist; + changed = true; + final entry = CollectionTrackEntry( + key: key, + track: track, + addedAt: now, + ); + return playlist.copyWith( + tracks: [entry, ...playlist.tracks], + updatedAt: now, + ); + }) + .toList(growable: false); + + if (!changed) return false; + + state = state.copyWith(playlists: updated); + await _save(); + return true; + } + + Future removeTrackFromPlaylist( + String playlistId, + String trackKey, + ) async { + await _ensureLoaded(); + final now = DateTime.now(); + var changed = false; + + final updated = state.playlists + .map((playlist) { + if (playlist.id != playlistId) return playlist; + final nextTracks = playlist.tracks + .where((entry) => entry.key != trackKey) + .toList(growable: false); + if (nextTracks.length == playlist.tracks.length) return playlist; + changed = true; + return playlist.copyWith(tracks: nextTracks, updatedAt: now); + }) + .toList(growable: false); + + if (!changed) return; + + state = state.copyWith(playlists: updated); + await _save(); + } + + /// Returns the directory for storing playlist cover images, creating it + /// if necessary. + Future _playlistCoversDir() async { + final appDir = await getApplicationSupportDirectory(); + final dir = Directory(p.join(appDir.path, 'playlist_covers')); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + return dir; + } + + /// Sets a custom cover image for a playlist by copying the source file + /// into the app's persistent storage. + Future setPlaylistCover( + String playlistId, + String sourceFilePath, + ) async { + await _ensureLoaded(); + final coversDir = await _playlistCoversDir(); + final ext = p.extension(sourceFilePath).toLowerCase(); + final destPath = p.join(coversDir.path, '$playlistId$ext'); + + // Copy image to persistent location + await File(sourceFilePath).copy(destPath); + + final now = DateTime.now(); + final updated = state.playlists + .map((playlist) { + if (playlist.id != playlistId) return playlist; + return playlist.copyWith( + coverImagePath: () => destPath, + updatedAt: now, + ); + }) + .toList(growable: false); + + state = state.copyWith(playlists: updated); + await _save(); + } + + /// Removes the custom cover image for a playlist (falls back to first + /// track's cover). + Future removePlaylistCover(String playlistId) async { + await _ensureLoaded(); + final playlist = state.playlistById(playlistId); + if (playlist == null) return; + + // Delete the file if it exists + final path = playlist.coverImagePath; + if (path != null) { + final file = File(path); + if (await file.exists()) { + await file.delete(); + } + } + + final now = DateTime.now(); + final updated = state.playlists + .map((pl) { + if (pl.id != playlistId) return pl; + return pl.copyWith(coverImagePath: () => null, updatedAt: now); + }) + .toList(growable: false); + + state = state.copyWith(playlists: updated); + await _save(); + } +} + +final libraryCollectionsProvider = + NotifierProvider( + LibraryCollectionsNotifier.new, + ); diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 1011e549..696d893e 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -45,6 +45,7 @@ class SettingsNotifier extends Notifier { LogBuffer.loggingEnabled = state.enableLogging; _syncLyricsSettingsToBackend(); + _syncNetworkCompatibilitySettingsToBackend(); } void _syncLyricsSettingsToBackend() { @@ -62,6 +63,16 @@ class SettingsNotifier extends Notifier { }); } + void _syncNetworkCompatibilitySettingsToBackend() { + final compatibilityMode = state.networkCompatibilityMode; + PlatformBridge.setNetworkCompatibilityOptions( + allowHttp: compatibilityMode, + insecureTls: compatibilityMode, + ).catchError((e) { + _log.w('Failed to sync network compatibility options to backend: $e'); + }); + } + Future _runMigrations(SharedPreferences prefs) async { final lastMigration = prefs.getInt(_migrationVersionKey) ?? 0; @@ -466,6 +477,12 @@ class SettingsNotifier extends Notifier { _saveSettings(); } + void setNetworkCompatibilityMode(bool enabled) { + state = state.copyWith(networkCompatibilityMode: enabled); + _saveSettings(); + _syncNetworkCompatibilitySettingsToBackend(); + } + void setLocalLibraryEnabled(bool enabled) { state = state.copyWith(localLibraryEnabled: enabled); _saveSettings(); diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index e9e756fc..555f7a1d 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -4,13 +4,13 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/l10n/l10n.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/providers/recent_access_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/file_access.dart'; +import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; import 'package:spotiflac_android/screens/artist_screen.dart'; import 'package:spotiflac_android/screens/home_tab.dart' @@ -116,7 +116,8 @@ class _AlbumScreenState extends ConsumerState { void _onScroll() { final expandedHeight = _calculateExpandedHeight(context); - final shouldShow = _scrollController.offset > (expandedHeight - kToolbarHeight - 20); + final shouldShow = + _scrollController.offset > (expandedHeight - kToolbarHeight - 20); if (shouldShow != _showTitleInAppBar) { setState(() => _showTitleInAppBar = shouldShow); } @@ -225,12 +226,14 @@ class _AlbumScreenState extends ConsumerState { Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final tracks = _tracks ?? []; + final pageBackgroundColor = colorScheme.surface; return Scaffold( + backgroundColor: pageBackgroundColor, body: CustomScrollView( controller: _scrollController, slivers: [ - _buildAppBar(context, colorScheme), + _buildAppBar(context, colorScheme, pageBackgroundColor), _buildInfoCard(context, colorScheme), if (_isLoading) const SliverToBoxAdapter( @@ -255,7 +258,11 @@ class _AlbumScreenState extends ConsumerState { ); } - Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) { + Widget _buildAppBar( + BuildContext context, + ColorScheme colorScheme, + Color pageBackgroundColor, + ) { final expandedHeight = _calculateExpandedHeight(context); final tracks = _tracks ?? []; final artistName = tracks.isNotEmpty ? tracks.first.artistName : null; @@ -265,7 +272,7 @@ class _AlbumScreenState extends ConsumerState { expandedHeight: expandedHeight, pinned: true, stretch: true, - backgroundColor: colorScheme.surface, + backgroundColor: pageBackgroundColor, surfaceTintColor: Colors.transparent, title: AnimatedOpacity( duration: const Duration(milliseconds: 200), @@ -289,14 +296,15 @@ class _AlbumScreenState extends ConsumerState { final showContent = collapseRatio > 0.3; return FlexibleSpaceBar( - collapseMode: CollapseMode.parallax, + collapseMode: CollapseMode.pin, background: Stack( fit: StackFit.expand, children: [ // Full-screen cover background (no blur, full resolution) if (widget.coverUrl != null) CachedNetworkImage( - imageUrl: _highResCoverUrl(widget.coverUrl) ?? widget.coverUrl!, + imageUrl: + _highResCoverUrl(widget.coverUrl) ?? widget.coverUrl!, fit: BoxFit.cover, cacheManager: CoverCacheManager.instance, placeholder: (_, _) => @@ -359,8 +367,7 @@ class _AlbumScreenState extends ConsumerState { if (artistName != null && artistName.isNotEmpty) ...[ const SizedBox(height: 6), GestureDetector( - onTap: () => - _navigateToArtist(context, artistName), + onTap: () => _navigateToArtist(context, artistName), child: Text( artistName, style: TextStyle( @@ -410,16 +417,14 @@ class _AlbumScreenState extends ConsumerState { ], ), ), - if (releaseDate != null && - releaseDate.isNotEmpty) + if (releaseDate != null && releaseDate.isNotEmpty) Container( padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 6, ), decoration: BoxDecoration( - color: - Colors.white.withValues(alpha: 0.2), + color: Colors.white.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(20), ), child: Row( @@ -445,16 +450,20 @@ class _AlbumScreenState extends ConsumerState { ], ), const SizedBox(height: 16), - FilledButton.icon( - onPressed: () => _downloadAll(context), - icon: const Icon(Icons.download, size: 18), - label: Text( - context.l10n.downloadAllCount(tracks.length), - ), - style: FilledButton.styleFrom( - minimumSize: const Size.fromHeight(48), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(24), + Center( + child: FilledButton.icon( + onPressed: () => _downloadAll(context), + icon: const Icon(Icons.download, size: 18), + label: Text( + context.l10n.downloadAllCount(tracks.length), + ), + style: FilledButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.black87, + minimumSize: const Size(0, 48), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), ), ), ), @@ -716,13 +725,6 @@ class _AlbumTrackItem extends ConsumerWidget { : false; 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; - - final showAsDownloaded = - isCompleted || (!isQueued && isInHistory) || isInLocalLibrary; return Padding( padding: const EdgeInsets.symmetric(horizontal: 8), @@ -798,18 +800,7 @@ class _AlbumTrackItem extends ConsumerWidget { ], ], ), - trailing: _buildDownloadButton( - context, - ref, - colorScheme, - isQueued: isQueued, - isDownloading: isDownloading, - isFinalizing: isFinalizing, - showAsDownloaded: showAsDownloaded, - isInHistory: isInHistory, - isInLocalLibrary: isInLocalLibrary, - progress: progress, - ), + trailing: TrackCollectionQuickActions(track: track), onTap: () => _handleTap( context, ref, @@ -869,117 +860,4 @@ class _AlbumTrackItem extends ConsumerWidget { 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 bool isInLocalLibrary, - 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, - isInLocalLibrary: isInLocalLibrary, - ), - 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, - ), - ), - ); - } - } } diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index d825ac3e..76c9973d 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -6,7 +6,6 @@ import 'package:intl/intl.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/models/track.dart'; -import 'package:spotiflac_android/models/download_item.dart'; import 'package:spotiflac_android/providers/track_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; @@ -18,6 +17,7 @@ import 'package:spotiflac_android/screens/album_screen.dart'; import 'package:spotiflac_android/screens/home_tab.dart' show ExtensionAlbumScreen; import 'package:spotiflac_android/widgets/download_service_picker.dart'; +import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; /// Simple in-memory cache for artist data class _ArtistCache { @@ -1255,13 +1255,6 @@ class _ArtistScreenState extends ConsumerState { : false; 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; - - final showAsDownloaded = - isCompleted || (!isQueued && isInHistory) || isInLocalLibrary; return InkWell( onTap: () => _handlePopularTrackTap( @@ -1346,16 +1339,8 @@ class _ArtistScreenState extends ConsumerState { ], ), ), - _buildPopularDownloadButton( + TrackCollectionQuickActions( track: track, - colorScheme: colorScheme, - isQueued: isQueued, - isDownloading: isDownloading, - isFinalizing: isFinalizing, - showAsDownloaded: showAsDownloaded, - isInHistory: isInHistory, - isInLocalLibrary: isInLocalLibrary, - progress: progress, ), ], ), @@ -1413,117 +1398,6 @@ class _ArtistScreenState extends ConsumerState { _downloadTrack(track); } - Widget _buildPopularDownloadButton({ - required Track track, - required ColorScheme colorScheme, - required bool isQueued, - required bool isDownloading, - required bool isFinalizing, - required bool showAsDownloaded, - required bool isInHistory, - required bool isInLocalLibrary, - required double progress, - }) { - const double size = 40.0; - const double iconSize = 20.0; - - if (showAsDownloaded) { - return GestureDetector( - onTap: () => _handlePopularTrackTap( - track, - isQueued: isQueued, - isInHistory: isInHistory, - isInLocalLibrary: isInLocalLibrary, - ), - 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: 2.5, - color: colorScheme.tertiary, - backgroundColor: colorScheme.surfaceContainerHighest, - ), - Icon(Icons.edit_note, color: colorScheme.tertiary, size: 14), - ], - ), - ); - } else if (isDownloading) { - return SizedBox( - width: size, - height: size, - child: Stack( - alignment: Alignment.center, - children: [ - CircularProgressIndicator( - value: progress > 0 ? progress : null, - strokeWidth: 2.5, - color: colorScheme.primary, - backgroundColor: colorScheme.surfaceContainerHighest, - ), - if (progress > 0) - Text( - '${(progress * 100).toInt()}', - style: TextStyle( - fontSize: 9, - 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: () => _downloadTrack(track), - child: Container( - width: size, - height: size, - decoration: BoxDecoration( - color: colorScheme.secondaryContainer, - shape: BoxShape.circle, - ), - child: Icon( - Icons.download, - color: colorScheme.onSecondaryContainer, - size: iconSize, - ), - ), - ); - } - } - void _downloadTrack(Track track) { final settings = ref.read(settingsProvider); ref.read(settingsProvider.notifier).setHasSearchedBefore(); diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index 23892a9e..3187aed0 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -11,7 +11,9 @@ import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/history_database.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/utils/file_access.dart'; +import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart'; import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart'; @@ -79,7 +81,8 @@ class _DownloadedAlbumScreenState extends ConsumerState { void _onScroll() { final expandedHeight = _calculateExpandedHeight(context); - final shouldShow = _scrollController.offset > (expandedHeight - kToolbarHeight - 20); + final shouldShow = + _scrollController.offset > (expandedHeight - kToolbarHeight - 20); if (shouldShow != _showTitleInAppBar) { setState(() => _showTitleInAppBar = shouldShow); } @@ -464,7 +467,7 @@ class _DownloadedAlbumScreenState extends ConsumerState { final showContent = collapseRatio > 0.3; return FlexibleSpaceBar( - collapseMode: CollapseMode.parallax, + collapseMode: CollapseMode.pin, background: Stack( fit: StackFit.expand, children: [ @@ -478,7 +481,8 @@ class _DownloadedAlbumScreenState extends ConsumerState { ) else if (widget.coverUrl != null) CachedNetworkImage( - imageUrl: _highResCoverUrl(widget.coverUrl) ?? widget.coverUrl!, + imageUrl: + _highResCoverUrl(widget.coverUrl) ?? widget.coverUrl!, fit: BoxFit.cover, cacheManager: CoverCacheManager.instance, placeholder: (_, _) => @@ -576,9 +580,10 @@ class _DownloadedAlbumScreenState extends ConsumerState { ), const SizedBox(width: 4), Text( - context.l10n.downloadedAlbumDownloadedCount( - tracks.length, - ), + context.l10n + .downloadedAlbumDownloadedCount( + tracks.length, + ), style: const TextStyle( color: Colors.white, fontWeight: FontWeight.w600, @@ -1099,6 +1104,9 @@ class _DownloadedAlbumScreenState extends ConsumerState { final historyDb = HistoryDatabase.instance; final newQuality = '${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}'; + final settings = ref.read(settingsProvider); + final shouldEmbedLyrics = + settings.embedLyrics && settings.lyricsMode != 'external'; for (int i = 0; i < total; i++) { if (!mounted) break; @@ -1131,6 +1139,15 @@ class _DownloadedAlbumScreenState extends ConsumerState { }); } } catch (_) {} + await ensureLyricsMetadataForConversion( + metadata: metadata, + sourcePath: item.filePath, + shouldEmbedLyrics: shouldEmbedLyrics, + trackName: item.trackName, + artistName: item.artistName, + spotifyId: item.spotifyId ?? '', + durationMs: (item.duration ?? 0) * 1000, + ); String? coverPath; try { diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index f60aa69b..2066e08e 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -24,8 +24,8 @@ import 'package:spotiflac_android/utils/app_bar_layout.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/screens/playlist_screen.dart'; import 'package:spotiflac_android/screens/downloaded_album_screen.dart'; -import 'package:spotiflac_android/models/download_item.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; +import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; class HomeTab extends ConsumerStatefulWidget { const HomeTab({super.key}); @@ -2957,13 +2957,6 @@ class _TrackItemWithStatus extends ConsumerWidget { } 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; - - final showAsDownloaded = - isCompleted || (!isQueued && isInHistory) || isInLocalLibrary; return Column( mainAxisSize: MainAxisSize.min, @@ -3068,17 +3061,8 @@ class _TrackItemWithStatus extends ConsumerWidget { ], ), ), - _buildDownloadButton( - context, - ref, - colorScheme, - isQueued: isQueued, - isDownloading: isDownloading, - isFinalizing: isFinalizing, - showAsDownloaded: showAsDownloaded, - isInHistory: isInHistory, - isInLocalLibrary: isInLocalLibrary, - progress: progress, + TrackCollectionQuickActions( + track: track, ), ], ), @@ -3145,119 +3129,6 @@ class _TrackItemWithStatus extends ConsumerWidget { 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 bool isInLocalLibrary, - 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, - isInLocalLibrary: isInLocalLibrary, - ), - 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, - ), - ), - ); - } - } } /// Widget for displaying album/playlist items in search results diff --git a/lib/screens/library_playlists_screen.dart b/lib/screens/library_playlists_screen.dart new file mode 100644 index 00000000..e1ad14b9 --- /dev/null +++ b/lib/screens/library_playlists_screen.dart @@ -0,0 +1,558 @@ +import 'dart:io'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/providers/library_collections_provider.dart'; +import 'package:spotiflac_android/screens/library_tracks_folder_screen.dart'; +import 'package:spotiflac_android/utils/app_bar_layout.dart'; + +class LibraryPlaylistsScreen extends ConsumerWidget { + const LibraryPlaylistsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final playlists = ref.watch( + libraryCollectionsProvider.select((state) => state.playlists), + ); + final colorScheme = Theme.of(context).colorScheme; + final topPadding = normalizedHeaderTopPadding(context); + + return Scaffold( + body: CustomScrollView( + slivers: [ + SliverAppBar( + expandedHeight: 120 + topPadding, + collapsedHeight: kToolbarHeight, + floating: false, + pinned: true, + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.pop(context), + ), + + flexibleSpace: LayoutBuilder( + builder: (context, constraints) { + final maxHeight = 120 + topPadding; + final minHeight = kToolbarHeight + topPadding; + final expandRatio = + ((constraints.maxHeight - minHeight) / + (maxHeight - minHeight)) + .clamp(0.0, 1.0); + final leftPadding = 56 - (32 * expandRatio); + + return FlexibleSpaceBar( + expandedTitleScale: 1.0, + titlePadding: EdgeInsets.only( + left: leftPadding, + bottom: 16, + ), + title: Text( + context.l10n.collectionPlaylists, + style: TextStyle( + fontSize: 20 + (8 * expandRatio), + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + ); + }, + ), + ), + if (playlists.isEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.playlist_play, + size: 60, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 12), + Text( + context.l10n.collectionNoPlaylistsYet, + style: Theme.of(context).textTheme.titleMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + context.l10n.collectionNoPlaylistsSubtitle, + textAlign: TextAlign.center, + style: + Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + ) + else + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + // Even indices = playlist tiles, odd indices = dividers + if (index.isOdd) { + return const Divider(height: 1); + } + final playlistIndex = index ~/ 2; + final playlist = playlists[playlistIndex]; + return ListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 2, + ), + leading: _buildPlaylistThumbnail(context, playlist), + title: Text(playlist.name), + subtitle: Text( + context.l10n.collectionPlaylistTracks( + playlist.tracks.length, + ), + ), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => LibraryTracksFolderScreen( + mode: LibraryTracksFolderMode.playlist, + playlistId: playlist.id, + ), + ), + ); + }, + onLongPress: () => + _showPlaylistOptionsSheet(context, ref, playlist), + ); + }, + childCount: playlists.length * 2 - 1, + ), + ), + ], + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: () => _showCreatePlaylistDialog(context, ref), + icon: const Icon(Icons.add), + label: Text(context.l10n.collectionCreatePlaylist), + ), + ); + } + + void _showPlaylistOptionsSheet( + BuildContext context, + WidgetRef ref, + UserPlaylistCollection playlist, + ) { + final colorScheme = Theme.of(context).colorScheme; + + showModalBottomSheet( + context: context, + backgroundColor: colorScheme.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Header: drag handle + thumbnail + playlist info + 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: [ + _buildPlaylistThumbnail(context, playlist), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + playlist.name, + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(fontWeight: FontWeight.w600), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + context.l10n.collectionPlaylistTracks( + playlist.tracks.length, + ), + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + Divider( + height: 1, + color: colorScheme.outlineVariant.withValues(alpha: 0.5), + ), + + // Rename + _PlaylistOptionTile( + icon: Icons.edit_outlined, + title: context.l10n.collectionRenamePlaylist, + onTap: () { + Navigator.pop(sheetContext); + _showRenamePlaylistDialog( + context, + ref, + playlist.id, + playlist.name, + ); + }, + ), + + // Change cover + _PlaylistOptionTile( + icon: Icons.image_outlined, + title: context.l10n.collectionPlaylistChangeCover, + onTap: () { + Navigator.pop(sheetContext); + _pickCoverImage(context, ref, playlist.id); + }, + ), + + // Delete + _PlaylistOptionTile( + icon: Icons.delete_outline, + iconColor: colorScheme.error, + title: context.l10n.collectionDeletePlaylist, + onTap: () { + Navigator.pop(sheetContext); + _confirmDeletePlaylist( + context, + ref, + playlist.id, + playlist.name, + ); + }, + ), + + const SizedBox(height: 16), + ], + ), + ), + ); + } + + Widget _buildPlaylistThumbnail( + BuildContext context, + UserPlaylistCollection playlist, + ) { + final colorScheme = Theme.of(context).colorScheme; + const double size = 48; + final borderRadius = BorderRadius.circular(8); + + // Priority: custom cover > first track cover URL > icon fallback + final customCoverPath = playlist.coverImagePath; + if (customCoverPath != null && customCoverPath.isNotEmpty) { + return ClipRRect( + borderRadius: borderRadius, + child: Image.file( + File(customCoverPath), + width: size, + height: size, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => _playlistIconFallback(colorScheme, size), + ), + ); + } + + final firstCoverUrl = playlist.tracks + .where((e) => e.track.coverUrl != null && e.track.coverUrl!.isNotEmpty) + .map((e) => e.track.coverUrl!) + .firstOrNull; + + if (firstCoverUrl != null) { + return ClipRRect( + borderRadius: borderRadius, + child: CachedNetworkImage( + imageUrl: firstCoverUrl, + width: size, + height: size, + fit: BoxFit.cover, + placeholder: (_, _) => _playlistIconFallback(colorScheme, size), + errorWidget: (_, _, _) => _playlistIconFallback(colorScheme, size), + ), + ); + } + + return _playlistIconFallback(colorScheme, size); + } + + Widget _playlistIconFallback(ColorScheme colorScheme, double size) { + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + Icons.queue_music, + color: colorScheme.onSurfaceVariant, + ), + ); + } + + Future _pickCoverImage( + BuildContext context, + WidgetRef ref, + String playlistId, + ) async { + final result = await FilePicker.platform.pickFiles( + type: FileType.image, + allowMultiple: false, + ); + if (result == null || result.files.isEmpty) return; + + final path = result.files.first.path; + if (path == null || path.isEmpty) return; + + await ref + .read(libraryCollectionsProvider.notifier) + .setPlaylistCover(playlistId, path); + } + + Future _showCreatePlaylistDialog( + BuildContext context, + WidgetRef ref, + ) async { + final controller = TextEditingController(); + final formKey = GlobalKey(); + + final playlistName = await showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + title: Text(dialogContext.l10n.collectionCreatePlaylist), + content: Form( + key: formKey, + child: TextFormField( + controller: controller, + autofocus: true, + decoration: InputDecoration( + hintText: dialogContext.l10n.collectionPlaylistNameHint, + ), + validator: (value) { + final trimmed = value?.trim() ?? ''; + if (trimmed.isEmpty) { + return dialogContext.l10n.collectionPlaylistNameRequired; + } + return null; + }, + onFieldSubmitted: (_) { + if (formKey.currentState?.validate() != true) return; + Navigator.of(dialogContext).pop(controller.text.trim()); + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: Text(dialogContext.l10n.dialogCancel), + ), + FilledButton( + onPressed: () { + if (formKey.currentState?.validate() != true) return; + Navigator.of(dialogContext).pop(controller.text.trim()); + }, + child: Text(dialogContext.l10n.actionCreate), + ), + ], + ); + }, + ); + + if (playlistName == null || + playlistName.trim().isEmpty || + !context.mounted) { + return; + } + + await ref + .read(libraryCollectionsProvider.notifier) + .createPlaylist(playlistName.trim()); + + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.collectionPlaylistCreated)), + ); + } + + Future _showRenamePlaylistDialog( + BuildContext context, + WidgetRef ref, + String playlistId, + String currentName, + ) async { + final controller = TextEditingController(text: currentName); + final formKey = GlobalKey(); + + final nextName = await showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + title: Text(dialogContext.l10n.collectionRenamePlaylist), + content: Form( + key: formKey, + child: TextFormField( + controller: controller, + autofocus: true, + decoration: InputDecoration( + hintText: dialogContext.l10n.collectionPlaylistNameHint, + ), + validator: (value) { + final trimmed = value?.trim() ?? ''; + if (trimmed.isEmpty) { + return dialogContext.l10n.collectionPlaylistNameRequired; + } + return null; + }, + onFieldSubmitted: (_) { + if (formKey.currentState?.validate() != true) return; + Navigator.of(dialogContext).pop(controller.text.trim()); + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: Text(dialogContext.l10n.dialogCancel), + ), + FilledButton( + onPressed: () { + if (formKey.currentState?.validate() != true) return; + Navigator.of(dialogContext).pop(controller.text.trim()); + }, + child: Text(dialogContext.l10n.dialogSave), + ), + ], + ); + }, + ); + + if (nextName == null || nextName.trim().isEmpty || !context.mounted) { + return; + } + + await ref + .read(libraryCollectionsProvider.notifier) + .renamePlaylist(playlistId, nextName.trim()); + + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.collectionPlaylistRenamed)), + ); + } + + Future _confirmDeletePlaylist( + BuildContext context, + WidgetRef ref, + String playlistId, + String playlistName, + ) async { + final confirmed = await showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + title: Text(dialogContext.l10n.collectionDeletePlaylist), + content: Text( + dialogContext.l10n.collectionDeletePlaylistMessage(playlistName), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: Text(dialogContext.l10n.dialogCancel), + ), + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: Text(dialogContext.l10n.dialogDelete), + ), + ], + ); + }, + ); + + if (confirmed != true || !context.mounted) return; + + await ref + .read(libraryCollectionsProvider.notifier) + .deletePlaylist(playlistId); + + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.collectionPlaylistDeleted)), + ); + } +} + +/// Styled like _OptionTile in track_collection_quick_actions.dart +class _PlaylistOptionTile extends StatelessWidget { + final IconData icon; + final Color? iconColor; + final String title; + final VoidCallback onTap; + + const _PlaylistOptionTile({ + required this.icon, + this.iconColor, + required this.title, + 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: iconColor ?? colorScheme.onPrimaryContainer, + size: 20, + ), + ), + title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)), + onTap: onTap, + ); + } +} diff --git a/lib/screens/library_tracks_folder_screen.dart b/lib/screens/library_tracks_folder_screen.dart new file mode 100644 index 00000000..e606460f --- /dev/null +++ b/lib/screens/library_tracks_folder_screen.dart @@ -0,0 +1,884 @@ +import 'dart:io'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/providers/download_queue_provider.dart'; +import 'package:spotiflac_android/providers/library_collections_provider.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/services/cover_cache_manager.dart'; +import 'package:spotiflac_android/utils/file_access.dart'; +import 'package:spotiflac_android/widgets/download_service_picker.dart'; +import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart'; + +class LibraryTracksFolderScreen extends ConsumerStatefulWidget { + final LibraryTracksFolderMode mode; + final String? playlistId; + + const LibraryTracksFolderScreen({ + super.key, + required this.mode, + this.playlistId, + }); + + @override + ConsumerState createState() => + _LibraryTracksFolderScreenState(); +} + +class _LibraryTracksFolderScreenState + extends ConsumerState { + bool _showTitleInAppBar = false; + final ScrollController _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + _scrollController.addListener(_onScroll); + } + + @override + void dispose() { + _scrollController.removeListener(_onScroll); + _scrollController.dispose(); + super.dispose(); + } + + void _onScroll() { + final expandedHeight = _calculateExpandedHeight(context); + final shouldShow = + _scrollController.offset > (expandedHeight - kToolbarHeight - 20); + if (shouldShow != _showTitleInAppBar) { + setState(() => _showTitleInAppBar = shouldShow); + } + } + + double _calculateExpandedHeight(BuildContext context) { + final mediaSize = MediaQuery.of(context).size; + return (mediaSize.height * 0.45).clamp(300.0, 420.0); + } + + IconData _modeIcon() { + return switch (widget.mode) { + LibraryTracksFolderMode.wishlist => Icons.bookmark, + LibraryTracksFolderMode.loved => Icons.favorite, + LibraryTracksFolderMode.playlist => Icons.queue_music, + }; + } + + /// Find the first available cover URL from entries. + String? _firstCoverUrl(List entries) { + for (final entry in entries) { + if (entry.track.coverUrl != null && entry.track.coverUrl!.isNotEmpty) { + return entry.track.coverUrl; + } + } + return null; + } + + /// Returns true if [url] is a local file path rather than a network URL. + bool _isCoverLocalPath(String url) { + return !url.startsWith('http://') && !url.startsWith('https://'); + } + + /// Upgrade cover URL to higher resolution for full-screen display. + String? _highResCoverUrl(String? url) { + if (url == null) return null; + // Spotify CDN: upgrade 300 → 640 + if (url.contains('ab67616d00001e02')) { + return url.replaceAll('ab67616d00001e02', 'ab67616d0000b273'); + } + // Deezer CDN: upgrade to 1000x1000 + final deezerRegex = RegExp(r'/(\d+)x(\d+)-(\d+)-(\d+)-(\d+)-(\d+)\.jpg$'); + if (url.contains('cdn-images.dzcdn.net') && deezerRegex.hasMatch(url)) { + return url.replaceAllMapped( + deezerRegex, + (m) => '/1000x1000-${m[3]}-${m[4]}-${m[5]}-${m[6]}.jpg', + ); + } + return url; + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final state = ref.watch(libraryCollectionsProvider); + final playlist = + widget.mode == LibraryTracksFolderMode.playlist && + widget.playlistId != null + ? state.playlistById(widget.playlistId!) + : null; + + final entries = switch (widget.mode) { + LibraryTracksFolderMode.wishlist => state.wishlist, + LibraryTracksFolderMode.loved => state.loved, + LibraryTracksFolderMode.playlist => + playlist?.tracks ?? const [], + }; + + final title = switch (widget.mode) { + LibraryTracksFolderMode.wishlist => context.l10n.collectionWishlist, + LibraryTracksFolderMode.loved => context.l10n.collectionLoved, + LibraryTracksFolderMode.playlist => + playlist?.name ?? context.l10n.collectionPlaylist, + }; + + final emptyTitle = switch (widget.mode) { + LibraryTracksFolderMode.wishlist => + context.l10n.collectionWishlistEmptyTitle, + LibraryTracksFolderMode.loved => context.l10n.collectionLovedEmptyTitle, + LibraryTracksFolderMode.playlist => + context.l10n.collectionPlaylistEmptyTitle, + }; + + final emptySubtitle = switch (widget.mode) { + LibraryTracksFolderMode.wishlist => + context.l10n.collectionWishlistEmptySubtitle, + LibraryTracksFolderMode.loved => + context.l10n.collectionLovedEmptySubtitle, + LibraryTracksFolderMode.playlist => + context.l10n.collectionPlaylistEmptySubtitle, + }; + + return Scaffold( + body: CustomScrollView( + controller: _scrollController, + slivers: [ + _buildAppBar(context, colorScheme, title, entries, playlist), + if (entries.isEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: _EmptyFolderState( + title: emptyTitle, + subtitle: emptySubtitle, + ), + ) + else + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final entry = entries[index]; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + _CollectionTrackTile( + entry: entry, + mode: widget.mode, + playlistId: widget.playlistId, + ), + if (index < entries.length - 1) + const Divider(height: 1), + ], + ); + }, + childCount: entries.length, + ), + ), + const SliverToBoxAdapter(child: SizedBox(height: 32)), + ], + ), + ); + } + + Future _pickCoverImage() async { + final playlistId = widget.playlistId; + if (playlistId == null) return; + + final result = await FilePicker.platform.pickFiles( + type: FileType.image, + allowMultiple: false, + ); + if (result == null || result.files.isEmpty) return; + + final path = result.files.first.path; + if (path == null || path.isEmpty) return; + + await ref + .read(libraryCollectionsProvider.notifier) + .setPlaylistCover(playlistId, path); + } + + Future _removeCoverImage() async { + final playlistId = widget.playlistId; + if (playlistId == null) return; + + await ref + .read(libraryCollectionsProvider.notifier) + .removePlaylistCover(playlistId); + } + + Widget _buildAppBar( + BuildContext context, + ColorScheme colorScheme, + String title, + List entries, + UserPlaylistCollection? playlist, + ) { + final expandedHeight = _calculateExpandedHeight(context); + final customCoverPath = playlist?.coverImagePath; + final isLovedMode = widget.mode == LibraryTracksFolderMode.loved; + final isPlaylistMode = widget.mode == LibraryTracksFolderMode.playlist; + // Loved always shows the heart icon (like Spotify's Liked Songs) + final coverUrl = isLovedMode ? null : _firstCoverUrl(entries); + final hasCustomCover = + customCoverPath != null && customCoverPath.isNotEmpty; + final hasCoverUrl = coverUrl != null; + + return SliverAppBar( + expandedHeight: expandedHeight, + pinned: true, + stretch: true, + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + title: AnimatedOpacity( + duration: const Duration(milliseconds: 200), + opacity: _showTitleInAppBar ? 1.0 : 0.0, + child: Text( + title, + style: TextStyle( + color: colorScheme.onSurface, + fontWeight: FontWeight.w600, + fontSize: 16, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + actions: [ + if (isPlaylistMode) + IconButton( + icon: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.4), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.camera_alt_outlined, + color: Colors.white, + size: 20, + ), + ), + onPressed: () => _showCoverOptionsSheet(context, hasCustomCover), + ), + ], + flexibleSpace: LayoutBuilder( + builder: (context, constraints) { + final collapseRatio = + (constraints.maxHeight - kToolbarHeight) / + (expandedHeight - kToolbarHeight); + final showContent = collapseRatio > 0.3; + + return FlexibleSpaceBar( + collapseMode: CollapseMode.pin, + background: Stack( + fit: StackFit.expand, + children: [ + // Cover background: custom > first track URL > icon + if (hasCustomCover) + Image.file( + File(customCoverPath), + fit: BoxFit.cover, + errorBuilder: (_, _, _) => Container( + color: colorScheme.surfaceContainerHighest, + child: Icon( + _modeIcon(), + size: 80, + color: colorScheme.onSurfaceVariant, + ), + ), + ) + else if (hasCoverUrl) + _isCoverLocalPath(coverUrl) + ? Image.file( + File(coverUrl), + fit: BoxFit.cover, + errorBuilder: (_, _, _) => + Container(color: colorScheme.surface), + ) + : CachedNetworkImage( + imageUrl: + _highResCoverUrl(coverUrl) ?? coverUrl, + fit: BoxFit.cover, + cacheManager: CoverCacheManager.instance, + placeholder: (_, _) => + Container(color: colorScheme.surface), + errorWidget: (_, _, _) => + Container(color: colorScheme.surface), + ) + else + Container( + color: colorScheme.surfaceContainerHighest, + child: Icon( + _modeIcon(), + size: 80, + color: colorScheme.onSurfaceVariant, + ), + ), + // Bottom gradient for readability + Positioned( + left: 0, + right: 0, + bottom: 0, + height: expandedHeight * 0.65, + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + Colors.black.withValues(alpha: 0.85), + ], + ), + ), + ), + ), + // Title and track count overlay + Positioned( + left: 20, + right: 20, + bottom: 40, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 150), + opacity: showContent ? 1.0 : 0.0, + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + style: const TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.bold, + height: 1.2, + ), + textAlign: TextAlign.center, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + if (entries.isNotEmpty) ...[ + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _modeIcon(), + size: 14, + color: Colors.white, + ), + const SizedBox(width: 4), + Text( + context.l10n.tracksCount(entries.length), + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 12, + ), + ), + ], + ), + ), + ], + ], + ), + ), + ), + ], + ), + stretchModes: const [StretchMode.zoomBackground], + ); + }, + ), + leading: IconButton( + icon: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.4), + shape: BoxShape.circle, + ), + child: const Icon(Icons.arrow_back, color: Colors.white), + ), + onPressed: () => Navigator.pop(context), + ), + ); + } + + void _showCoverOptionsSheet(BuildContext context, bool hasCustomCover) { + final colorScheme = Theme.of(context).colorScheme; + + showModalBottomSheet( + context: context, + backgroundColor: colorScheme.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + margin: const EdgeInsets.only(top: 12), + width: 40, + height: 4, + decoration: BoxDecoration( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 16), + ListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 4, + ), + leading: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.image_outlined, + color: colorScheme.onPrimaryContainer, + ), + ), + title: Text(context.l10n.collectionPlaylistChangeCover), + onTap: () { + Navigator.pop(sheetContext); + _pickCoverImage(); + }, + ), + if (hasCustomCover) + ListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 4, + ), + leading: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: colorScheme.errorContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.delete_outline, + color: colorScheme.onErrorContainer, + ), + ), + title: Text(context.l10n.collectionPlaylistRemoveCover), + onTap: () { + Navigator.pop(sheetContext); + _removeCoverImage(); + }, + ), + const SizedBox(height: 16), + ], + ), + ), + ); + } +} + +class _CollectionTrackTile extends ConsumerWidget { + final CollectionTrackEntry entry; + final LibraryTracksFolderMode mode; + final String? playlistId; + + const _CollectionTrackTile({ + required this.entry, + required this.mode, + required this.playlistId, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final track = entry.track; + + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + leading: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: track.coverUrl != null && track.coverUrl!.isNotEmpty + ? _buildTrackCover(context, track.coverUrl!, 52) + : Container( + width: 52, + height: 52, + color: Theme.of(context).colorScheme.surfaceContainerHighest, + child: Icon( + Icons.music_note, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis), + subtitle: Text( + track.artistName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: IconButton( + icon: Icon( + Icons.more_vert, + color: Theme.of(context).colorScheme.onSurfaceVariant, + size: 20, + ), + onPressed: () => _showTrackOptionsSheet(context, ref), + ), + onTap: mode == LibraryTracksFolderMode.wishlist + ? () => _downloadTrack(context, ref) + : mode == LibraryTracksFolderMode.playlist + ? () => _openInMusicPlayer(context, ref) + : null, + onLongPress: () => _showTrackOptionsSheet(context, ref), + ); + } + + /// Builds a cover image widget that handles both network URLs and local file paths. + Widget _buildTrackCover(BuildContext context, String coverUrl, double size) { + final isLocal = + !coverUrl.startsWith('http://') && !coverUrl.startsWith('https://'); + final colorScheme = Theme.of(context).colorScheme; + + if (isLocal) { + return Image.file( + File(coverUrl), + width: size, + height: size, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => Container( + width: size, + height: size, + color: colorScheme.surfaceContainerHighest, + child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant), + ), + ); + } + + return CachedNetworkImage( + imageUrl: coverUrl, + width: size, + height: size, + fit: BoxFit.cover, + memCacheWidth: (size * 2).toInt(), + cacheManager: CoverCacheManager.instance, + errorWidget: (_, _, _) => Container( + width: size, + height: size, + color: colorScheme.surfaceContainerHighest, + child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant), + ), + ); + } + + void _showTrackOptionsSheet(BuildContext context, WidgetRef ref) { + final track = entry.track; + final colorScheme = Theme.of(context).colorScheme; + final isDownloaded = ref.read( + downloadHistoryProvider.select((state) => state.isDownloaded(track.id)), + ); + // Wishlist: only show "Add to Playlist" if track is already downloaded + final showAddToPlaylist = + mode != LibraryTracksFolderMode.wishlist || isDownloaded; + + showModalBottomSheet( + context: context, + backgroundColor: colorScheme.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Header: drag handle + cover + track info + 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: track.coverUrl != null && + track.coverUrl!.isNotEmpty + ? _buildTrackCover(context, track.coverUrl!, 56) + : Container( + width: 56, + height: 56, + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.music_note, + color: colorScheme.onSurfaceVariant, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + track.name, + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(fontWeight: FontWeight.w600), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + track.artistName, + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + ), + ], + ), + Divider( + height: 1, + color: colorScheme.outlineVariant.withValues(alpha: 0.5), + ), + + // Add to playlist (hidden in wishlist unless already downloaded) + if (showAddToPlaylist) + _CollectionOptionTile( + icon: Icons.playlist_add, + title: context.l10n.collectionAddToPlaylist, + onTap: () { + Navigator.pop(sheetContext); + showAddTrackToPlaylistSheet(context, ref, track); + }, + ), + + // Remove from folder / playlist + _CollectionOptionTile( + icon: Icons.remove_circle_outline, + iconColor: colorScheme.error, + title: mode == LibraryTracksFolderMode.playlist + ? context.l10n.collectionRemoveFromPlaylist + : context.l10n.collectionRemoveFromFolder, + onTap: () { + Navigator.pop(sheetContext); + _removeFromCurrentFolder(context, ref); + }, + ), + + const SizedBox(height: 16), + ], + ), + ), + ); + } + + Future _removeFromCurrentFolder( + BuildContext context, + WidgetRef ref, + ) async { + final notifier = ref.read(libraryCollectionsProvider.notifier); + final key = entry.key; + + switch (mode) { + case LibraryTracksFolderMode.wishlist: + await notifier.removeFromWishlist(key); + break; + case LibraryTracksFolderMode.loved: + await notifier.removeFromLoved(key); + break; + case LibraryTracksFolderMode.playlist: + if (playlistId != null) { + await notifier.removeTrackFromPlaylist(playlistId!, key); + } + break; + } + + if (!context.mounted) return; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.collectionRemoved(entry.track.name))), + ); + } + + void _downloadTrack(BuildContext context, WidgetRef ref) { + final track = entry.track; + final settings = ref.read(settingsProvider); + + if (settings.askQualityBeforeDownload) { + DownloadServicePicker.show( + context, + trackName: track.name, + artistName: track.artistName, + coverUrl: track.coverUrl, + onSelect: (quality, service) { + ref + .read(downloadQueueProvider.notifier) + .addToQueue(track, service, qualityOverride: quality); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.snackbarAddedToQueue(track.name)), + ), + ); + }, + ); + } else { + ref + .read(downloadQueueProvider.notifier) + .addToQueue(track, settings.defaultService); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.snackbarAddedToQueue(track.name)), + ), + ); + } + } + + Future _openInMusicPlayer(BuildContext context, WidgetRef ref) async { + final track = entry.track; + final historyItem = ref + .read(downloadHistoryProvider.notifier) + .getBySpotifyId(track.id); + + if (historyItem == null) return; + + final exists = await fileExists(historyItem.filePath); + if (!exists) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.snackbarCannotOpenFile('File not found'), + ), + ), + ); + return; + } + + try { + await openFile(historyItem.filePath); + } catch (e) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.snackbarCannotOpenFile(e.toString())), + ), + ); + } + } +} + +/// Styled like _OptionTile in track_collection_quick_actions.dart +class _CollectionOptionTile extends StatelessWidget { + final IconData icon; + final Color? iconColor; + final String title; + final VoidCallback onTap; + + const _CollectionOptionTile({ + required this.icon, + this.iconColor, + required this.title, + 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: iconColor ?? colorScheme.onPrimaryContainer, + size: 20, + ), + ), + title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)), + onTap: onTap, + ); + } +} + +class _EmptyFolderState extends StatelessWidget { + final String title; + final String subtitle; + + const _EmptyFolderState({required this.title, required this.subtitle}); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.folder_open, + size: 60, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 12), + Text( + title, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 6), + Text( + subtitle, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ); + } +} + +enum LibraryTracksFolderMode { wishlist, loved, playlist } diff --git a/lib/screens/local_album_screen.dart b/lib/screens/local_album_screen.dart index e144882f..e7642a45 100644 --- a/lib/screens/local_album_screen.dart +++ b/lib/screens/local_album_screen.dart @@ -6,6 +6,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/utils/file_access.dart'; +import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart'; import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/ffmpeg_service.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; @@ -66,7 +67,8 @@ class _LocalAlbumScreenState extends ConsumerState { void _onScroll() { final expandedHeight = _calculateExpandedHeight(context); - final shouldShow = _scrollController.offset > (expandedHeight - kToolbarHeight - 20); + final shouldShow = + _scrollController.offset > (expandedHeight - kToolbarHeight - 20); if (shouldShow != _showTitleInAppBar) { setState(() => _showTitleInAppBar = shouldShow); } @@ -311,7 +313,7 @@ class _LocalAlbumScreenState extends ConsumerState { final showContent = collapseRatio > 0.3; return FlexibleSpaceBar( - collapseMode: CollapseMode.parallax, + collapseMode: CollapseMode.pin, background: Stack( fit: StackFit.expand, children: [ @@ -1188,6 +1190,9 @@ class _LocalAlbumScreenState extends ConsumerState { int successCount = 0; final total = selected.length; final localDb = LibraryDatabase.instance; + final settings = ref.read(settingsProvider); + final shouldEmbedLyrics = + settings.embedLyrics && settings.lyricsMode != 'external'; for (int i = 0; i < total; i++) { if (!mounted) break; @@ -1220,6 +1225,14 @@ class _LocalAlbumScreenState extends ConsumerState { }); } } catch (_) {} + await ensureLyricsMetadataForConversion( + metadata: metadata, + sourcePath: item.filePath, + shouldEmbedLyrics: shouldEmbedLyrics, + trackName: item.trackName, + artistName: item.artistName, + durationMs: (item.duration ?? 0) * 1000, + ); String? coverPath; try { diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index 97de41dc..f127d9d7 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -5,12 +5,12 @@ import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/l10n/l10n.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/utils/file_access.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; +import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; class PlaylistScreen extends ConsumerStatefulWidget { final String playlistName; @@ -119,7 +119,8 @@ class _PlaylistScreenState extends ConsumerState { void _onScroll() { final expandedHeight = _calculateExpandedHeight(context); - final shouldShow = _scrollController.offset > (expandedHeight - kToolbarHeight - 20); + final shouldShow = + _scrollController.offset > (expandedHeight - kToolbarHeight - 20); if (shouldShow != _showTitleInAppBar) { setState(() => _showTitleInAppBar = shouldShow); } @@ -196,14 +197,15 @@ class _PlaylistScreenState extends ConsumerState { final showContent = collapseRatio > 0.3; return FlexibleSpaceBar( - collapseMode: CollapseMode.parallax, + collapseMode: CollapseMode.pin, background: Stack( fit: StackFit.expand, children: [ // Full-screen cover background if (widget.coverUrl != null) CachedNetworkImage( - imageUrl: _highResCoverUrl(widget.coverUrl) ?? widget.coverUrl!, + imageUrl: + _highResCoverUrl(widget.coverUrl) ?? widget.coverUrl!, fit: BoxFit.cover, cacheManager: CoverCacheManager.instance, placeholder: (_, _) => @@ -295,16 +297,20 @@ class _PlaylistScreenState extends ConsumerState { ), ), const SizedBox(height: 16), - FilledButton.icon( - onPressed: () => _downloadAll(context), - icon: const Icon(Icons.download, size: 18), - label: Text( - context.l10n.downloadAllCount(_tracks.length), - ), - style: FilledButton.styleFrom( - minimumSize: const Size.fromHeight(48), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(24), + Center( + child: FilledButton.icon( + onPressed: () => _downloadAll(context), + icon: const Icon(Icons.download, size: 18), + label: Text( + context.l10n.downloadAllCount(_tracks.length), + ), + style: FilledButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.black87, + minimumSize: const Size(0, 48), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), ), ), ), @@ -509,13 +515,6 @@ class _PlaylistTrackItem extends ConsumerWidget { : false; 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; - - final showAsDownloaded = - isCompleted || (!isQueued && isInHistory) || isInLocalLibrary; return Padding( padding: const EdgeInsets.symmetric(horizontal: 8), @@ -603,17 +602,8 @@ class _PlaylistTrackItem extends ConsumerWidget { ], ], ), - trailing: _buildDownloadButton( - context, - ref, - colorScheme, - isQueued: isQueued, - isDownloading: isDownloading, - isFinalizing: isFinalizing, - showAsDownloaded: showAsDownloaded, - isInHistory: isInHistory, - isInLocalLibrary: isInLocalLibrary, - progress: progress, + trailing: TrackCollectionQuickActions( + track: track, ), onTap: () => _handleTap( context, @@ -674,117 +664,4 @@ class _PlaylistTrackItem extends ConsumerWidget { 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 bool isInLocalLibrary, - 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, - isInLocalLibrary: isInLocalLibrary, - ), - 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, - ), - ), - ); - } - } } diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 6dc317f7..f26a8b2f 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -13,8 +13,11 @@ import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/utils/app_bar_layout.dart'; import 'package:spotiflac_android/utils/file_access.dart'; +import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart'; import 'package:spotiflac_android/models/download_item.dart'; +import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; +import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/services/library_database.dart'; @@ -22,6 +25,7 @@ import 'package:spotiflac_android/services/history_database.dart'; import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart'; import 'package:spotiflac_android/screens/downloaded_album_screen.dart'; +import 'package:spotiflac_android/screens/library_tracks_folder_screen.dart'; import 'package:spotiflac_android/screens/local_album_screen.dart'; /// Represents the source of a library item @@ -112,6 +116,76 @@ class UnifiedLibraryItem { '${trackName.toLowerCase()}|${artistName.toLowerCase()}|${albumName.toLowerCase()}'; String get albumKey => '${albumName.toLowerCase()}|${artistName.toLowerCase()}'; + + /// Returns the collection key used to match this item against playlist + /// entries. Uses the same logic as [trackCollectionKey] from the collections + /// provider: prefer ISRC, fall back to source:id. + String get collectionKey { + if (historyItem != null) { + final isrc = historyItem!.isrc?.trim(); + if (isrc != null && isrc.isNotEmpty) return 'isrc:${isrc.toUpperCase()}'; + final source = historyItem!.service.trim().isNotEmpty + ? historyItem!.service.trim() + : 'builtin'; + return '$source:${historyItem!.id}'; + } + if (localItem != null) { + final isrc = localItem!.isrc?.trim(); + if (isrc != null && isrc.isNotEmpty) return 'isrc:${isrc.toUpperCase()}'; + return 'local:${localItem!.id}'; + } + return 'builtin:$id'; + } + + /// Convert to a [Track] for adding to collections/playlists. + Track toTrack() { + if (historyItem != null) { + final h = historyItem!; + return Track( + id: h.id, + name: h.trackName, + artistName: h.artistName, + albumName: h.albumName, + albumArtist: h.albumArtist, + coverUrl: h.coverUrl, + isrc: h.isrc, + duration: h.duration ?? 0, + trackNumber: h.trackNumber, + discNumber: h.discNumber, + releaseDate: h.releaseDate, + source: h.service, + ); + } + if (localItem != null) { + final l = localItem!; + // Store coverPath (even local file paths) in coverUrl so playlist + // entries retain the cover. All renderers must check whether the + // value is a URL or a local path and use the appropriate widget. + return Track( + id: l.id, + name: l.trackName, + artistName: l.artistName, + albumName: l.albumName, + albumArtist: l.albumArtist, + coverUrl: l.coverPath, + isrc: l.isrc, + duration: l.duration ?? 0, + trackNumber: l.trackNumber, + discNumber: l.discNumber, + releaseDate: l.releaseDate, + source: 'local', + ); + } + // Fallback — should not happen + return Track( + id: id, + name: trackName, + artistName: artistName, + albumName: albumName, + coverUrl: coverUrl, + duration: 0, + ); + } } class _GroupedAlbum { @@ -296,6 +370,9 @@ class _QueueTabState extends ConsumerState { bool _isSelectionMode = false; final Set _selectedIds = {}; + bool _isPlaylistSelectionMode = false; + final Set _selectedPlaylistIds = {}; + PageController? _filterPageController; final List _filterModes = ['all', 'albums', 'singles']; bool _isPageControllerInitialized = false; @@ -696,6 +773,211 @@ class _QueueTabState extends ConsumerState { }); } + // --- Playlist selection mode --- + + void _enterPlaylistSelectionMode(String playlistId) { + HapticFeedback.mediumImpact(); + setState(() { + _isPlaylistSelectionMode = true; + _selectedPlaylistIds.add(playlistId); + }); + } + + void _exitPlaylistSelectionMode() { + setState(() { + _isPlaylistSelectionMode = false; + _selectedPlaylistIds.clear(); + }); + } + + void _togglePlaylistSelection(String playlistId) { + setState(() { + if (_selectedPlaylistIds.contains(playlistId)) { + _selectedPlaylistIds.remove(playlistId); + if (_selectedPlaylistIds.isEmpty) { + _isPlaylistSelectionMode = false; + } + } else { + _selectedPlaylistIds.add(playlistId); + } + }); + } + + void _selectAllPlaylists(List playlists) { + setState(() { + _selectedPlaylistIds.addAll(playlists.map((e) => e.id)); + }); + } + + Future _deleteSelectedPlaylists(BuildContext context) async { + final count = _selectedPlaylistIds.length; + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(ctx.l10n.collectionDeletePlaylist), + content: Text( + 'Delete $count ${count == 1 ? 'playlist' : 'playlists'}?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text(ctx.l10n.dialogCancel), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + style: FilledButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.error, + ), + child: Text(ctx.l10n.dialogDelete), + ), + ], + ), + ); + + if (confirmed != true || !context.mounted) return; + + final notifier = ref.read(libraryCollectionsProvider.notifier); + for (final id in _selectedPlaylistIds.toList()) { + await notifier.deletePlaylist(id); + } + + if (!context.mounted) return; + _exitPlaylistSelectionMode(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + '$count ${count == 1 ? 'playlist' : 'playlists'} deleted', + ), + ), + ); + } + + /// Bottom action bar for playlist selection mode. + Widget _buildPlaylistSelectionBottomBar( + BuildContext context, + ColorScheme colorScheme, + List playlists, + double bottomPadding, + ) { + final selectedCount = _selectedPlaylistIds.length; + final allSelected = + selectedCount == playlists.length && playlists.isNotEmpty; + + return Container( + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHigh, + borderRadius: const BorderRadius.vertical(top: Radius.circular(28)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.15), + blurRadius: 12, + offset: const Offset(0, -4), + ), + ], + ), + child: SafeArea( + top: false, + child: Padding( + padding: EdgeInsets.fromLTRB(16, 16, 16, bottomPadding > 0 ? 8 : 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 32, + height: 4, + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: colorScheme.outlineVariant, + borderRadius: BorderRadius.circular(2), + ), + ), + + Row( + children: [ + IconButton.filledTonal( + onPressed: _exitPlaylistSelectionMode, + icon: const Icon(Icons.close), + style: IconButton.styleFrom( + backgroundColor: colorScheme.surfaceContainerHighest, + ), + ), + const SizedBox(width: 12), + + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '$selectedCount selected', + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + Text( + allSelected + ? 'All playlists selected' + : 'Tap playlists to select', + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ], + ), + ), + + TextButton.icon( + onPressed: () { + if (allSelected) { + _exitPlaylistSelectionMode(); + } else { + _selectAllPlaylists(playlists); + } + }, + icon: Icon( + allSelected ? Icons.deselect : Icons.select_all, + size: 20, + ), + label: Text(allSelected ? 'Deselect' : 'Select All'), + style: TextButton.styleFrom( + foregroundColor: colorScheme.primary, + ), + ), + ], + ), + + const SizedBox(height: 12), + + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: selectedCount > 0 + ? () => _deleteSelectedPlaylists(context) + : null, + icon: const Icon(Icons.delete_outline), + label: Text( + selectedCount > 0 + ? 'Delete $selectedCount ${selectedCount == 1 ? 'playlist' : 'playlists'}' + : 'Select playlists to delete', + ), + style: FilledButton.styleFrom( + backgroundColor: selectedCount > 0 + ? colorScheme.error + : colorScheme.surfaceContainerHighest, + foregroundColor: selectedCount > 0 + ? colorScheme.onError + : colorScheme.onSurfaceVariant, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + ), + ), + ], + ), + ), + ), + ); + } + String _getQualityBadgeText(String quality) { final q = quality.trim().toLowerCase(); if (q.contains('bit')) { @@ -1691,6 +1973,289 @@ class _QueueTabState extends ConsumerState { ).then((_) => _searchFocusNode.unfocus()); } + void _openWishlistFolder() { + _searchFocusNode.unfocus(); + Navigator.of(context) + .push( + MaterialPageRoute( + builder: (_) => const LibraryTracksFolderScreen( + mode: LibraryTracksFolderMode.wishlist, + ), + ), + ) + .then((_) => _searchFocusNode.unfocus()); + } + + void _openLovedFolder() { + _searchFocusNode.unfocus(); + Navigator.of(context) + .push( + MaterialPageRoute( + builder: (_) => const LibraryTracksFolderScreen( + mode: LibraryTracksFolderMode.loved, + ), + ), + ) + .then((_) => _searchFocusNode.unfocus()); + } + + void _openPlaylistById(String playlistId) { + _searchFocusNode.unfocus(); + Navigator.of(context) + .push( + MaterialPageRoute( + builder: (_) => LibraryTracksFolderScreen( + mode: LibraryTracksFolderMode.playlist, + playlistId: playlistId, + ), + ), + ) + .then((_) => _searchFocusNode.unfocus()); + } + + Future _showCreatePlaylistDialog(BuildContext context) async { + final controller = TextEditingController(); + final formKey = GlobalKey(); + + final playlistName = await showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + title: Text(dialogContext.l10n.collectionCreatePlaylist), + content: Form( + key: formKey, + child: TextFormField( + controller: controller, + autofocus: true, + decoration: InputDecoration( + hintText: dialogContext.l10n.collectionPlaylistNameHint, + ), + validator: (value) { + final trimmed = value?.trim() ?? ''; + if (trimmed.isEmpty) { + return dialogContext.l10n.collectionPlaylistNameRequired; + } + return null; + }, + onFieldSubmitted: (_) { + if (formKey.currentState?.validate() != true) return; + Navigator.of(dialogContext).pop(controller.text.trim()); + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: Text(dialogContext.l10n.dialogCancel), + ), + FilledButton( + onPressed: () { + if (formKey.currentState?.validate() != true) return; + Navigator.of(dialogContext).pop(controller.text.trim()); + }, + child: Text(dialogContext.l10n.actionCreate), + ), + ], + ); + }, + ); + + if (playlistName == null || playlistName.isEmpty) return; + await ref + .read(libraryCollectionsProvider.notifier) + .createPlaylist(playlistName); + } + + /// Build a playlist cover thumbnail (custom cover > first track cover > icon fallback). + /// Pass a finite [size] (e.g. 56) for list view, or `null` for grid view + /// where the widget should expand to fill its parent. + Widget _buildPlaylistCover( + UserPlaylistCollection playlist, + ColorScheme colorScheme, [ + double? size, + ]) { + final borderRadius = BorderRadius.circular(8); + + final customCoverPath = playlist.coverImagePath; + if (customCoverPath != null && customCoverPath.isNotEmpty) { + return ClipRRect( + borderRadius: borderRadius, + child: Image.file( + File(customCoverPath), + width: size, + height: size, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => + _playlistIconFallback(colorScheme, size), + ), + ); + } + + final firstCoverUrl = playlist.tracks + .where((e) => e.track.coverUrl != null && e.track.coverUrl!.isNotEmpty) + .map((e) => e.track.coverUrl!) + .firstOrNull; + + if (firstCoverUrl != null) { + // Guard against local file paths that may have been stored as coverUrl + final isLocalPath = !firstCoverUrl.startsWith('http://') && + !firstCoverUrl.startsWith('https://'); + if (isLocalPath) { + return ClipRRect( + borderRadius: borderRadius, + child: Image.file( + File(firstCoverUrl), + width: size, + height: size, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => + _playlistIconFallback(colorScheme, size), + ), + ); + } + return ClipRRect( + borderRadius: borderRadius, + child: CachedNetworkImage( + imageUrl: firstCoverUrl, + width: size, + height: size, + fit: BoxFit.cover, + placeholder: (_, _) => + _playlistIconFallback(colorScheme, size), + errorWidget: (_, _, _) => + _playlistIconFallback(colorScheme, size), + ), + ); + } + + return _playlistIconFallback(colorScheme, size); + } + + /// Icon fallback for playlists with no cover. + /// When [size] is null the container expands to fill its parent (grid view) + /// and uses a fixed icon size. + Widget _playlistIconFallback(ColorScheme colorScheme, [double? size]) { + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: const Color(0xFF5085A5), + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + Icons.queue_music, + color: Colors.white, + size: size != null ? size * 0.5 : 40, + ), + ); + } + + /// Handle a track being dropped onto a playlist. + /// When selection mode is active and the dragged item is among the selected, + /// all selected tracks are added to the playlist. + Future _onTrackDroppedOnPlaylist( + BuildContext context, + UnifiedLibraryItem item, + String playlistId, + String playlistName, { + List allItems = const [], + }) async { + final notifier = ref.read(libraryCollectionsProvider.notifier); + + // If in selection mode and the dragged item is selected, add ALL selected + if (_isSelectionMode && + _selectedIds.isNotEmpty && + _selectedIds.contains(item.id)) { + final selectedItems = allItems + .where((e) => _selectedIds.contains(e.id)) + .toList(); + // Fallback: if allItems is empty or no match, at least add the dragged item + if (selectedItems.isEmpty) { + selectedItems.add(item); + } + + int addedCount = 0; + int alreadyCount = 0; + for (final selected in selectedItems) { + final track = selected.toTrack(); + final added = await notifier.addTrackToPlaylist(playlistId, track); + if (added) { + addedCount++; + } else { + alreadyCount++; + } + } + + if (!context.mounted) return; + final message = addedCount > 0 + ? 'Added $addedCount ${addedCount == 1 ? 'track' : 'tracks'} to $playlistName' + '${alreadyCount > 0 ? ' ($alreadyCount already in playlist)' : ''}' + : context.l10n.collectionAlreadyInPlaylist(playlistName); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message)), + ); + _exitSelectionMode(); + return; + } + + // Single track drop + final track = item.toTrack(); + final added = await notifier.addTrackToPlaylist(playlistId, track); + + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + added + ? context.l10n.collectionAddedToPlaylist(playlistName) + : context.l10n.collectionAlreadyInPlaylist(playlistName), + ), + ), + ); + } + + /// Build a compact floating feedback widget shown while dragging a track. + /// Shows the count when multiple tracks are selected and being dragged. + Widget _buildDragFeedback( + BuildContext context, + UnifiedLibraryItem item, + ColorScheme colorScheme, + ) { + final isDraggingMultiple = _isSelectionMode && + _selectedIds.contains(item.id) && + _selectedIds.length > 1; + final count = isDraggingMultiple ? _selectedIds.length : 1; + + return Material( + elevation: 6, + borderRadius: BorderRadius.circular(12), + color: colorScheme.surfaceContainerHighest, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.playlist_add, size: 18, color: colorScheme.primary), + const SizedBox(width: 8), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 180), + child: Text( + isDraggingMultiple + ? '$count tracks' + : item.trackName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ); + } + @override Widget build(BuildContext context) { _initializePageController(); @@ -1708,6 +2273,7 @@ class _QueueTabState extends ConsumerState { final localLibraryItems = localLibraryEnabled ? ref.watch(localLibraryProvider.select((s) => s.items)) : const []; + final collectionState = ref.watch(libraryCollectionsProvider); _ensureHistoryCaches(allHistoryItems, localLibraryItems); final historyViewMode = ref.watch( @@ -1747,6 +2313,7 @@ class _QueueTabState extends ConsumerState { albumCounts: historyStats.albumCounts, localAlbumCounts: historyStats.localAlbumCounts, localLibraryItems: localLibraryItems, + collectionState: collectionState, ), ); } @@ -2020,6 +2587,7 @@ class _QueueTabState extends ConsumerState { hasQueueItems: hasQueueItems, filterData: filterData, localLibraryItems: localLibraryItems, + collectionState: collectionState, ); }, ), @@ -2043,11 +2611,29 @@ class _QueueTabState extends ConsumerState { albumCounts: historyStats.albumCounts, localLibraryItems: localLibraryItems, localAlbumCounts: historyStats.localAlbumCounts, + collectionState: collectionState, ), bottomPadding, ) : const SizedBox.shrink(), ), + + // Playlist selection bottom bar + AnimatedPositioned( + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, + left: 0, + right: 0, + bottom: _isPlaylistSelectionMode ? 0 : -(200 + bottomPadding), + child: _isPlaylistSelectionMode + ? _buildPlaylistSelectionBottomBar( + context, + colorScheme, + collectionState.playlists, + bottomPadding, + ) + : const SizedBox.shrink(), + ), ], ), ); @@ -2060,6 +2646,7 @@ class _QueueTabState extends ConsumerState { required Map albumCounts, required List localLibraryItems, required Map localAlbumCounts, + required LibraryCollectionsState collectionState, }) { final historyItems = _resolveHistoryItems( filterMode: filterMode, @@ -2075,7 +2662,19 @@ class _QueueTabState extends ConsumerState { ); // Apply advanced filters to match what's displayed - return _applyAdvancedFilters(unifiedItems); + final filtered = _applyAdvancedFilters(unifiedItems); + + // Exclude tracks already in a playlist + final playlistTrackKeys = {}; + for (final playlist in collectionState.playlists) { + for (final entry in playlist.tracks) { + playlistTrackKeys.add(entry.key); + } + } + if (playlistTrackKeys.isEmpty) return filtered; + return filtered + .where((item) => !playlistTrackKeys.contains(item.collectionKey)) + .toList(growable: false); } List _getUnifiedItems({ @@ -2139,6 +2738,7 @@ class _QueueTabState extends ConsumerState { required Map albumCounts, required Map localAlbumCounts, required List localLibraryItems, + required LibraryCollectionsState collectionState, }) { final historyItems = _resolveHistoryItems( filterMode: filterMode, @@ -2156,7 +2756,24 @@ class _QueueTabState extends ConsumerState { localLibraryItems: localLibraryItems, localAlbumCounts: localAlbumCounts, ); - final filteredUnifiedItems = _applyAdvancedFilters(unifiedItems); + final filtered = _applyAdvancedFilters(unifiedItems); + + // Remove tracks that are already in any playlist so they don't appear + // in the main tracks list. When a track is removed from a playlist (or + // the playlist is deleted) it will automatically reappear here because it + // will no longer be in the set. + final playlistTrackKeys = {}; + for (final playlist in collectionState.playlists) { + for (final entry in playlist.tracks) { + playlistTrackKeys.add(entry.key); + } + } + + final filteredUnifiedItems = playlistTrackKeys.isEmpty + ? filtered + : filtered + .where((item) => !playlistTrackKeys.contains(item.collectionKey)) + .toList(growable: false); return _FilterContentData( historyItems: historyItems, @@ -2229,6 +2846,358 @@ class _QueueTabState extends ConsumerState { ); } + /// Build a Spotify-style collection list item (Wishlist, Loved, Playlists) + Widget _buildCollectionListItem({ + required BuildContext context, + required ColorScheme colorScheme, + IconData? icon, + Color? iconColor, + Color? iconBgColor, + Widget? coverWidget, + required String title, + required String subtitle, + required VoidCallback onTap, + VoidCallback? onLongPress, + }) { + final cover = coverWidget ?? + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: iconBgColor ?? colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon ?? Icons.folder, color: iconColor ?? Colors.white, size: 28), + ); + + return InkWell( + onTap: onTap, + onLongPress: onLongPress, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + child: Row( + children: [ + SizedBox(width: 56, height: 56, child: cover), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + /// Build a collection grid item for grid view mode + Widget _buildCollectionGridItem({ + required BuildContext context, + required ColorScheme colorScheme, + IconData? icon, + Color? iconColor, + Color? iconBgColor, + Widget? coverWidget, + required String title, + required int count, + required VoidCallback onTap, + VoidCallback? onLongPress, + }) { + final cover = coverWidget ?? + Container( + decoration: BoxDecoration( + color: iconBgColor ?? colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon ?? Icons.folder, color: iconColor ?? Colors.white, size: 40), + ); + + return GestureDetector( + onTap: onTap, + onLongPress: onLongPress, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AspectRatio( + aspectRatio: 1, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: cover, + ), + ), + const SizedBox(height: 6), + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + Text( + '$count ${count == 1 ? 'item' : 'items'}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ); + } + + /// Build a collection item at [index] for the unified "All" tab grid view. + /// Index 0 = Wishlist, 1 = Loved, 2+ = individual playlists. + Widget _buildAllTabGridCollectionItem({ + required BuildContext context, + required ColorScheme colorScheme, + required int index, + required LibraryCollectionsState collectionState, + List filteredUnifiedItems = const [], + }) { + if (index == 0) { + return _buildCollectionGridItem( + context: context, + colorScheme: colorScheme, + icon: Icons.add_circle_outline, + iconColor: Colors.white, + iconBgColor: const Color(0xFF1DB954), + title: context.l10n.collectionWishlist, + count: collectionState.wishlistCount, + onTap: _openWishlistFolder, + ); + } else if (index == 1) { + return _buildCollectionGridItem( + context: context, + colorScheme: colorScheme, + icon: Icons.favorite, + iconColor: Colors.white, + iconBgColor: const Color(0xFF8C67AC), + title: context.l10n.collectionLoved, + count: collectionState.lovedCount, + onTap: _openLovedFolder, + ); + } else { + final playlist = collectionState.playlists[index - 2]; + final isSelected = _selectedPlaylistIds.contains(playlist.id); + return DragTarget( + onWillAcceptWithDetails: (_) => !_isPlaylistSelectionMode, + onAcceptWithDetails: (details) { + _onTrackDroppedOnPlaylist( + context, + details.data, + playlist.id, + playlist.name, + allItems: filteredUnifiedItems, + ); + }, + builder: (context, candidateData, rejectedData) { + final isHovering = candidateData.isNotEmpty; + return AnimatedContainer( + duration: const Duration(milliseconds: 150), + decoration: isHovering + ? BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: colorScheme.primary, + width: 2, + ), + color: colorScheme.primary.withValues(alpha: 0.1), + ) + : isSelected + ? BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: colorScheme.primary, + width: 2, + ), + color: colorScheme.primary.withValues(alpha: 0.08), + ) + : null, + child: Stack( + children: [ + _buildCollectionGridItem( + context: context, + colorScheme: colorScheme, + coverWidget: _buildPlaylistCover(playlist, colorScheme), + title: playlist.name, + count: playlist.tracks.length, + onTap: _isPlaylistSelectionMode + ? () => _togglePlaylistSelection(playlist.id) + : () => _openPlaylistById(playlist.id), + onLongPress: _isPlaylistSelectionMode + ? () => _togglePlaylistSelection(playlist.id) + : () => _enterPlaylistSelectionMode(playlist.id), + ), + if (_isPlaylistSelectionMode) + Positioned( + top: 4, + right: 4, + child: Container( + decoration: BoxDecoration( + color: isSelected + ? colorScheme.primary + : colorScheme.surface.withValues(alpha: 0.85), + shape: BoxShape.circle, + border: Border.all( + color: isSelected + ? colorScheme.primary + : colorScheme.outline, + width: 2, + ), + ), + child: isSelected + ? Icon(Icons.check, size: 16, + color: colorScheme.onPrimary) + : const SizedBox(width: 16, height: 16), + ), + ), + ], + ), + ); + }, + ); + } + } + + /// Build a collection item at [index] for the unified "All" tab list view. + /// Index 0 = Wishlist, 1 = Loved, 2+ = individual playlists. + Widget _buildAllTabListCollectionItem({ + required BuildContext context, + required ColorScheme colorScheme, + required int index, + required LibraryCollectionsState collectionState, + List filteredUnifiedItems = const [], + }) { + if (index == 0) { + return _buildCollectionListItem( + context: context, + colorScheme: colorScheme, + icon: Icons.add_circle_outline, + iconColor: Colors.white, + iconBgColor: const Color(0xFF1DB954), + title: context.l10n.collectionWishlist, + subtitle: + '${context.l10n.collectionFoldersTitle} • ${collectionState.wishlistCount} ${collectionState.wishlistCount == 1 ? 'track' : 'tracks'}', + onTap: _openWishlistFolder, + ); + } else if (index == 1) { + return _buildCollectionListItem( + context: context, + colorScheme: colorScheme, + icon: Icons.favorite, + iconColor: Colors.white, + iconBgColor: const Color(0xFF8C67AC), + title: context.l10n.collectionLoved, + subtitle: + '${context.l10n.collectionFoldersTitle} • ${collectionState.lovedCount} ${collectionState.lovedCount == 1 ? 'track' : 'tracks'}', + onTap: _openLovedFolder, + ); + } else { + final playlist = collectionState.playlists[index - 2]; + final isSelected = _selectedPlaylistIds.contains(playlist.id); + return DragTarget( + onWillAcceptWithDetails: (_) => !_isPlaylistSelectionMode, + onAcceptWithDetails: (details) { + _onTrackDroppedOnPlaylist( + context, + details.data, + playlist.id, + playlist.name, + allItems: filteredUnifiedItems, + ); + }, + builder: (context, candidateData, rejectedData) { + final isHovering = candidateData.isNotEmpty; + return AnimatedContainer( + duration: const Duration(milliseconds: 150), + decoration: isHovering + ? BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: colorScheme.primary, + width: 2, + ), + color: colorScheme.primary.withValues(alpha: 0.1), + ) + : isSelected + ? BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: colorScheme.primary, + width: 2, + ), + color: colorScheme.primary.withValues(alpha: 0.08), + ) + : null, + child: Row( + children: [ + if (_isPlaylistSelectionMode) + Padding( + padding: const EdgeInsets.only(left: 8), + child: Container( + decoration: BoxDecoration( + color: isSelected + ? colorScheme.primary + : Colors.transparent, + shape: BoxShape.circle, + border: Border.all( + color: isSelected + ? colorScheme.primary + : colorScheme.outline, + width: 2, + ), + ), + child: isSelected + ? Icon(Icons.check, size: 18, + color: colorScheme.onPrimary) + : const SizedBox(width: 18, height: 18), + ), + ), + Expanded( + child: _buildCollectionListItem( + context: context, + colorScheme: colorScheme, + coverWidget: _buildPlaylistCover(playlist, colorScheme, 56), + title: playlist.name, + subtitle: + '${playlist.tracks.length} ${playlist.tracks.length == 1 ? 'track' : 'tracks'}', + onTap: _isPlaylistSelectionMode + ? () => _togglePlaylistSelection(playlist.id) + : () => _openPlaylistById(playlist.id), + onLongPress: _isPlaylistSelectionMode + ? () => _togglePlaylistSelection(playlist.id) + : () => _enterPlaylistSelectionMode(playlist.id), + ), + ), + ], + ), + ); + }, + ); + } + } + Widget _buildFilterContent({ required BuildContext context, required ColorScheme colorScheme, @@ -2237,6 +3206,7 @@ class _QueueTabState extends ConsumerState { required bool hasQueueItems, required _FilterContentData filterData, required List localLibraryItems, + required LibraryCollectionsState collectionState, }) { final historyItems = filterData.historyItems; final showFilteringIndicator = filterData.showFilteringIndicator; @@ -2284,10 +3254,9 @@ class _QueueTabState extends ConsumerState { ), if (!_isSelectionMode && filteredUnifiedItems.isNotEmpty) TextButton.icon( - onPressed: () => - _enterSelectionMode(filteredUnifiedItems.first.id), - icon: const Icon(Icons.checklist, size: 18), - label: Text(context.l10n.actionSelect), + onPressed: () => _showCreatePlaylistDialog(context), + icon: const Icon(Icons.add, size: 20), + label: Text(context.l10n.collectionCreatePlaylist), style: TextButton.styleFrom( visualDensity: VisualDensity.compact, ), @@ -2297,6 +3266,9 @@ class _QueueTabState extends ConsumerState { ), ), + // Collection folders as list items (Spotify-style) in "All" tab + // are now rendered inline with tracks below (unified sliver) + if ((filteredGroupedAlbums.isNotEmpty || filteredGroupedLocalAlbums.isNotEmpty) && filterMode == 'albums') @@ -2444,45 +3416,119 @@ class _QueueTabState extends ConsumerState { ), ), - // Unified list for 'all' filter (merged downloaded + local) - if (filteredUnifiedItems.isNotEmpty && filterMode == 'all') - historyViewMode == 'grid' - ? SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 16), - sliver: SliverGrid( - gridDelegate: - const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - mainAxisSpacing: 8, - crossAxisSpacing: 8, - childAspectRatio: 0.75, + // Unified list/grid for 'all' filter: collection items + tracks combined + if (filterMode == 'all') ...[ + if (historyViewMode == 'grid') + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverGrid( + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + childAspectRatio: 0.75, + ), + delegate: SliverChildBuilderDelegate((context, index) { + final collectionCount = + 2 + collectionState.playlists.length; + if (index < collectionCount) { + return _buildAllTabGridCollectionItem( + context: context, + colorScheme: colorScheme, + index: index, + collectionState: collectionState, + filteredUnifiedItems: filteredUnifiedItems, + ); + } + final trackIndex = index - collectionCount; + if (trackIndex < filteredUnifiedItems.length) { + final item = filteredUnifiedItems[trackIndex]; + return KeyedSubtree( + key: ValueKey(item.id), + child: LongPressDraggable( + data: item, + feedback: _buildDragFeedback( + context, + item, + colorScheme, + ), + childWhenDragging: Opacity( + opacity: 0.4, + child: _buildUnifiedGridItem( + context, + item, + colorScheme, + ), ), - delegate: SliverChildBuilderDelegate((context, index) { - final item = filteredUnifiedItems[index]; - return KeyedSubtree( - key: ValueKey(item.id), child: _buildUnifiedGridItem( context, item, colorScheme, ), - ); - }, childCount: filteredUnifiedItems.length), - ), - ) - : SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - final item = filteredUnifiedItems[index]; - return KeyedSubtree( - key: ValueKey(item.id), + ), + ); + } + return const SizedBox.shrink(); + }, + childCount: + 2 + + collectionState.playlists.length + + filteredUnifiedItems.length, + ), + ), + ) + else + SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + final collectionCount = + 2 + collectionState.playlists.length; + if (index < collectionCount) { + return _buildAllTabListCollectionItem( + context: context, + colorScheme: colorScheme, + index: index, + collectionState: collectionState, + filteredUnifiedItems: filteredUnifiedItems, + ); + } + final trackIndex = index - collectionCount; + if (trackIndex < filteredUnifiedItems.length) { + final item = filteredUnifiedItems[trackIndex]; + return KeyedSubtree( + key: ValueKey(item.id), + child: LongPressDraggable( + data: item, + feedback: _buildDragFeedback( + context, + item, + colorScheme, + ), + childWhenDragging: Opacity( + opacity: 0.4, + child: _buildUnifiedLibraryItem( + context, + item, + colorScheme, + ), + ), child: _buildUnifiedLibraryItem( context, item, colorScheme, ), - ); - }, childCount: filteredUnifiedItems.length), - ), + ), + ); + } + return const SizedBox.shrink(); + }, + childCount: + 2 + + collectionState.playlists.length + + filteredUnifiedItems.length, + ), + ), + ], // Singles filter - show unified items (downloaded + local singles) if (filterMode == 'singles') @@ -2519,10 +3565,9 @@ class _QueueTabState extends ConsumerState { ), if (!_isSelectionMode && filteredUnifiedItems.isNotEmpty) TextButton.icon( - onPressed: () => - _enterSelectionMode(filteredUnifiedItems.first.id), - icon: const Icon(Icons.checklist, size: 18), - label: Text(context.l10n.actionSelect), + onPressed: () => _showCreatePlaylistDialog(context), + icon: const Icon(Icons.add, size: 20), + label: Text(context.l10n.collectionCreatePlaylist), style: TextButton.styleFrom( visualDensity: VisualDensity.compact, ), @@ -3442,6 +4487,9 @@ class _QueueTabState extends ConsumerState { final historyDb = HistoryDatabase.instance; final newQuality = '${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}'; + final settings = ref.read(settingsProvider); + final shouldEmbedLyrics = + settings.embedLyrics && settings.lyricsMode != 'external'; for (int i = 0; i < total; i++) { if (!mounted) break; @@ -3475,6 +4523,17 @@ class _QueueTabState extends ConsumerState { }); } } catch (_) {} + await ensureLyricsMetadataForConversion( + metadata: metadata, + sourcePath: item.filePath, + shouldEmbedLyrics: shouldEmbedLyrics, + trackName: item.trackName, + artistName: item.artistName, + spotifyId: item.historyItem?.spotifyId ?? '', + durationMs: + ((item.historyItem?.duration ?? item.localItem?.duration) ?? 0) * + 1000, + ); // Extract cover art String? coverPath; @@ -4455,14 +5514,18 @@ class _QueueTabState extends ConsumerState { ), ), const SizedBox(width: 8), - Text( - dateStr, - style: Theme.of(context).textTheme.labelSmall - ?.copyWith( - color: colorScheme.onSurfaceVariant.withValues( - alpha: 0.7, + Flexible( + child: Text( + dateStr, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: colorScheme.onSurfaceVariant.withValues( + alpha: 0.7, + ), ), - ), + ), ), if (item.quality != null && item.quality!.isNotEmpty) ...[ diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index d85d447f..142035af 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -175,10 +175,7 @@ class _SearchScreenState extends ConsumerState { ), ], ), - trailing: IconButton( - icon: Icon(Icons.download, color: colorScheme.primary), - onPressed: () => _downloadTrack(track), - ), + trailing: null, onTap: () => _downloadTrack(track), ); } diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index ac07f913..6577367b 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -312,8 +312,10 @@ class _DownloadSettingsPageState extends ConsumerState { SettingsItem( icon: Icons.lyrics_outlined, title: context.l10n.lyricsMode, - subtitle: - _getLyricsModeLabel(context, settings.lyricsMode), + subtitle: _getLyricsModeLabel( + context, + settings.lyricsMode, + ), onTap: () => _showLyricsModePicker( context, ref, @@ -534,6 +536,19 @@ class _DownloadSettingsPageState extends ConsumerState { settings.downloadNetworkMode, ), ), + SettingsSwitchItem( + icon: Icons.security_outlined, + title: 'Network compatibility mode', + subtitle: settings.networkCompatibilityMode + ? 'Enabled: try HTTP + accept invalid TLS certificates (unsafe)' + : 'Off: strict HTTPS certificate validation (recommended)', + value: settings.networkCompatibilityMode, + onChanged: (value) { + ref + .read(settingsProvider.notifier) + .setNetworkCompatibilityMode(value); + }, + ), SettingsSwitchItem( icon: Icons.file_download_outlined, title: context.l10n.settingsAutoExportFailed, diff --git a/lib/screens/settings/library_settings_page.dart b/lib/screens/settings/library_settings_page.dart index 696baee9..d8d32dda 100644 --- a/lib/screens/settings/library_settings_page.dart +++ b/lib/screens/settings/library_settings_page.dart @@ -656,14 +656,8 @@ class _LibraryHeroCard extends StatelessWidget { const SizedBox(height: 4), Text( isScanning - ? context.l10n - .libraryTracksCount(scannedFiles) - .replaceAll(scannedFiles.toString(), '') - .trim() - : context.l10n - .libraryTracksCount(displayCount) - .replaceAll(displayCount.toString(), '') - .trim(), + ? context.l10n.libraryTracksUnit(scannedFiles) + : context.l10n.libraryTracksUnit(displayCount), style: TextStyle( fontSize: 16, color: colorScheme.onSurfaceVariant, diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 5be27f4e..29bf2b21 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -548,7 +548,7 @@ class _TrackMetadataScreenState extends ConsumerState { final showContent = collapseRatio > 0.3; return FlexibleSpaceBar( - collapseMode: CollapseMode.parallax, + collapseMode: CollapseMode.pin, background: _buildHeaderBackground( context, colorScheme, diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 9b25456f..6e121f4c 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -133,6 +133,16 @@ class PlatformBridge { await _channel.invokeMethod('setDownloadDirectory', {'path': path}); } + static Future setNetworkCompatibilityOptions({ + required bool allowHttp, + required bool insecureTls, + }) async { + await _channel.invokeMethod('setNetworkCompatibilityOptions', { + 'allow_http': allowHttp, + 'insecure_tls': insecureTls, + }); + } + static Future> checkDuplicate( String outputDir, String isrc, @@ -244,7 +254,10 @@ class PlatformBridge { return result as bool? ?? false; } - static Future shareMultipleContentUris(List uris, {String title = ''}) async { + static Future shareMultipleContentUris( + List uris, { + String title = '', + }) async { final result = await _channel.invokeMethod('shareMultipleContentUris', { 'uris': uris, 'title': title, diff --git a/lib/utils/lyrics_metadata_helper.dart b/lib/utils/lyrics_metadata_helper.dart new file mode 100644 index 00000000..2e17c397 --- /dev/null +++ b/lib/utils/lyrics_metadata_helper.dart @@ -0,0 +1,76 @@ +import 'dart:io'; + +import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/utils/file_access.dart'; + +bool hasEmbeddedLyricsMetadata(Map metadata) { + final lyrics = (metadata['LYRICS'] ?? '').trim(); + if (lyrics.isNotEmpty) return true; + + final unsyncedLyrics = (metadata['UNSYNCEDLYRICS'] ?? '').trim(); + if (unsyncedLyrics.isNotEmpty) return true; + + return false; +} + +String _sidecarLrcPath(String path) { + final slash = path.lastIndexOf(Platform.pathSeparator); + final dot = path.lastIndexOf('.'); + if (dot > slash) { + return '${path.substring(0, dot)}.lrc'; + } + return '$path.lrc'; +} + +Future ensureLyricsMetadataForConversion({ + required Map metadata, + required String sourcePath, + required bool shouldEmbedLyrics, + required String trackName, + required String artistName, + String spotifyId = '', + int durationMs = 0, +}) async { + if (!shouldEmbedLyrics || hasEmbeddedLyricsMetadata(metadata)) { + return; + } + + String? lyrics; + + // Prefer sidecar .lrc when available to avoid network calls. + if (!isContentUri(sourcePath)) { + try { + final lrcPath = _sidecarLrcPath(sourcePath); + final lrcFile = File(lrcPath); + if (await lrcFile.exists()) { + final content = (await lrcFile.readAsString()).trim(); + if (content.isNotEmpty) { + lyrics = content; + } + } + } catch (_) {} + } + + if (lyrics == null || lyrics.isEmpty) { + try { + final fetched = await PlatformBridge.getLyricsLRC( + spotifyId, + trackName, + artistName, + durationMs: durationMs, + ); + final normalized = fetched.trim(); + if (normalized.isNotEmpty && + normalized.toLowerCase() != '[instrumental:true]') { + lyrics = normalized; + } + } catch (_) {} + } + + if (lyrics == null || lyrics.isEmpty) { + return; + } + + metadata['LYRICS'] = lyrics; + metadata['UNSYNCEDLYRICS'] = lyrics; +} diff --git a/lib/widgets/playlist_picker_sheet.dart b/lib/widgets/playlist_picker_sheet.dart new file mode 100644 index 00000000..7eb7ee42 --- /dev/null +++ b/lib/widgets/playlist_picker_sheet.dart @@ -0,0 +1,187 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/providers/library_collections_provider.dart'; + +Future showAddTrackToPlaylistSheet( + BuildContext context, + WidgetRef ref, + Track track, +) async { + final notifier = ref.read(libraryCollectionsProvider.notifier); + final state = ref.read(libraryCollectionsProvider); + + if (!context.mounted) return; + + await showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (sheetContext) { + final playlists = ref.watch(libraryCollectionsProvider).playlists; + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.playlist_add), + title: Text(sheetContext.l10n.collectionAddToPlaylist), + subtitle: Text('${track.name} • ${track.artistName}'), + ), + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.add_circle_outline), + title: Text(sheetContext.l10n.collectionCreatePlaylist), + onTap: () async { + Navigator.of(sheetContext).pop(); + final name = await _promptPlaylistName(context); + if (name == null || name.trim().isEmpty || !context.mounted) { + return; + } + final playlistId = await notifier.createPlaylist(name.trim()); + final added = await notifier.addTrackToPlaylist( + playlistId, + track, + ); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + added + ? context.l10n.collectionAddedToPlaylist(name.trim()) + : context.l10n.collectionAlreadyInPlaylist( + name.trim(), + ), + ), + ), + ); + }, + ), + if (playlists.isEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 24), + child: Text( + sheetContext.l10n.collectionNoPlaylistsYet, + style: Theme.of(sheetContext).textTheme.bodyMedium?.copyWith( + color: Theme.of(sheetContext).colorScheme.onSurfaceVariant, + ), + ), + ) + else + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 320), + child: ListView.builder( + shrinkWrap: true, + itemCount: playlists.length, + itemBuilder: (context, index) { + final playlist = playlists[index]; + final alreadyInPlaylist = playlist.containsTrack(track); + return ListTile( + leading: Icon( + alreadyInPlaylist + ? Icons.playlist_add_check + : Icons.queue_music, + ), + title: Text(playlist.name), + subtitle: Text( + context.l10n.collectionPlaylistTracks( + playlist.tracks.length, + ), + ), + enabled: !alreadyInPlaylist, + onTap: !alreadyInPlaylist + ? () async { + final added = await notifier.addTrackToPlaylist( + playlist.id, + track, + ); + if (!context.mounted) return; + Navigator.of(sheetContext).pop(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + added + ? context.l10n + .collectionAddedToPlaylist( + playlist.name, + ) + : context.l10n + .collectionAlreadyInPlaylist( + playlist.name, + ), + ), + ), + ); + } + : null, + ); + }, + ), + ), + const SizedBox(height: 8), + ], + ), + ); + }, + ); + + if (!context.mounted) return; + + final afterState = ref.read(libraryCollectionsProvider); + if (afterState.playlists.length != state.playlists.length) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.collectionPlaylistCreated)), + ); + } +} + +Future _promptPlaylistName(BuildContext context) async { + final controller = TextEditingController(); + final formKey = GlobalKey(); + + final result = await showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + title: Text(dialogContext.l10n.collectionCreatePlaylist), + content: Form( + key: formKey, + child: TextFormField( + controller: controller, + autofocus: true, + textInputAction: TextInputAction.done, + decoration: InputDecoration( + hintText: dialogContext.l10n.collectionPlaylistNameHint, + ), + validator: (value) { + final trimmed = value?.trim() ?? ''; + if (trimmed.isEmpty) { + return dialogContext.l10n.collectionPlaylistNameRequired; + } + return null; + }, + onFieldSubmitted: (_) { + if (formKey.currentState?.validate() != true) return; + Navigator.of(dialogContext).pop(controller.text.trim()); + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: Text(dialogContext.l10n.dialogCancel), + ), + FilledButton( + onPressed: () { + if (formKey.currentState?.validate() != true) return; + Navigator.of(dialogContext).pop(controller.text.trim()); + }, + child: Text(dialogContext.l10n.actionCreate), + ), + ], + ); + }, + ); + + return result; +} diff --git a/lib/widgets/track_collection_quick_actions.dart b/lib/widgets/track_collection_quick_actions.dart new file mode 100644 index 00000000..1f9e345d --- /dev/null +++ b/lib/widgets/track_collection_quick_actions.dart @@ -0,0 +1,254 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/providers/library_collections_provider.dart'; +import 'package:spotiflac_android/services/cover_cache_manager.dart'; +import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart'; + +class TrackCollectionQuickActions extends ConsumerWidget { + final Track track; + + const TrackCollectionQuickActions({ + super.key, + required this.track, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colorScheme = Theme.of(context).colorScheme; + + return IconButton( + icon: Icon( + Icons.more_vert, + color: colorScheme.onSurfaceVariant, + size: 20, + ), + onPressed: () => _showTrackOptionsSheet(context, ref), + padding: const EdgeInsets.only(left: 12), + constraints: const BoxConstraints(minWidth: 36, minHeight: 36), + ); + } + + void _showTrackOptionsSheet(BuildContext context, WidgetRef ref) { + final colorScheme = Theme.of(context).colorScheme; + + showModalBottomSheet( + context: context, + backgroundColor: colorScheme.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + builder: (sheetContext) => _TrackOptionsSheet(track: track), + ); + } +} + +class _TrackOptionsSheet extends ConsumerWidget { + final Track track; + + const _TrackOptionsSheet({required this.track}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colorScheme = Theme.of(context).colorScheme; + + final isLoved = ref.watch( + libraryCollectionsProvider.select((state) => state.isLoved(track)), + ); + final isInWishlist = ref.watch( + libraryCollectionsProvider.select((state) => state.isInWishlist(track)), + ); + + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Header with drag handle + track info (matches _TrackInfoHeader) + 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: track.coverUrl != null && track.coverUrl!.isNotEmpty + ? CachedNetworkImage( + imageUrl: track.coverUrl!, + width: 56, + height: 56, + fit: BoxFit.cover, + memCacheWidth: 112, + cacheManager: CoverCacheManager.instance, + errorWidget: (context, url, error) => 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: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + track.name, + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(fontWeight: FontWeight.w600), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + track.artistName, + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + ), + ], + ), + Divider( + height: 1, + color: colorScheme.outlineVariant.withValues(alpha: 0.5), + ), + + // Action items (matches _QualityOption style) + _OptionTile( + icon: isLoved ? Icons.favorite : Icons.favorite_border, + iconColor: isLoved ? colorScheme.error : null, + title: isLoved + ? context.l10n.trackOptionRemoveFromLoved + : context.l10n.trackOptionAddToLoved, + onTap: () async { + Navigator.pop(context); + final added = await ref + .read(libraryCollectionsProvider.notifier) + .toggleLoved(track); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + added + ? context.l10n.collectionAddedToLoved(track.name) + : context.l10n.collectionRemovedFromLoved(track.name), + ), + ), + ); + }, + ), + _OptionTile( + icon: isInWishlist + ? Icons.playlist_add_check_circle + : Icons.add_circle_outline, + iconColor: isInWishlist ? colorScheme.primary : null, + title: isInWishlist + ? context.l10n.trackOptionRemoveFromWishlist + : context.l10n.trackOptionAddToWishlist, + onTap: () async { + Navigator.pop(context); + final added = await ref + .read(libraryCollectionsProvider.notifier) + .toggleWishlist(track); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + added + ? context.l10n.collectionAddedToWishlist(track.name) + : context.l10n.collectionRemovedFromWishlist( + track.name), + ), + ), + ); + }, + ), + _OptionTile( + icon: Icons.playlist_add, + title: context.l10n.collectionAddToPlaylist, + onTap: () { + Navigator.pop(context); + showAddTrackToPlaylistSheet(context, ref, track); + }, + ), + + const SizedBox(height: 16), + ], + ), + ); + } +} + +/// Styled like _QualityOption in download_service_picker.dart +class _OptionTile extends StatelessWidget { + final IconData icon; + final Color? iconColor; + final String title; + final VoidCallback onTap; + + const _OptionTile({ + required this.icon, + this.iconColor, + required this.title, + 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: iconColor ?? colorScheme.onPrimaryContainer, + size: 20, + ), + ), + title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)), + onTap: onTap, + ); + } +}