diff --git a/CHANGELOG.md b/CHANGELOG.md index 54308e14..3b7c758b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,105 +1,43 @@ # Changelog -## [4.0.1] - 2026-02-26 +## [3.7.0] - 2026-03-04 -### Added -- **Clickable Metadata Navigation**: Added reusable `ClickableArtistName` and `ClickableAlbumName` -- **Love Action in Media Notification**: Added custom notification action (`toggle_love`) with new Android favorite/favorite-border status icons +Hey everyone, thank you so much for sticking with SpotiFLAC Mobile. + +Starting from this release, we're rolling the version back from **v4.x to v3.x**. + +### Removed + +- **Internal Audio Player** — Removed `just_audio`, `audio_service`, and `audio_session` dependencies entirely. The internal playback engine (smart queue, media notification, shuffle/repeat, lyrics sync, prefetch, playback state persistence) has been completely removed. Playback now delegates to the system's external player. +- **PlaybackItem Model** — No longer needed without internal playback. +- **MiniPlayerBar Widget** — Removed the in-app mini player UI. +- **Media Notification Controls** — Removed notification drawables (`ic_stat_favorite`, `ic_stat_favorite_border`) and the `keep.xml` resource file. +- **Player Mode Setting** — The `playerMode` setting has been removed since external player is now the only mode. +- **Online Playback Feature** — Online streaming mode, DASH pipeline, and related components introduced in v4.0.0 are gone from the main branch. ### Changed -- **Track Metadata Model Expansion**: `Track` now carries `artistId` and `albumId`, propagated across search, queue, playback, CSV import, and extension mapping flows -- **Full-Screen Player UX**: Top bar now supports swipe-down dismiss; artist/album text is now tappable; and in-player love toggle is available next to track metadata -- **Playlist Picker Flow Refactor**: Reworked playlist picker sheet into stateful multi-select flow with explicit Done action and improved create-playlist handling -- **CSV Import Interaction Flow**: Added single-flight import guard, more reliable progress dialog lifecycle, and safer local navigator usage -- **Amazon API**: Amazon metadata fetch `amzn.afkarxyz.fun` -- **Qobuz URL Resolution Strategy**: Removed legacy/Jumo fallback path; now uses standard API pool (deeb) -- **Update Checker Asset Targeting**: Update selection now prioritizes arm64/universal assets only -- **Donate Page Supporters**: Updated highlighted donor/supporter list entries +- **MainActivity** now extends `FlutterFragmentActivity` directly (previously `AudioServiceFragmentActivity`). +- **PlaybackController** simplified from ~1200 lines to ~87 lines — now only resolves local file paths and opens them via external player. +- **ProGuard rules** cleaned up — removed audio_service/just_audio/audio_session rules. +- **Qobuz** migrated to MusicDL API (Thanks @Ruubiiiii for Hosting the API). -### Fixed +### Note +There are three main reasons behind this decision: -- **FLAC External Lyrics Output**: External `.lrc` writing now works consistently for lyrics mode `external`/`both`, with SAF conversion paths avoiding duplicate writes -- **Loved-State Notification Sync**: Playback notification controls now refresh correctly when loved state changes -- **Queue Selection Touch Handling**: Selection overlays/check indicators no longer block tap gestures in queue and playlist selection modes -- **Vorbis-to-ID3 Tag Mapping Robustness**: FFmpeg metadata conversion now normalizes keys and handles aliases like `TRCK` and `TPOS` -- **Nested Dialog Navigation Safety**: Adjusted dialog navigator scope in CSV import and track-delete flows to prevent navigator mismatch issues -- **Artist/Album Routing Reliability**: Track metadata routing now reuses resolved artist/album IDs across album/artist/home/search/queue/player surfaces -- **Release Workflow Go Toolchain**: Pinned CI release workflow Go version to `1.25.7` for consistent build behavior + 1. **Respecting the API providers** — After giving it some thought, we realized that the streaming feature was indirectly hurting the API providers who have been generous enough to make their services available. They already offer streaming directly on their own websites, and it only feels right to direct streaming usage back to their platforms. + + 2. **Long-term sustainability** — We want SpotiFLAC to be around for as long as possible. Keeping certain features in the app could attract unwanted attention and put the project's continued existence at risk. Removing them is a proactive step to keep things running smoothly for everyone. + +**Still want online playback? Check out these services:** +- [DabMusic](https://dabmusic.xyz) +- [SquidWTF](https://tidal.squid.wtf) + +Thank you for your understanding and continued support. This decision was made to ensure the long-term sustainability of the app and to respect the ecosystem that has been supporting SpotiFLAC all along. You guys are the best, and we truly appreciate each and every one of you! --- -## [4.0.0] - 2026-02-22 - -> **Major update warning:** This release introduces a large streaming-focused refactor and broad cross-app behavior changes. -> -> **Diff scope (`cdc583678558223ecbb552176b53727d303ae218..HEAD`):** 121 files changed, 28,354 insertions(+), 4,598 deletions(-). - -### Added - -- **End-to-End Streaming Mode**: Full streaming playback flow with full-screen player, synced lyrics, media controls, and queue-aware tap behavior across album, artist, playlist, home, and search screens -- **Smart Queue System**: ML-based queue auto-curation with related artist discovery, plus a dedicated playback queue view -- **DASH Streaming Pipeline**: Native DASH manifest playback support with local proxy integration and FFmpeg tunnel fallback for unsupported paths -- **Playback State Persistence**: Player state and queue continuity restored across app restarts -- **Adaptive Playback Engine**: EventChannel-driven playback/progress updates (replacing polling) and adaptive prefetch behavior -- **Queue Reliability Controls**: New auto-skip unavailable tracks option during queue playback -- **Player Quick Action**: New download button in full-screen player top bar -- **Metadata Control**: New global master switch for embed metadata behavior -- **Setup Flow Update**: Initial setup now prioritizes mode selection instead of Spotify API setup -- **Library Workflow Expansion**: Playlist-first library redesign, drag-and-drop categorization, folder multi-select, and batch playlist picker flows -- **SongLink Region Setting**: Region selection support for metadata/linking behavior -- **Track Interaction UX**: Long-press context menus for track actions across major collection screens -- **Batch Tools**: Multi-select share, batch convert, and batch re-enrich improvements for downloaded/local/queue workflows - -### Changed - -- **Global Mode-Driven Actions**: Interaction mode now drives behavior app-wide (download-oriented vs streaming-oriented actions) -- **UI Redesign and Responsiveness**: Full-screen cover/parallax rollout and responsive fixes for filter sheets and full-screen player in small screens/landscape -- **Performance Optimizations**: Granular Riverpod consumers, selective provider watching, computation caching, debounced extension storage writes, and lifecycle cleanups -- **Lyrics Loading Strategy**: Lyrics are now lazy-loaded only when the lyrics view is visible -- **Persistence Backend Refactor**: Core persistence paths migrated to SQLite-backed stores for app state and library collections -- **Shared Code Refactor**: Duplicated logic extracted into shared Dart/Go utilities for cleaner boundaries and maintainability - -### Fixed - -- **iOS Build Compatibility**: Resolved `RepeatMode` naming collision with Flutter SDK symbols -- **Playback Completion Handling**: Fixed track completion restart issues and queue-end completion synchronization -- **Streaming Stability**: Added guards for playback race conditions during queue/stream state transitions -- **Provider I/O Safety**: Improved Android/Go file descriptor handling for SAF-based outputs -- **Metadata Matching Robustness**: Improved title matching with strict emoji handling and name+artist fallback lookup behavior -- **Navigation Behavior**: Back button now exits app correctly instead of unexpectedly returning to home - ---- - -## [4.0.0] - 2026-02-22 - -### Added - -- **Interaction Mode Setting**: New "Interaction Mode" toggle in Options settings to switch between Downloader Mode (tap to queue downloads) and Streaming Mode (tap to play instantly) - - Affects album, artist discography, playlist, home explore, and search screens - - All action buttons (Download All, Download Selected, Download Discography) dynamically switch to Play equivalents when in Streaming Mode -- **Streaming Playback Integration**: Tapping tracks in Streaming Mode plays them via `playTrackStreamAndSetQueue` with full queue support across all collection screens (album, artist, playlist, home, search) -- **Long-Press Track Context Menus**: Added `onLongPress` handler on track items across album, artist, home, playlist, and search screens to open the track options bottom sheet via `TrackCollectionQuickActions.showTrackOptionsSheet` -- **USDT TRC20 Crypto Donation**: Added USDT (TRC20) wallet address to Donate page with tap-to-copy-to-clipboard functionality and snackbar confirmation -- **Localization**: Added interaction mode and streaming playback strings across all 14 supported locales (`optionsInteractionMode`, `modeDownloader`, `modeDownloaderSubtitle`, `modeStreaming`, `modeStreamingSubtitle`, `playAllCount`, `discographyPlay`, `discographyPlayAll`, `discographyPlaySelected`) -- **Indonesian (ID) Localization**: Full translations for all new streaming mode strings - -### Changed - -- **Mini Player Bar Layout**: Media section (cover art / lyrics) now uses fixed-height `SizedBox` (50% screen height, clamped 300–560px) instead of `Expanded` for more consistent layout -- **Lyrics Font Size Increase**: Synced lyrics current line 22→24px, non-current 18→19px; word-by-word highlight 22→24px; unsynced 18→19px -- **Playback Media Controls**: Removed stop button from notification media controls for cleaner transport bar -- **Playback Queue Exhaustion**: Player now properly syncs `ProcessingState.completed` state when queue is exhausted instead of silently stopping -- **`TrackCollectionQuickActions.showTrackOptionsSheet` Made Static**: Extracted to a public static method so all screens can invoke it directly for long-press handling -- **Bottom Spacing in Mini Player**: Reduced from 16px to 4px for tighter layout - -### Fixed - -- **Playback State Not Updating on Queue End**: Fixed playback notification staying in "playing" state when all tracks in queue have finished - ---- - -## [3.7.0] - 2026-02-19 +## [3.6.0] - 2026-02-19 ### Added diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro index d9752c2c..2bd4f8d5 100644 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -80,16 +80,6 @@ -keep class io.flutter.plugins.pathprovider.** { *; } -keep class dev.flutter.pigeon.** { *; } -# Audio Service (media playback notification) - CRITICAL for release builds --keep class com.ryanheise.audioservice.** { *; } --keep class com.ryanheise.audio_session.** { *; } --keep class com.ryanheise.just_audio.** { *; } - -# AndroidX Media / MediaSession (used by audio_service) --keep class androidx.media.** { *; } --keep class android.support.v4.media.** { *; } --dontwarn android.support.v4.media.** - # Local Notifications -keep class com.dexterous.** { *; } -keep class com.dexterous.flutterlocalnotifications.** { *; } 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 1c8769d2..c30a1775 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -7,7 +7,7 @@ import android.os.Build import androidx.activity.OnBackPressedCallback import androidx.activity.result.contract.ActivityResultContracts import androidx.documentfile.provider.DocumentFile -import com.ryanheise.audioservice.AudioServiceFragmentActivity +import io.flutter.embedding.android.FlutterFragmentActivity import io.flutter.embedding.android.FlutterActivityLaunchConfigs.BackgroundMode import io.flutter.embedding.android.FlutterFragment import io.flutter.embedding.android.RenderMode @@ -32,7 +32,7 @@ import java.io.FileInputStream import java.io.FileOutputStream import java.util.Locale -class MainActivity: AudioServiceFragmentActivity() { +class MainActivity: FlutterFragmentActivity() { private val CHANNEL = "com.zarz.spotiflac/backend" private val DOWNLOAD_PROGRESS_STREAM_CHANNEL = "com.zarz.spotiflac/download_progress_stream" @@ -50,6 +50,7 @@ class MainActivity: AudioServiceFragmentActivity() { private var libraryScanProgressStreamJob: Job? = null private var libraryScanProgressEventSink: EventChannel.EventSink? = null private var lastLibraryScanProgressPayload: String? = null + private var flutterBackCallback: OnBackPressedCallback? = null @Volatile private var safScanCancel = false @Volatile private var safScanActive = false private val safTreeLauncher = registerForActivityResult( @@ -1370,11 +1371,12 @@ class MainActivity: AudioServiceFragmentActivity() { // which disables Flutter's own OnBackPressedCallback and causes the // system default (finish activity) to run. This callback guarantees // popRoute is always forwarded to Flutter, where PopScope handles it. - onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { + flutterBackCallback = object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { flutterEngine.navigationChannel.popRoute() } - }) + } + onBackPressedDispatcher.addCallback(this, flutterBackCallback!!) val messenger = flutterEngine.dartExecutor.binaryMessenger @@ -1416,6 +1418,15 @@ class MainActivity: AudioServiceFragmentActivity() { scope.launch { try { when (call.method) { + "exitApp" -> { + flutterBackCallback?.isEnabled = false + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + finishAndRemoveTask() + } else { + finish() + } + result.success(null) + } "parseSpotifyUrl" -> { val url = call.argument("url") ?: "" val response = withContext(Dispatchers.IO) { diff --git a/android/app/src/main/res/drawable/ic_stat_favorite.xml b/android/app/src/main/res/drawable/ic_stat_favorite.xml deleted file mode 100644 index 6ef85758..00000000 --- a/android/app/src/main/res/drawable/ic_stat_favorite.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - \ No newline at end of file diff --git a/android/app/src/main/res/drawable/ic_stat_favorite_border.xml b/android/app/src/main/res/drawable/ic_stat_favorite_border.xml deleted file mode 100644 index 7e803abf..00000000 --- a/android/app/src/main/res/drawable/ic_stat_favorite_border.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - \ No newline at end of file diff --git a/android/app/src/main/res/raw/keep.xml b/android/app/src/main/res/raw/keep.xml deleted file mode 100644 index c71ae08e..00000000 --- a/android/app/src/main/res/raw/keep.xml +++ /dev/null @@ -1,3 +0,0 @@ - - diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index 044e1315..52653172 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -2,6 +2,7 @@ package gobackend import ( "bufio" + "bytes" "context" "encoding/json" "errors" @@ -30,8 +31,21 @@ var ( const ( qobuzTrackGetBaseURL = "https://www.qobuz.com/api.json/0.2/track/get?track_id=" qobuzTrackSearchBaseURL = "https://www.qobuz.com/api.json/0.2/track/search?query=" + qobuzTrackPlayBaseURL = "https://play.qobuz.com/track/" + qobuzDownloadAPIURL = "https://www.musicdl.me/api/qobuz/download" + qobuzDabMusicAPIURL = "https://dabmusic.xyz/api/stream?trackId=" + qobuzDeebAPIURL = "https://dab.yeet.su/api/stream?trackId=" + qobuzDebugKeyXORMask = byte(0x5A) ) +var qobuzDebugKeyObfuscated = []byte{ + 0x69, 0x3b, 0x38, 0x3e, 0x36, 0x37, 0x35, 0x2f, 0x36, 0x3b, + 0x33, 0x29, 0x2e, 0x32, 0x3f, 0x3d, 0x35, 0x3b, 0x2e, 0x3b, + 0x34, 0x3e, 0x34, 0x35, 0x35, 0x34, 0x3f, 0x39, 0x35, 0x37, + 0x3f, 0x29, 0x3f, 0x2c, 0x3f, 0x34, 0x39, 0x36, 0x35, 0x29, + 0x3f, +} + type QobuzTrack struct { ID int64 `json:"id"` Title string `json:"title"` @@ -368,45 +382,181 @@ func (q *QobuzDownloader) GetTrackByID(trackID int64) (*QobuzTrack, error) { func (q *QobuzDownloader) GetAvailableAPIs() []string { return []string{ - "https://dab.yeet.su/api/stream?trackId=", - "https://dabmusic.xyz/api/stream?trackId=", + qobuzDownloadAPIURL, } } -func extractQobuzDownloadURLFromBody(body []byte) (string, error) { +type qobuzAPIProvider struct { + Name string + URL string + Kind string +} + +const ( + qobuzAPIKindMusicDL = "musicdl" + qobuzAPIKindStandard = "standard" +) + +func (q *QobuzDownloader) GetAvailableProviders() []qobuzAPIProvider { + return []qobuzAPIProvider{ + {Name: "musicdl", URL: qobuzDownloadAPIURL, Kind: qobuzAPIKindMusicDL}, + {Name: "dabmusic", URL: qobuzDabMusicAPIURL, Kind: qobuzAPIKindStandard}, + // "deeb" is mapped from the legacy reference fallback endpoint. + {Name: "deeb", URL: qobuzDeebAPIURL, Kind: qobuzAPIKindStandard}, + } +} + +type qobuzDownloadInfo struct { + DownloadURL string + BitDepth int + SampleRate int +} + +func extractQobuzDownloadInfoFromBody(body []byte) (qobuzDownloadInfo, error) { var raw map[string]any if err := json.Unmarshal(body, &raw); err != nil { - return "", fmt.Errorf("invalid JSON: %v", err) + return qobuzDownloadInfo{}, fmt.Errorf("invalid JSON: %v", err) } if errMsg, ok := raw["error"].(string); ok && strings.TrimSpace(errMsg) != "" { - return "", fmt.Errorf("%s", errMsg) + return qobuzDownloadInfo{}, fmt.Errorf("%s", errMsg) + } + if detail, ok := raw["detail"].(string); ok && strings.TrimSpace(detail) != "" { + return qobuzDownloadInfo{}, fmt.Errorf("%s", detail) } if success, ok := raw["success"].(bool); ok && !success { if msg, ok := raw["message"].(string); ok && strings.TrimSpace(msg) != "" { - return "", fmt.Errorf("%s", msg) + return qobuzDownloadInfo{}, fmt.Errorf("%s", msg) } - return "", fmt.Errorf("api returned success=false") + return qobuzDownloadInfo{}, fmt.Errorf("api returned success=false") } + info := qobuzDownloadInfo{ + BitDepth: qobuzParseBitDepth(raw["bit_depth"]), + SampleRate: qobuzParseSampleRate(raw["sampling_rate"]), + } + if urlVal, ok := raw["download_url"].(string); ok && strings.TrimSpace(urlVal) != "" { + info.DownloadURL = strings.TrimSpace(urlVal) + return info, nil + } if urlVal, ok := raw["url"].(string); ok && strings.TrimSpace(urlVal) != "" { - return strings.TrimSpace(urlVal), nil + info.DownloadURL = strings.TrimSpace(urlVal) + return info, nil } if linkVal, ok := raw["link"].(string); ok && strings.TrimSpace(linkVal) != "" { - return strings.TrimSpace(linkVal), nil + info.DownloadURL = strings.TrimSpace(linkVal) + return info, nil } if data, ok := raw["data"].(map[string]any); ok { + if info.BitDepth == 0 { + info.BitDepth = qobuzParseBitDepth(data["bit_depth"]) + } + if info.SampleRate == 0 { + info.SampleRate = qobuzParseSampleRate(data["sampling_rate"]) + } + if urlVal, ok := data["download_url"].(string); ok && strings.TrimSpace(urlVal) != "" { + info.DownloadURL = strings.TrimSpace(urlVal) + return info, nil + } if urlVal, ok := data["url"].(string); ok && strings.TrimSpace(urlVal) != "" { - return strings.TrimSpace(urlVal), nil + info.DownloadURL = strings.TrimSpace(urlVal) + return info, nil } if linkVal, ok := data["link"].(string); ok && strings.TrimSpace(linkVal) != "" { - return strings.TrimSpace(linkVal), nil + info.DownloadURL = strings.TrimSpace(linkVal) + return info, nil } } - return "", fmt.Errorf("no download URL in response") + return qobuzDownloadInfo{}, fmt.Errorf("no download URL in response") +} + +func extractQobuzDownloadURLFromBody(body []byte) (string, error) { + info, err := extractQobuzDownloadInfoFromBody(body) + if err != nil { + return "", err + } + return info.DownloadURL, nil +} + +func qobuzParseBitDepth(value any) int { + switch v := value.(type) { + case float64: + return int(v) + case int: + return v + case int64: + return int(v) + case json.Number: + n, _ := v.Int64() + return int(n) + default: + return 0 + } +} + +func qobuzParseSampleRate(value any) int { + switch v := value.(type) { + case float64: + if v > 0 && v < 1000 { + return int(v * 1000) + } + return int(v) + case int: + if v > 0 && v < 1000 { + return v * 1000 + } + return v + case int64: + if v > 0 && v < 1000 { + return int(v * 1000) + } + return int(v) + case json.Number: + if n, err := v.Float64(); err == nil { + if n > 0 && n < 1000 { + return int(n * 1000) + } + return int(n) + } + return 0 + default: + return 0 + } +} + +func normalizeQobuzQualityCode(quality string) string { + switch strings.ToLower(strings.TrimSpace(quality)) { + case "", "5", "6", "cd", "lossless": + return "6" + case "7", "hi-res": + return "7" + case "27", "hi-res-max": + return "27" + default: + return "6" + } +} + +func mapQobuzQualityCodeToAPI(qualityCode string) string { + switch normalizeQobuzQualityCode(qualityCode) { + case "27": + return "hi-res-max" + case "7": + return "hi-res" + default: + return "cd" + } +} + +func getQobuzDebugKey() string { + decoded := make([]byte, len(qobuzDebugKeyObfuscated)) + for i, b := range qobuzDebugKeyObfuscated { + decoded[i] = b ^ qobuzDebugKeyXORMask + } + return string(decoded) } func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) { @@ -688,10 +838,10 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam } type qobuzAPIResult struct { - apiURL string - downloadURL string - err error - duration time.Duration + provider qobuzAPIProvider + info qobuzDownloadInfo + err error + duration time.Duration } // Qobuz API timeout configuration @@ -711,35 +861,72 @@ func getQobuzAPITimeout() time.Duration { } // fetchQobuzURLWithRetry fetches download URL from a single Qobuz API with retry logic -func fetchQobuzURLWithRetry(api string, trackID int64, quality string, timeout time.Duration) (string, error) { - return fetchQobuzURLSingleAttempt(api, trackID, quality, timeout, "") +func fetchQobuzURLWithRetry(provider qobuzAPIProvider, trackID int64, quality string, timeout time.Duration) (qobuzDownloadInfo, error) { + return fetchQobuzURLSingleAttempt(provider, trackID, quality, timeout, "") } // fetchQobuzURLSingleAttempt fetches download URL with retry logic for a single API+country combination -func fetchQobuzURLSingleAttempt(api string, trackID int64, quality string, timeout time.Duration, country string) (string, error) { +func fetchQobuzURLSingleAttempt(provider qobuzAPIProvider, trackID int64, quality string, timeout time.Duration, country string) (qobuzDownloadInfo, error) { var lastErr error retryDelay := qobuzRetryDelay + var payloadBytes []byte + if provider.Kind == qobuzAPIKindMusicDL { + requestQuality := mapQobuzQualityCodeToAPI(quality) + payload := map[string]any{ + "quality": requestQuality, + "upload_to_r2": false, + "url": fmt.Sprintf("%s%d", qobuzTrackPlayBaseURL, trackID), + } + var err error + payloadBytes, err = json.Marshal(payload) + if err != nil { + return qobuzDownloadInfo{}, fmt.Errorf("failed to encode qobuz request: %w", err) + } + } for attempt := 0; attempt <= qobuzMaxRetries; attempt++ { if attempt > 0 { - GoLog("[Qobuz] Retry %d/%d for %s after %v\n", attempt, qobuzMaxRetries, api, retryDelay) + GoLog("[Qobuz] Retry %d/%d for %s after %v\n", attempt, qobuzMaxRetries, provider.Name, retryDelay) time.Sleep(retryDelay) retryDelay *= 2 // Exponential backoff } client := NewHTTPClientWithTimeout(timeout) - reqURL := fmt.Sprintf("%s%d&quality=%s", api, trackID, quality) + reqURL := provider.URL if country != "" { - reqURL += "&country=" + country + reqURL += "?country=" + url.QueryEscape(country) } - req, err := http.NewRequest("GET", reqURL, nil) + var ( + req *http.Request + err error + ) + if provider.Kind == qobuzAPIKindStandard { + separator := "&" + if !strings.Contains(reqURL, "?") { + separator = "?" + } + reqURL = fmt.Sprintf( + "%s%d%squality=%s", + reqURL, + trackID, + separator, + url.QueryEscape(normalizeQobuzQualityCode(quality)), + ) + req, err = http.NewRequest("GET", reqURL, nil) + } else { + req, err = http.NewRequest("POST", reqURL, bytes.NewReader(payloadBytes)) + } if err != nil { lastErr = err continue } + if provider.Kind == qobuzAPIKindMusicDL { + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Debug-Key", getQobuzDebugKey()) + } - resp, err := client.Do(req) + resp, err := DoRequestWithUserAgent(client, req) if err != nil { lastErr = err // Check for retryable errors (timeout, connection reset) @@ -772,7 +959,7 @@ func fetchQobuzURLSingleAttempt(api string, trackID int64, quality string, timeo if resp.StatusCode != 200 { io.Copy(io.Discard, resp.Body) resp.Body.Close() - return "", fmt.Errorf("HTTP %d", resp.StatusCode) + return qobuzDownloadInfo{}, fmt.Errorf("HTTP %d", resp.StatusCode) } body, err := io.ReadAll(resp.Body) @@ -783,117 +970,115 @@ func fetchQobuzURLSingleAttempt(api string, trackID int64, quality string, timeo } if len(body) > 0 && body[0] == '<' { - return "", fmt.Errorf("received HTML instead of JSON") + return qobuzDownloadInfo{}, fmt.Errorf("received HTML instead of JSON") } - urlVal, parseErr := extractQobuzDownloadURLFromBody(body) + info, parseErr := extractQobuzDownloadInfoFromBody(body) if parseErr == nil { - return urlVal, nil + return info, nil } lastErr = parseErr continue } if lastErr != nil { - return "", lastErr + return qobuzDownloadInfo{}, lastErr } - return "", fmt.Errorf("all retries failed") + return qobuzDownloadInfo{}, fmt.Errorf("all retries failed") } -func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (string, string, error) { - if len(apis) == 0 { - return "", "", fmt.Errorf("no APIs available") +func getQobuzDownloadURLParallel(providers []qobuzAPIProvider, trackID int64, quality string) (qobuzAPIProvider, qobuzDownloadInfo, error) { + if len(providers) == 0 { + return qobuzAPIProvider{}, qobuzDownloadInfo{}, fmt.Errorf("no APIs available") } - GoLog("[Qobuz] Requesting download URL from %d APIs in parallel (with retry)...\n", len(apis)) + GoLog("[Qobuz] Requesting download URL from %d APIs in parallel (with retry)...\n", len(providers)) - resultChan := make(chan qobuzAPIResult, len(apis)) + resultChan := make(chan qobuzAPIResult, len(providers)) startTime := time.Now() timeout := getQobuzAPITimeout() - for _, apiURL := range apis { - go func(api string) { + for _, provider := range providers { + go func(provider qobuzAPIProvider) { reqStart := time.Now() - downloadURL, err := fetchQobuzURLWithRetry(api, trackID, quality, timeout) + info, err := fetchQobuzURLWithRetry(provider, trackID, quality, timeout) resultChan <- qobuzAPIResult{ - apiURL: api, - downloadURL: downloadURL, - err: err, - duration: time.Since(reqStart), + provider: provider, + info: info, + err: err, + duration: time.Since(reqStart), } - }(apiURL) + }(provider) } var errors []string - for i := 0; i < len(apis); i++ { + for i := 0; i < len(providers); i++ { result := <-resultChan if result.err == nil { - GoLog("[Qobuz] [Parallel] Got response from %s in %v\n", result.apiURL, result.duration) + GoLog("[Qobuz] [Parallel] Got response from %s in %v\n", result.provider.Name, result.duration) go func(remaining int) { for j := 0; j < remaining; j++ { <-resultChan } - }(len(apis) - i - 1) + }(len(providers) - i - 1) GoLog("[Qobuz] [Parallel] Total time: %v (first success)\n", time.Since(startTime)) - return result.apiURL, result.downloadURL, nil + return result.provider, result.info, nil } errMsg := result.err.Error() if len(errMsg) > 50 { errMsg = errMsg[:50] + "..." } - errors = append(errors, fmt.Sprintf("%s: %s", result.apiURL, errMsg)) + errors = append(errors, fmt.Sprintf("%s: %s", result.provider.Name, errMsg)) } - GoLog("[Qobuz] [Parallel] All %d APIs failed in %v\n", len(apis), time.Since(startTime)) - return "", "", fmt.Errorf("all %d Qobuz APIs failed. Errors: %v", len(apis), errors) + GoLog("[Qobuz] [Parallel] All %d APIs failed in %v\n", len(providers), time.Since(startTime)) + return qobuzAPIProvider{}, qobuzDownloadInfo{}, fmt.Errorf("all %d Qobuz APIs failed. Errors: %v", len(providers), errors) } -func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string, error) { - apis := q.GetAvailableAPIs() - if len(apis) == 0 { - return "", fmt.Errorf("no Qobuz API available") +func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (qobuzDownloadInfo, error) { + providers := q.GetAvailableProviders() + if len(providers) == 0 { + return qobuzDownloadInfo{}, fmt.Errorf("no Qobuz API available") } - qualityCode := strings.TrimSpace(quality) - if qualityCode == "" || qualityCode == "5" { - qualityCode = "6" - } + qualityCode := normalizeQobuzQualityCode(quality) - downloadFunc := func(qual string) (string, error) { - _, downloadURL, err := getQobuzDownloadURLParallel(apis, trackID, qual) + downloadFunc := func(qual string) (qobuzDownloadInfo, error) { + provider, info, err := getQobuzDownloadURLParallel(providers, trackID, qual) if err != nil { - return "", err + return qobuzDownloadInfo{}, err } - return downloadURL, nil + GoLog("[Qobuz] Download URL resolved via %s\n", provider.Name) + return info, nil } - downloadURL, err := downloadFunc(qualityCode) + downloadInfo, err := downloadFunc(qualityCode) if err == nil { - return downloadURL, nil + return downloadInfo, nil } currentQuality := qualityCode if currentQuality == "27" { GoLog("[Qobuz] Hi-res (27) failed, trying 24-bit (7)...\n") - downloadURL, err = downloadFunc("7") + downloadInfo, err = downloadFunc("7") if err == nil { - return downloadURL, nil + return downloadInfo, nil } currentQuality = "7" } if currentQuality == "7" { GoLog("[Qobuz] 24-bit failed, trying 16-bit (6)...\n") - downloadURL, err = downloadFunc("6") + downloadInfo, err = downloadFunc("6") if err == nil { - return downloadURL, nil + return downloadInfo, nil } } - return "", fmt.Errorf("all Qobuz APIs failed: %w", err) + return qobuzDownloadInfo{}, fmt.Errorf("all Qobuz APIs failed: %w", err) } func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error { @@ -1150,10 +1335,19 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { actualSampleRate := int(track.MaximumSamplingRate * 1000) GoLog("[Qobuz] Actual quality: %d-bit/%.1fkHz\n", actualBitDepth, track.MaximumSamplingRate) - downloadURL, err := downloader.GetDownloadURL(track.ID, qobuzQuality) + downloadInfo, err := downloader.GetDownloadURL(track.ID, qobuzQuality) if err != nil { return QobuzDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err) } + if downloadInfo.BitDepth > 0 { + actualBitDepth = downloadInfo.BitDepth + } + if downloadInfo.SampleRate > 0 { + actualSampleRate = downloadInfo.SampleRate + } + if actualBitDepth > 0 || actualSampleRate > 0 { + GoLog("[Qobuz] API returned quality: %d-bit/%dHz\n", actualBitDepth, actualSampleRate) + } var parallelResult *ParallelDownloadResult parallelDone := make(chan struct{}) @@ -1176,7 +1370,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { ) }() - if err := downloader.DownloadFile(downloadURL, outputPath, req.OutputFD, req.ItemID); err != nil { + if err := downloader.DownloadFile(downloadInfo.DownloadURL, outputPath, req.OutputFD, req.ItemID); err != nil { if errors.Is(err, ErrDownloadCancelled) { return QobuzDownloadResult{}, ErrDownloadCancelled } diff --git a/go_backend/qobuz_test.go b/go_backend/qobuz_test.go index 124cd61e..0382b62f 100644 --- a/go_backend/qobuz_test.go +++ b/go_backend/qobuz_test.go @@ -3,6 +3,24 @@ package gobackend import "testing" func TestExtractQobuzDownloadURLFromBody(t *testing.T) { + t.Run("reads top-level download_url and quality metadata", func(t *testing.T) { + body := []byte(`{"success":true,"download_url":"https://example.test/new.flac","bit_depth":24,"sampling_rate":96}`) + + info, err := extractQobuzDownloadInfoFromBody(body) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if info.DownloadURL != "https://example.test/new.flac" { + t.Fatalf("unexpected URL: %q", info.DownloadURL) + } + if info.BitDepth != 24 { + t.Fatalf("unexpected bit depth: %d", info.BitDepth) + } + if info.SampleRate != 96000 { + t.Fatalf("unexpected sample rate: %d", info.SampleRate) + } + }) + t.Run("reads nested data.url", func(t *testing.T) { body := []byte(`{"success":true,"data":{"url":"https://example.test/audio.flac"}}`) @@ -44,4 +62,74 @@ func TestExtractQobuzDownloadURLFromBody(t *testing.T) { t.Fatalf("expected blocked error, got %v", err) } }) + + t.Run("returns detail error", func(t *testing.T) { + body := []byte(`{"detail":"Invalid quality 'lossless'. Choose from: ['mp3', 'cd', 'hi-res', 'hi-res-max']"}`) + + _, err := extractQobuzDownloadURLFromBody(body) + if err == nil || err.Error() != "Invalid quality 'lossless'. Choose from: ['mp3', 'cd', 'hi-res', 'hi-res-max']" { + t.Fatalf("expected detail error, got %v", err) + } + }) +} + +func TestNormalizeQobuzQualityCode(t *testing.T) { + tests := map[string]string{ + "": "6", + "5": "6", + "6": "6", + "cd": "6", + "lossless": "6", + "7": "7", + "hi-res": "7", + "27": "27", + "hi-res-max": "27", + "unexpected": "6", + } + + for input, want := range tests { + if got := normalizeQobuzQualityCode(input); got != want { + t.Fatalf("normalizeQobuzQualityCode(%q) = %q, want %q", input, got, want) + } + } +} + +func TestGetQobuzDebugKey(t *testing.T) { + got := getQobuzDebugKey() + if len(got) != len(qobuzDebugKeyObfuscated) { + t.Fatalf("unexpected debug key length: %d", len(got)) + } + for i := range got { + if got[i]^qobuzDebugKeyXORMask != qobuzDebugKeyObfuscated[i] { + t.Fatalf("unexpected debug key reconstruction at index %d", i) + } + } +} + +func TestQobuzAvailableProviders(t *testing.T) { + providers := NewQobuzDownloader().GetAvailableProviders() + if len(providers) != 3 { + t.Fatalf("expected 3 Qobuz providers, got %d", len(providers)) + } + + want := map[string]string{ + "musicdl": qobuzAPIKindMusicDL, + "dabmusic": qobuzAPIKindStandard, + "deeb": qobuzAPIKindStandard, + } + + for _, provider := range providers { + wantKind, ok := want[provider.Name] + if !ok { + t.Fatalf("unexpected provider %q", provider.Name) + } + if provider.Kind != wantKind { + t.Fatalf("provider %q has kind %q, want %q", provider.Name, provider.Kind, wantKind) + } + delete(want, provider.Name) + } + + if len(want) != 0 { + t.Fatalf("missing providers: %v", want) + } } diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart index 121bd07e..e786ec11 100644 --- a/lib/constants/app_info.dart +++ b/lib/constants/app_info.dart @@ -1,8 +1,8 @@ /// App version and info constants /// Update version here only - all other files will reference this class AppInfo { - static const String version = '4.0.1'; - static const String buildNumber = '102'; + static const String version = '3.7.0'; + static const String buildNumber = '103'; static const String fullVersion = '$version+$buildNumber'; diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index bccbf1fc..85962c2a 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -5699,318 +5699,6 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'You can switch between modes anytime in Settings.'** String get setupModeChangeableLater; - - /// Title for Smart Queue toggle in settings - /// - /// In en, this message translates to: - /// **'Smart Queue'** - String get settingsSmartQueueTitle; - - /// Subtitle for Smart Queue toggle in settings - /// - /// In en, this message translates to: - /// **'Automatically discover and add similar tracks to your queue'** - String get settingsSmartQueueSubtitle; - - /// Title for the What's New screen - /// - /// In en, this message translates to: - /// **'What\'s New in 4.0'** - String get whatsNewTitle; - - /// Subtitle for the What's New screen - /// - /// In en, this message translates to: - /// **'SpotiFLAC has evolved — here\'s what changed since 3.x'** - String get whatsNewSubtitle; - - /// Welcome page title in What's New screen - /// - /// In en, this message translates to: - /// **'SpotiFLAC Mobile 4.0'** - String get whatsNewWelcomeTitle; - - /// Welcome page description in What's New screen - /// - /// In en, this message translates to: - /// **'Welcome back! This is a major update packed with new features. Swipe through to see what\'s changed.'** - String get whatsNewWelcomeDesc; - - /// Welcome page tip 1 - /// - /// In en, this message translates to: - /// **'New streaming mode with instant playback'** - String get whatsNewWelcomeTip1; - - /// Welcome page tip 2 - /// - /// In en, this message translates to: - /// **'Redesigned library and full-screen player'** - String get whatsNewWelcomeTip2; - - /// Welcome page tip 3 - /// - /// In en, this message translates to: - /// **'Batch tools, performance boosts, and more'** - String get whatsNewWelcomeTip3; - - /// What's New feature: Streaming Mode title - /// - /// In en, this message translates to: - /// **'Streaming Mode'** - String get whatsNewStreamingTitle; - - /// What's New feature: Streaming Mode description - /// - /// In en, this message translates to: - /// **'Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.'** - String get whatsNewStreamingDesc; - - /// What's New feature: Smart Queue title - /// - /// In en, this message translates to: - /// **'Smart Queue'** - String get whatsNewSmartQueueTitle; - - /// What's New feature: Smart Queue description - /// - /// In en, this message translates to: - /// **'Your queue auto-curates with related tracks and artist discovery. Never run out of music.'** - String get whatsNewSmartQueueDesc; - - /// What's New feature: Dual Mode title - /// - /// In en, this message translates to: - /// **'Dual Mode'** - String get whatsNewDualModeTitle; - - /// What's New feature: Dual Mode description - /// - /// In en, this message translates to: - /// **'Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.'** - String get whatsNewDualModeDesc; - - /// What's New feature: Library redesign title - /// - /// In en, this message translates to: - /// **'Redesigned Library'** - String get whatsNewLibraryTitle; - - /// What's New feature: Library redesign description - /// - /// In en, this message translates to: - /// **'Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.'** - String get whatsNewLibraryDesc; - - /// What's New feature: Full-Screen Player title - /// - /// In en, this message translates to: - /// **'Full-Screen Player'** - String get whatsNewPlayerTitle; - - /// What's New feature: Full-Screen Player description - /// - /// In en, this message translates to: - /// **'Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.'** - String get whatsNewPlayerDesc; - - /// What's New feature: Context Menus title - /// - /// In en, this message translates to: - /// **'Long-Press Menus'** - String get whatsNewContextMenuTitle; - - /// What's New feature: Context Menus description - /// - /// In en, this message translates to: - /// **'Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.'** - String get whatsNewContextMenuDesc; - - /// What's New feature: Performance title - /// - /// In en, this message translates to: - /// **'Performance'** - String get whatsNewPerformanceTitle; - - /// What's New feature: Performance description - /// - /// In en, this message translates to: - /// **'Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.'** - String get whatsNewPerformanceDesc; - - /// What's New feature: Batch Tools title - /// - /// In en, this message translates to: - /// **'Batch Tools'** - String get whatsNewBatchToolsTitle; - - /// What's New feature: Batch Tools description - /// - /// In en, this message translates to: - /// **'Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.'** - String get whatsNewBatchToolsDesc; - - /// What's New tip: streaming instant play - /// - /// In en, this message translates to: - /// **'Tap any track to start playing instantly'** - String get whatsNewStreamingTip1; - - /// What's New tip: streaming synced lyrics - /// - /// In en, this message translates to: - /// **'Synced lyrics in the full-screen player'** - String get whatsNewStreamingTip2; - - /// What's New tip: streaming download from player - /// - /// In en, this message translates to: - /// **'Download tracks directly from the player'** - String get whatsNewStreamingTip3; - - /// What's New tip: smart queue auto-fill - /// - /// In en, this message translates to: - /// **'Queue auto-fills with related tracks'** - String get whatsNewSmartQueueTip1; - - /// What's New tip: smart queue artist discovery - /// - /// In en, this message translates to: - /// **'Discover new artists as you listen'** - String get whatsNewSmartQueueTip2; - - /// What's New tip: smart queue endless - /// - /// In en, this message translates to: - /// **'Never run out of music to play'** - String get whatsNewSmartQueueTip3; - - /// What's New tip: dual mode switch - /// - /// In en, this message translates to: - /// **'Switch modes anytime in Settings'** - String get whatsNewDualModeTip1; - - /// What's New tip: dual mode adaptive UI - /// - /// In en, this message translates to: - /// **'UI buttons adapt to your current mode'** - String get whatsNewDualModeTip2; - - /// What's New tip: dual mode use cases - /// - /// In en, this message translates to: - /// **'Download for offline, stream for instant play'** - String get whatsNewDualModeTip3; - - /// What's New tip: library drag and drop - /// - /// In en, this message translates to: - /// **'Drag and drop to organize playlists'** - String get whatsNewLibraryTip1; - - /// What's New tip: library custom covers - /// - /// In en, this message translates to: - /// **'Set custom cover images for playlists'** - String get whatsNewLibraryTip2; - - /// What's New tip: library multi-select - /// - /// In en, this message translates to: - /// **'Multi-select tracks for batch actions'** - String get whatsNewLibraryTip3; - - /// What's New tip: player parallax - /// - /// In en, this message translates to: - /// **'Cover art with parallax scrolling effect'** - String get whatsNewPlayerTip1; - - /// What's New tip: player persistence - /// - /// In en, this message translates to: - /// **'Playback persists across app restarts'** - String get whatsNewPlayerTip2; - - /// What's New tip: player lyrics - /// - /// In en, this message translates to: - /// **'Synced lyrics while you listen'** - String get whatsNewPlayerTip3; - - /// What's New tip: context menu add to playlist - /// - /// In en, this message translates to: - /// **'Add tracks to any playlist instantly'** - String get whatsNewContextMenuTip1; - - /// What's New tip: context menu share/convert - /// - /// In en, this message translates to: - /// **'Share or convert with one tap'** - String get whatsNewContextMenuTip2; - - /// What's New tip: context menu re-enrich - /// - /// In en, this message translates to: - /// **'Re-enrich metadata when needed'** - String get whatsNewContextMenuTip3; - - /// What's New tip: batch share - /// - /// In en, this message translates to: - /// **'Share multiple tracks at once'** - String get whatsNewBatchToolsTip1; - - /// What's New tip: batch convert - /// - /// In en, this message translates to: - /// **'Batch convert to MP3 or Opus format'** - String get whatsNewBatchToolsTip2; - - /// What's New tip: batch re-enrich - /// - /// In en, this message translates to: - /// **'Re-enrich metadata across your library'** - String get whatsNewBatchToolsTip3; - - /// What's New tip: performance startup - /// - /// In en, this message translates to: - /// **'Faster app startup time'** - String get whatsNewPerformanceTip1; - - /// What's New tip: performance memory - /// - /// In en, this message translates to: - /// **'Reduced memory usage during playback'** - String get whatsNewPerformanceTip2; - - /// What's New tip: performance SQLite - /// - /// In en, this message translates to: - /// **'SQLite-backed storage for reliability'** - String get whatsNewPerformanceTip3; - - /// Ready card message on last What's New page - /// - /// In en, this message translates to: - /// **'You\'re all set — enjoy the new SpotiFLAC!'** - String get whatsNewReadyMessage; - - /// Button text to dismiss What's New screen - /// - /// In en, this message translates to: - /// **'Let\'s Go'** - String get whatsNewGetStarted; - - /// Page indicator text in What's New screen - /// - /// In en, this message translates to: - /// **'{current} of {total}'** - String whatsNewPageIndicator(int current, int total); } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 30bea9cf..f5287500 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -3288,177 +3288,4 @@ class AppLocalizationsDe extends AppLocalizations { @override String get setupModeChangeableLater => 'Du kannst jederzeit in den Einstellungen zwischen den Modi wechseln.'; - - @override - String get settingsSmartQueueTitle => 'Smart Queue'; - - @override - String get settingsSmartQueueSubtitle => - 'Automatisch ähnliche Titel entdecken und zu deiner Warteschlange hinzufügen'; - - @override - String get whatsNewTitle => 'What\'s New in 4.0'; - - @override - String get whatsNewSubtitle => - 'SpotiFLAC has evolved — here\'s what changed since 3.x'; - - @override - String get whatsNewWelcomeTitle => 'SpotiFLAC Mobile 4.0'; - - @override - String get whatsNewWelcomeDesc => - 'Welcome back! This is a major update packed with new features. Swipe through to see what\'s changed.'; - - @override - String get whatsNewWelcomeTip1 => 'New streaming mode with instant playback'; - - @override - String get whatsNewWelcomeTip2 => 'Redesigned library and full-screen player'; - - @override - String get whatsNewWelcomeTip3 => 'Batch tools, performance boosts, and more'; - - @override - String get whatsNewStreamingTitle => 'Streaming Mode'; - - @override - String get whatsNewStreamingDesc => - 'Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.'; - - @override - String get whatsNewSmartQueueTitle => 'Smart Queue'; - - @override - String get whatsNewSmartQueueDesc => - 'Your queue auto-curates with related tracks and artist discovery. Never run out of music.'; - - @override - String get whatsNewDualModeTitle => 'Dual Mode'; - - @override - String get whatsNewDualModeDesc => - 'Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.'; - - @override - String get whatsNewLibraryTitle => 'Redesigned Library'; - - @override - String get whatsNewLibraryDesc => - 'Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.'; - - @override - String get whatsNewPlayerTitle => 'Full-Screen Player'; - - @override - String get whatsNewPlayerDesc => - 'Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.'; - - @override - String get whatsNewContextMenuTitle => 'Long-Press Menus'; - - @override - String get whatsNewContextMenuDesc => - 'Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.'; - - @override - String get whatsNewPerformanceTitle => 'Performance'; - - @override - String get whatsNewPerformanceDesc => - 'Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.'; - - @override - String get whatsNewBatchToolsTitle => 'Batch Tools'; - - @override - String get whatsNewBatchToolsDesc => - 'Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.'; - - @override - String get whatsNewStreamingTip1 => - 'Tap any track to start playing instantly'; - - @override - String get whatsNewStreamingTip2 => 'Synced lyrics in the full-screen player'; - - @override - String get whatsNewStreamingTip3 => - 'Download tracks directly from the player'; - - @override - String get whatsNewSmartQueueTip1 => 'Queue auto-fills with related tracks'; - - @override - String get whatsNewSmartQueueTip2 => 'Discover new artists as you listen'; - - @override - String get whatsNewSmartQueueTip3 => 'Never run out of music to play'; - - @override - String get whatsNewDualModeTip1 => 'Switch modes anytime in Settings'; - - @override - String get whatsNewDualModeTip2 => 'UI buttons adapt to your current mode'; - - @override - String get whatsNewDualModeTip3 => - 'Download for offline, stream for instant play'; - - @override - String get whatsNewLibraryTip1 => 'Drag and drop to organize playlists'; - - @override - String get whatsNewLibraryTip2 => 'Set custom cover images for playlists'; - - @override - String get whatsNewLibraryTip3 => 'Multi-select tracks for batch actions'; - - @override - String get whatsNewPlayerTip1 => 'Cover art with parallax scrolling effect'; - - @override - String get whatsNewPlayerTip2 => 'Playback persists across app restarts'; - - @override - String get whatsNewPlayerTip3 => 'Synced lyrics while you listen'; - - @override - String get whatsNewContextMenuTip1 => 'Add tracks to any playlist instantly'; - - @override - String get whatsNewContextMenuTip2 => 'Share or convert with one tap'; - - @override - String get whatsNewContextMenuTip3 => 'Re-enrich metadata when needed'; - - @override - String get whatsNewBatchToolsTip1 => 'Share multiple tracks at once'; - - @override - String get whatsNewBatchToolsTip2 => 'Batch convert to MP3 or Opus format'; - - @override - String get whatsNewBatchToolsTip3 => 'Re-enrich metadata across your library'; - - @override - String get whatsNewPerformanceTip1 => 'Faster app startup time'; - - @override - String get whatsNewPerformanceTip2 => 'Reduced memory usage during playback'; - - @override - String get whatsNewPerformanceTip3 => 'SQLite-backed storage for reliability'; - - @override - String get whatsNewReadyMessage => - 'You\'re all set — enjoy the new SpotiFLAC!'; - - @override - String get whatsNewGetStarted => 'Let\'s Go'; - - @override - String whatsNewPageIndicator(int current, int total) { - return '$current of $total'; - } } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index e9544e94..e5da5324 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -3266,177 +3266,4 @@ class AppLocalizationsEn extends AppLocalizations { @override String get setupModeChangeableLater => 'You can switch between modes anytime in Settings.'; - - @override - String get settingsSmartQueueTitle => 'Smart Queue'; - - @override - String get settingsSmartQueueSubtitle => - 'Automatically discover and add similar tracks to your queue'; - - @override - String get whatsNewTitle => 'What\'s New in 4.0'; - - @override - String get whatsNewSubtitle => - 'SpotiFLAC has evolved — here\'s what changed since 3.x'; - - @override - String get whatsNewWelcomeTitle => 'SpotiFLAC Mobile 4.0'; - - @override - String get whatsNewWelcomeDesc => - 'Welcome back! This is a major update packed with new features. Swipe through to see what\'s changed.'; - - @override - String get whatsNewWelcomeTip1 => 'New streaming mode with instant playback'; - - @override - String get whatsNewWelcomeTip2 => 'Redesigned library and full-screen player'; - - @override - String get whatsNewWelcomeTip3 => 'Batch tools, performance boosts, and more'; - - @override - String get whatsNewStreamingTitle => 'Streaming Mode'; - - @override - String get whatsNewStreamingDesc => - 'Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.'; - - @override - String get whatsNewSmartQueueTitle => 'Smart Queue'; - - @override - String get whatsNewSmartQueueDesc => - 'Your queue auto-curates with related tracks and artist discovery. Never run out of music.'; - - @override - String get whatsNewDualModeTitle => 'Dual Mode'; - - @override - String get whatsNewDualModeDesc => - 'Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.'; - - @override - String get whatsNewLibraryTitle => 'Redesigned Library'; - - @override - String get whatsNewLibraryDesc => - 'Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.'; - - @override - String get whatsNewPlayerTitle => 'Full-Screen Player'; - - @override - String get whatsNewPlayerDesc => - 'Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.'; - - @override - String get whatsNewContextMenuTitle => 'Long-Press Menus'; - - @override - String get whatsNewContextMenuDesc => - 'Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.'; - - @override - String get whatsNewPerformanceTitle => 'Performance'; - - @override - String get whatsNewPerformanceDesc => - 'Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.'; - - @override - String get whatsNewBatchToolsTitle => 'Batch Tools'; - - @override - String get whatsNewBatchToolsDesc => - 'Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.'; - - @override - String get whatsNewStreamingTip1 => - 'Tap any track to start playing instantly'; - - @override - String get whatsNewStreamingTip2 => 'Synced lyrics in the full-screen player'; - - @override - String get whatsNewStreamingTip3 => - 'Download tracks directly from the player'; - - @override - String get whatsNewSmartQueueTip1 => 'Queue auto-fills with related tracks'; - - @override - String get whatsNewSmartQueueTip2 => 'Discover new artists as you listen'; - - @override - String get whatsNewSmartQueueTip3 => 'Never run out of music to play'; - - @override - String get whatsNewDualModeTip1 => 'Switch modes anytime in Settings'; - - @override - String get whatsNewDualModeTip2 => 'UI buttons adapt to your current mode'; - - @override - String get whatsNewDualModeTip3 => - 'Download for offline, stream for instant play'; - - @override - String get whatsNewLibraryTip1 => 'Drag and drop to organize playlists'; - - @override - String get whatsNewLibraryTip2 => 'Set custom cover images for playlists'; - - @override - String get whatsNewLibraryTip3 => 'Multi-select tracks for batch actions'; - - @override - String get whatsNewPlayerTip1 => 'Cover art with parallax scrolling effect'; - - @override - String get whatsNewPlayerTip2 => 'Playback persists across app restarts'; - - @override - String get whatsNewPlayerTip3 => 'Synced lyrics while you listen'; - - @override - String get whatsNewContextMenuTip1 => 'Add tracks to any playlist instantly'; - - @override - String get whatsNewContextMenuTip2 => 'Share or convert with one tap'; - - @override - String get whatsNewContextMenuTip3 => 'Re-enrich metadata when needed'; - - @override - String get whatsNewBatchToolsTip1 => 'Share multiple tracks at once'; - - @override - String get whatsNewBatchToolsTip2 => 'Batch convert to MP3 or Opus format'; - - @override - String get whatsNewBatchToolsTip3 => 'Re-enrich metadata across your library'; - - @override - String get whatsNewPerformanceTip1 => 'Faster app startup time'; - - @override - String get whatsNewPerformanceTip2 => 'Reduced memory usage during playback'; - - @override - String get whatsNewPerformanceTip3 => 'SQLite-backed storage for reliability'; - - @override - String get whatsNewReadyMessage => - 'You\'re all set — enjoy the new SpotiFLAC!'; - - @override - String get whatsNewGetStarted => 'Let\'s Go'; - - @override - String whatsNewPageIndicator(int current, int total) { - return '$current of $total'; - } } diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 5f805c85..303eb62b 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -3267,179 +3267,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get setupModeChangeableLater => 'Puedes cambiar entre modos en cualquier momento en Ajustes.'; - - @override - String get settingsSmartQueueTitle => 'Smart Queue'; - - @override - String get settingsSmartQueueSubtitle => - 'Descubre y añade automáticamente pistas similares a tu cola de reproducción'; - - @override - String get whatsNewTitle => 'What\'s New in 4.0'; - - @override - String get whatsNewSubtitle => - 'SpotiFLAC has evolved — here\'s what changed since 3.x'; - - @override - String get whatsNewWelcomeTitle => 'SpotiFLAC Mobile 4.0'; - - @override - String get whatsNewWelcomeDesc => - 'Welcome back! This is a major update packed with new features. Swipe through to see what\'s changed.'; - - @override - String get whatsNewWelcomeTip1 => 'New streaming mode with instant playback'; - - @override - String get whatsNewWelcomeTip2 => 'Redesigned library and full-screen player'; - - @override - String get whatsNewWelcomeTip3 => 'Batch tools, performance boosts, and more'; - - @override - String get whatsNewStreamingTitle => 'Streaming Mode'; - - @override - String get whatsNewStreamingDesc => - 'Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.'; - - @override - String get whatsNewSmartQueueTitle => 'Smart Queue'; - - @override - String get whatsNewSmartQueueDesc => - 'Your queue auto-curates with related tracks and artist discovery. Never run out of music.'; - - @override - String get whatsNewDualModeTitle => 'Dual Mode'; - - @override - String get whatsNewDualModeDesc => - 'Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.'; - - @override - String get whatsNewLibraryTitle => 'Redesigned Library'; - - @override - String get whatsNewLibraryDesc => - 'Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.'; - - @override - String get whatsNewPlayerTitle => 'Full-Screen Player'; - - @override - String get whatsNewPlayerDesc => - 'Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.'; - - @override - String get whatsNewContextMenuTitle => 'Long-Press Menus'; - - @override - String get whatsNewContextMenuDesc => - 'Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.'; - - @override - String get whatsNewPerformanceTitle => 'Performance'; - - @override - String get whatsNewPerformanceDesc => - 'Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.'; - - @override - String get whatsNewBatchToolsTitle => 'Batch Tools'; - - @override - String get whatsNewBatchToolsDesc => - 'Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.'; - - @override - String get whatsNewStreamingTip1 => - 'Tap any track to start playing instantly'; - - @override - String get whatsNewStreamingTip2 => 'Synced lyrics in the full-screen player'; - - @override - String get whatsNewStreamingTip3 => - 'Download tracks directly from the player'; - - @override - String get whatsNewSmartQueueTip1 => 'Queue auto-fills with related tracks'; - - @override - String get whatsNewSmartQueueTip2 => 'Discover new artists as you listen'; - - @override - String get whatsNewSmartQueueTip3 => 'Never run out of music to play'; - - @override - String get whatsNewDualModeTip1 => 'Switch modes anytime in Settings'; - - @override - String get whatsNewDualModeTip2 => 'UI buttons adapt to your current mode'; - - @override - String get whatsNewDualModeTip3 => - 'Download for offline, stream for instant play'; - - @override - String get whatsNewLibraryTip1 => 'Drag and drop to organize playlists'; - - @override - String get whatsNewLibraryTip2 => 'Set custom cover images for playlists'; - - @override - String get whatsNewLibraryTip3 => 'Multi-select tracks for batch actions'; - - @override - String get whatsNewPlayerTip1 => 'Cover art with parallax scrolling effect'; - - @override - String get whatsNewPlayerTip2 => 'Playback persists across app restarts'; - - @override - String get whatsNewPlayerTip3 => 'Synced lyrics while you listen'; - - @override - String get whatsNewContextMenuTip1 => 'Add tracks to any playlist instantly'; - - @override - String get whatsNewContextMenuTip2 => 'Share or convert with one tap'; - - @override - String get whatsNewContextMenuTip3 => 'Re-enrich metadata when needed'; - - @override - String get whatsNewBatchToolsTip1 => 'Share multiple tracks at once'; - - @override - String get whatsNewBatchToolsTip2 => 'Batch convert to MP3 or Opus format'; - - @override - String get whatsNewBatchToolsTip3 => 'Re-enrich metadata across your library'; - - @override - String get whatsNewPerformanceTip1 => 'Faster app startup time'; - - @override - String get whatsNewPerformanceTip2 => 'Reduced memory usage during playback'; - - @override - String get whatsNewPerformanceTip3 => 'SQLite-backed storage for reliability'; - - @override - String get whatsNewReadyMessage => - 'You\'re all set — enjoy the new SpotiFLAC!'; - - @override - String get whatsNewGetStarted => 'Let\'s Go'; - - @override - String whatsNewPageIndicator(int current, int total) { - return '$current of $total'; - } } /// The translations for Spanish Castilian, as used in Spain (`es_ES`). @@ -6447,11 +6274,4 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get setupModeChangeableLater => 'Puedes cambiar entre modos en cualquier momento en Ajustes.'; - - @override - String get settingsSmartQueueTitle => 'Smart Queue'; - - @override - String get settingsSmartQueueSubtitle => - 'Descubre y añade automáticamente pistas similares a tu cola de reproducción'; } diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 2909fc46..8c5febcf 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -3273,177 +3273,4 @@ class AppLocalizationsFr extends AppLocalizations { @override String get setupModeChangeableLater => 'Vous pouvez changer de mode à tout moment dans les Paramètres.'; - - @override - String get settingsSmartQueueTitle => 'Smart Queue'; - - @override - String get settingsSmartQueueSubtitle => - 'Découvrir et ajouter automatiquement des pistes similaires à votre file d\'attente'; - - @override - String get whatsNewTitle => 'What\'s New in 4.0'; - - @override - String get whatsNewSubtitle => - 'SpotiFLAC has evolved — here\'s what changed since 3.x'; - - @override - String get whatsNewWelcomeTitle => 'SpotiFLAC Mobile 4.0'; - - @override - String get whatsNewWelcomeDesc => - 'Welcome back! This is a major update packed with new features. Swipe through to see what\'s changed.'; - - @override - String get whatsNewWelcomeTip1 => 'New streaming mode with instant playback'; - - @override - String get whatsNewWelcomeTip2 => 'Redesigned library and full-screen player'; - - @override - String get whatsNewWelcomeTip3 => 'Batch tools, performance boosts, and more'; - - @override - String get whatsNewStreamingTitle => 'Streaming Mode'; - - @override - String get whatsNewStreamingDesc => - 'Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.'; - - @override - String get whatsNewSmartQueueTitle => 'Smart Queue'; - - @override - String get whatsNewSmartQueueDesc => - 'Your queue auto-curates with related tracks and artist discovery. Never run out of music.'; - - @override - String get whatsNewDualModeTitle => 'Dual Mode'; - - @override - String get whatsNewDualModeDesc => - 'Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.'; - - @override - String get whatsNewLibraryTitle => 'Redesigned Library'; - - @override - String get whatsNewLibraryDesc => - 'Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.'; - - @override - String get whatsNewPlayerTitle => 'Full-Screen Player'; - - @override - String get whatsNewPlayerDesc => - 'Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.'; - - @override - String get whatsNewContextMenuTitle => 'Long-Press Menus'; - - @override - String get whatsNewContextMenuDesc => - 'Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.'; - - @override - String get whatsNewPerformanceTitle => 'Performance'; - - @override - String get whatsNewPerformanceDesc => - 'Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.'; - - @override - String get whatsNewBatchToolsTitle => 'Batch Tools'; - - @override - String get whatsNewBatchToolsDesc => - 'Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.'; - - @override - String get whatsNewStreamingTip1 => - 'Tap any track to start playing instantly'; - - @override - String get whatsNewStreamingTip2 => 'Synced lyrics in the full-screen player'; - - @override - String get whatsNewStreamingTip3 => - 'Download tracks directly from the player'; - - @override - String get whatsNewSmartQueueTip1 => 'Queue auto-fills with related tracks'; - - @override - String get whatsNewSmartQueueTip2 => 'Discover new artists as you listen'; - - @override - String get whatsNewSmartQueueTip3 => 'Never run out of music to play'; - - @override - String get whatsNewDualModeTip1 => 'Switch modes anytime in Settings'; - - @override - String get whatsNewDualModeTip2 => 'UI buttons adapt to your current mode'; - - @override - String get whatsNewDualModeTip3 => - 'Download for offline, stream for instant play'; - - @override - String get whatsNewLibraryTip1 => 'Drag and drop to organize playlists'; - - @override - String get whatsNewLibraryTip2 => 'Set custom cover images for playlists'; - - @override - String get whatsNewLibraryTip3 => 'Multi-select tracks for batch actions'; - - @override - String get whatsNewPlayerTip1 => 'Cover art with parallax scrolling effect'; - - @override - String get whatsNewPlayerTip2 => 'Playback persists across app restarts'; - - @override - String get whatsNewPlayerTip3 => 'Synced lyrics while you listen'; - - @override - String get whatsNewContextMenuTip1 => 'Add tracks to any playlist instantly'; - - @override - String get whatsNewContextMenuTip2 => 'Share or convert with one tap'; - - @override - String get whatsNewContextMenuTip3 => 'Re-enrich metadata when needed'; - - @override - String get whatsNewBatchToolsTip1 => 'Share multiple tracks at once'; - - @override - String get whatsNewBatchToolsTip2 => 'Batch convert to MP3 or Opus format'; - - @override - String get whatsNewBatchToolsTip3 => 'Re-enrich metadata across your library'; - - @override - String get whatsNewPerformanceTip1 => 'Faster app startup time'; - - @override - String get whatsNewPerformanceTip2 => 'Reduced memory usage during playback'; - - @override - String get whatsNewPerformanceTip3 => 'SQLite-backed storage for reliability'; - - @override - String get whatsNewReadyMessage => - 'You\'re all set — enjoy the new SpotiFLAC!'; - - @override - String get whatsNewGetStarted => 'Let\'s Go'; - - @override - String whatsNewPageIndicator(int current, int total) { - return '$current of $total'; - } } diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index 8b90147c..3c3388d9 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -3267,177 +3267,4 @@ class AppLocalizationsHi extends AppLocalizations { @override String get setupModeChangeableLater => 'आप सेटिंग्स में कभी भी मोड बदल सकते हैं।'; - - @override - String get settingsSmartQueueTitle => 'Smart Queue'; - - @override - String get settingsSmartQueueSubtitle => - 'स्वचालित रूप से समान ट्रैक खोजें और अपनी कतार में जोड़ें'; - - @override - String get whatsNewTitle => 'What\'s New in 4.0'; - - @override - String get whatsNewSubtitle => - 'SpotiFLAC has evolved — here\'s what changed since 3.x'; - - @override - String get whatsNewWelcomeTitle => 'SpotiFLAC Mobile 4.0'; - - @override - String get whatsNewWelcomeDesc => - 'Welcome back! This is a major update packed with new features. Swipe through to see what\'s changed.'; - - @override - String get whatsNewWelcomeTip1 => 'New streaming mode with instant playback'; - - @override - String get whatsNewWelcomeTip2 => 'Redesigned library and full-screen player'; - - @override - String get whatsNewWelcomeTip3 => 'Batch tools, performance boosts, and more'; - - @override - String get whatsNewStreamingTitle => 'Streaming Mode'; - - @override - String get whatsNewStreamingDesc => - 'Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.'; - - @override - String get whatsNewSmartQueueTitle => 'Smart Queue'; - - @override - String get whatsNewSmartQueueDesc => - 'Your queue auto-curates with related tracks and artist discovery. Never run out of music.'; - - @override - String get whatsNewDualModeTitle => 'Dual Mode'; - - @override - String get whatsNewDualModeDesc => - 'Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.'; - - @override - String get whatsNewLibraryTitle => 'Redesigned Library'; - - @override - String get whatsNewLibraryDesc => - 'Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.'; - - @override - String get whatsNewPlayerTitle => 'Full-Screen Player'; - - @override - String get whatsNewPlayerDesc => - 'Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.'; - - @override - String get whatsNewContextMenuTitle => 'Long-Press Menus'; - - @override - String get whatsNewContextMenuDesc => - 'Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.'; - - @override - String get whatsNewPerformanceTitle => 'Performance'; - - @override - String get whatsNewPerformanceDesc => - 'Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.'; - - @override - String get whatsNewBatchToolsTitle => 'Batch Tools'; - - @override - String get whatsNewBatchToolsDesc => - 'Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.'; - - @override - String get whatsNewStreamingTip1 => - 'Tap any track to start playing instantly'; - - @override - String get whatsNewStreamingTip2 => 'Synced lyrics in the full-screen player'; - - @override - String get whatsNewStreamingTip3 => - 'Download tracks directly from the player'; - - @override - String get whatsNewSmartQueueTip1 => 'Queue auto-fills with related tracks'; - - @override - String get whatsNewSmartQueueTip2 => 'Discover new artists as you listen'; - - @override - String get whatsNewSmartQueueTip3 => 'Never run out of music to play'; - - @override - String get whatsNewDualModeTip1 => 'Switch modes anytime in Settings'; - - @override - String get whatsNewDualModeTip2 => 'UI buttons adapt to your current mode'; - - @override - String get whatsNewDualModeTip3 => - 'Download for offline, stream for instant play'; - - @override - String get whatsNewLibraryTip1 => 'Drag and drop to organize playlists'; - - @override - String get whatsNewLibraryTip2 => 'Set custom cover images for playlists'; - - @override - String get whatsNewLibraryTip3 => 'Multi-select tracks for batch actions'; - - @override - String get whatsNewPlayerTip1 => 'Cover art with parallax scrolling effect'; - - @override - String get whatsNewPlayerTip2 => 'Playback persists across app restarts'; - - @override - String get whatsNewPlayerTip3 => 'Synced lyrics while you listen'; - - @override - String get whatsNewContextMenuTip1 => 'Add tracks to any playlist instantly'; - - @override - String get whatsNewContextMenuTip2 => 'Share or convert with one tap'; - - @override - String get whatsNewContextMenuTip3 => 'Re-enrich metadata when needed'; - - @override - String get whatsNewBatchToolsTip1 => 'Share multiple tracks at once'; - - @override - String get whatsNewBatchToolsTip2 => 'Batch convert to MP3 or Opus format'; - - @override - String get whatsNewBatchToolsTip3 => 'Re-enrich metadata across your library'; - - @override - String get whatsNewPerformanceTip1 => 'Faster app startup time'; - - @override - String get whatsNewPerformanceTip2 => 'Reduced memory usage during playback'; - - @override - String get whatsNewPerformanceTip3 => 'SQLite-backed storage for reliability'; - - @override - String get whatsNewReadyMessage => - 'You\'re all set — enjoy the new SpotiFLAC!'; - - @override - String get whatsNewGetStarted => 'Let\'s Go'; - - @override - String whatsNewPageIndicator(int current, int total) { - return '$current of $total'; - } } diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 7a9fad81..c687f715 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -3282,186 +3282,4 @@ class AppLocalizationsId extends AppLocalizations { @override String get setupModeChangeableLater => 'Anda dapat beralih antar mode kapan saja di Pengaturan.'; - - @override - String get settingsSmartQueueTitle => 'Smart Queue'; - - @override - String get settingsSmartQueueSubtitle => - 'Secara otomatis temukan dan tambahkan trek serupa ke antrean Anda'; - - @override - String get whatsNewTitle => 'Yang Baru di 4.0'; - - @override - String get whatsNewSubtitle => - 'SpotiFLAC telah berevolusi — inilah yang berubah sejak 3.x'; - - @override - String get whatsNewWelcomeTitle => 'SpotiFLAC Mobile 4.0'; - - @override - String get whatsNewWelcomeDesc => - 'Selamat datang kembali! Ini pembaruan besar dengan banyak fitur baru. Geser untuk melihat apa yang berubah.'; - - @override - String get whatsNewWelcomeTip1 => - 'Mode streaming baru dengan pemutaran instan'; - - @override - String get whatsNewWelcomeTip2 => - 'Perpustakaan dan pemutar layar penuh yang didesain ulang'; - - @override - String get whatsNewWelcomeTip3 => - 'Alat massal, peningkatan performa, dan lainnya'; - - @override - String get whatsNewStreamingTitle => 'Mode Streaming'; - - @override - String get whatsNewStreamingDesc => - 'Ketuk trek apa pun untuk langsung diputar — tanpa perlu mengunduh. Pemutar layar penuh dengan lirik tersinkron dan kontrol media.'; - - @override - String get whatsNewSmartQueueTitle => 'Smart Queue'; - - @override - String get whatsNewSmartQueueDesc => - 'Antrean Anda otomatis mengkurasi trek terkait dan penemuan artis. Tak pernah kehabisan musik.'; - - @override - String get whatsNewDualModeTitle => 'Mode Ganda'; - - @override - String get whatsNewDualModeDesc => - 'Beralih antara mode Pengunduh dan Streaming kapan saja. Semua tombol menyesuaikan secara otomatis.'; - - @override - String get whatsNewLibraryTitle => 'Perpustakaan Baru'; - - @override - String get whatsNewLibraryDesc => - 'Tata letak berbasis playlist dengan kategorisasi seret-dan-lepas, sampul kustom, dan aksi massal multi-pilih.'; - - @override - String get whatsNewPlayerTitle => 'Pemutar Layar Penuh'; - - @override - String get whatsNewPlayerDesc => - 'Paralaks seni sampul, lirik tersinkron, pemutaran tetap tersimpan saat restart, dan tombol unduh di pemutar.'; - - @override - String get whatsNewContextMenuTitle => 'Menu Tekan Lama'; - - @override - String get whatsNewContextMenuDesc => - 'Tekan lama trek apa pun untuk aksi cepat — tambah ke playlist, bagikan, konversi, atau perbarui metadata.'; - - @override - String get whatsNewPerformanceTitle => 'Performa'; - - @override - String get whatsNewPerformanceDesc => - 'Startup lebih cepat, penggunaan memori berkurang, penyimpanan berbasis SQLite, dan pembaruan UI yang lebih efisien.'; - - @override - String get whatsNewBatchToolsTitle => 'Alat Massal'; - - @override - String get whatsNewBatchToolsDesc => - 'Berbagi multi-pilih, konversi massal ke MP3/Opus, dan perbarui metadata secara massal di seluruh perpustakaan.'; - - @override - String get whatsNewStreamingTip1 => - 'Ketuk trek apa pun untuk langsung memutar'; - - @override - String get whatsNewStreamingTip2 => 'Lirik tersinkron di pemutar layar penuh'; - - @override - String get whatsNewStreamingTip3 => 'Unduh trek langsung dari pemutar'; - - @override - String get whatsNewSmartQueueTip1 => - 'Antrean terisi otomatis dengan trek terkait'; - - @override - String get whatsNewSmartQueueTip2 => 'Temukan artis baru saat mendengarkan'; - - @override - String get whatsNewSmartQueueTip3 => - 'Tak pernah kehabisan musik untuk diputar'; - - @override - String get whatsNewDualModeTip1 => 'Beralih mode kapan saja di Pengaturan'; - - @override - String get whatsNewDualModeTip2 => 'Tombol UI menyesuaikan dengan mode Anda'; - - @override - String get whatsNewDualModeTip3 => - 'Unduh untuk offline, streaming untuk putar langsung'; - - @override - String get whatsNewLibraryTip1 => 'Seret dan lepas untuk mengatur playlist'; - - @override - String get whatsNewLibraryTip2 => 'Atur gambar sampul kustom untuk playlist'; - - @override - String get whatsNewLibraryTip3 => 'Pilih banyak trek untuk aksi massal'; - - @override - String get whatsNewPlayerTip1 => 'Seni sampul dengan efek paralaks'; - - @override - String get whatsNewPlayerTip2 => 'Pemutaran tetap tersimpan saat restart'; - - @override - String get whatsNewPlayerTip3 => 'Lirik tersinkron saat mendengarkan'; - - @override - String get whatsNewContextMenuTip1 => - 'Tambahkan trek ke playlist mana pun langsung'; - - @override - String get whatsNewContextMenuTip2 => - 'Bagikan atau konversi dengan satu ketukan'; - - @override - String get whatsNewContextMenuTip3 => 'Perbarui metadata saat diperlukan'; - - @override - String get whatsNewBatchToolsTip1 => 'Bagikan banyak trek sekaligus'; - - @override - String get whatsNewBatchToolsTip2 => - 'Konversi massal ke format MP3 atau Opus'; - - @override - String get whatsNewBatchToolsTip3 => - 'Perbarui metadata di seluruh perpustakaan'; - - @override - String get whatsNewPerformanceTip1 => 'Waktu startup aplikasi lebih cepat'; - - @override - String get whatsNewPerformanceTip2 => - 'Penggunaan memori berkurang saat pemutaran'; - - @override - String get whatsNewPerformanceTip3 => - 'Penyimpanan berbasis SQLite untuk keandalan'; - - @override - String get whatsNewReadyMessage => 'Siap — nikmati SpotiFLAC yang baru!'; - - @override - String get whatsNewGetStarted => 'Ayo Mulai'; - - @override - String whatsNewPageIndicator(int current, int total) { - return '$current dari $total'; - } } diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 7628d16b..78b99777 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -3246,176 +3246,4 @@ class AppLocalizationsJa extends AppLocalizations { @override String get setupModeChangeableLater => '設定からいつでもモードを切り替えられます。'; - - @override - String get settingsSmartQueueTitle => 'Smart Queue'; - - @override - String get settingsSmartQueueSubtitle => '類似トラックを自動的に検出してキューに追加'; - - @override - String get whatsNewTitle => 'What\'s New in 4.0'; - - @override - String get whatsNewSubtitle => - 'SpotiFLAC has evolved — here\'s what changed since 3.x'; - - @override - String get whatsNewWelcomeTitle => 'SpotiFLAC Mobile 4.0'; - - @override - String get whatsNewWelcomeDesc => - 'Welcome back! This is a major update packed with new features. Swipe through to see what\'s changed.'; - - @override - String get whatsNewWelcomeTip1 => 'New streaming mode with instant playback'; - - @override - String get whatsNewWelcomeTip2 => 'Redesigned library and full-screen player'; - - @override - String get whatsNewWelcomeTip3 => 'Batch tools, performance boosts, and more'; - - @override - String get whatsNewStreamingTitle => 'Streaming Mode'; - - @override - String get whatsNewStreamingDesc => - 'Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.'; - - @override - String get whatsNewSmartQueueTitle => 'Smart Queue'; - - @override - String get whatsNewSmartQueueDesc => - 'Your queue auto-curates with related tracks and artist discovery. Never run out of music.'; - - @override - String get whatsNewDualModeTitle => 'Dual Mode'; - - @override - String get whatsNewDualModeDesc => - 'Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.'; - - @override - String get whatsNewLibraryTitle => 'Redesigned Library'; - - @override - String get whatsNewLibraryDesc => - 'Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.'; - - @override - String get whatsNewPlayerTitle => 'Full-Screen Player'; - - @override - String get whatsNewPlayerDesc => - 'Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.'; - - @override - String get whatsNewContextMenuTitle => 'Long-Press Menus'; - - @override - String get whatsNewContextMenuDesc => - 'Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.'; - - @override - String get whatsNewPerformanceTitle => 'Performance'; - - @override - String get whatsNewPerformanceDesc => - 'Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.'; - - @override - String get whatsNewBatchToolsTitle => 'Batch Tools'; - - @override - String get whatsNewBatchToolsDesc => - 'Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.'; - - @override - String get whatsNewStreamingTip1 => - 'Tap any track to start playing instantly'; - - @override - String get whatsNewStreamingTip2 => 'Synced lyrics in the full-screen player'; - - @override - String get whatsNewStreamingTip3 => - 'Download tracks directly from the player'; - - @override - String get whatsNewSmartQueueTip1 => 'Queue auto-fills with related tracks'; - - @override - String get whatsNewSmartQueueTip2 => 'Discover new artists as you listen'; - - @override - String get whatsNewSmartQueueTip3 => 'Never run out of music to play'; - - @override - String get whatsNewDualModeTip1 => 'Switch modes anytime in Settings'; - - @override - String get whatsNewDualModeTip2 => 'UI buttons adapt to your current mode'; - - @override - String get whatsNewDualModeTip3 => - 'Download for offline, stream for instant play'; - - @override - String get whatsNewLibraryTip1 => 'Drag and drop to organize playlists'; - - @override - String get whatsNewLibraryTip2 => 'Set custom cover images for playlists'; - - @override - String get whatsNewLibraryTip3 => 'Multi-select tracks for batch actions'; - - @override - String get whatsNewPlayerTip1 => 'Cover art with parallax scrolling effect'; - - @override - String get whatsNewPlayerTip2 => 'Playback persists across app restarts'; - - @override - String get whatsNewPlayerTip3 => 'Synced lyrics while you listen'; - - @override - String get whatsNewContextMenuTip1 => 'Add tracks to any playlist instantly'; - - @override - String get whatsNewContextMenuTip2 => 'Share or convert with one tap'; - - @override - String get whatsNewContextMenuTip3 => 'Re-enrich metadata when needed'; - - @override - String get whatsNewBatchToolsTip1 => 'Share multiple tracks at once'; - - @override - String get whatsNewBatchToolsTip2 => 'Batch convert to MP3 or Opus format'; - - @override - String get whatsNewBatchToolsTip3 => 'Re-enrich metadata across your library'; - - @override - String get whatsNewPerformanceTip1 => 'Faster app startup time'; - - @override - String get whatsNewPerformanceTip2 => 'Reduced memory usage during playback'; - - @override - String get whatsNewPerformanceTip3 => 'SQLite-backed storage for reliability'; - - @override - String get whatsNewReadyMessage => - 'You\'re all set — enjoy the new SpotiFLAC!'; - - @override - String get whatsNewGetStarted => 'Let\'s Go'; - - @override - String whatsNewPageIndicator(int current, int total) { - return '$current of $total'; - } } diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index c9aa8d92..ce04dc34 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -3259,176 +3259,4 @@ class AppLocalizationsKo extends AppLocalizations { @override String get setupModeChangeableLater => '설정에서 언제든지 모드를 전환할 수 있습니다.'; - - @override - String get settingsSmartQueueTitle => 'Smart Queue'; - - @override - String get settingsSmartQueueSubtitle => '유사한 트랙을 자동으로 검색하여 대기열에 추가'; - - @override - String get whatsNewTitle => 'What\'s New in 4.0'; - - @override - String get whatsNewSubtitle => - 'SpotiFLAC has evolved — here\'s what changed since 3.x'; - - @override - String get whatsNewWelcomeTitle => 'SpotiFLAC Mobile 4.0'; - - @override - String get whatsNewWelcomeDesc => - 'Welcome back! This is a major update packed with new features. Swipe through to see what\'s changed.'; - - @override - String get whatsNewWelcomeTip1 => 'New streaming mode with instant playback'; - - @override - String get whatsNewWelcomeTip2 => 'Redesigned library and full-screen player'; - - @override - String get whatsNewWelcomeTip3 => 'Batch tools, performance boosts, and more'; - - @override - String get whatsNewStreamingTitle => 'Streaming Mode'; - - @override - String get whatsNewStreamingDesc => - 'Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.'; - - @override - String get whatsNewSmartQueueTitle => 'Smart Queue'; - - @override - String get whatsNewSmartQueueDesc => - 'Your queue auto-curates with related tracks and artist discovery. Never run out of music.'; - - @override - String get whatsNewDualModeTitle => 'Dual Mode'; - - @override - String get whatsNewDualModeDesc => - 'Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.'; - - @override - String get whatsNewLibraryTitle => 'Redesigned Library'; - - @override - String get whatsNewLibraryDesc => - 'Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.'; - - @override - String get whatsNewPlayerTitle => 'Full-Screen Player'; - - @override - String get whatsNewPlayerDesc => - 'Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.'; - - @override - String get whatsNewContextMenuTitle => 'Long-Press Menus'; - - @override - String get whatsNewContextMenuDesc => - 'Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.'; - - @override - String get whatsNewPerformanceTitle => 'Performance'; - - @override - String get whatsNewPerformanceDesc => - 'Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.'; - - @override - String get whatsNewBatchToolsTitle => 'Batch Tools'; - - @override - String get whatsNewBatchToolsDesc => - 'Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.'; - - @override - String get whatsNewStreamingTip1 => - 'Tap any track to start playing instantly'; - - @override - String get whatsNewStreamingTip2 => 'Synced lyrics in the full-screen player'; - - @override - String get whatsNewStreamingTip3 => - 'Download tracks directly from the player'; - - @override - String get whatsNewSmartQueueTip1 => 'Queue auto-fills with related tracks'; - - @override - String get whatsNewSmartQueueTip2 => 'Discover new artists as you listen'; - - @override - String get whatsNewSmartQueueTip3 => 'Never run out of music to play'; - - @override - String get whatsNewDualModeTip1 => 'Switch modes anytime in Settings'; - - @override - String get whatsNewDualModeTip2 => 'UI buttons adapt to your current mode'; - - @override - String get whatsNewDualModeTip3 => - 'Download for offline, stream for instant play'; - - @override - String get whatsNewLibraryTip1 => 'Drag and drop to organize playlists'; - - @override - String get whatsNewLibraryTip2 => 'Set custom cover images for playlists'; - - @override - String get whatsNewLibraryTip3 => 'Multi-select tracks for batch actions'; - - @override - String get whatsNewPlayerTip1 => 'Cover art with parallax scrolling effect'; - - @override - String get whatsNewPlayerTip2 => 'Playback persists across app restarts'; - - @override - String get whatsNewPlayerTip3 => 'Synced lyrics while you listen'; - - @override - String get whatsNewContextMenuTip1 => 'Add tracks to any playlist instantly'; - - @override - String get whatsNewContextMenuTip2 => 'Share or convert with one tap'; - - @override - String get whatsNewContextMenuTip3 => 'Re-enrich metadata when needed'; - - @override - String get whatsNewBatchToolsTip1 => 'Share multiple tracks at once'; - - @override - String get whatsNewBatchToolsTip2 => 'Batch convert to MP3 or Opus format'; - - @override - String get whatsNewBatchToolsTip3 => 'Re-enrich metadata across your library'; - - @override - String get whatsNewPerformanceTip1 => 'Faster app startup time'; - - @override - String get whatsNewPerformanceTip2 => 'Reduced memory usage during playback'; - - @override - String get whatsNewPerformanceTip3 => 'SQLite-backed storage for reliability'; - - @override - String get whatsNewReadyMessage => - 'You\'re all set — enjoy the new SpotiFLAC!'; - - @override - String get whatsNewGetStarted => 'Let\'s Go'; - - @override - String whatsNewPageIndicator(int current, int total) { - return '$current of $total'; - } } diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index ed4810d0..052c9204 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -3267,177 +3267,4 @@ class AppLocalizationsNl extends AppLocalizations { @override String get setupModeChangeableLater => 'Je kunt op elk moment wisselen tussen modi in Instellingen.'; - - @override - String get settingsSmartQueueTitle => 'Smart Queue'; - - @override - String get settingsSmartQueueSubtitle => - 'Ontdek automatisch vergelijkbare nummers en voeg ze toe aan je wachtrij'; - - @override - String get whatsNewTitle => 'What\'s New in 4.0'; - - @override - String get whatsNewSubtitle => - 'SpotiFLAC has evolved — here\'s what changed since 3.x'; - - @override - String get whatsNewWelcomeTitle => 'SpotiFLAC Mobile 4.0'; - - @override - String get whatsNewWelcomeDesc => - 'Welcome back! This is a major update packed with new features. Swipe through to see what\'s changed.'; - - @override - String get whatsNewWelcomeTip1 => 'New streaming mode with instant playback'; - - @override - String get whatsNewWelcomeTip2 => 'Redesigned library and full-screen player'; - - @override - String get whatsNewWelcomeTip3 => 'Batch tools, performance boosts, and more'; - - @override - String get whatsNewStreamingTitle => 'Streaming Mode'; - - @override - String get whatsNewStreamingDesc => - 'Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.'; - - @override - String get whatsNewSmartQueueTitle => 'Smart Queue'; - - @override - String get whatsNewSmartQueueDesc => - 'Your queue auto-curates with related tracks and artist discovery. Never run out of music.'; - - @override - String get whatsNewDualModeTitle => 'Dual Mode'; - - @override - String get whatsNewDualModeDesc => - 'Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.'; - - @override - String get whatsNewLibraryTitle => 'Redesigned Library'; - - @override - String get whatsNewLibraryDesc => - 'Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.'; - - @override - String get whatsNewPlayerTitle => 'Full-Screen Player'; - - @override - String get whatsNewPlayerDesc => - 'Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.'; - - @override - String get whatsNewContextMenuTitle => 'Long-Press Menus'; - - @override - String get whatsNewContextMenuDesc => - 'Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.'; - - @override - String get whatsNewPerformanceTitle => 'Performance'; - - @override - String get whatsNewPerformanceDesc => - 'Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.'; - - @override - String get whatsNewBatchToolsTitle => 'Batch Tools'; - - @override - String get whatsNewBatchToolsDesc => - 'Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.'; - - @override - String get whatsNewStreamingTip1 => - 'Tap any track to start playing instantly'; - - @override - String get whatsNewStreamingTip2 => 'Synced lyrics in the full-screen player'; - - @override - String get whatsNewStreamingTip3 => - 'Download tracks directly from the player'; - - @override - String get whatsNewSmartQueueTip1 => 'Queue auto-fills with related tracks'; - - @override - String get whatsNewSmartQueueTip2 => 'Discover new artists as you listen'; - - @override - String get whatsNewSmartQueueTip3 => 'Never run out of music to play'; - - @override - String get whatsNewDualModeTip1 => 'Switch modes anytime in Settings'; - - @override - String get whatsNewDualModeTip2 => 'UI buttons adapt to your current mode'; - - @override - String get whatsNewDualModeTip3 => - 'Download for offline, stream for instant play'; - - @override - String get whatsNewLibraryTip1 => 'Drag and drop to organize playlists'; - - @override - String get whatsNewLibraryTip2 => 'Set custom cover images for playlists'; - - @override - String get whatsNewLibraryTip3 => 'Multi-select tracks for batch actions'; - - @override - String get whatsNewPlayerTip1 => 'Cover art with parallax scrolling effect'; - - @override - String get whatsNewPlayerTip2 => 'Playback persists across app restarts'; - - @override - String get whatsNewPlayerTip3 => 'Synced lyrics while you listen'; - - @override - String get whatsNewContextMenuTip1 => 'Add tracks to any playlist instantly'; - - @override - String get whatsNewContextMenuTip2 => 'Share or convert with one tap'; - - @override - String get whatsNewContextMenuTip3 => 'Re-enrich metadata when needed'; - - @override - String get whatsNewBatchToolsTip1 => 'Share multiple tracks at once'; - - @override - String get whatsNewBatchToolsTip2 => 'Batch convert to MP3 or Opus format'; - - @override - String get whatsNewBatchToolsTip3 => 'Re-enrich metadata across your library'; - - @override - String get whatsNewPerformanceTip1 => 'Faster app startup time'; - - @override - String get whatsNewPerformanceTip2 => 'Reduced memory usage during playback'; - - @override - String get whatsNewPerformanceTip3 => 'SQLite-backed storage for reliability'; - - @override - String get whatsNewReadyMessage => - 'You\'re all set — enjoy the new SpotiFLAC!'; - - @override - String get whatsNewGetStarted => 'Let\'s Go'; - - @override - String whatsNewPageIndicator(int current, int total) { - return '$current of $total'; - } } diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 9a341fad..0262c76f 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -3267,179 +3267,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get setupModeChangeableLater => 'Você pode alternar entre os modos a qualquer momento nas Configurações.'; - - @override - String get settingsSmartQueueTitle => 'Smart Queue'; - - @override - String get settingsSmartQueueSubtitle => - 'Descubra e adicione automaticamente faixas semelhantes à sua fila'; - - @override - String get whatsNewTitle => 'What\'s New in 4.0'; - - @override - String get whatsNewSubtitle => - 'SpotiFLAC has evolved — here\'s what changed since 3.x'; - - @override - String get whatsNewWelcomeTitle => 'SpotiFLAC Mobile 4.0'; - - @override - String get whatsNewWelcomeDesc => - 'Welcome back! This is a major update packed with new features. Swipe through to see what\'s changed.'; - - @override - String get whatsNewWelcomeTip1 => 'New streaming mode with instant playback'; - - @override - String get whatsNewWelcomeTip2 => 'Redesigned library and full-screen player'; - - @override - String get whatsNewWelcomeTip3 => 'Batch tools, performance boosts, and more'; - - @override - String get whatsNewStreamingTitle => 'Streaming Mode'; - - @override - String get whatsNewStreamingDesc => - 'Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.'; - - @override - String get whatsNewSmartQueueTitle => 'Smart Queue'; - - @override - String get whatsNewSmartQueueDesc => - 'Your queue auto-curates with related tracks and artist discovery. Never run out of music.'; - - @override - String get whatsNewDualModeTitle => 'Dual Mode'; - - @override - String get whatsNewDualModeDesc => - 'Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.'; - - @override - String get whatsNewLibraryTitle => 'Redesigned Library'; - - @override - String get whatsNewLibraryDesc => - 'Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.'; - - @override - String get whatsNewPlayerTitle => 'Full-Screen Player'; - - @override - String get whatsNewPlayerDesc => - 'Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.'; - - @override - String get whatsNewContextMenuTitle => 'Long-Press Menus'; - - @override - String get whatsNewContextMenuDesc => - 'Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.'; - - @override - String get whatsNewPerformanceTitle => 'Performance'; - - @override - String get whatsNewPerformanceDesc => - 'Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.'; - - @override - String get whatsNewBatchToolsTitle => 'Batch Tools'; - - @override - String get whatsNewBatchToolsDesc => - 'Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.'; - - @override - String get whatsNewStreamingTip1 => - 'Tap any track to start playing instantly'; - - @override - String get whatsNewStreamingTip2 => 'Synced lyrics in the full-screen player'; - - @override - String get whatsNewStreamingTip3 => - 'Download tracks directly from the player'; - - @override - String get whatsNewSmartQueueTip1 => 'Queue auto-fills with related tracks'; - - @override - String get whatsNewSmartQueueTip2 => 'Discover new artists as you listen'; - - @override - String get whatsNewSmartQueueTip3 => 'Never run out of music to play'; - - @override - String get whatsNewDualModeTip1 => 'Switch modes anytime in Settings'; - - @override - String get whatsNewDualModeTip2 => 'UI buttons adapt to your current mode'; - - @override - String get whatsNewDualModeTip3 => - 'Download for offline, stream for instant play'; - - @override - String get whatsNewLibraryTip1 => 'Drag and drop to organize playlists'; - - @override - String get whatsNewLibraryTip2 => 'Set custom cover images for playlists'; - - @override - String get whatsNewLibraryTip3 => 'Multi-select tracks for batch actions'; - - @override - String get whatsNewPlayerTip1 => 'Cover art with parallax scrolling effect'; - - @override - String get whatsNewPlayerTip2 => 'Playback persists across app restarts'; - - @override - String get whatsNewPlayerTip3 => 'Synced lyrics while you listen'; - - @override - String get whatsNewContextMenuTip1 => 'Add tracks to any playlist instantly'; - - @override - String get whatsNewContextMenuTip2 => 'Share or convert with one tap'; - - @override - String get whatsNewContextMenuTip3 => 'Re-enrich metadata when needed'; - - @override - String get whatsNewBatchToolsTip1 => 'Share multiple tracks at once'; - - @override - String get whatsNewBatchToolsTip2 => 'Batch convert to MP3 or Opus format'; - - @override - String get whatsNewBatchToolsTip3 => 'Re-enrich metadata across your library'; - - @override - String get whatsNewPerformanceTip1 => 'Faster app startup time'; - - @override - String get whatsNewPerformanceTip2 => 'Reduced memory usage during playback'; - - @override - String get whatsNewPerformanceTip3 => 'SQLite-backed storage for reliability'; - - @override - String get whatsNewReadyMessage => - 'You\'re all set — enjoy the new SpotiFLAC!'; - - @override - String get whatsNewGetStarted => 'Let\'s Go'; - - @override - String whatsNewPageIndicator(int current, int total) { - return '$current of $total'; - } } /// The translations for Portuguese, as used in Portugal (`pt_PT`). @@ -6441,11 +6268,4 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get setupModeChangeableLater => 'Pode alternar entre modos a qualquer momento nas Definições.'; - - @override - String get settingsSmartQueueTitle => 'Smart Queue'; - - @override - String get settingsSmartQueueSubtitle => - 'Descubra e adicione automaticamente faixas semelhantes à sua fila'; } diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 3df13ff8..e6b4a1f8 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -3365,177 +3365,4 @@ class AppLocalizationsRu extends AppLocalizations { @override String get setupModeChangeableLater => 'Вы можете переключаться между режимами в любое время в Настройках.'; - - @override - String get settingsSmartQueueTitle => 'Smart Queue'; - - @override - String get settingsSmartQueueSubtitle => - 'Автоматически находите и добавляйте похожие треки в очередь воспроизведения'; - - @override - String get whatsNewTitle => 'What\'s New in 4.0'; - - @override - String get whatsNewSubtitle => - 'SpotiFLAC has evolved — here\'s what changed since 3.x'; - - @override - String get whatsNewWelcomeTitle => 'SpotiFLAC Mobile 4.0'; - - @override - String get whatsNewWelcomeDesc => - 'Welcome back! This is a major update packed with new features. Swipe through to see what\'s changed.'; - - @override - String get whatsNewWelcomeTip1 => 'New streaming mode with instant playback'; - - @override - String get whatsNewWelcomeTip2 => 'Redesigned library and full-screen player'; - - @override - String get whatsNewWelcomeTip3 => 'Batch tools, performance boosts, and more'; - - @override - String get whatsNewStreamingTitle => 'Streaming Mode'; - - @override - String get whatsNewStreamingDesc => - 'Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.'; - - @override - String get whatsNewSmartQueueTitle => 'Smart Queue'; - - @override - String get whatsNewSmartQueueDesc => - 'Your queue auto-curates with related tracks and artist discovery. Never run out of music.'; - - @override - String get whatsNewDualModeTitle => 'Dual Mode'; - - @override - String get whatsNewDualModeDesc => - 'Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.'; - - @override - String get whatsNewLibraryTitle => 'Redesigned Library'; - - @override - String get whatsNewLibraryDesc => - 'Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.'; - - @override - String get whatsNewPlayerTitle => 'Full-Screen Player'; - - @override - String get whatsNewPlayerDesc => - 'Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.'; - - @override - String get whatsNewContextMenuTitle => 'Long-Press Menus'; - - @override - String get whatsNewContextMenuDesc => - 'Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.'; - - @override - String get whatsNewPerformanceTitle => 'Performance'; - - @override - String get whatsNewPerformanceDesc => - 'Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.'; - - @override - String get whatsNewBatchToolsTitle => 'Batch Tools'; - - @override - String get whatsNewBatchToolsDesc => - 'Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.'; - - @override - String get whatsNewStreamingTip1 => - 'Tap any track to start playing instantly'; - - @override - String get whatsNewStreamingTip2 => 'Synced lyrics in the full-screen player'; - - @override - String get whatsNewStreamingTip3 => - 'Download tracks directly from the player'; - - @override - String get whatsNewSmartQueueTip1 => 'Queue auto-fills with related tracks'; - - @override - String get whatsNewSmartQueueTip2 => 'Discover new artists as you listen'; - - @override - String get whatsNewSmartQueueTip3 => 'Never run out of music to play'; - - @override - String get whatsNewDualModeTip1 => 'Switch modes anytime in Settings'; - - @override - String get whatsNewDualModeTip2 => 'UI buttons adapt to your current mode'; - - @override - String get whatsNewDualModeTip3 => - 'Download for offline, stream for instant play'; - - @override - String get whatsNewLibraryTip1 => 'Drag and drop to organize playlists'; - - @override - String get whatsNewLibraryTip2 => 'Set custom cover images for playlists'; - - @override - String get whatsNewLibraryTip3 => 'Multi-select tracks for batch actions'; - - @override - String get whatsNewPlayerTip1 => 'Cover art with parallax scrolling effect'; - - @override - String get whatsNewPlayerTip2 => 'Playback persists across app restarts'; - - @override - String get whatsNewPlayerTip3 => 'Synced lyrics while you listen'; - - @override - String get whatsNewContextMenuTip1 => 'Add tracks to any playlist instantly'; - - @override - String get whatsNewContextMenuTip2 => 'Share or convert with one tap'; - - @override - String get whatsNewContextMenuTip3 => 'Re-enrich metadata when needed'; - - @override - String get whatsNewBatchToolsTip1 => 'Share multiple tracks at once'; - - @override - String get whatsNewBatchToolsTip2 => 'Batch convert to MP3 or Opus format'; - - @override - String get whatsNewBatchToolsTip3 => 'Re-enrich metadata across your library'; - - @override - String get whatsNewPerformanceTip1 => 'Faster app startup time'; - - @override - String get whatsNewPerformanceTip2 => 'Reduced memory usage during playback'; - - @override - String get whatsNewPerformanceTip3 => 'SQLite-backed storage for reliability'; - - @override - String get whatsNewReadyMessage => - 'You\'re all set — enjoy the new SpotiFLAC!'; - - @override - String get whatsNewGetStarted => 'Let\'s Go'; - - @override - String whatsNewPageIndicator(int current, int total) { - return '$current of $total'; - } } diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index 935c31d4..164833ef 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -3281,177 +3281,4 @@ class AppLocalizationsTr extends AppLocalizations { @override String get setupModeChangeableLater => 'Ayarlar\'dan istediğiniz zaman modlar arasında geçiş yapabilirsiniz.'; - - @override - String get settingsSmartQueueTitle => 'Smart Queue'; - - @override - String get settingsSmartQueueSubtitle => - 'Sıranıza otomatik olarak benzer parçalar keşfedin ve ekleyin'; - - @override - String get whatsNewTitle => 'What\'s New in 4.0'; - - @override - String get whatsNewSubtitle => - 'SpotiFLAC has evolved — here\'s what changed since 3.x'; - - @override - String get whatsNewWelcomeTitle => 'SpotiFLAC Mobile 4.0'; - - @override - String get whatsNewWelcomeDesc => - 'Welcome back! This is a major update packed with new features. Swipe through to see what\'s changed.'; - - @override - String get whatsNewWelcomeTip1 => 'New streaming mode with instant playback'; - - @override - String get whatsNewWelcomeTip2 => 'Redesigned library and full-screen player'; - - @override - String get whatsNewWelcomeTip3 => 'Batch tools, performance boosts, and more'; - - @override - String get whatsNewStreamingTitle => 'Streaming Mode'; - - @override - String get whatsNewStreamingDesc => - 'Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.'; - - @override - String get whatsNewSmartQueueTitle => 'Smart Queue'; - - @override - String get whatsNewSmartQueueDesc => - 'Your queue auto-curates with related tracks and artist discovery. Never run out of music.'; - - @override - String get whatsNewDualModeTitle => 'Dual Mode'; - - @override - String get whatsNewDualModeDesc => - 'Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.'; - - @override - String get whatsNewLibraryTitle => 'Redesigned Library'; - - @override - String get whatsNewLibraryDesc => - 'Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.'; - - @override - String get whatsNewPlayerTitle => 'Full-Screen Player'; - - @override - String get whatsNewPlayerDesc => - 'Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.'; - - @override - String get whatsNewContextMenuTitle => 'Long-Press Menus'; - - @override - String get whatsNewContextMenuDesc => - 'Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.'; - - @override - String get whatsNewPerformanceTitle => 'Performance'; - - @override - String get whatsNewPerformanceDesc => - 'Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.'; - - @override - String get whatsNewBatchToolsTitle => 'Batch Tools'; - - @override - String get whatsNewBatchToolsDesc => - 'Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.'; - - @override - String get whatsNewStreamingTip1 => - 'Tap any track to start playing instantly'; - - @override - String get whatsNewStreamingTip2 => 'Synced lyrics in the full-screen player'; - - @override - String get whatsNewStreamingTip3 => - 'Download tracks directly from the player'; - - @override - String get whatsNewSmartQueueTip1 => 'Queue auto-fills with related tracks'; - - @override - String get whatsNewSmartQueueTip2 => 'Discover new artists as you listen'; - - @override - String get whatsNewSmartQueueTip3 => 'Never run out of music to play'; - - @override - String get whatsNewDualModeTip1 => 'Switch modes anytime in Settings'; - - @override - String get whatsNewDualModeTip2 => 'UI buttons adapt to your current mode'; - - @override - String get whatsNewDualModeTip3 => - 'Download for offline, stream for instant play'; - - @override - String get whatsNewLibraryTip1 => 'Drag and drop to organize playlists'; - - @override - String get whatsNewLibraryTip2 => 'Set custom cover images for playlists'; - - @override - String get whatsNewLibraryTip3 => 'Multi-select tracks for batch actions'; - - @override - String get whatsNewPlayerTip1 => 'Cover art with parallax scrolling effect'; - - @override - String get whatsNewPlayerTip2 => 'Playback persists across app restarts'; - - @override - String get whatsNewPlayerTip3 => 'Synced lyrics while you listen'; - - @override - String get whatsNewContextMenuTip1 => 'Add tracks to any playlist instantly'; - - @override - String get whatsNewContextMenuTip2 => 'Share or convert with one tap'; - - @override - String get whatsNewContextMenuTip3 => 'Re-enrich metadata when needed'; - - @override - String get whatsNewBatchToolsTip1 => 'Share multiple tracks at once'; - - @override - String get whatsNewBatchToolsTip2 => 'Batch convert to MP3 or Opus format'; - - @override - String get whatsNewBatchToolsTip3 => 'Re-enrich metadata across your library'; - - @override - String get whatsNewPerformanceTip1 => 'Faster app startup time'; - - @override - String get whatsNewPerformanceTip2 => 'Reduced memory usage during playback'; - - @override - String get whatsNewPerformanceTip3 => 'SQLite-backed storage for reliability'; - - @override - String get whatsNewReadyMessage => - 'You\'re all set — enjoy the new SpotiFLAC!'; - - @override - String get whatsNewGetStarted => 'Let\'s Go'; - - @override - String whatsNewPageIndicator(int current, int total) { - return '$current of $total'; - } } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index c4a6f4aa..f0b5c82b 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -3259,178 +3259,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get setupModeChangeableLater => '您可以随时在设置中切换模式。'; - - @override - String get settingsSmartQueueTitle => 'Smart Queue'; - - @override - String get settingsSmartQueueSubtitle => '自动发现并将相似曲目添加到您的队列中'; - - @override - String get whatsNewTitle => 'What\'s New in 4.0'; - - @override - String get whatsNewSubtitle => - 'SpotiFLAC has evolved — here\'s what changed since 3.x'; - - @override - String get whatsNewWelcomeTitle => 'SpotiFLAC Mobile 4.0'; - - @override - String get whatsNewWelcomeDesc => - 'Welcome back! This is a major update packed with new features. Swipe through to see what\'s changed.'; - - @override - String get whatsNewWelcomeTip1 => 'New streaming mode with instant playback'; - - @override - String get whatsNewWelcomeTip2 => 'Redesigned library and full-screen player'; - - @override - String get whatsNewWelcomeTip3 => 'Batch tools, performance boosts, and more'; - - @override - String get whatsNewStreamingTitle => 'Streaming Mode'; - - @override - String get whatsNewStreamingDesc => - 'Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.'; - - @override - String get whatsNewSmartQueueTitle => 'Smart Queue'; - - @override - String get whatsNewSmartQueueDesc => - 'Your queue auto-curates with related tracks and artist discovery. Never run out of music.'; - - @override - String get whatsNewDualModeTitle => 'Dual Mode'; - - @override - String get whatsNewDualModeDesc => - 'Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.'; - - @override - String get whatsNewLibraryTitle => 'Redesigned Library'; - - @override - String get whatsNewLibraryDesc => - 'Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.'; - - @override - String get whatsNewPlayerTitle => 'Full-Screen Player'; - - @override - String get whatsNewPlayerDesc => - 'Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.'; - - @override - String get whatsNewContextMenuTitle => 'Long-Press Menus'; - - @override - String get whatsNewContextMenuDesc => - 'Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.'; - - @override - String get whatsNewPerformanceTitle => 'Performance'; - - @override - String get whatsNewPerformanceDesc => - 'Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.'; - - @override - String get whatsNewBatchToolsTitle => 'Batch Tools'; - - @override - String get whatsNewBatchToolsDesc => - 'Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.'; - - @override - String get whatsNewStreamingTip1 => - 'Tap any track to start playing instantly'; - - @override - String get whatsNewStreamingTip2 => 'Synced lyrics in the full-screen player'; - - @override - String get whatsNewStreamingTip3 => - 'Download tracks directly from the player'; - - @override - String get whatsNewSmartQueueTip1 => 'Queue auto-fills with related tracks'; - - @override - String get whatsNewSmartQueueTip2 => 'Discover new artists as you listen'; - - @override - String get whatsNewSmartQueueTip3 => 'Never run out of music to play'; - - @override - String get whatsNewDualModeTip1 => 'Switch modes anytime in Settings'; - - @override - String get whatsNewDualModeTip2 => 'UI buttons adapt to your current mode'; - - @override - String get whatsNewDualModeTip3 => - 'Download for offline, stream for instant play'; - - @override - String get whatsNewLibraryTip1 => 'Drag and drop to organize playlists'; - - @override - String get whatsNewLibraryTip2 => 'Set custom cover images for playlists'; - - @override - String get whatsNewLibraryTip3 => 'Multi-select tracks for batch actions'; - - @override - String get whatsNewPlayerTip1 => 'Cover art with parallax scrolling effect'; - - @override - String get whatsNewPlayerTip2 => 'Playback persists across app restarts'; - - @override - String get whatsNewPlayerTip3 => 'Synced lyrics while you listen'; - - @override - String get whatsNewContextMenuTip1 => 'Add tracks to any playlist instantly'; - - @override - String get whatsNewContextMenuTip2 => 'Share or convert with one tap'; - - @override - String get whatsNewContextMenuTip3 => 'Re-enrich metadata when needed'; - - @override - String get whatsNewBatchToolsTip1 => 'Share multiple tracks at once'; - - @override - String get whatsNewBatchToolsTip2 => 'Batch convert to MP3 or Opus format'; - - @override - String get whatsNewBatchToolsTip3 => 'Re-enrich metadata across your library'; - - @override - String get whatsNewPerformanceTip1 => 'Faster app startup time'; - - @override - String get whatsNewPerformanceTip2 => 'Reduced memory usage during playback'; - - @override - String get whatsNewPerformanceTip3 => 'SQLite-backed storage for reliability'; - - @override - String get whatsNewReadyMessage => - 'You\'re all set — enjoy the new SpotiFLAC!'; - - @override - String get whatsNewGetStarted => 'Let\'s Go'; - - @override - String whatsNewPageIndicator(int current, int total) { - return '$current of $total'; - } } /// The translations for Chinese, as used in China (`zh_CN`). @@ -6397,12 +6225,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get setupModeChangeableLater => '您可以随时在设置中切换模式。'; - - @override - String get settingsSmartQueueTitle => 'Smart Queue'; - - @override - String get settingsSmartQueueSubtitle => '自动发现并将相似曲目添加到您的队列中'; } /// The translations for Chinese, as used in Taiwan (`zh_TW`). @@ -9369,10 +9191,4 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get setupModeChangeableLater => '您可以隨時在設定中切換模式。'; - - @override - String get settingsSmartQueueTitle => 'Smart Queue'; - - @override - String get settingsSmartQueueSubtitle => '自動探索並將相似曲目新增到您的佇列中'; } diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index cf06c2ed..869d5559 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -3879,7 +3879,5 @@ "setupModeStreamingFeature1": "Streame Titel sofort ohne Herunterladen", "setupModeStreamingFeature2": "Smart Queue entdeckt automatisch neue Musik für dich", "setupModeStreamingFeature3": "Spiele jeden Titel auf Abruf mit Wiedergabesteuerung", - "setupModeChangeableLater": "Du kannst jederzeit in den Einstellungen zwischen den Modi wechseln.", - "settingsSmartQueueTitle": "Smart Queue", - "settingsSmartQueueSubtitle": "Automatisch ähnliche Titel entdecken und zu deiner Warteschlange hinzufügen" + "setupModeChangeableLater": "Du kannst jederzeit in den Einstellungen zwischen den Modi wechseln." } \ No newline at end of file diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 562073ff..709c1213 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -2495,117 +2495,5 @@ "setupModeStreamingFeature3": "Play any track on demand with playback controls", "@setupModeStreamingFeature3": {"description": "Streaming mode feature 3"}, "setupModeChangeableLater": "You can switch between modes anytime in Settings.", - "@setupModeChangeableLater": {"description": "Hint that mode can be changed later"}, - - "settingsSmartQueueTitle": "Smart Queue", - "@settingsSmartQueueTitle": {"description": "Title for Smart Queue toggle in settings"}, - "settingsSmartQueueSubtitle": "Automatically discover and add similar tracks to your queue", - "@settingsSmartQueueSubtitle": {"description": "Subtitle for Smart Queue toggle in settings"}, - - "whatsNewTitle": "What's New in 4.0", - "@whatsNewTitle": {"description": "Title for the What's New screen"}, - "whatsNewSubtitle": "SpotiFLAC has evolved — here's what changed since 3.x", - "@whatsNewSubtitle": {"description": "Subtitle for the What's New screen"}, - "whatsNewWelcomeTitle": "SpotiFLAC Mobile 4.0", - "@whatsNewWelcomeTitle": {"description": "Welcome page title in What's New screen"}, - "whatsNewWelcomeDesc": "Welcome back! This is a major update packed with new features. Swipe through to see what's changed.", - "@whatsNewWelcomeDesc": {"description": "Welcome page description in What's New screen"}, - "whatsNewWelcomeTip1": "New streaming mode with instant playback", - "@whatsNewWelcomeTip1": {"description": "Welcome page tip 1"}, - "whatsNewWelcomeTip2": "Redesigned library and full-screen player", - "@whatsNewWelcomeTip2": {"description": "Welcome page tip 2"}, - "whatsNewWelcomeTip3": "Batch tools, performance boosts, and more", - "@whatsNewWelcomeTip3": {"description": "Welcome page tip 3"}, - "whatsNewStreamingTitle": "Streaming Mode", - "@whatsNewStreamingTitle": {"description": "What's New feature: Streaming Mode title"}, - "whatsNewStreamingDesc": "Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.", - "@whatsNewStreamingDesc": {"description": "What's New feature: Streaming Mode description"}, - "whatsNewSmartQueueTitle": "Smart Queue", - "@whatsNewSmartQueueTitle": {"description": "What's New feature: Smart Queue title"}, - "whatsNewSmartQueueDesc": "Your queue auto-curates with related tracks and artist discovery. Never run out of music.", - "@whatsNewSmartQueueDesc": {"description": "What's New feature: Smart Queue description"}, - "whatsNewDualModeTitle": "Dual Mode", - "@whatsNewDualModeTitle": {"description": "What's New feature: Dual Mode title"}, - "whatsNewDualModeDesc": "Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.", - "@whatsNewDualModeDesc": {"description": "What's New feature: Dual Mode description"}, - "whatsNewLibraryTitle": "Redesigned Library", - "@whatsNewLibraryTitle": {"description": "What's New feature: Library redesign title"}, - "whatsNewLibraryDesc": "Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.", - "@whatsNewLibraryDesc": {"description": "What's New feature: Library redesign description"}, - "whatsNewPlayerTitle": "Full-Screen Player", - "@whatsNewPlayerTitle": {"description": "What's New feature: Full-Screen Player title"}, - "whatsNewPlayerDesc": "Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.", - "@whatsNewPlayerDesc": {"description": "What's New feature: Full-Screen Player description"}, - "whatsNewContextMenuTitle": "Long-Press Menus", - "@whatsNewContextMenuTitle": {"description": "What's New feature: Context Menus title"}, - "whatsNewContextMenuDesc": "Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.", - "@whatsNewContextMenuDesc": {"description": "What's New feature: Context Menus description"}, - "whatsNewPerformanceTitle": "Performance", - "@whatsNewPerformanceTitle": {"description": "What's New feature: Performance title"}, - "whatsNewPerformanceDesc": "Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.", - "@whatsNewPerformanceDesc": {"description": "What's New feature: Performance description"}, - "whatsNewBatchToolsTitle": "Batch Tools", - "@whatsNewBatchToolsTitle": {"description": "What's New feature: Batch Tools title"}, - "whatsNewBatchToolsDesc": "Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.", - "@whatsNewBatchToolsDesc": {"description": "What's New feature: Batch Tools description"}, - "whatsNewStreamingTip1": "Tap any track to start playing instantly", - "@whatsNewStreamingTip1": {"description": "What's New tip: streaming instant play"}, - "whatsNewStreamingTip2": "Synced lyrics in the full-screen player", - "@whatsNewStreamingTip2": {"description": "What's New tip: streaming synced lyrics"}, - "whatsNewStreamingTip3": "Download tracks directly from the player", - "@whatsNewStreamingTip3": {"description": "What's New tip: streaming download from player"}, - "whatsNewSmartQueueTip1": "Queue auto-fills with related tracks", - "@whatsNewSmartQueueTip1": {"description": "What's New tip: smart queue auto-fill"}, - "whatsNewSmartQueueTip2": "Discover new artists as you listen", - "@whatsNewSmartQueueTip2": {"description": "What's New tip: smart queue artist discovery"}, - "whatsNewSmartQueueTip3": "Never run out of music to play", - "@whatsNewSmartQueueTip3": {"description": "What's New tip: smart queue endless"}, - "whatsNewDualModeTip1": "Switch modes anytime in Settings", - "@whatsNewDualModeTip1": {"description": "What's New tip: dual mode switch"}, - "whatsNewDualModeTip2": "UI buttons adapt to your current mode", - "@whatsNewDualModeTip2": {"description": "What's New tip: dual mode adaptive UI"}, - "whatsNewDualModeTip3": "Download for offline, stream for instant play", - "@whatsNewDualModeTip3": {"description": "What's New tip: dual mode use cases"}, - "whatsNewLibraryTip1": "Drag and drop to organize playlists", - "@whatsNewLibraryTip1": {"description": "What's New tip: library drag and drop"}, - "whatsNewLibraryTip2": "Set custom cover images for playlists", - "@whatsNewLibraryTip2": {"description": "What's New tip: library custom covers"}, - "whatsNewLibraryTip3": "Multi-select tracks for batch actions", - "@whatsNewLibraryTip3": {"description": "What's New tip: library multi-select"}, - "whatsNewPlayerTip1": "Cover art with parallax scrolling effect", - "@whatsNewPlayerTip1": {"description": "What's New tip: player parallax"}, - "whatsNewPlayerTip2": "Playback persists across app restarts", - "@whatsNewPlayerTip2": {"description": "What's New tip: player persistence"}, - "whatsNewPlayerTip3": "Synced lyrics while you listen", - "@whatsNewPlayerTip3": {"description": "What's New tip: player lyrics"}, - "whatsNewContextMenuTip1": "Add tracks to any playlist instantly", - "@whatsNewContextMenuTip1": {"description": "What's New tip: context menu add to playlist"}, - "whatsNewContextMenuTip2": "Share or convert with one tap", - "@whatsNewContextMenuTip2": {"description": "What's New tip: context menu share/convert"}, - "whatsNewContextMenuTip3": "Re-enrich metadata when needed", - "@whatsNewContextMenuTip3": {"description": "What's New tip: context menu re-enrich"}, - "whatsNewBatchToolsTip1": "Share multiple tracks at once", - "@whatsNewBatchToolsTip1": {"description": "What's New tip: batch share"}, - "whatsNewBatchToolsTip2": "Batch convert to MP3 or Opus format", - "@whatsNewBatchToolsTip2": {"description": "What's New tip: batch convert"}, - "whatsNewBatchToolsTip3": "Re-enrich metadata across your library", - "@whatsNewBatchToolsTip3": {"description": "What's New tip: batch re-enrich"}, - "whatsNewPerformanceTip1": "Faster app startup time", - "@whatsNewPerformanceTip1": {"description": "What's New tip: performance startup"}, - "whatsNewPerformanceTip2": "Reduced memory usage during playback", - "@whatsNewPerformanceTip2": {"description": "What's New tip: performance memory"}, - "whatsNewPerformanceTip3": "SQLite-backed storage for reliability", - "@whatsNewPerformanceTip3": {"description": "What's New tip: performance SQLite"}, - "whatsNewReadyMessage": "You're all set — enjoy the new SpotiFLAC!", - "@whatsNewReadyMessage": {"description": "Ready card message on last What's New page"}, - "whatsNewGetStarted": "Let's Go", - "@whatsNewGetStarted": {"description": "Button text to dismiss What's New screen"}, - "whatsNewPageIndicator": "{current} of {total}", - "@whatsNewPageIndicator": { - "description": "Page indicator text in What's New screen", - "placeholders": { - "current": {"type": "int"}, - "total": {"type": "int"} - } - } + "@setupModeChangeableLater": {"description": "Hint that mode can be changed later"} } diff --git a/lib/l10n/arb/app_es.arb b/lib/l10n/arb/app_es.arb index 8d9a8fb9..f53b9487 100644 --- a/lib/l10n/arb/app_es.arb +++ b/lib/l10n/arb/app_es.arb @@ -2576,7 +2576,5 @@ "setupModeStreamingFeature1": "Transmite pistas al instante sin descargar", "setupModeStreamingFeature2": "Smart Queue descubre automáticamente nueva música para ti", "setupModeStreamingFeature3": "Reproduce cualquier pista bajo demanda con controles de reproducción", - "setupModeChangeableLater": "Puedes cambiar entre modos en cualquier momento en Ajustes.", - "settingsSmartQueueTitle": "Smart Queue", - "settingsSmartQueueSubtitle": "Descubre y añade automáticamente pistas similares a tu cola de reproducción" + "setupModeChangeableLater": "Puedes cambiar entre modos en cualquier momento en Ajustes." } \ No newline at end of file diff --git a/lib/l10n/arb/app_es_ES.arb b/lib/l10n/arb/app_es_ES.arb index ccb341db..6065fd9d 100644 --- a/lib/l10n/arb/app_es_ES.arb +++ b/lib/l10n/arb/app_es_ES.arb @@ -3879,7 +3879,5 @@ "setupModeStreamingFeature1": "Transmite pistas al instante sin descargar", "setupModeStreamingFeature2": "Smart Queue descubre automáticamente nueva música para ti", "setupModeStreamingFeature3": "Reproduce cualquier pista bajo demanda con controles de reproducción", - "setupModeChangeableLater": "Puedes cambiar entre modos en cualquier momento en Ajustes.", - "settingsSmartQueueTitle": "Smart Queue", - "settingsSmartQueueSubtitle": "Descubre y añade automáticamente pistas similares a tu cola de reproducción" + "setupModeChangeableLater": "Puedes cambiar entre modos en cualquier momento en Ajustes." } \ No newline at end of file diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index 351c4531..d52346ef 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -3879,7 +3879,5 @@ "setupModeStreamingFeature1": "Diffusez des pistes instantanément sans télécharger", "setupModeStreamingFeature2": "Smart Queue découvre automatiquement de nouvelle musique pour vous", "setupModeStreamingFeature3": "Écoutez n'importe quelle piste à la demande avec les contrôles de lecture", - "setupModeChangeableLater": "Vous pouvez changer de mode à tout moment dans les Paramètres.", - "settingsSmartQueueTitle": "Smart Queue", - "settingsSmartQueueSubtitle": "Découvrir et ajouter automatiquement des pistes similaires à votre file d'attente" + "setupModeChangeableLater": "Vous pouvez changer de mode à tout moment dans les Paramètres." } \ No newline at end of file diff --git a/lib/l10n/arb/app_hi.arb b/lib/l10n/arb/app_hi.arb index 7517340d..e57da31b 100644 --- a/lib/l10n/arb/app_hi.arb +++ b/lib/l10n/arb/app_hi.arb @@ -3879,7 +3879,5 @@ "setupModeStreamingFeature1": "बिना डाउनलोड किए तुरंत ट्रैक स्ट्रीम करें", "setupModeStreamingFeature2": "Smart Queue स्वचालित रूप से आपके लिए नया संगीत खोजता है", "setupModeStreamingFeature3": "प्लेबैक नियंत्रण के साथ किसी भी ट्रैक को मांग पर चलाएं", - "setupModeChangeableLater": "आप सेटिंग्स में कभी भी मोड बदल सकते हैं।", - "settingsSmartQueueTitle": "Smart Queue", - "settingsSmartQueueSubtitle": "स्वचालित रूप से समान ट्रैक खोजें और अपनी कतार में जोड़ें" + "setupModeChangeableLater": "आप सेटिंग्स में कभी भी मोड बदल सकते हैं।" } \ No newline at end of file diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index c2d4b7d8..cf4de43b 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -4201,9 +4201,6 @@ "setupModeStreamingFeature2": "Smart Queue secara otomatis menemukan musik baru untuk Anda", "setupModeStreamingFeature3": "Putar trek apa pun sesuai permintaan dengan kontrol pemutaran", "setupModeChangeableLater": "Anda dapat beralih antar mode kapan saja di Pengaturan.", - "settingsSmartQueueTitle": "Smart Queue", - "settingsSmartQueueSubtitle": "Secara otomatis temukan dan tambahkan trek serupa ke antrean Anda", - "selectionShareCount": "Bagikan {count} {count, plural, =1{trek} other{trek}}", "@selectionShareCount": { "description": "Share button text with count in selection mode", @@ -4249,112 +4246,5 @@ "total": {"type": "int"}, "format": {"type": "String"} } - }, - - "whatsNewTitle": "Yang Baru di 4.0", - "@whatsNewTitle": {"description": "Title for the What's New screen"}, - "whatsNewSubtitle": "SpotiFLAC telah berevolusi — inilah yang berubah sejak 3.x", - "@whatsNewSubtitle": {"description": "Subtitle for the What's New screen"}, - "whatsNewWelcomeTitle": "SpotiFLAC Mobile 4.0", - "@whatsNewWelcomeTitle": {"description": "Welcome page title in What's New screen"}, - "whatsNewWelcomeDesc": "Selamat datang kembali! Ini pembaruan besar dengan banyak fitur baru. Geser untuk melihat apa yang berubah.", - "@whatsNewWelcomeDesc": {"description": "Welcome page description in What's New screen"}, - "whatsNewWelcomeTip1": "Mode streaming baru dengan pemutaran instan", - "@whatsNewWelcomeTip1": {"description": "Welcome page tip 1"}, - "whatsNewWelcomeTip2": "Perpustakaan dan pemutar layar penuh yang didesain ulang", - "@whatsNewWelcomeTip2": {"description": "Welcome page tip 2"}, - "whatsNewWelcomeTip3": "Alat massal, peningkatan performa, dan lainnya", - "@whatsNewWelcomeTip3": {"description": "Welcome page tip 3"}, - "whatsNewStreamingTitle": "Mode Streaming", - "@whatsNewStreamingTitle": {"description": "What's New feature: Streaming Mode title"}, - "whatsNewStreamingDesc": "Ketuk trek apa pun untuk langsung diputar — tanpa perlu mengunduh. Pemutar layar penuh dengan lirik tersinkron dan kontrol media.", - "@whatsNewStreamingDesc": {"description": "What's New feature: Streaming Mode description"}, - "whatsNewSmartQueueTitle": "Smart Queue", - "@whatsNewSmartQueueTitle": {"description": "What's New feature: Smart Queue title"}, - "whatsNewSmartQueueDesc": "Antrean Anda otomatis mengkurasi trek terkait dan penemuan artis. Tak pernah kehabisan musik.", - "@whatsNewSmartQueueDesc": {"description": "What's New feature: Smart Queue description"}, - "whatsNewDualModeTitle": "Mode Ganda", - "@whatsNewDualModeTitle": {"description": "What's New feature: Dual Mode title"}, - "whatsNewDualModeDesc": "Beralih antara mode Pengunduh dan Streaming kapan saja. Semua tombol menyesuaikan secara otomatis.", - "@whatsNewDualModeDesc": {"description": "What's New feature: Dual Mode description"}, - "whatsNewLibraryTitle": "Perpustakaan Baru", - "@whatsNewLibraryTitle": {"description": "What's New feature: Library redesign title"}, - "whatsNewLibraryDesc": "Tata letak berbasis playlist dengan kategorisasi seret-dan-lepas, sampul kustom, dan aksi massal multi-pilih.", - "@whatsNewLibraryDesc": {"description": "What's New feature: Library redesign description"}, - "whatsNewPlayerTitle": "Pemutar Layar Penuh", - "@whatsNewPlayerTitle": {"description": "What's New feature: Full-Screen Player title"}, - "whatsNewPlayerDesc": "Paralaks seni sampul, lirik tersinkron, pemutaran tetap tersimpan saat restart, dan tombol unduh di pemutar.", - "@whatsNewPlayerDesc": {"description": "What's New feature: Full-Screen Player description"}, - "whatsNewContextMenuTitle": "Menu Tekan Lama", - "@whatsNewContextMenuTitle": {"description": "What's New feature: Context Menus title"}, - "whatsNewContextMenuDesc": "Tekan lama trek apa pun untuk aksi cepat — tambah ke playlist, bagikan, konversi, atau perbarui metadata.", - "@whatsNewContextMenuDesc": {"description": "What's New feature: Context Menus description"}, - "whatsNewPerformanceTitle": "Performa", - "@whatsNewPerformanceTitle": {"description": "What's New feature: Performance title"}, - "whatsNewPerformanceDesc": "Startup lebih cepat, penggunaan memori berkurang, penyimpanan berbasis SQLite, dan pembaruan UI yang lebih efisien.", - "@whatsNewPerformanceDesc": {"description": "What's New feature: Performance description"}, - "whatsNewBatchToolsTitle": "Alat Massal", - "@whatsNewBatchToolsTitle": {"description": "What's New feature: Batch Tools title"}, - "whatsNewBatchToolsDesc": "Berbagi multi-pilih, konversi massal ke MP3/Opus, dan perbarui metadata secara massal di seluruh perpustakaan.", - "@whatsNewBatchToolsDesc": {"description": "What's New feature: Batch Tools description"}, - "whatsNewStreamingTip1": "Ketuk trek apa pun untuk langsung memutar", - "@whatsNewStreamingTip1": {"description": "What's New tip: streaming instant play"}, - "whatsNewStreamingTip2": "Lirik tersinkron di pemutar layar penuh", - "@whatsNewStreamingTip2": {"description": "What's New tip: streaming synced lyrics"}, - "whatsNewStreamingTip3": "Unduh trek langsung dari pemutar", - "@whatsNewStreamingTip3": {"description": "What's New tip: streaming download from player"}, - "whatsNewSmartQueueTip1": "Antrean terisi otomatis dengan trek terkait", - "@whatsNewSmartQueueTip1": {"description": "What's New tip: smart queue auto-fill"}, - "whatsNewSmartQueueTip2": "Temukan artis baru saat mendengarkan", - "@whatsNewSmartQueueTip2": {"description": "What's New tip: smart queue artist discovery"}, - "whatsNewSmartQueueTip3": "Tak pernah kehabisan musik untuk diputar", - "@whatsNewSmartQueueTip3": {"description": "What's New tip: smart queue endless"}, - "whatsNewDualModeTip1": "Beralih mode kapan saja di Pengaturan", - "@whatsNewDualModeTip1": {"description": "What's New tip: dual mode switch"}, - "whatsNewDualModeTip2": "Tombol UI menyesuaikan dengan mode Anda", - "@whatsNewDualModeTip2": {"description": "What's New tip: dual mode adaptive UI"}, - "whatsNewDualModeTip3": "Unduh untuk offline, streaming untuk putar langsung", - "@whatsNewDualModeTip3": {"description": "What's New tip: dual mode use cases"}, - "whatsNewLibraryTip1": "Seret dan lepas untuk mengatur playlist", - "@whatsNewLibraryTip1": {"description": "What's New tip: library drag and drop"}, - "whatsNewLibraryTip2": "Atur gambar sampul kustom untuk playlist", - "@whatsNewLibraryTip2": {"description": "What's New tip: library custom covers"}, - "whatsNewLibraryTip3": "Pilih banyak trek untuk aksi massal", - "@whatsNewLibraryTip3": {"description": "What's New tip: library multi-select"}, - "whatsNewPlayerTip1": "Seni sampul dengan efek paralaks", - "@whatsNewPlayerTip1": {"description": "What's New tip: player parallax"}, - "whatsNewPlayerTip2": "Pemutaran tetap tersimpan saat restart", - "@whatsNewPlayerTip2": {"description": "What's New tip: player persistence"}, - "whatsNewPlayerTip3": "Lirik tersinkron saat mendengarkan", - "@whatsNewPlayerTip3": {"description": "What's New tip: player lyrics"}, - "whatsNewContextMenuTip1": "Tambahkan trek ke playlist mana pun langsung", - "@whatsNewContextMenuTip1": {"description": "What's New tip: context menu add to playlist"}, - "whatsNewContextMenuTip2": "Bagikan atau konversi dengan satu ketukan", - "@whatsNewContextMenuTip2": {"description": "What's New tip: context menu share/convert"}, - "whatsNewContextMenuTip3": "Perbarui metadata saat diperlukan", - "@whatsNewContextMenuTip3": {"description": "What's New tip: context menu re-enrich"}, - "whatsNewBatchToolsTip1": "Bagikan banyak trek sekaligus", - "@whatsNewBatchToolsTip1": {"description": "What's New tip: batch share"}, - "whatsNewBatchToolsTip2": "Konversi massal ke format MP3 atau Opus", - "@whatsNewBatchToolsTip2": {"description": "What's New tip: batch convert"}, - "whatsNewBatchToolsTip3": "Perbarui metadata di seluruh perpustakaan", - "@whatsNewBatchToolsTip3": {"description": "What's New tip: batch re-enrich"}, - "whatsNewPerformanceTip1": "Waktu startup aplikasi lebih cepat", - "@whatsNewPerformanceTip1": {"description": "What's New tip: performance startup"}, - "whatsNewPerformanceTip2": "Penggunaan memori berkurang saat pemutaran", - "@whatsNewPerformanceTip2": {"description": "What's New tip: performance memory"}, - "whatsNewPerformanceTip3": "Penyimpanan berbasis SQLite untuk keandalan", - "@whatsNewPerformanceTip3": {"description": "What's New tip: performance SQLite"}, - "whatsNewReadyMessage": "Siap — nikmati SpotiFLAC yang baru!", - "@whatsNewReadyMessage": {"description": "Ready card message on last What's New page"}, - "whatsNewGetStarted": "Ayo Mulai", - "@whatsNewGetStarted": {"description": "Button text to dismiss What's New screen"}, - "whatsNewPageIndicator": "{current} dari {total}", - "@whatsNewPageIndicator": { - "description": "Page indicator text in What's New screen", - "placeholders": { - "current": {"type": "int"}, - "total": {"type": "int"} - } } } diff --git a/lib/l10n/arb/app_ja.arb b/lib/l10n/arb/app_ja.arb index 12f1259b..8d66d905 100644 --- a/lib/l10n/arb/app_ja.arb +++ b/lib/l10n/arb/app_ja.arb @@ -3879,7 +3879,5 @@ "setupModeStreamingFeature1": "ダウンロードせずにトラックを即座にストリーミング", "setupModeStreamingFeature2": "Smart Queueが自動的に新しい音楽を見つけます", "setupModeStreamingFeature3": "再生コントロールで任意のトラックをオンデマンド再生", - "setupModeChangeableLater": "設定からいつでもモードを切り替えられます。", - "settingsSmartQueueTitle": "Smart Queue", - "settingsSmartQueueSubtitle": "類似トラックを自動的に検出してキューに追加" + "setupModeChangeableLater": "設定からいつでもモードを切り替えられます。" } \ No newline at end of file diff --git a/lib/l10n/arb/app_ko.arb b/lib/l10n/arb/app_ko.arb index a350b7a2..6c3fa14d 100644 --- a/lib/l10n/arb/app_ko.arb +++ b/lib/l10n/arb/app_ko.arb @@ -3879,7 +3879,5 @@ "setupModeStreamingFeature1": "다운로드 없이 트랙을 즉시 스트리밍", "setupModeStreamingFeature2": "Smart Queue가 자동으로 새로운 음악을 발견합니다", "setupModeStreamingFeature3": "재생 컨트롤로 원하는 트랙을 온디맨드 재생", - "setupModeChangeableLater": "설정에서 언제든지 모드를 전환할 수 있습니다.", - "settingsSmartQueueTitle": "Smart Queue", - "settingsSmartQueueSubtitle": "유사한 트랙을 자동으로 검색하여 대기열에 추가" + "setupModeChangeableLater": "설정에서 언제든지 모드를 전환할 수 있습니다." } \ No newline at end of file diff --git a/lib/l10n/arb/app_nl.arb b/lib/l10n/arb/app_nl.arb index 34ceb20a..ebac52c4 100644 --- a/lib/l10n/arb/app_nl.arb +++ b/lib/l10n/arb/app_nl.arb @@ -3879,7 +3879,5 @@ "setupModeStreamingFeature1": "Stream nummers direct zonder te downloaden", "setupModeStreamingFeature2": "Smart Queue ontdekt automatisch nieuwe muziek voor je", "setupModeStreamingFeature3": "Speel elk nummer op aanvraag af met afspeelbediening", - "setupModeChangeableLater": "Je kunt op elk moment wisselen tussen modi in Instellingen.", - "settingsSmartQueueTitle": "Smart Queue", - "settingsSmartQueueSubtitle": "Ontdek automatisch vergelijkbare nummers en voeg ze toe aan je wachtrij" + "setupModeChangeableLater": "Je kunt op elk moment wisselen tussen modi in Instellingen." } \ No newline at end of file diff --git a/lib/l10n/arb/app_pt.arb b/lib/l10n/arb/app_pt.arb index 391b81c3..9c7f026d 100644 --- a/lib/l10n/arb/app_pt.arb +++ b/lib/l10n/arb/app_pt.arb @@ -2576,7 +2576,5 @@ "setupModeStreamingFeature1": "Transmita faixas instantaneamente sem baixar", "setupModeStreamingFeature2": "Smart Queue descobre automaticamente novas músicas para você", "setupModeStreamingFeature3": "Reproduza qualquer faixa sob demanda com controles de reprodução", - "setupModeChangeableLater": "Você pode alternar entre os modos a qualquer momento nas Configurações.", - "settingsSmartQueueTitle": "Smart Queue", - "settingsSmartQueueSubtitle": "Descubra e adicione automaticamente faixas semelhantes à sua fila" + "setupModeChangeableLater": "Você pode alternar entre os modos a qualquer momento nas Configurações." } \ No newline at end of file diff --git a/lib/l10n/arb/app_pt_PT.arb b/lib/l10n/arb/app_pt_PT.arb index c3e621f9..57592cd5 100644 --- a/lib/l10n/arb/app_pt_PT.arb +++ b/lib/l10n/arb/app_pt_PT.arb @@ -3879,7 +3879,5 @@ "setupModeStreamingFeature1": "Transmita faixas instantaneamente sem transferir", "setupModeStreamingFeature2": "Smart Queue descobre automaticamente novas músicas para si", "setupModeStreamingFeature3": "Reproduza qualquer faixa a pedido com controlos de reprodução", - "setupModeChangeableLater": "Pode alternar entre modos a qualquer momento nas Definições.", - "settingsSmartQueueTitle": "Smart Queue", - "settingsSmartQueueSubtitle": "Descubra e adicione automaticamente faixas semelhantes à sua fila" + "setupModeChangeableLater": "Pode alternar entre modos a qualquer momento nas Definições." } \ No newline at end of file diff --git a/lib/l10n/arb/app_ru.arb b/lib/l10n/arb/app_ru.arb index 818424cf..f8f9d028 100644 --- a/lib/l10n/arb/app_ru.arb +++ b/lib/l10n/arb/app_ru.arb @@ -3879,7 +3879,5 @@ "setupModeStreamingFeature1": "Слушайте треки мгновенно без скачивания", "setupModeStreamingFeature2": "Smart Queue автоматически подбирает новую музыку для вас", "setupModeStreamingFeature3": "Воспроизводите любой трек по запросу с элементами управления", - "setupModeChangeableLater": "Вы можете переключаться между режимами в любое время в Настройках.", - "settingsSmartQueueTitle": "Smart Queue", - "settingsSmartQueueSubtitle": "Автоматически находите и добавляйте похожие треки в очередь воспроизведения" + "setupModeChangeableLater": "Вы можете переключаться между режимами в любое время в Настройках." } \ No newline at end of file diff --git a/lib/l10n/arb/app_tr.arb b/lib/l10n/arb/app_tr.arb index 0b736169..ef9184f9 100644 --- a/lib/l10n/arb/app_tr.arb +++ b/lib/l10n/arb/app_tr.arb @@ -3879,7 +3879,5 @@ "setupModeStreamingFeature1": "İndirmeden parçaları anında yayınlayın", "setupModeStreamingFeature2": "Smart Queue sizin için otomatik olarak yeni müzik keşfeder", "setupModeStreamingFeature3": "İstediğiniz parçayı oynatma kontrolleriyle çalın", - "setupModeChangeableLater": "Ayarlar'dan istediğiniz zaman modlar arasında geçiş yapabilirsiniz.", - "settingsSmartQueueTitle": "Smart Queue", - "settingsSmartQueueSubtitle": "Sıranıza otomatik olarak benzer parçalar keşfedin ve ekleyin" + "setupModeChangeableLater": "Ayarlar'dan istediğiniz zaman modlar arasında geçiş yapabilirsiniz." } \ No newline at end of file diff --git a/lib/l10n/arb/app_zh.arb b/lib/l10n/arb/app_zh.arb index bc4c4b4e..0209b3a6 100644 --- a/lib/l10n/arb/app_zh.arb +++ b/lib/l10n/arb/app_zh.arb @@ -2576,7 +2576,5 @@ "setupModeStreamingFeature1": "无需下载即可即时播放曲目", "setupModeStreamingFeature2": "Smart Queue 自动为您发现新音乐", "setupModeStreamingFeature3": "通过播放控件随时点播任意曲目", - "setupModeChangeableLater": "您可以随时在设置中切换模式。", - "settingsSmartQueueTitle": "Smart Queue", - "settingsSmartQueueSubtitle": "自动发现并将相似曲目添加到您的队列中" + "setupModeChangeableLater": "您可以随时在设置中切换模式。" } \ No newline at end of file diff --git a/lib/l10n/arb/app_zh_CN.arb b/lib/l10n/arb/app_zh_CN.arb index 6f34877c..b26eb35e 100644 --- a/lib/l10n/arb/app_zh_CN.arb +++ b/lib/l10n/arb/app_zh_CN.arb @@ -3879,7 +3879,5 @@ "setupModeStreamingFeature1": "无需下载即可即时播放曲目", "setupModeStreamingFeature2": "Smart Queue 自动为您发现新音乐", "setupModeStreamingFeature3": "通过播放控件随时点播任意曲目", - "setupModeChangeableLater": "您可以随时在设置中切换模式。", - "settingsSmartQueueTitle": "Smart Queue", - "settingsSmartQueueSubtitle": "自动发现并将相似曲目添加到您的队列中" + "setupModeChangeableLater": "您可以随时在设置中切换模式。" } \ No newline at end of file diff --git a/lib/l10n/arb/app_zh_TW.arb b/lib/l10n/arb/app_zh_TW.arb index 91fb2903..51935549 100644 --- a/lib/l10n/arb/app_zh_TW.arb +++ b/lib/l10n/arb/app_zh_TW.arb @@ -3879,7 +3879,5 @@ "setupModeStreamingFeature1": "無需下載即可即時串流曲目", "setupModeStreamingFeature2": "Smart Queue 自動為您探索新音樂", "setupModeStreamingFeature3": "透過播放控制項隨時點播任意曲目", - "setupModeChangeableLater": "您可以隨時在設定中切換模式。", - "settingsSmartQueueTitle": "Smart Queue", - "settingsSmartQueueSubtitle": "自動探索並將相似曲目新增到您的佇列中" + "setupModeChangeableLater": "您可以隨時在設定中切換模式。" } \ No newline at end of file diff --git a/lib/models/playback_item.dart b/lib/models/playback_item.dart deleted file mode 100644 index 877674d0..00000000 --- a/lib/models/playback_item.dart +++ /dev/null @@ -1,91 +0,0 @@ -import 'package:spotiflac_android/models/track.dart'; - -class PlaybackItem { - final String id; - final String title; - final String artist; - final String album; - final String coverUrl; - final String sourceUri; - final bool isLocal; - final String service; - final int durationMs; - - // Stream quality metadata - final String format; - final int bitDepth; - final int sampleRate; - final int bitrate; - - // Original track reference for queue operations - final Track? track; - - const PlaybackItem({ - required this.id, - required this.title, - required this.artist, - this.album = '', - this.coverUrl = '', - required this.sourceUri, - this.isLocal = false, - this.service = '', - this.durationMs = 0, - this.format = '', - this.bitDepth = 0, - this.sampleRate = 0, - this.bitrate = 0, - this.track, - }); - - PlaybackItem copyWith({ - String? sourceUri, - String? service, - String? format, - int? bitDepth, - int? sampleRate, - int? bitrate, - }) { - return PlaybackItem( - id: id, - title: title, - artist: artist, - album: album, - coverUrl: coverUrl, - sourceUri: sourceUri ?? this.sourceUri, - isLocal: isLocal, - service: service ?? this.service, - durationMs: durationMs, - format: format ?? this.format, - bitDepth: bitDepth ?? this.bitDepth, - sampleRate: sampleRate ?? this.sampleRate, - bitrate: bitrate ?? this.bitrate, - track: track, - ); - } - - /// Human-readable quality label for UI display - String get qualityLabel { - final parts = []; - - if (format.isNotEmpty) { - parts.add(format.toUpperCase()); - } - - if (bitDepth > 0 && sampleRate > 0) { - final srKhz = sampleRate >= 1000 - ? '${(sampleRate / 1000).toStringAsFixed(sampleRate % 1000 == 0 ? 0 : 1)}kHz' - : '${sampleRate}Hz'; - parts.add('$bitDepth-bit / $srKhz'); - } else if (bitrate > 0) { - parts.add('${bitrate}kbps'); - } - - return parts.join(' '); - } - - /// Whether this item has cover art that is a local file path - bool get hasLocalCover { - if (coverUrl.isEmpty) return false; - return !coverUrl.startsWith('http://') && !coverUrl.startsWith('https://'); - } -} diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 97c29817..545e7440 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -11,9 +11,6 @@ class AppSettings { final String storageMode; // 'app' or 'saf' final String downloadTreeUri; // SAF persistable tree URI final bool autoFallback; - final bool autoSkipUnavailableTracks; - final String playerMode; // 'internal' or 'external' - final bool smartQueueEnabled; // Enable smart curated autoplay queue final bool embedMetadata; // Master switch for metadata/cover/lyrics embedding final bool embedLyrics; final bool maxQualityCover; @@ -92,9 +89,6 @@ class AppSettings { this.storageMode = 'app', this.downloadTreeUri = '', this.autoFallback = true, - this.autoSkipUnavailableTracks = true, - this.playerMode = 'internal', - this.smartQueueEnabled = true, this.embedMetadata = true, this.embedLyrics = true, this.maxQualityCover = true, @@ -160,10 +154,7 @@ class AppSettings { String? downloadDirectory, String? storageMode, String? downloadTreeUri, - bool? autoFallback, - bool? autoSkipUnavailableTracks, - String? playerMode, - bool? smartQueueEnabled, + bool? autoFallback, bool? embedMetadata, bool? embedLyrics, bool? maxQualityCover, @@ -223,10 +214,6 @@ class AppSettings { storageMode: storageMode ?? this.storageMode, downloadTreeUri: downloadTreeUri ?? this.downloadTreeUri, autoFallback: autoFallback ?? this.autoFallback, - autoSkipUnavailableTracks: - autoSkipUnavailableTracks ?? this.autoSkipUnavailableTracks, - playerMode: playerMode ?? this.playerMode, - smartQueueEnabled: smartQueueEnabled ?? this.smartQueueEnabled, embedMetadata: embedMetadata ?? this.embedMetadata, embedLyrics: embedLyrics ?? this.embedLyrics, maxQualityCover: maxQualityCover ?? this.maxQualityCover, diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index b484ee15..933178e3 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -14,9 +14,6 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( storageMode: json['storageMode'] as String? ?? 'app', downloadTreeUri: json['downloadTreeUri'] as String? ?? '', autoFallback: json['autoFallback'] as bool? ?? true, - autoSkipUnavailableTracks: json['autoSkipUnavailableTracks'] as bool? ?? true, - playerMode: json['playerMode'] as String? ?? 'internal', - smartQueueEnabled: json['smartQueueEnabled'] as bool? ?? true, embedMetadata: json['embedMetadata'] as bool? ?? true, embedLyrics: json['embedLyrics'] as bool? ?? true, maxQualityCover: json['maxQualityCover'] as bool? ?? true, @@ -93,9 +90,6 @@ Map _$AppSettingsToJson( 'storageMode': instance.storageMode, 'downloadTreeUri': instance.downloadTreeUri, 'autoFallback': instance.autoFallback, - 'autoSkipUnavailableTracks': instance.autoSkipUnavailableTracks, - 'playerMode': instance.playerMode, - 'smartQueueEnabled': instance.smartQueueEnabled, 'embedMetadata': instance.embedMetadata, 'embedLyrics': instance.embedLyrics, 'maxQualityCover': instance.maxQualityCover, diff --git a/lib/providers/playback_provider.dart b/lib/providers/playback_provider.dart index 90863db5..c35f6a00 100644 --- a/lib/providers/playback_provider.dart +++ b/lib/providers/playback_provider.dart @@ -1,1218 +1,87 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:math'; - -import 'package:audio_service/audio_service.dart' as audio_service; -import 'package:audio_session/audio_session.dart'; -import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:just_audio/just_audio.dart'; -import 'package:spotiflac_android/models/playback_item.dart'; import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; -import 'package:spotiflac_android/providers/library_collections_provider.dart'; -import 'package:spotiflac_android/providers/settings_provider.dart'; -import 'package:spotiflac_android/services/ffmpeg_service.dart'; import 'package:spotiflac_android/services/library_database.dart'; -import 'package:spotiflac_android/services/platform_bridge.dart'; -import 'package:spotiflac_android/utils/artist_utils.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/logger.dart'; -import 'package:shared_preferences/shared_preferences.dart'; final _log = AppLogger('PlaybackProvider'); -// ─── Repeat mode ───────────────────────────────────────────────────────────── -enum RepeatMode { off, all, one } - -// ─── Lyrics types ──────────────────────────────────────────────────────────── - -/// A single word/syllable within a lyrics line, with its own timing. -class LyricsWord { - final String text; - final int startMs; - final int endMs; - - const LyricsWord({ - required this.text, - required this.startMs, - required this.endMs, - }); -} - -/// A single lyrics line, optionally with per-word timing. -class LyricsLine { - final int startMs; - final int endMs; - final String text; - final List words; - - const LyricsLine({ - required this.startMs, - required this.endMs, - required this.text, - this.words = const [], - }); - - bool get hasWordSync => words.isNotEmpty; -} - -/// Parsed lyrics data ready for display. -class LyricsData { - final List lines; - final String syncType; // LINE_SYNCED, UNSYNCED - final String source; // LRCLIB, Apple Music, etc. - final bool instrumental; - final bool isWordSynced; // true if any line has word-level timing - - const LyricsData({ - this.lines = const [], - this.syncType = '', - this.source = '', - this.instrumental = false, - this.isWordSynced = false, - }); - - bool get isSynced => syncType == 'LINE_SYNCED'; - bool get isEmpty => lines.isEmpty && !instrumental; -} - -// ─── State ─────────────────────────────────────────────────────────────────── class PlaybackState { - final PlaybackItem? currentItem; - final bool isPlaying; - final bool isBuffering; - final bool isLoading; - final Duration position; - final Duration bufferedPosition; - final Duration duration; - final String? error; - final String? errorType; - final bool seekSupported; - - // Queue - final List queue; - final int currentIndex; - final bool shuffle; - final RepeatMode repeatMode; - - // Lyrics - final LyricsData? lyrics; - final bool lyricsLoading; - - const PlaybackState({ - this.currentItem, - this.isPlaying = false, - this.isBuffering = false, - this.isLoading = false, - this.position = Duration.zero, - this.bufferedPosition = Duration.zero, - this.duration = Duration.zero, - this.error, - this.errorType, - this.seekSupported = true, - this.queue = const [], - this.currentIndex = -1, - this.shuffle = false, - this.repeatMode = RepeatMode.off, - this.lyrics, - this.lyricsLoading = false, - }); - - bool get hasNext => queue.isNotEmpty && currentIndex < queue.length - 1; - bool get hasPrevious => queue.isNotEmpty && currentIndex > 0; - - PlaybackState copyWith({ - PlaybackItem? currentItem, - bool clearCurrentItem = false, - bool? isPlaying, - bool? isBuffering, - bool? isLoading, - Duration? position, - Duration? bufferedPosition, - Duration? duration, - String? error, - String? errorType, - bool? seekSupported, - bool clearError = false, - List? queue, - int? currentIndex, - bool? shuffle, - RepeatMode? repeatMode, - LyricsData? lyrics, - bool clearLyrics = false, - bool? lyricsLoading, - }) { - return PlaybackState( - currentItem: clearCurrentItem ? null : (currentItem ?? this.currentItem), - isPlaying: isPlaying ?? this.isPlaying, - isBuffering: isBuffering ?? this.isBuffering, - isLoading: isLoading ?? this.isLoading, - position: position ?? this.position, - bufferedPosition: bufferedPosition ?? this.bufferedPosition, - duration: duration ?? this.duration, - error: clearError ? null : (error ?? this.error), - errorType: clearError ? null : (errorType ?? this.errorType), - seekSupported: seekSupported ?? this.seekSupported, - queue: queue ?? this.queue, - currentIndex: currentIndex ?? this.currentIndex, - shuffle: shuffle ?? this.shuffle, - repeatMode: repeatMode ?? this.repeatMode, - lyrics: clearLyrics ? null : (lyrics ?? this.lyrics), - lyricsLoading: lyricsLoading ?? this.lyricsLoading, - ); - } + const PlaybackState(); } -// ─── Audio Handler (audio_service bridge) ──────────────────────────────────── -class _SpotiFLACAudioHandler extends audio_service.BaseAudioHandler - with audio_service.SeekHandler { - final Future Function() _onPlay; - final Future Function() _onPause; - final Future Function() _onSkipNext; - final Future Function() _onSkipPrevious; - final Future Function() _onStop; - final Future Function(Duration position) _onSeek; - final Future Function() _onToggleLove; - - _SpotiFLACAudioHandler({ - required Future Function() onPlay, - required Future Function() onPause, - required Future Function() onSkipNext, - required Future Function() onSkipPrevious, - required Future Function() onStop, - required Future Function(Duration position) onSeek, - required Future Function() onToggleLove, - }) : _onPlay = onPlay, - _onPause = onPause, - _onSkipNext = onSkipNext, - _onSkipPrevious = onSkipPrevious, - _onStop = onStop, - _onSeek = onSeek, - _onToggleLove = onToggleLove; - - @override - Future customAction(String name, [Map? extras]) async { - if (name == 'toggle_love') { - try { - await _onToggleLove(); - } catch (e) { - _log.e('Notification toggle love failed: $e'); - } - } - return super.customAction(name, extras); - } - - @override - Future play() async { - try { - await _onPlay(); - } catch (e) { - _log.e('Notification play failed: $e'); - } - } - - @override - Future pause() async { - try { - await _onPause(); - } catch (e) { - _log.e('Notification pause failed: $e'); - } - } - - @override - Future seek(Duration position) => _onSeek(position); - - @override - Future stop() async { - try { - await _onStop(); - } catch (e) { - _log.e('Notification stop failed: $e'); - } - } - - @override - Future skipToNext() async { - try { - await _onSkipNext(); - } catch (e) { - _log.e('Notification next failed: $e'); - } - } - - @override - Future skipToPrevious() async { - try { - await _onSkipPrevious(); - } catch (e) { - _log.e('Notification previous failed: $e'); - } - } -} - -// ─── Controller ────────────────────────────────────────────────────────────── class PlaybackController extends Notifier { - static const String _playbackSnapshotKey = 'playback_snapshot_v1'; - static const String _smartQueueModelKey = 'smart_queue_model_v1'; - final AudioPlayer _player = AudioPlayer(); - final List> _subscriptions = []; - Timer? _snapshotSaveTimer; - Timer? _smartQueueModelSaveTimer; - _SpotiFLACAudioHandler? _audioHandler; - var _initialized = false; - static const Duration _prefetchThresholdFloor = Duration(seconds: 12); - static const Duration _prefetchThresholdCeiling = Duration(seconds: 40); - static const Duration _prefetchEarlyKickoffPosition = Duration(seconds: 6); - static const Duration _prefetchRetryCooldown = Duration(seconds: 3); - static const int _maxPrefetchAttemptsPerTrack = 2; - static const int _smartQueueTriggerRemainingTracks = 2; - static const int _smartQueueTargetRemainingTracks = 6; - static const int _smartQueueMaxAutoAddsPerSession = 40; - static const int _smartQueueRecentPlayedWindow = 40; - static const int _smartQueueCandidatePoolLimit = 28; - static const int _smartQueueOfflinePoolMaxItems = 1800; - static const int _smartQueueRelatedArtistsLimit = 3; - static const int _smartQueueMaxAffinityKeys = 160; - static const int _smartQueueSessionWindowSize = 10; - static const int _smartQueueMaxArtistRepeats = 2; - static const int _smartQueueMaxDecadeDriftYears = 20; - static const int _smartQueueMaxTempoJumpBpm = 42; - static const int _smartQueueMaxTempoHints = 720; - static const int _smartQueueMaxSkipStreak = 6; - static const double _smartQueuePrimarySourceRatio = 0.68; - static const String _smartQueueSpotifyExtensionId = 'spotify-web'; - static const Duration _smartQueueRefillCooldown = Duration(seconds: 18); - static const Duration _smartQueueSearchCacheTtl = Duration(minutes: 3); - static const Duration _smartQueueFeedbackMaxAge = Duration(hours: 6); - static const double _smartQueueLearningRate = 0.2; - int? _prefetchingQueueIndex; - int? _lastPrefetchAttemptIndex; - final Map _prefetchAttemptCounts = {}; - final Map _prefetchLastAttemptAt = {}; - final Map> _prefetchLatencyByServiceMs = - >{}; - final Random _smartQueueRandom = Random(); - final List _recentPlayedTrackKeys = []; - final Map - _smartQueuePendingFeedbackByTrack = {}; - final Map _smartQueueSearchCache = - {}; - final Map - _smartQueueRelatedArtistsCache = {}; - final Map _smartQueueWeights = { - 'bias': -0.15, - 'same_artist': 0.06, - 'same_album': 0.04, - 'duration_similarity': 0.8, - 'source_match': 0.18, - 'release_year_similarity': 0.32, - 'artist_affinity': 0.55, - 'source_affinity': 0.3, - 'novelty': 0.65, - 'session_alignment': 0.42, - 'hour_affinity': 0.21, - 'skip_context': 0.29, - 'tempo_continuity': 0.26, - 'year_cohesion': 0.22, - }; - final Map _smartQueueArtistAffinity = {}; - final Map _smartQueueSourceAffinity = {}; - final Map _smartQueueHourAffinity = {}; - final Map _smartQueueTempoHintByTrackKey = {}; - final List<_SmartQueueSessionSignal> _smartQueueSessionSignals = - <_SmartQueueSessionSignal>[]; - bool _smartQueueRefillInFlight = false; - DateTime? _lastSmartQueueRefillAt; - int _smartQueueAutoAddedCount = 0; - int _smartQueueSkipStreak = 0; - _SmartQueueSessionProfile _smartQueueSessionProfile = - const _SmartQueueSessionProfile( - mode: _SmartQueueSessionMode.balanced, - targetDurationSec: 215, - preferredSourceKey: '', - ); - - // Shuffle order: indices into queue - List _shuffleOrder = []; - int _shufflePosition = -1; - int _playRequestEpoch = 0; - Duration? _pendingResumePosition; - int? _pendingResumeIndex; - int _lastProgressSnapshotMs = -1; - int _lyricsGeneration = 0; - AppLifecycleListener? _appLifecycleListener; - @override - PlaybackState build() { - if (!_initialized) { - _initialized = true; - _init(); - ref.onDispose(_disposeInternal); - } - return const PlaybackState(); + PlaybackState build() => const PlaybackState(); + + Future playLocalPath({ + required String path, + required String title, + required String artist, + String album = '', + String coverUrl = '', + Track? track, + }) async { + _log.d('Opening external player for "$title" by $artist: $path'); + await openFile(path); } - void _init() { - unawaited(_configureAudioSession()); - unawaited(_initAudioService()); - unawaited(_restorePlaybackSnapshot()); - unawaited(_restoreSmartQueueModel()); - _appLifecycleListener ??= AppLifecycleListener( - onInactive: () => unawaited(_savePlaybackSnapshot()), - onPause: () => unawaited(_savePlaybackSnapshot()), - onDetach: () => unawaited(_savePlaybackSnapshot()), - onHide: () => unawaited(_savePlaybackSnapshot()), - ); + Future playTrackList(List tracks, {int startIndex = 0}) async { + if (tracks.isEmpty) return; - ref.listen(libraryCollectionsProvider, (previous, next) { - final track = state.currentItem?.track; - if (track != null) { - final wasLoved = previous?.isLoved(track) ?? false; - final isLoved = next.isLoved(track); - if (wasLoved != isLoved) { - _syncServicePlaybackState(_player.processingState, _player.playing); - } + final orderedTracks = _orderedTracksFromStartIndex(tracks, startIndex); + for (final track in orderedTracks) { + final resolvedPath = await _resolveTrackPath(track); + if (resolvedPath == null) { + continue; } - }); - ref.listen(settingsProvider.select((s) => s.playerMode), ( - previous, - next, - ) { - if (previous == next) return; - if (next != 'external') return; - final current = state.currentItem; - if (current == null || !current.isLocal) return; - unawaited(dismissPlayer()); - }); - - _subscriptions.add( - _player.playerStateStream.listen((playerState) { - final playing = playerState.playing; - final processingState = playerState.processingState; - - state = state.copyWith( - isPlaying: playing, - isBuffering: - processingState == ProcessingState.loading || - processingState == ProcessingState.buffering, - isLoading: false, - ); - - // Update audio_service playback state - _syncServicePlaybackState(processingState, playing); - - // Handle track completion - if (processingState == ProcessingState.completed) { - _onTrackCompleted(); - } - }), - ); - - _subscriptions.add( - _player - .createPositionStream( - minPeriod: const Duration(milliseconds: 16), - maxPeriod: const Duration(milliseconds: 33), - ) - .listen((position) { - final hasPendingResume = - state.currentIndex >= 0 && - _pendingResumePositionForIndex(state.currentIndex) != null; - final shouldKeepRestoredPosition = - _player.processingState == ProcessingState.idle && - hasPendingResume && - position == Duration.zero && - state.position > Duration.zero; - if (shouldKeepRestoredPosition) { - return; - } - state = state.copyWith(position: position); - _maybePrefetchNext(position); - _maybeTriggerSmartQueueRefill(position); - _scheduleSnapshotSaveForProgress(position); - }), - ); - - _subscriptions.add( - _player.bufferedPositionStream.listen((bufferedPosition) { - state = state.copyWith(bufferedPosition: bufferedPosition); - }), - ); - - _subscriptions.add( - _player.durationStream.listen((duration) { - final hasPendingResume = - state.currentIndex >= 0 && - _pendingResumePositionForIndex(state.currentIndex) != null; - final shouldKeepRestoredDuration = - _player.processingState == ProcessingState.idle && - hasPendingResume && - duration == null && - state.duration > Duration.zero; - if (shouldKeepRestoredDuration) { - return; - } - final fallbackDuration = _fallbackDurationForItem(state.currentItem); - final resolvedDuration = duration != null && duration > Duration.zero - ? duration - : fallbackDuration; - if (state.duration != resolvedDuration) { - state = state.copyWith(duration: resolvedDuration); - } - - if (duration != null && - duration > Duration.zero && - state.currentIndex >= 0 && - state.currentIndex < state.queue.length) { - final durationMs = duration.inMilliseconds; - final currentItem = state.currentItem; - final updatedCurrentItem = - currentItem != null && currentItem.durationMs != durationMs - ? PlaybackItem( - id: currentItem.id, - title: currentItem.title, - artist: currentItem.artist, - album: currentItem.album, - coverUrl: currentItem.coverUrl, - sourceUri: currentItem.sourceUri, - isLocal: currentItem.isLocal, - service: currentItem.service, - durationMs: durationMs, - format: currentItem.format, - bitDepth: currentItem.bitDepth, - sampleRate: currentItem.sampleRate, - bitrate: currentItem.bitrate, - track: currentItem.track, - ) - : currentItem; - - final queueItem = state.queue[state.currentIndex]; - final shouldUpdateQueueItem = queueItem.durationMs != durationMs; - - if (updatedCurrentItem != currentItem || shouldUpdateQueueItem) { - final updatedQueue = [...state.queue]; - if (shouldUpdateQueueItem) { - updatedQueue[state.currentIndex] = PlaybackItem( - id: queueItem.id, - title: queueItem.title, - artist: queueItem.artist, - album: queueItem.album, - coverUrl: queueItem.coverUrl, - sourceUri: queueItem.sourceUri, - isLocal: queueItem.isLocal, - service: queueItem.service, - durationMs: durationMs, - format: queueItem.format, - bitDepth: queueItem.bitDepth, - sampleRate: queueItem.sampleRate, - bitrate: queueItem.bitrate, - track: queueItem.track, - ); - } - - state = state.copyWith( - currentItem: updatedCurrentItem, - queue: updatedQueue, - ); - unawaited(_savePlaybackSnapshot()); - } - } - - // Update notification duration when known - if (state.currentItem != null && duration != null) { - _updateMediaItemNotification(state.currentItem!); - } - }), - ); - - _subscriptions.add( - _player.playbackEventStream.listen( - (_) {}, - onError: (Object error, StackTrace stackTrace) { - _log.e('Playback error: $error'); - state = state.copyWith( - isLoading: false, - isPlaying: false, - isBuffering: false, - error: error.toString(), - errorType: 'playback_failed', - ); - }, - ), - ); - } - - Future _initAudioService() async { - try { - _audioHandler = - await audio_service.AudioService.init<_SpotiFLACAudioHandler>( - builder: () => _SpotiFLACAudioHandler( - onPlay: _handleNotificationPlay, - onPause: _handleNotificationPause, - onSkipNext: _handleNotificationNext, - onSkipPrevious: _handleNotificationPrevious, - onStop: _handleNotificationStop, - onSeek: seek, - onToggleLove: _handleNotificationToggleLove, - ), - config: const audio_service.AudioServiceConfig( - androidNotificationChannelId: 'com.zarz.spotiflac.playback', - androidNotificationChannelName: 'Music Playback', - androidNotificationOngoing: true, - androidShowNotificationBadge: true, - androidStopForegroundOnPause: true, - ), - ); - } catch (e) { - _log.w('AudioService init failed: $e'); - } - } - - Future _configureAudioSession() async { - try { - final session = await AudioSession.instance; - await session.configure(const AudioSessionConfiguration.music()); - } catch (e) { - _log.w('Audio session configuration failed: $e'); - } - } - - Future _handleNotificationPlay() async { - if (_player.processingState == ProcessingState.idle && - state.queue.isNotEmpty) { - final resumeIndex = state.currentIndex < 0 ? 0 : state.currentIndex; - await _playQueueIndex(resumeIndex); + _log.d( + 'Opening first available external track for list playback: ' + '"${track.name}" by ${track.artistName} -> $resolvedPath', + ); + await openFile(resolvedPath); return; } - await _player.play(); + + throw Exception( + 'No local audio file is available to open. Download the track first.', + ); } - Future _handleNotificationPause() async { - await _player.pause(); - } - - Future _handleNotificationNext() async { - await skipNext(); - } - - Future _handleNotificationPrevious() async { - await skipPrevious(); - } - - Future _handleNotificationStop() async { - await stop(); - } - - Future _handleNotificationToggleLove() async { - final track = state.currentItem?.track; - if (track != null) { - await ref.read(libraryCollectionsProvider.notifier).toggleLoved(track); - } - } - - void _syncServicePlaybackState( - ProcessingState processingState, - bool playing, - ) { - final handler = _audioHandler; - if (handler == null) return; - - audio_service.AudioProcessingState serviceState; - switch (processingState) { - case ProcessingState.idle: - serviceState = audio_service.AudioProcessingState.idle; - case ProcessingState.loading: - serviceState = audio_service.AudioProcessingState.loading; - case ProcessingState.buffering: - serviceState = audio_service.AudioProcessingState.buffering; - case ProcessingState.ready: - serviceState = audio_service.AudioProcessingState.ready; - case ProcessingState.completed: - serviceState = audio_service.AudioProcessingState.completed; + List _orderedTracksFromStartIndex(List tracks, int startIndex) { + final safeStart = startIndex.clamp(0, tracks.length - 1); + if (safeStart == 0) { + return List.from(tracks, growable: false); } - final track = state.currentItem?.track; - final isLoved = - track != null && ref.read(libraryCollectionsProvider).isLoved(track); - - final controls = [ - audio_service.MediaControl.custom( - androidIcon: isLoved - ? 'drawable/ic_stat_favorite' - : 'drawable/ic_stat_favorite_border', - label: isLoved ? 'Unlove' : 'Love', - name: 'toggle_love', - ), - audio_service.MediaControl.skipToPrevious, - if (playing) - audio_service.MediaControl.pause - else - audio_service.MediaControl.play, - audio_service.MediaControl.skipToNext, + return [ + ...tracks.sublist(safeStart), + ...tracks.sublist(0, safeStart), ]; - - final systemActions = {}; - if (state.seekSupported) { - systemActions.addAll(const { - audio_service.MediaAction.seek, - audio_service.MediaAction.seekForward, - audio_service.MediaAction.seekBackward, - }); - } - - handler.playbackState.add( - audio_service.PlaybackState( - controls: controls, - systemActions: systemActions, - androidCompactActionIndices: _compactIndices(controls), - processingState: serviceState, - playing: playing, - updatePosition: _player.position, - bufferedPosition: _player.bufferedPosition, - speed: _player.speed, - ), - ); } - List _compactIndices(List controls) { - // Always show prev(0), play/pause(1), next(2) in compact notification - final count = controls.length; - if (count >= 3) return const [0, 1, 2]; - return List.generate(count, (i) => i); - } - - Uri? _resolveMediaArtUri(String coverUrl) { - final raw = coverUrl.trim(); - if (raw.isEmpty) return null; - - if (raw.startsWith('http://') || - raw.startsWith('https://') || - raw.startsWith('file://') || - raw.startsWith('content://')) { - return Uri.tryParse(raw); - } - - // Treat bare local paths as file URIs so notification can load local art. - return Uri.file(raw); - } - - void _updateMediaItemNotification(PlaybackItem item) { - final handler = _audioHandler; - if (handler == null) return; - - handler.mediaItem.add( - audio_service.MediaItem( - id: item.id, - album: item.album, - title: item.title, - artist: item.artist, - duration: state.duration, - artUri: _resolveMediaArtUri(item.coverUrl), - extras: { - if ((item.track?.isrc ?? '').trim().isNotEmpty) - 'isrc': item.track!.isrc!.trim(), - 'trackName': item.title, - 'artistName': item.artist, - if (item.album.isNotEmpty) 'albumName': item.album, - if (item.coverUrl.isNotEmpty) 'coverUrl': item.coverUrl, - if (item.sourceUri.isNotEmpty) 'sourceUri': item.sourceUri, - 'isLocal': item.isLocal, - if (item.service.isNotEmpty) 'service': item.service, - if (item.format.isNotEmpty) 'format': item.format, - }, - ), - ); - } - - // ─── Track completion ──────────────────────────────────────────────────── - void _onTrackCompleted() { - _learnFromCurrentTrackOutcome(completedNaturally: true); - final completedItem = state.currentItem; - if (completedItem != null) { - _rememberRecentPlayed(completedItem); - } - - if (state.repeatMode == RepeatMode.one) { - // Replay current track - unawaited(_restartCurrentTrack(playAfterSeek: true)); - return; - } - - final nextIndex = _resolveNextIndex(); - if (nextIndex != null) { - unawaited(_playQueueIndex(nextIndex)); - } else { - unawaited(_handleQueueExhausted()); - } - } - - Future _handleQueueExhausted() async { - final added = await _autoRefillSmartQueue(force: true); - if (added > 0) { - final nextIndex = _resolveNextIndex(); - if (nextIndex != null) { - await _playQueueIndex(nextIndex); - return; - } - } - - // Queue exhausted - state = state.copyWith(isPlaying: false, position: Duration.zero); - _syncServicePlaybackState(ProcessingState.completed, false); - } - - Future _restartCurrentTrack({bool playAfterSeek = false}) async { - try { - if (state.seekSupported) { - await _player.seek(Duration.zero); - if (playAfterSeek) { - await _player.play(); - } - return; - } - - final index = state.currentIndex; - if (index >= 0 && index < state.queue.length) { - await _playQueueIndex(index); - return; - } - - _setPlaybackError( - 'Failed to restart track from the beginning.', - type: 'playback_failed', - ); - } catch (e) { - _log.e('Failed to restart current track: $e'); - _setPlaybackError('Failed to restart track: $e', type: 'playback_failed'); - } - } - - int? _resolveNextIndex() { - if (state.queue.isEmpty) return null; - - if (state.shuffle) { - _shufflePosition++; - if (_shufflePosition < _shuffleOrder.length) { - return _shuffleOrder[_shufflePosition]; - } - // Shuffle exhausted - if (state.repeatMode == RepeatMode.all) { - _regenerateShuffleOrder(); - _shufflePosition = 0; - return _shuffleOrder.isNotEmpty ? _shuffleOrder[0] : null; - } - return null; - } - - final next = state.currentIndex + 1; - if (next < state.queue.length) return next; - if (state.repeatMode == RepeatMode.all) return 0; - return null; - } - - int? _resolvePreviousIndex() { - if (state.queue.isEmpty) return null; - - if (state.shuffle) { - if (_shufflePosition > 0) { - _shufflePosition--; - return _shuffleOrder[_shufflePosition]; - } - return null; - } - - final prev = state.currentIndex - 1; - if (prev >= 0) return prev; - if (state.repeatMode == RepeatMode.all) return state.queue.length - 1; - return null; - } - - void _regenerateShuffleOrder() { - final rng = Random(); - _shuffleOrder = List.generate(state.queue.length, (i) => i)..shuffle(rng); - } - - void _regenerateShuffleOrderPreservingCurrentProgress() { - final queueLength = state.queue.length; - if (queueLength == 0) { - _shuffleOrder = []; - _shufflePosition = -1; - return; - } - - final currentIndex = state.currentIndex; - if (currentIndex < 0 || currentIndex >= queueLength) { - _regenerateShuffleOrder(); - _shufflePosition = -1; - return; - } - - final rng = Random(); - final playedAndCurrent = List.generate(currentIndex + 1, (i) => i); - final upcoming = List.generate( - queueLength - currentIndex - 1, - (i) => currentIndex + i + 1, - )..shuffle(rng); - - _shuffleOrder = [...playedAndCurrent, ...upcoming]; - _shufflePosition = currentIndex; - } - - List getQueueDisplayOrder() { - if (state.queue.isEmpty) return const []; - - if (!state.shuffle) { - return List.generate(state.queue.length, (i) => i); - } - - final seen = {}; - final normalized = []; - for (final idx in _shuffleOrder) { - if (idx >= 0 && idx < state.queue.length && seen.add(idx)) { - normalized.add(idx); - } - } - for (var i = 0; i < state.queue.length; i++) { - if (seen.add(i)) { - normalized.add(i); - } - } - return normalized; - } - - int getCurrentDisplayQueuePosition({List? displayOrder}) { - final order = displayOrder ?? getQueueDisplayOrder(); - if (order.isEmpty) return -1; - - if (!state.shuffle) { - if (state.currentIndex < 0 || state.currentIndex >= order.length) { - return 0; - } - return state.currentIndex; - } - - final position = order.indexOf(state.currentIndex); - if (position >= 0) return position; - return 0; - } - - int _startNewPlayRequest() { - _playRequestEpoch++; - return _playRequestEpoch; - } - - void _resetPrefetchCycleState() { - _prefetchingQueueIndex = null; - _lastPrefetchAttemptIndex = null; - _prefetchAttemptCounts.clear(); - _prefetchLastAttemptAt.clear(); - } - - bool _isPlayRequestCurrent(int epoch) => epoch == _playRequestEpoch; - - void _clearLyricsForTrackChange({ - PlaybackItem? upcomingItem, - bool updateCurrentItem = true, - }) { - // Invalidate any in-flight lyrics fetch from previous track. - _lyricsGeneration++; - state = state.copyWith( - clearCurrentItem: !updateCurrentItem, - currentItem: updateCurrentItem - ? (upcomingItem ?? state.currentItem) - : null, - lyricsLoading: false, - clearLyrics: true, - ); - } - - Map _serializePlaybackItem(PlaybackItem item) => { - 'id': item.id, - 'title': item.title, - 'artist': item.artist, - 'album': item.album, - 'coverUrl': item.coverUrl, - 'sourceUri': item.sourceUri, - 'isLocal': item.isLocal, - 'service': item.service, - 'durationMs': item.durationMs, - 'format': item.format, - 'bitDepth': item.bitDepth, - 'sampleRate': item.sampleRate, - 'bitrate': item.bitrate, - if (item.track != null) 'track': item.track!.toJson(), - }; - - PlaybackItem? _deserializePlaybackItem(Map? json) { - if (json == null) return null; - final id = (json['id'] as String?)?.trim() ?? ''; - if (id.isEmpty) return null; - - Track? track; - try { - final trackJson = json['track']; - if (trackJson is Map) { - track = Track.fromJson(Map.from(trackJson)); - } - } catch (_) {} - - return PlaybackItem( - id: id, - title: (json['title'] as String?) ?? '', - artist: (json['artist'] as String?) ?? '', - album: (json['album'] as String?) ?? '', - coverUrl: (json['coverUrl'] as String?) ?? '', - sourceUri: (json['sourceUri'] as String?) ?? '', - isLocal: json['isLocal'] == true, - service: (json['service'] as String?) ?? '', - durationMs: (json['durationMs'] as num?)?.toInt() ?? 0, - format: (json['format'] as String?) ?? '', - bitDepth: (json['bitDepth'] as num?)?.toInt() ?? 0, - sampleRate: (json['sampleRate'] as num?)?.toInt() ?? 0, - bitrate: (json['bitrate'] as num?)?.toInt() ?? 0, - track: track, - ); - } - - Future _savePlaybackSnapshot() async { - try { - final prefs = await SharedPreferences.getInstance(); - final payload = { - 'queue': state.queue - .map(_serializePlaybackItem) - .toList(growable: false), - 'currentIndex': state.currentIndex, - 'positionMs': state.position.inMilliseconds, - 'durationMs': state.duration > Duration.zero - ? state.duration.inMilliseconds - : (state.currentItem?.durationMs ?? 0), - 'shuffle': state.shuffle, - 'repeatMode': state.repeatMode.index, - }; - await prefs.setString(_playbackSnapshotKey, jsonEncode(payload)); - } catch (e) { - _log.w('Failed to save playback snapshot: $e'); - } - } - - Future _restorePlaybackSnapshot() async { - try { - final prefs = await SharedPreferences.getInstance(); - final raw = prefs.getString(_playbackSnapshotKey); - if (raw == null || raw.isEmpty) return; - - final decoded = jsonDecode(raw); - if (decoded is! Map) return; - final payload = Map.from(decoded); - - final queueRaw = payload['queue']; - final restoredQueue = []; - if (queueRaw is List) { - for (final entry in queueRaw) { - if (entry is! Map) continue; - final item = _deserializePlaybackItem( - Map.from(entry), - ); - if (item != null) restoredQueue.add(item); - } - } - if (restoredQueue.isEmpty) return; - - var restoredIndex = (payload['currentIndex'] as num?)?.toInt() ?? 0; - restoredIndex = restoredIndex.clamp(0, restoredQueue.length - 1).toInt(); - final restoredPositionMs = (payload['positionMs'] as num?)?.toInt() ?? 0; - final restoredDurationMs = (payload['durationMs'] as num?)?.toInt() ?? 0; - final restoredRepeatIndex = (payload['repeatMode'] as num?)?.toInt() ?? 0; - final restoredRepeatMode = - restoredRepeatIndex >= 0 && - restoredRepeatIndex < RepeatMode.values.length - ? RepeatMode.values[restoredRepeatIndex] - : RepeatMode.off; - - state = state.copyWith( - queue: restoredQueue, - currentIndex: restoredIndex, - currentItem: restoredQueue[restoredIndex], - isPlaying: false, - isBuffering: false, - isLoading: false, - position: Duration(milliseconds: restoredPositionMs), - bufferedPosition: Duration.zero, - duration: restoredDurationMs > 0 - ? Duration(milliseconds: restoredDurationMs) - : (restoredQueue[restoredIndex].durationMs > 0 - ? Duration( - milliseconds: restoredQueue[restoredIndex].durationMs, - ) - : Duration.zero), - shuffle: payload['shuffle'] == true, - repeatMode: restoredRepeatMode, - clearError: true, - ); - _pendingResumePosition = restoredPositionMs > 0 - ? Duration(milliseconds: restoredPositionMs) - : null; - _pendingResumeIndex = restoredPositionMs > 0 ? restoredIndex : null; - _lastProgressSnapshotMs = restoredPositionMs; - - if (state.shuffle) { - _regenerateShuffleOrder(); - _shufflePosition = _shuffleOrder.indexOf(state.currentIndex); - if (_shufflePosition < 0) _shufflePosition = 0; - } else { - _shuffleOrder = []; - _shufflePosition = -1; - } - } catch (e) { - _log.w('Failed to restore playback snapshot: $e'); - } - } - - Future _restoreSmartQueueModel() async { - try { - final prefs = await SharedPreferences.getInstance(); - final raw = prefs.getString(_smartQueueModelKey); - if (raw == null || raw.isEmpty) return; - - final decoded = jsonDecode(raw); - if (decoded is! Map) return; - final payload = Map.from(decoded); - - final weightsRaw = payload['weights']; - if (weightsRaw is Map) { - for (final entry in weightsRaw.entries) { - final key = entry.key.toString(); - final value = (entry.value as num?)?.toDouble(); - if (value == null) continue; - _smartQueueWeights[key] = value; - } - } - - _smartQueueArtistAffinity.clear(); - final artistRaw = payload['artistAffinity']; - if (artistRaw is Map) { - for (final entry in artistRaw.entries) { - final key = entry.key.toString().trim().toLowerCase(); - if (key.isEmpty) continue; - final value = (entry.value as num?)?.toDouble(); - if (value == null) continue; - _smartQueueArtistAffinity[key] = value.clamp(-1.0, 1.0); - } - } - - _smartQueueSourceAffinity.clear(); - final sourceRaw = payload['sourceAffinity']; - if (sourceRaw is Map) { - for (final entry in sourceRaw.entries) { - final key = entry.key.toString().trim().toLowerCase(); - if (key.isEmpty) continue; - final value = (entry.value as num?)?.toDouble(); - if (value == null) continue; - _smartQueueSourceAffinity[key] = value.clamp(-1.0, 1.0); - } - } - - _smartQueueHourAffinity.clear(); - final hourRaw = payload['hourAffinity']; - if (hourRaw is Map) { - for (final entry in hourRaw.entries) { - final key = entry.key.toString().trim().toLowerCase(); - if (key.isEmpty) continue; - final value = (entry.value as num?)?.toDouble(); - if (value == null) continue; - _smartQueueHourAffinity[key] = value.clamp(-1.0, 1.0); - } - } - } catch (e) { - _log.w('Failed to restore smart queue model: $e'); - } - } - - void _scheduleSmartQueueModelSave() { - _smartQueueModelSaveTimer?.cancel(); - _smartQueueModelSaveTimer = Timer(const Duration(seconds: 2), () { - unawaited(_persistSmartQueueModel()); - }); - } - - Future _persistSmartQueueModel() async { - try { - final prefs = await SharedPreferences.getInstance(); - final payload = { - 'weights': _smartQueueWeights, - 'artistAffinity': _smartQueueArtistAffinity, - 'sourceAffinity': _smartQueueSourceAffinity, - 'hourAffinity': _smartQueueHourAffinity, - }; - await prefs.setString(_smartQueueModelKey, jsonEncode(payload)); - } catch (e) { - _log.w('Failed to save smart queue model: $e'); - } - } - - PlaybackItem _buildQueueItemFromTrack(Track track) { + Future _resolveTrackPath(Track track) async { final localState = ref.read(localLibraryProvider); final historyState = ref.read(downloadHistoryProvider); - final localItem = _findLocalLibraryItemForTrack(track, localState); + final historyNotifier = ref.read(downloadHistoryProvider.notifier); - if (localItem != null && localItem.filePath.isNotEmpty) { - final localUri = _uriFromPath(localItem.filePath); - final localDurationMs = - localItem.duration != null && localItem.duration! > 0 - ? localItem.duration! * 1000 - : _trackDurationMs(track); - return PlaybackItem( - id: localItem.id, - title: localItem.trackName, - artist: localItem.artistName, - album: localItem.albumName, - coverUrl: localItem.coverPath ?? track.coverUrl ?? '', - sourceUri: localUri.toString(), - isLocal: true, - service: 'offline', - durationMs: localDurationMs, - track: track, - ); + final localItem = _findLocalLibraryItemForTrack(track, localState); + if (localItem != null && await fileExists(localItem.filePath)) { + return localItem.filePath; } final historyItem = _findDownloadHistoryItemForTrack(track, historyState); - if (historyItem != null && historyItem.filePath.isNotEmpty) { - final localUri = _uriFromPath(historyItem.filePath); - final localDurationMs = - historyItem.duration != null && historyItem.duration! > 0 - ? historyItem.duration! * 1000 - : _trackDurationMs(track); - final playbackId = (historyItem.spotifyId ?? '').trim().isNotEmpty - ? historyItem.spotifyId!.trim() - : historyItem.id; - return PlaybackItem( - id: playbackId, - title: historyItem.trackName, - artist: historyItem.artistName, - album: historyItem.albumName, - coverUrl: historyItem.coverUrl ?? track.coverUrl ?? '', - sourceUri: localUri.toString(), - isLocal: true, - service: 'offline', - durationMs: localDurationMs, - track: track, - ); + if (historyItem != null) { + if (await fileExists(historyItem.filePath)) { + return historyItem.filePath; + } + historyNotifier.removeFromHistory(historyItem.id); } - return PlaybackItem( - id: track.id, - title: track.name, - artist: track.artistName, - album: track.albumName, - coverUrl: track.coverUrl ?? '', - sourceUri: '', - durationMs: _trackDurationMs(track), - track: track, - ); + return null; } LocalLibraryItem? _findLocalLibraryItemForTrack( @@ -1231,7 +100,9 @@ class PlaybackController extends Notifier { final isrc = track.isrc?.trim(); if (isrc != null && isrc.isNotEmpty) { final byIsrc = localState.getByIsrc(isrc); - if (byIsrc != null) return byIsrc; + if (byIsrc != null) { + return byIsrc; + } } return localState.findByTrackAndArtist(track.name, track.artistName); @@ -1243,13 +114,17 @@ class PlaybackController extends Notifier { ) { for (final candidateId in _spotifyIdLookupCandidates(track.id)) { final bySpotifyId = historyState.getBySpotifyId(candidateId); - if (bySpotifyId != null) return bySpotifyId; + if (bySpotifyId != null) { + return bySpotifyId; + } } final isrc = track.isrc?.trim(); if (isrc != null && isrc.isNotEmpty) { final byIsrc = historyState.getByIsrc(isrc); - if (byIsrc != null) return byIsrc; + if (byIsrc != null) { + return byIsrc; + } } return historyState.findByTrackAndArtist(track.name, track.artistName); @@ -1257,13 +132,17 @@ class PlaybackController extends Notifier { List _spotifyIdLookupCandidates(String rawId) { final trimmed = rawId.trim(); - if (trimmed.isEmpty) return const []; + if (trimmed.isEmpty) { + return const []; + } final candidates = {trimmed}; final lowered = trimmed.toLowerCase(); if (lowered.startsWith('spotify:track:')) { final compact = trimmed.split(':').last.trim(); - if (compact.isNotEmpty) candidates.add(compact); + if (compact.isNotEmpty) { + candidates.add(compact); + } } else if (!trimmed.contains(':')) { candidates.add('spotify:track:$trimmed'); } @@ -1281,3258 +160,6 @@ class PlaybackController extends Notifier { return candidates.toList(growable: false); } - - int _trackDurationMs(Track track) { - if (track.duration <= 0) return 0; - return track.duration * 1000; - } - - Duration _fallbackDurationForItem(PlaybackItem? item) { - final ms = item?.durationMs ?? 0; - if (ms <= 0) return Duration.zero; - return Duration(milliseconds: ms); - } - - // ─── Public: play local file ───────────────────────────────────────────── - Future playLocalPath({ - required String path, - required String title, - required String artist, - String album = '', - String coverUrl = '', - Track? track, - }) async { - final requestEpoch = _startNewPlayRequest(); - _resetPrefetchCycleState(); - _resetSmartQueueSessionState(clearRecent: true); - _pendingResumePosition = null; - _pendingResumeIndex = null; - final uri = _uriFromPath(path); - final fallbackTrack = Track( - id: path, - name: title, - artistName: artist, - albumName: album, - coverUrl: coverUrl.isNotEmpty ? coverUrl : null, - duration: 0, - source: 'local', - ); - final item = PlaybackItem( - id: path, - title: title, - artist: artist, - album: album, - coverUrl: coverUrl, - sourceUri: uri.toString(), - isLocal: true, - service: 'offline', - track: track ?? fallbackTrack, - ); - - final routeToExternal = _shouldRouteToExternalPlayer(item); - _clearLyricsForTrackChange( - upcomingItem: item, - updateCurrentItem: !routeToExternal, - ); - - if (routeToExternal) { - state = state.copyWith( - clearCurrentItem: true, - queue: const [], - currentIndex: -1, - isLoading: false, - isBuffering: false, - isPlaying: false, - seekSupported: false, - position: Duration.zero, - bufferedPosition: Duration.zero, - duration: Duration.zero, - clearError: true, - ); - unawaited(_savePlaybackSnapshot()); - await _setSourceAndPlay(uri, item, expectedRequestEpoch: requestEpoch); - return; - } - - // Replacing single-track playback should also replace queue to avoid stale UI. - state = state.copyWith( - seekSupported: true, - clearError: true, - queue: [item], - currentIndex: 0, - ); - unawaited(_savePlaybackSnapshot()); - - if (state.shuffle) { - _regenerateShuffleOrder(); - _shufflePosition = _shuffleOrder.indexOf(0); - if (_shufflePosition < 0) _shufflePosition = 0; - } else { - _shuffleOrder = []; - _shufflePosition = -1; - } - - await _setSourceAndPlay(uri, item, expectedRequestEpoch: requestEpoch); - } - - // ─── Public: play a list of tracks (set queue) ─────────────────────────── - Future playTrackList(List tracks, {int startIndex = 0}) async { - if (tracks.isEmpty) return; - _resetPrefetchCycleState(); - _resetSmartQueueSessionState(clearRecent: true); - - final items = tracks.map(_buildQueueItemFromTrack).toList(growable: false); - _pendingResumePosition = null; - _pendingResumeIndex = null; - - state = state.copyWith( - queue: items, - currentIndex: startIndex.clamp(0, items.length - 1), - ); - unawaited(_savePlaybackSnapshot()); - - if (state.shuffle) { - _regenerateShuffleOrder(); - // Place the starting track at the front of the shuffle order - // so playback begins from it, then continues in random order. - final pos = _shuffleOrder.indexOf(state.currentIndex); - if (pos > 0) { - _shuffleOrder.removeAt(pos); - _shuffleOrder.insert(0, state.currentIndex); - } - _shufflePosition = 0; - } - - await _playQueueIndex(state.currentIndex); - } - - // ─── Public: add track to queue ────────────────────────────────────────── - void addToQueue(Track track) { - final item = _buildQueueItemFromTrack(track); - - final newQueue = [...state.queue, item]; - state = state.copyWith(queue: newQueue); - unawaited(_savePlaybackSnapshot()); - - if (state.shuffle) { - _shuffleOrder.add(newQueue.length - 1); - } - } - - // ─── Public: remove from queue ─────────────────────────────────────────── - void removeFromQueue(int index) { - if (index < 0 || index >= state.queue.length) return; - - final newQueue = [...state.queue]..removeAt(index); - var newIndex = state.currentIndex; - if (index < newIndex) { - newIndex--; - } else if (index == newIndex) { - newIndex = newIndex.clamp(0, newQueue.length - 1); - } - - state = state.copyWith(queue: newQueue, currentIndex: newIndex); - unawaited(_savePlaybackSnapshot()); - if (state.shuffle) _regenerateShuffleOrder(); - } - - // ─── Public: clear queue ───────────────────────────────────────────────── - void clearQueue() { - _resetPrefetchCycleState(); - _resetSmartQueueSessionState(clearRecent: false); - _lastProgressSnapshotMs = -1; - state = state.copyWith(queue: [], currentIndex: -1); - unawaited(_savePlaybackSnapshot()); - _shuffleOrder = []; - _shufflePosition = -1; - _pendingResumePosition = null; - _pendingResumeIndex = null; - } - - // ─── Public: jump to specific queue index ──────────────────────────────── - Future playQueueIndex(int index) async { - if (index < 0 || index >= state.queue.length) return; - if (index == state.currentIndex) return; - await _playQueueIndex(index); - } - - // ─── Public: skip next / previous ──────────────────────────────────────── - Future skipNext() async { - _learnFromCurrentTrackOutcome(completedNaturally: false); - final nextIndex = _resolveNextIndex(); - if (nextIndex != null) { - await _playQueueIndex(nextIndex); - } - } - - Future skipPrevious() async { - // If > 3 seconds into track, restart instead of going previous - if (_player.position.inSeconds > 3) { - await _restartCurrentTrack(); - return; - } - - final prevIndex = _resolvePreviousIndex(); - if (prevIndex != null) { - await _playQueueIndex(prevIndex); - } else { - await _restartCurrentTrack(); - } - } - - // ─── Public: toggle shuffle ────────────────────────────────────────────── - void toggleShuffle() { - final newShuffle = !state.shuffle; - state = state.copyWith(shuffle: newShuffle); - - if (newShuffle) { - _regenerateShuffleOrderPreservingCurrentProgress(); - } else { - _shuffleOrder = []; - _shufflePosition = -1; - } - unawaited(_savePlaybackSnapshot()); - } - - // ─── Public: cycle repeat mode ─────────────────────────────────────────── - void cycleRepeatMode() { - final modes = RepeatMode.values; - final next = (state.repeatMode.index + 1) % modes.length; - state = state.copyWith(repeatMode: modes[next]); - } - - // ─── Public: toggle play/pause ─────────────────────────────────────────── - Future togglePlayPause() async { - if (_player.playing) { - await _player.pause(); - } else { - if (_player.processingState == ProcessingState.completed) { - final hasCurrentTrack = - state.currentIndex >= 0 || state.currentItem != null; - if (hasCurrentTrack) { - await _restartCurrentTrack(playAfterSeek: true); - return; - } - } - - if (_player.processingState == ProcessingState.idle && - state.queue.isNotEmpty) { - final resumeIndex = state.currentIndex < 0 ? 0 : state.currentIndex; - await _playQueueIndex(resumeIndex); - return; - } - await _player.play(); - } - } - - // ─── Public: seek ──────────────────────────────────────────────────────── - Future seek(Duration position) async { - if (!state.seekSupported) { - _setPlaybackError( - 'Seeking is not supported for this stream.', - type: 'seek_not_supported', - ); - return; - } - await _player.seek(position); - } - - // ─── Public: stop ──────────────────────────────────────────────────────── - Future stop() async { - _startNewPlayRequest(); - _lyricsGeneration++; - final lastKnownPosition = state.position; - final lastKnownDuration = state.duration; - await FFmpegService.stopLiveDecryptedStream(); - await FFmpegService.stopNativeDashManifestPlayback(); - await FFmpegService.cleanupInactivePreparedNativeDashManifests(); - await _player.stop(); - _resetPrefetchCycleState(); - _lastProgressSnapshotMs = lastKnownPosition.inMilliseconds; - _audioHandler?.playbackState.add( - audio_service.PlaybackState( - processingState: audio_service.AudioProcessingState.idle, - playing: false, - ), - ); - _audioHandler?.mediaItem.add(null); - - state = state.copyWith( - isPlaying: false, - isBuffering: false, - isLoading: false, - seekSupported: true, - position: lastKnownPosition, - bufferedPosition: Duration.zero, - duration: lastKnownDuration, - clearError: true, - clearLyrics: true, - ); - unawaited(_savePlaybackSnapshot()); - } - - /// Stops playback and dismisses the mini player UI entirely. - Future dismissPlayer() async { - await stop(); - _pendingResumePosition = null; - _pendingResumeIndex = null; - _lastProgressSnapshotMs = -1; - - state = state.copyWith( - clearCurrentItem: true, - queue: const [], - currentIndex: -1, - position: Duration.zero, - bufferedPosition: Duration.zero, - duration: Duration.zero, - clearError: true, - clearLyrics: true, - lyricsLoading: false, - ); - - try { - final prefs = await SharedPreferences.getInstance(); - await prefs.remove(_playbackSnapshotKey); - } catch (e) { - _log.w('Failed to clear playback snapshot on dismiss: $e'); - } - } - - void clearError() { - state = state.copyWith(clearError: true); - } - - // ─── Internal ──────────────────────────────────────────────────────────── - - Future _playQueueIndex(int index) async { - if (index < 0 || index >= state.queue.length) return; - - final previousItem = state.currentItem; - final requestEpoch = _startNewPlayRequest(); - _resetPrefetchCycleState(); - final pendingResumePosition = _pendingResumePositionForIndex(index); - final item = state.queue[index]; - if (previousItem != null && - _trackKeyFromPlaybackItem(previousItem) != - _trackKeyFromPlaybackItem(item)) { - _rememberRecentPlayed(previousItem); - } - final routeToExternal = _shouldRouteToExternalPlayer(item); - _clearLyricsForTrackChange( - upcomingItem: item, - updateCurrentItem: !routeToExternal, - ); - state = state.copyWith( - currentIndex: index, - clearCurrentItem: routeToExternal, - currentItem: routeToExternal ? null : item, - isLoading: routeToExternal ? false : true, - isBuffering: routeToExternal ? false : true, - isPlaying: false, - seekSupported: routeToExternal - ? false - : _inferSeekSupportedForQueueItem(item), - position: routeToExternal - ? Duration.zero - : (pendingResumePosition != null && - pendingResumePosition > Duration.zero - ? pendingResumePosition - : Duration.zero), - bufferedPosition: Duration.zero, - duration: routeToExternal - ? Duration.zero - : _fallbackDurationForItem(item), - clearError: true, - ); - await _savePlaybackSnapshot(); - - if (item.sourceUri.isEmpty) { - final skipped = await _handleQueueItemPlaybackFailure( - failedIndex: index, - expectedRequestEpoch: requestEpoch, - error: Exception('Track is not available locally. Download it first.'), - fallbackType: 'source_missing', - ); - if (skipped) { - return; - } - return; - } - - // Already have a URI - if (item.sourceUri.isNotEmpty) { - final uri = _uriFromPath(item.sourceUri); - try { - await _setSourceAndPlay( - uri, - item, - initialPosition: pendingResumePosition, - expectedRequestEpoch: requestEpoch, - ); - if (!_isPlayRequestCurrent(requestEpoch) || - (!routeToExternal && state.currentIndex != index)) { - return; - } - _clearPendingResumeForIndex(index); - } catch (e) { - if (!_isPlayRequestCurrent(requestEpoch)) return; - final skipped = await _handleQueueItemPlaybackFailure( - failedIndex: index, - expectedRequestEpoch: requestEpoch, - error: e, - fallbackType: 'playback_failed', - ); - if (skipped) { - return; - } - } - } - } - - Future _setSourceAndPlay( - Uri uri, - PlaybackItem item, { - Duration? initialPosition, - int? expectedRequestEpoch, - }) async { - if (expectedRequestEpoch != null && - !_isPlayRequestCurrent(expectedRequestEpoch)) { - return; - } - - final handledByExternal = await _tryPlayWithExternalPlayerIfConfigured( - uri: uri, - item: item, - expectedRequestEpoch: expectedRequestEpoch, - ); - if (handledByExternal) { - return; - } - - final sourceUrl = uri.toString(); - await FFmpegService.activatePreparedNativeDashManifest(sourceUrl); - if (!FFmpegService.isActiveLiveDecryptedUrl(sourceUrl)) { - await FFmpegService.stopLiveDecryptedStream(); - } - if (!FFmpegService.isActiveNativeDashManifestUrl(sourceUrl)) { - await FFmpegService.stopNativeDashManifestPlayback(); - } - - final startPosition = - initialPosition != null && initialPosition > Duration.zero - ? initialPosition - : Duration.zero; - state = state.copyWith( - currentItem: item, - isLoading: true, - isBuffering: true, - isPlaying: false, - position: startPosition, - bufferedPosition: Duration.zero, - duration: _fallbackDurationForItem(item), - clearError: true, - ); - unawaited(_savePlaybackSnapshot()); - - _updateMediaItemNotification(item); - - try { - if (expectedRequestEpoch != null && - !_isPlayRequestCurrent(expectedRequestEpoch)) { - return; - } - final isDirectLocalFile = uri.scheme == 'file'; - if (isDirectLocalFile) { - final filePath = uri.toFilePath(); - if (startPosition > Duration.zero) { - await _player.setFilePath(filePath, initialPosition: startPosition); - } else { - await _player.setFilePath(filePath); - } - } else { - if (startPosition > Duration.zero) { - await _player.setAudioSource( - AudioSource.uri(uri), - initialPosition: startPosition, - ); - } else { - await _player.setAudioSource(AudioSource.uri(uri)); - } - } - if (expectedRequestEpoch != null && - !_isPlayRequestCurrent(expectedRequestEpoch)) { - return; - } - await _player.play(); - } catch (e) { - if (expectedRequestEpoch != null && - !_isPlayRequestCurrent(expectedRequestEpoch)) { - return; - } - if (FFmpegService.isActiveLiveDecryptedUrl(sourceUrl)) { - await FFmpegService.stopLiveDecryptedStream(); - } - if (FFmpegService.isActiveNativeDashManifestUrl(sourceUrl)) { - await FFmpegService.stopNativeDashManifestPlayback(); - } - _log.e('Failed to play source: $e'); - _setPlaybackError(e.toString(), type: 'playback_failed'); - rethrow; - } - } - - Future _tryPlayWithExternalPlayerIfConfigured({ - required Uri uri, - required PlaybackItem item, - int? expectedRequestEpoch, - }) async { - if (!_shouldRouteToExternalPlayer(item)) return false; - - final externalPath = _externalPathFromPlaybackUri(uri); - if (externalPath == null || externalPath.isEmpty) return false; - - _log.d('Opening with external player: $externalPath'); - _updateMediaItemNotification(item); - - try { - await FFmpegService.stopLiveDecryptedStream(); - await FFmpegService.stopNativeDashManifestPlayback(); - await _player.stop(); - await openFile(externalPath); - if (expectedRequestEpoch != null && - !_isPlayRequestCurrent(expectedRequestEpoch)) { - return true; - } - state = state.copyWith( - clearCurrentItem: true, - queue: const [], - currentIndex: -1, - isLoading: false, - isBuffering: false, - isPlaying: false, - seekSupported: false, - position: Duration.zero, - bufferedPosition: Duration.zero, - duration: Duration.zero, - clearError: true, - clearLyrics: true, - lyricsLoading: false, - ); - _syncServicePlaybackState(ProcessingState.idle, false); - unawaited(_savePlaybackSnapshot()); - return true; - } catch (e) { - if (expectedRequestEpoch != null && - !_isPlayRequestCurrent(expectedRequestEpoch)) { - return true; - } - _log.w('External player open failed: $e'); - state = state.copyWith( - isLoading: false, - isBuffering: false, - isPlaying: false, - ); - _setPlaybackError( - 'Failed to open in external player: $e', - type: 'external_player_failed', - ); - return true; - } - } - - bool _shouldRouteToExternalPlayer(PlaybackItem item) { - final settings = ref.read(settingsProvider); - return settings.playerMode == 'external' && item.isLocal; - } - - String? _externalPathFromPlaybackUri(Uri uri) { - if (uri.scheme == 'content') { - return uri.toString(); - } - if (uri.scheme == 'file') { - try { - return uri.toFilePath(); - } catch (_) { - return uri.path.isNotEmpty ? uri.path : null; - } - } - if (!uri.hasScheme) { - final asString = uri.toString().trim(); - return asString.isNotEmpty ? asString : null; - } - return null; - } - - // ─── Lyrics fetching + parsing ─────────────────────────────────────────── - - Future _fetchLyricsForItem(PlaybackItem item) async { - final generation = ++_lyricsGeneration; - _log.d('Lyrics fetch start: ${item.artist} - ${item.title} (${item.id})'); - state = state.copyWith(lyricsLoading: true, clearLyrics: true); - - try { - final localLyrics = await _tryLoadLocalLyricsForItem(item); - if (generation != _lyricsGeneration) return; - if (localLyrics != null) { - _log.d( - 'Lyrics loaded from local source: ${localLyrics.source} (sync=${localLyrics.syncType}, lines=${localLyrics.lines.length}, wordSync=${localLyrics.isWordSynced})', - ); - state = state.copyWith(lyricsLoading: false, lyrics: localLyrics); - return; - } - - final result = await PlatformBridge.fetchLyrics( - item.id, - item.title, - item.artist, - durationMs: item.durationMs, - ); - - // Discard if a newer track has started since - if (generation != _lyricsGeneration) return; - - final success = result['success'] == true; - final instrumental = result['instrumental'] == true; - final syncType = (result['sync_type'] as String?) ?? ''; - final source = (result['source'] as String?) ?? ''; - - if (!success && !instrumental) { - _log.d('Lyrics fetch returned no usable lyrics for ${item.id}'); - state = state.copyWith( - lyricsLoading: false, - lyrics: const LyricsData(), - ); - return; - } - - if (instrumental) { - _log.d('Lyrics fetch result is instrumental from: $source'); - state = state.copyWith( - lyricsLoading: false, - lyrics: LyricsData( - instrumental: true, - source: source, - syncType: syncType, - ), - ); - return; - } - - final rawLines = result['lines'] as List? ?? []; - final parsed = _parseLyricsLines(rawLines, syncType); - _log.d( - 'Lyrics fetch success from $source (sync=$syncType, lines=${parsed.lines.length}, wordSync=${parsed.hasWordSync})', - ); - - state = state.copyWith( - lyricsLoading: false, - lyrics: LyricsData( - lines: parsed.lines, - syncType: syncType, - source: source, - isWordSynced: parsed.hasWordSync, - ), - ); - } catch (e) { - if (generation != _lyricsGeneration) return; - _log.w('Lyrics fetch failed for ${item.id}: $e'); - state = state.copyWith(lyricsLoading: false, lyrics: const LyricsData()); - } - } - - /// Public method to manually refetch lyrics (e.g. retry button). - Future refetchLyrics() async { - await ensureLyricsLoaded(force: true); - } - - /// Load lyrics only when needed (e.g. when lyrics page is visible). - Future ensureLyricsLoaded({bool force = false}) async { - final item = state.currentItem; - if (item == null) return; - final lifecycleState = WidgetsBinding.instance.lifecycleState; - if (!force && - lifecycleState != null && - lifecycleState != AppLifecycleState.resumed) { - return; - } - if (!force) { - if (state.lyricsLoading) return; - if (state.lyrics != null) return; - } - await _fetchLyricsForItem(item); - } - - Future _tryLoadLocalLyricsForItem(PlaybackItem item) async { - final localPath = _resolveLocalLyricsLookupPath(item); - if (localPath == null) return null; - - try { - final result = await PlatformBridge.getLyricsLRCWithSource( - item.id, - item.title, - item.artist, - filePath: localPath, - durationMs: item.durationMs, - ); - return _lyricsDataFromLrcLookupResult(result); - } catch (e) { - _log.d('Local lyrics lookup skipped for ${item.id}: $e'); - return null; - } - } - - String? _resolveLocalLyricsLookupPath(PlaybackItem item) { - if (!item.isLocal) return null; - final sourceUri = item.sourceUri.trim(); - if (sourceUri.isEmpty) return null; - if (sourceUri.startsWith('content://')) return sourceUri; - if (sourceUri.startsWith('/')) return sourceUri; - - final uri = Uri.tryParse(sourceUri); - if (uri == null) return null; - if (uri.scheme == 'content') return sourceUri; - if (uri.scheme == 'file') { - try { - return uri.toFilePath(); - } catch (_) { - return uri.path.isNotEmpty ? uri.path : null; - } - } - return null; - } - - LyricsData? _lyricsDataFromLrcLookupResult(Map result) { - final rawLyrics = (result['lyrics'] as String?)?.trim() ?? ''; - final sourceRaw = (result['source'] as String?)?.trim() ?? ''; - final syncTypeRaw = (result['sync_type'] as String?)?.trim().toUpperCase(); - final instrumental = - result['instrumental'] == true || rawLyrics == '[instrumental:true]'; - final source = sourceRaw.isNotEmpty ? sourceRaw : 'Embedded'; - - if (instrumental) { - final syncType = syncTypeRaw == 'LINE_SYNCED' || syncTypeRaw == 'UNSYNCED' - ? syncTypeRaw! - : 'UNSYNCED'; - return LyricsData(instrumental: true, source: source, syncType: syncType); - } - if (rawLyrics.isEmpty) return null; - - final parsed = _parseLrcLyrics(rawLyrics); - if (parsed.lines.isEmpty) return null; - final effectiveSyncType = parsed.hasTimedLines ? 'LINE_SYNCED' : 'UNSYNCED'; - final syncType = syncTypeRaw == 'LINE_SYNCED' || syncTypeRaw == 'UNSYNCED' - ? syncTypeRaw! - : effectiveSyncType; - return LyricsData( - lines: parsed.lines, - syncType: syncType, - source: source, - isWordSynced: parsed.hasWordSync, - ); - } - - /// Parse raw lines from Go backend into [LyricsLine] list. - static ({List lines, bool hasWordSync}) _parseLyricsLines( - List rawLines, - String syncType, - ) { - final lines = []; - var hasAnyWordSync = false; - - for (var i = 0; i < rawLines.length; i++) { - final raw = rawLines[i] as Map; - final startMs = (raw['startTimeMs'] as num?)?.toInt() ?? 0; - final endMs = (raw['endTimeMs'] as num?)?.toInt() ?? 0; - final wordsRaw = (raw['words'] as String?) ?? ''; - - // Strip voice tags (v1:, v2:) from the beginning - var cleanedText = wordsRaw; - if (cleanedText.startsWith('v1:') || cleanedText.startsWith('v2:')) { - cleanedText = cleanedText.substring(3); - } - - // Parse word-by-word inline timestamps: word - final words = _parseInlineWordTimestamps(cleanedText, startMs); - if (words.isNotEmpty) hasAnyWordSync = true; - - // Clean text for display (remove inline timestamps) - final displayText = _stripInlineTimestamps(cleanedText); - - // Calculate end time: use provided endMs, or next line's start, or +5s - var effectiveEnd = endMs; - if (effectiveEnd <= startMs && i + 1 < rawLines.length) { - final nextStart = - (rawLines[i + 1] as Map)['startTimeMs'] as num?; - effectiveEnd = nextStart?.toInt() ?? (startMs + 5000); - } - if (effectiveEnd <= startMs) effectiveEnd = startMs + 5000; - - lines.add( - LyricsLine( - startMs: startMs, - endMs: effectiveEnd, - text: displayText.trim(), - words: words, - ), - ); - } - - return (lines: lines, hasWordSync: hasAnyWordSync); - } - - static final RegExp _lrcLineTimestampPattern = RegExp( - r'\[(\d{2}):(\d{2})\.(\d{2,3})\]', - ); - static final RegExp _lrcMetadataPattern = RegExp(r'^\[[a-zA-Z]+:.*\]$'); - static final RegExp _lrcSpeakerPrefixPattern = RegExp( - r'^(v1|v2):\s*', - caseSensitive: false, - ); - - static ({List lines, bool hasWordSync, bool hasTimedLines}) - _parseLrcLyrics(String lrcText) { - final timed = []; - final unsyncedTexts = []; - var hasAnyWordSync = false; - - for (final rawLine in lrcText.split('\n')) { - final trimmed = rawLine.trim(); - if (trimmed.isEmpty || trimmed == '[instrumental:true]') continue; - - final timestamps = _lrcLineTimestampPattern.allMatches(trimmed).toList(); - if (timestamps.isEmpty) { - if (_lrcMetadataPattern.hasMatch(trimmed)) continue; - final unsynced = _stripInlineTimestamps( - trimmed.replaceFirst(_lrcSpeakerPrefixPattern, ''), - ); - if (unsynced.isNotEmpty) { - unsyncedTexts.add(unsynced); - } - continue; - } - - final timedText = trimmed - .replaceAll(_lrcLineTimestampPattern, '') - .replaceFirst(_lrcSpeakerPrefixPattern, '') - .trim(); - final displayText = _stripInlineTimestamps(timedText); - if (displayText.isEmpty) continue; - - for (final match in timestamps) { - final startMs = _lrcInlineToMs( - match.group(1)!, - match.group(2)!, - match.group(3)!, - ); - final words = _parseInlineWordTimestamps(timedText, startMs); - if (words.isNotEmpty) hasAnyWordSync = true; - timed.add( - LyricsLine( - startMs: startMs, - endMs: startMs + 5000, - text: displayText, - words: words, - ), - ); - } - } - - if (timed.isNotEmpty) { - timed.sort((a, b) => a.startMs.compareTo(b.startMs)); - final normalized = []; - for (var i = 0; i < timed.length; i++) { - final current = timed[i]; - final nextStart = i + 1 < timed.length - ? timed[i + 1].startMs - : current.startMs + 5000; - final endMs = nextStart > current.startMs - ? nextStart - : current.startMs + 5000; - normalized.add( - LyricsLine( - startMs: current.startMs, - endMs: endMs, - text: current.text, - words: current.words, - ), - ); - } - return ( - lines: normalized, - hasWordSync: hasAnyWordSync, - hasTimedLines: true, - ); - } - - final unsynced = unsyncedTexts - .map((text) => LyricsLine(startMs: 0, endMs: 0, text: text)) - .toList(growable: false); - return (lines: unsynced, hasWordSync: false, hasTimedLines: false); - } - - /// Parse inline `` timestamps in enhanced LRC word-by-word format. - static List _parseInlineWordTimestamps( - String text, - int lineStartMs, - ) { - // Pattern: or - final pattern = RegExp(r'<(\d{2}):(\d{2})\.(\d{2,3})>'); - final matches = pattern.allMatches(text).toList(); - if (matches.isEmpty) return []; - - final words = []; - - for (var i = 0; i < matches.length; i++) { - final match = matches[i]; - final startMs = _lrcInlineToMs( - match.group(1)!, - match.group(2)!, - match.group(3)!, - ); - - // Text runs from after this timestamp to the next timestamp (or end) - final textStart = match.end; - final textEnd = i + 1 < matches.length - ? matches[i + 1].start - : text.length; - final wordText = text.substring(textStart, textEnd); - - if (wordText.trim().isEmpty) continue; - - // End time is the start of the next word, or line end + buffer - final endMs = i + 1 < matches.length - ? _lrcInlineToMs( - matches[i + 1].group(1)!, - matches[i + 1].group(2)!, - matches[i + 1].group(3)!, - ) - : startMs + 2000; - - words.add(LyricsWord(text: wordText, startMs: startMs, endMs: endMs)); - } - - return words; - } - - static int _lrcInlineToMs(String min, String sec, String cs) { - final m = int.tryParse(min) ?? 0; - final s = int.tryParse(sec) ?? 0; - var c = int.tryParse(cs) ?? 0; - if (cs.length == 2) c *= 10; - return m * 60000 + s * 1000 + c; - } - - /// Remove inline timestamps like for clean display text. - static String _stripInlineTimestamps(String text) { - return text - .replaceAll(RegExp(r'<\d{2}:\d{2}\.\d{2,3}>'), '') - .replaceAll(RegExp(r'\[bg:.*?\]'), '') - .trim(); - } - - void _resetSmartQueueSessionState({required bool clearRecent}) { - _smartQueueRefillInFlight = false; - _lastSmartQueueRefillAt = null; - _smartQueueAutoAddedCount = 0; - _smartQueueSkipStreak = 0; - _smartQueueSessionProfile = const _SmartQueueSessionProfile( - mode: _SmartQueueSessionMode.balanced, - targetDurationSec: 215, - preferredSourceKey: '', - ); - _smartQueuePendingFeedbackByTrack.clear(); - _smartQueueSearchCache.clear(); - _smartQueueRelatedArtistsCache.clear(); - if (clearRecent) { - _recentPlayedTrackKeys.clear(); - _smartQueueSessionSignals.clear(); - _smartQueueTempoHintByTrackKey.clear(); - } - } - - bool _isSmartQueueEnabled() { - final settings = ref.read(settingsProvider); - if (!settings.smartQueueEnabled) return false; - if (state.repeatMode == RepeatMode.all || - state.repeatMode == RepeatMode.one) { - return false; - } - if (state.isLoading || state.currentIndex < 0 || state.queue.isEmpty) { - return false; - } - if (state.currentItem?.track == null) return false; - if (_smartQueueAutoAddedCount >= _smartQueueMaxAutoAddsPerSession) { - return false; - } - return true; - } - - String _normalizeSmartQueueKey(String value) => value.trim().toLowerCase(); - - String _trackKeyFromTrack(Track track) { - final isrc = _normalizeSmartQueueKey(track.isrc ?? ''); - if (isrc.isNotEmpty) return 'isrc:$isrc'; - - final source = _normalizeSmartQueueKey(track.source ?? ''); - final id = _normalizeSmartQueueKey(track.id); - if (source.isNotEmpty && id.isNotEmpty) return 'src:$source:$id'; - if (id.isNotEmpty) return 'id:$id'; - - final title = _normalizeSmartQueueKey(track.name); - final artist = _normalizeSmartQueueKey(track.artistName); - if (title.isNotEmpty || artist.isNotEmpty) { - return 'name:$title|$artist'; - } - return ''; - } - - String _trackKeyFromPlaybackItem(PlaybackItem item) { - final fromTrack = item.track; - if (fromTrack != null) { - final key = _trackKeyFromTrack(fromTrack); - if (key.isNotEmpty) return key; - } - - final id = _normalizeSmartQueueKey(item.id); - if (id.isNotEmpty) return 'id:$id'; - - final title = _normalizeSmartQueueKey(item.title); - final artist = _normalizeSmartQueueKey(item.artist); - if (title.isNotEmpty || artist.isNotEmpty) { - return 'name:$title|$artist'; - } - return ''; - } - - void _rememberRecentPlayed(PlaybackItem item) { - final key = _trackKeyFromPlaybackItem(item); - if (key.isEmpty) return; - _recentPlayedTrackKeys.remove(key); - _recentPlayedTrackKeys.insert(0, key); - if (_recentPlayedTrackKeys.length > _smartQueueRecentPlayedWindow) { - _recentPlayedTrackKeys.removeRange( - _smartQueueRecentPlayedWindow, - _recentPlayedTrackKeys.length, - ); - } - } - - void _learnFromCurrentTrackOutcome({required bool completedNaturally}) { - final current = state.currentItem; - if (current == null) return; - final key = _trackKeyFromPlaybackItem(current); - if (key.isEmpty) return; - - final durationMs = max( - 1, - state.duration.inMilliseconds > 0 - ? state.duration.inMilliseconds - : current.durationMs, - ); - final positionMs = state.position.inMilliseconds.clamp(0, durationMs); - final listenRatio = completedNaturally ? 1.0 : (positionMs / durationMs); - final skipStreakBefore = _smartQueueSkipStreak; - if (current.track != null) { - _recordSmartQueueSessionSignal( - track: current.track!, - listenRatio: listenRatio, - completedNaturally: completedNaturally, - ); - } - _updateSmartQueueSkipStreak( - listenRatio: listenRatio, - completedNaturally: completedNaturally, - ); - - final context = _smartQueuePendingFeedbackByTrack.remove(key); - if (context == null) return; - if (DateTime.now().difference(context.addedAt) > - _smartQueueFeedbackMaxAge) { - return; - } - - final hourBucket = _currentSmartQueueHourBucket(); - final reward = _smartQueueRewardFromListenRatio( - listenRatio: listenRatio, - completedNaturally: completedNaturally, - currentSkipStreak: skipStreakBefore, - hourAffinityRaw: _smartQueueHourAffinity[hourBucket] ?? 0.0, - ); - _updateSmartQueueModel( - features: context.features, - reward: reward, - track: current.track, - hourBucket: hourBucket, - ); - } - - double _smartQueueRewardFromListenRatio({ - required double listenRatio, - required bool completedNaturally, - required int currentSkipStreak, - required double hourAffinityRaw, - }) { - double reward; - if (completedNaturally || listenRatio >= 0.98) { - reward = 1.0; - } else if (listenRatio >= 0.75) { - reward = 0.85; - } else if (listenRatio >= 0.50) { - reward = 0.65; - } else if (listenRatio >= 0.25) { - reward = 0.35; - } else if (listenRatio >= 0.12) { - reward = 0.15; - } else { - reward = 0.0; - } - - // Contextual bandit shaping: adjust reward based on current context. - final hourAffinity = ((hourAffinityRaw + 1.0) / 2.0).clamp(0.0, 1.0); - reward += (hourAffinity - 0.5) * 0.10; - if (!completedNaturally && listenRatio < 0.25 && currentSkipStreak >= 2) { - reward -= 0.08; - } - if (completedNaturally && currentSkipStreak >= 2) { - reward += 0.05; - } - return reward.clamp(0.0, 1.0); - } - - void _updateSmartQueueSkipStreak({ - required double listenRatio, - required bool completedNaturally, - }) { - if (completedNaturally || listenRatio >= 0.70) { - _smartQueueSkipStreak = 0; - return; - } - if (listenRatio < 0.35) { - _smartQueueSkipStreak = min( - _smartQueueMaxSkipStreak, - _smartQueueSkipStreak + 1, - ); - return; - } - _smartQueueSkipStreak = max(0, _smartQueueSkipStreak - 1); - } - - String _currentSmartQueueHourBucket() { - final hour = DateTime.now().hour; - return 'h${hour.toString().padLeft(2, '0')}'; - } - - void _recordSmartQueueSessionSignal({ - required Track track, - required double listenRatio, - required bool completedNaturally, - }) { - _smartQueueSessionSignals.add( - _SmartQueueSessionSignal( - artistKey: _normalizeSmartQueueKey(track.artistName), - sourceKey: _sourceKey(track.source ?? ''), - durationSec: max(1, track.duration), - releaseYear: _parseYear(track.releaseDate), - listenRatio: listenRatio.clamp(0.0, 1.0), - skipped: !completedNaturally && listenRatio < 0.70, - ), - ); - final maxSignals = _smartQueueSessionWindowSize * 6; - if (_smartQueueSessionSignals.length > maxSignals) { - _smartQueueSessionSignals.removeRange( - 0, - _smartQueueSessionSignals.length - maxSignals, - ); - } - } - - void _refreshSmartQueueSessionProfile({required Track seed}) { - final recent = - _smartQueueSessionSignals.length <= _smartQueueSessionWindowSize - ? List<_SmartQueueSessionSignal>.from(_smartQueueSessionSignals) - : _smartQueueSessionSignals.sublist( - _smartQueueSessionSignals.length - _smartQueueSessionWindowSize, - ); - if (recent.isEmpty) { - _smartQueueSessionProfile = _SmartQueueSessionProfile( - mode: _SmartQueueSessionMode.balanced, - targetDurationSec: max(140, seed.duration), - targetYear: _parseYear(seed.releaseDate), - preferredSourceKey: _sourceKey(seed.source ?? ''), - ); - return; - } - - final avgDuration = - recent.map((s) => s.durationSec.toDouble()).reduce((a, b) => a + b) / - recent.length; - final avgListen = - recent.map((s) => s.listenRatio).reduce((a, b) => a + b) / - recent.length; - final skipRate = recent.where((s) => s.skipped).length / recent.length; - final variance = - recent - .map((s) => pow((s.durationSec - avgDuration).toDouble(), 2)) - .reduce((a, b) => a + b) / - recent.length; - final durationStdDev = sqrt(variance); - - _SmartQueueSessionMode mode = _SmartQueueSessionMode.balanced; - if (skipRate > 0.45 || avgDuration < 190) { - mode = _SmartQueueSessionMode.energetic; - } else if (avgDuration > 280 && skipRate < 0.28) { - mode = _SmartQueueSessionMode.chill; - } else if (durationStdDev < 45 && avgListen >= 0.58) { - mode = _SmartQueueSessionMode.focus; - } - - final years = - recent - .map((s) => s.releaseYear) - .whereType() - .toList(growable: false) - ..sort(); - final targetYear = years.isEmpty - ? _parseYear(seed.releaseDate) - : years[years.length ~/ 2]; - final sourceCounts = {}; - for (final signal in recent) { - if (signal.sourceKey.isEmpty) continue; - sourceCounts[signal.sourceKey] = - (sourceCounts[signal.sourceKey] ?? 0) + 1; - } - var preferredSourceKey = _sourceKey(seed.source ?? ''); - if (sourceCounts.isNotEmpty) { - preferredSourceKey = - (sourceCounts.entries.toList() - ..sort((a, b) => b.value.compareTo(a.value))) - .first - .key; - } - final targetDurationSec = switch (mode) { - _SmartQueueSessionMode.chill => max(240, avgDuration.round()), - _SmartQueueSessionMode.focus => avgDuration.round().clamp(170, 320), - _SmartQueueSessionMode.energetic => avgDuration.round().clamp(120, 220), - _SmartQueueSessionMode.balanced => avgDuration.round().clamp(145, 280), - }; - - _smartQueueSessionProfile = _SmartQueueSessionProfile( - mode: mode, - targetDurationSec: targetDurationSec, - targetYear: targetYear, - preferredSourceKey: preferredSourceKey, - ); - } - - void _updateAffinity(Map map, String key, double reward) { - final normalizedKey = _normalizeSmartQueueKey(key); - if (normalizedKey.isEmpty) return; - - final current = map[normalizedKey] ?? 0.0; - final target = (reward * 2.0) - 1.0; // [0,1] -> [-1,1] - final updated = (current * 0.85) + (target * 0.15); - map[normalizedKey] = updated.clamp(-1.0, 1.0); - - while (map.length > _smartQueueMaxAffinityKeys) { - map.remove(map.keys.first); - } - } - - void _updateSmartQueueModel({ - required Map features, - required double reward, - Track? track, - required String hourBucket, - }) { - final clippedReward = reward.clamp(0.0, 1.0); - final prediction = _smartQueuePredict(features); - final error = clippedReward - prediction; - - final nextBias = - (_smartQueueWeights['bias'] ?? 0.0) + (_smartQueueLearningRate * error); - _smartQueueWeights['bias'] = nextBias.clamp(-3.0, 3.0); - - for (final entry in features.entries) { - final currentWeight = _smartQueueWeights[entry.key] ?? 0.0; - final updatedWeight = - currentWeight + (_smartQueueLearningRate * error * entry.value); - _smartQueueWeights[entry.key] = updatedWeight.clamp(-3.0, 3.0); - } - - if (track != null) { - _updateAffinity( - _smartQueueArtistAffinity, - track.artistName, - clippedReward, - ); - _updateAffinity( - _smartQueueSourceAffinity, - _sourceKey(track.source ?? ''), - clippedReward, - ); - _updateAffinity(_smartQueueHourAffinity, hourBucket, clippedReward); - } - - _scheduleSmartQueueModelSave(); - } - - double _smartQueuePredict(Map features) { - var logit = _smartQueueWeights['bias'] ?? 0.0; - for (final entry in features.entries) { - logit += (_smartQueueWeights[entry.key] ?? 0.0) * entry.value; - } - return _sigmoid(logit); - } - - double _sigmoid(double x) => 1.0 / (1.0 + exp(-x)); - - void _maybeTriggerSmartQueueRefill(Duration position) { - if (!_isSmartQueueEnabled()) return; - if (_smartQueueRefillInFlight) return; - - final remaining = state.queue.length - state.currentIndex - 1; - if (remaining > _smartQueueTriggerRemainingTracks) return; - if (position < const Duration(seconds: 8)) return; - - final lastRefill = _lastSmartQueueRefillAt; - if (lastRefill != null && - DateTime.now().difference(lastRefill) < _smartQueueRefillCooldown) { - return; - } - - unawaited(_autoRefillSmartQueue(force: false)); - } - - Future _autoRefillSmartQueue({required bool force}) async { - if (!_isSmartQueueEnabled()) return 0; - if (_smartQueueRefillInFlight) return 0; - - final remaining = max(0, state.queue.length - state.currentIndex - 1); - final needed = _smartQueueTargetRemainingTracks - remaining; - if (!force && needed <= 0) return 0; - - final lastRefill = _lastSmartQueueRefillAt; - if (!force && - lastRefill != null && - DateTime.now().difference(lastRefill) < _smartQueueRefillCooldown) { - return 0; - } - - final seed = state.currentItem?.track; - if (seed == null) return 0; - _refreshSmartQueueSessionProfile(seed: seed); - - final epoch = _playRequestEpoch; - _smartQueueRefillInFlight = true; - try { - _pruneSmartQueueCaches(); - - final candidates = await _fetchSmartQueueCandidates( - seed, - limit: _smartQueueCandidatePoolLimit, - ); - if (_playRequestEpoch != epoch) return 0; - if (candidates.isEmpty) return 0; - - final existingTrackKeys = {}; - for (final item in state.queue) { - final key = _trackKeyFromPlaybackItem(item); - if (key.isNotEmpty) existingTrackKeys.add(key); - } - existingTrackKeys.addAll(_recentPlayedTrackKeys); - - final scored = <_SmartQueueCandidate>[]; - for (final candidate in candidates) { - final candidateEntry = _buildSmartQueueCandidate( - seed: seed, - candidate: candidate, - existingTrackKeys: existingTrackKeys, - ); - if (candidateEntry == null) continue; - scored.add(candidateEntry); - } - if (scored.isEmpty) return 0; - - scored.sort((a, b) => b.score.compareTo(a.score)); - final targetCount = force ? max(1, needed) : max(0, needed); - if (targetCount <= 0) return 0; - final selected = _selectSmartQueueCandidates( - seed: seed, - sessionProfile: _smartQueueSessionProfile, - scored: scored, - targetCount: targetCount, - ); - if (selected.isEmpty) return 0; - if (_playRequestEpoch != epoch) return 0; - - final queueBefore = state.queue.length; - final updatedQueue = [...state.queue]; - for (final selection in selected) { - final item = _buildQueueItemFromTrack(selection.track); - updatedQueue.add(item); - final itemKey = _trackKeyFromPlaybackItem(item); - if (itemKey.isNotEmpty) { - _smartQueuePendingFeedbackByTrack[itemKey] = - _SmartQueueLearningContext( - features: selection.features, - addedAt: DateTime.now(), - ); - } - } - - state = state.copyWith(queue: updatedQueue); - if (state.shuffle) { - for (var idx = queueBefore; idx < updatedQueue.length; idx++) { - _shuffleOrder.add(idx); - } - } - - _smartQueueAutoAddedCount += selected.length; - _lastSmartQueueRefillAt = DateTime.now(); - unawaited(_savePlaybackSnapshot()); - final sourceSummary = {}; - for (final selection in selected) { - final source = _resolveSmartQueueSourceLabel(selection.track); - sourceSummary[source] = (sourceSummary[source] ?? 0) + 1; - } - final summaryText = sourceSummary.entries - .map((entry) => '${entry.key}:${entry.value}') - .join(', '); - _log.d( - 'Smart queue appended ${selected.length} tracks (remaining=$remaining, session=${_smartQueueSessionProfile.mode.name}, sources=[$summaryText])', - ); - return selected.length; - } catch (e) { - _log.d('Smart queue refill skipped: $e'); - return 0; - } finally { - _smartQueueRefillInFlight = false; - } - } - - Future> _fetchSmartQueueCandidates( - Track seed, { - required int limit, - }) async { - final queries = { - '${seed.artistName} ${seed.name}'.trim(), - seed.artistName.trim(), - '${seed.artistName} ${seed.albumName}'.trim(), - }.where((q) => q.isNotEmpty).take(3).toList(growable: false); - - if (queries.isEmpty) return const []; - - final perQueryLimit = max(10, (limit / queries.length).ceil() + 4); - final results = await Future.wait( - queries.map( - (q) => _searchTracksForSmartQueue(q, trackLimit: perQueryLimit), - ), - ); - - final merged = []; - for (final list in results) { - merged.addAll(list); - if (merged.length >= limit * 2) break; - } - - final relatedArtistTracks = await _fetchRelatedArtistTracksForSmartQueue( - seed, - fallbackTracks: merged, - limit: limit, - ); - if (relatedArtistTracks.isNotEmpty) { - merged.addAll(relatedArtistTracks); - } - - if (merged.isEmpty) { - merged.addAll( - _fallbackOfflineTracksForSmartQueue(seed: seed, limit: max(12, limit)), - ); - } - - return merged; - } - - List _fallbackOfflineTracksForSmartQueue({ - required Track seed, - required int limit, - }) { - if (limit <= 0) return const []; - - final pool = _buildOfflineTrackPoolForSmartQueue( - maxItems: _smartQueueOfflinePoolMaxItems, - ); - if (pool.isEmpty) return const []; - - final seedKey = _trackKeyFromTrack(seed); - final seedArtist = _normalizeSmartQueueKey(seed.artistName); - final seedAlbum = _normalizeSmartQueueKey(seed.albumName); - final seedSource = _sourceKey(seed.source ?? ''); - - final scored = <_OfflineSmartQueueTrackHit>[]; - for (final track in pool) { - final key = _trackKeyFromTrack(track); - if (key.isEmpty || key == seedKey) continue; - - var score = 0.35; - final artistKey = _normalizeSmartQueueKey(track.artistName); - final albumKey = _normalizeSmartQueueKey(track.albumName); - final sourceKey = _sourceKey(track.source ?? ''); - if (artistKey.isNotEmpty && artistKey == seedArtist) { - score += 2.1; - } - if (albumKey.isNotEmpty && albumKey == seedAlbum) { - score += 1.25; - } - if (sourceKey == seedSource) { - score += 0.35; - } - score += _durationSimilarity(seed.duration, track.duration) * 0.55; - score += - _releaseYearSimilarity(seed.releaseDate, track.releaseDate) * 0.3; - score += _smartQueueRandom.nextDouble() * 0.08; - scored.add(_OfflineSmartQueueTrackHit(track: track, score: score)); - } - - scored.sort((a, b) => b.score.compareTo(a.score)); - return scored - .take(limit) - .map((entry) => entry.track) - .toList(growable: false); - } - - Future> _fetchRelatedArtistTracksForSmartQueue( - Track seed, { - required List fallbackTracks, - required int limit, - }) async { - final seedArtist = _normalizeSmartQueueKey(seed.artistName); - if (seedArtist.isEmpty) return const []; - - final relatedArtists = await _discoverRelatedArtistsForSmartQueue( - seed, - fallbackTracks: fallbackTracks, - limit: _smartQueueRelatedArtistsLimit, - ); - if (relatedArtists.isEmpty) return const []; - - final perArtistLimit = max( - 6, - (limit / max(1, relatedArtists.length)).ceil(), - ); - final results = await Future.wait( - relatedArtists.map( - (artist) => - _searchTracksForSmartQueue(artist.name, trackLimit: perArtistLimit), - ), - ); - - final merged = []; - for (final tracks in results) { - for (final track in tracks) { - final artist = _normalizeSmartQueueKey(track.artistName); - if (artist.isEmpty || artist == seedArtist) continue; - merged.add(track); - } - if (merged.length >= limit) break; - } - return merged; - } - - Future> _discoverRelatedArtistsForSmartQueue( - Track seed, { - required List fallbackTracks, - required int limit, - }) async { - final seedArtist = _normalizeSmartQueueKey(seed.artistName); - if (seedArtist.isEmpty || limit <= 0) return const []; - - final cacheKey = 'seed:$seedArtist'; - final cached = _smartQueueRelatedArtistsCache[cacheKey]; - final now = DateTime.now(); - if (cached != null && - now.difference(cached.fetchedAt) < _smartQueueSearchCacheTtl) { - return cached.artists.take(limit).toList(growable: false); - } - - final relatedByName = {}; - void addCandidate(_SmartQueueRelatedArtist candidate) { - final key = _normalizeSmartQueueKey(candidate.name); - if (key.isEmpty || key == seedArtist) return; - final existing = relatedByName[key]; - if (existing == null || candidate.score > existing.score) { - relatedByName[key] = candidate; - } - } - - final spotifySeed = await _findArtistSeedBySearch( - queryArtistName: seed.artistName, - provider: 'spotify', - ); - if (spotifySeed != null) { - final related = await _fetchRelatedArtistsFromProviderSeed(spotifySeed); - for (final item in related) { - addCandidate(item); - } - } - - final deezerSeed = await _findArtistSeedBySearch( - queryArtistName: seed.artistName, - provider: 'deezer', - ); - if (deezerSeed != null) { - final related = await _fetchRelatedArtistsFromProviderSeed(deezerSeed); - for (final item in related) { - addCandidate(item); - } - } - - // Fallback heuristic from current track candidates if provider APIs don't return enough. - if (relatedByName.length < limit) { - final counts = {}; - for (final track in fallbackTracks.take(80)) { - final artistName = track.artistName.trim(); - final key = _normalizeSmartQueueKey(artistName); - if (key.isEmpty || key == seedArtist) continue; - counts[key] = (counts[key] ?? 0) + 1; - } - for (final entry in counts.entries) { - addCandidate( - _SmartQueueRelatedArtist( - name: entry.key, - provider: 'fallback', - score: min(1.0, 0.25 + (entry.value * 0.14)), - ), - ); - } - } - - final sorted = relatedByName.values.toList() - ..sort((a, b) => b.score.compareTo(a.score)); - _smartQueueRelatedArtistsCache[cacheKey] = _SmartQueueRelatedArtistsCache( - artists: sorted, - fetchedAt: now, - ); - return sorted.take(limit).toList(growable: false); - } - - Future<_SmartQueueArtistSeed?> _findArtistSeedBySearch({ - required String queryArtistName, - required String provider, - }) async { - final normalizedProvider = provider.trim().toLowerCase(); - final query = queryArtistName.trim(); - if (query.isEmpty) return null; - - final artists = await _searchArtistsForSmartQueue( - query: query, - provider: normalizedProvider, - limit: 8, - ); - if (artists.isEmpty) return null; - - artists.sort((a, b) => b.score.compareTo(a.score)); - return artists.first; - } - - Future> _fetchRelatedArtistsFromProviderSeed( - _SmartQueueArtistSeed seed, - ) async { - if (seed.provider == 'spotify') { - return _fetchSpotifyRelatedArtistsForSmartQueue(seed); - } - return _buildOfflineRelatedArtistsFromSeed( - seed, - providerLabel: seed.provider, - ); - } - - Future> - _fetchSpotifyRelatedArtistsForSmartQueue(_SmartQueueArtistSeed seed) async { - return _buildOfflineRelatedArtistsFromSeed( - seed, - providerLabel: _smartQueueSpotifyExtensionId, - ); - } - - Future> _buildOfflineRelatedArtistsFromSeed( - _SmartQueueArtistSeed seed, { - required String providerLabel, - }) async { - final seedArtistKey = _normalizeSmartQueueKey(seed.name); - if (seedArtistKey.isEmpty) return const []; - - final pool = _buildOfflineTrackPoolForSmartQueue( - maxItems: _smartQueueOfflinePoolMaxItems, - ); - if (pool.isEmpty) return const []; - - final candidateScoreByKey = {}; - final candidateNameByKey = {}; - final seedAlbumKeys = {}; - - void addCandidate(String rawName, double score) { - final name = rawName.trim(); - final key = _normalizeSmartQueueKey(name); - if (key.isEmpty || key == seedArtistKey || score <= 0) return; - candidateNameByKey[key] = name; - candidateScoreByKey[key] = (candidateScoreByKey[key] ?? 0) + score; - } - - for (final track in pool) { - final artists = _extractArtistNamesForSmartQueue(track.artistName); - if (artists.isEmpty) continue; - - final containsSeed = artists.any( - (artistName) => _normalizeSmartQueueKey(artistName) == seedArtistKey, - ); - if (containsSeed) { - for (final artistName in artists) { - final key = _normalizeSmartQueueKey(artistName); - if (key == seedArtistKey) continue; - addCandidate(artistName, 0.85); - } - final albumKey = _normalizeSmartQueueKey(track.albumName); - if (albumKey.isNotEmpty) { - seedAlbumKeys.add(albumKey); - } - } - } - - if (seedAlbumKeys.isNotEmpty) { - for (final track in pool) { - final albumKey = _normalizeSmartQueueKey(track.albumName); - if (albumKey.isEmpty || !seedAlbumKeys.contains(albumKey)) continue; - for (final artistName in _extractArtistNamesForSmartQueue( - track.artistName, - )) { - final key = _normalizeSmartQueueKey(artistName); - if (key == seedArtistKey) continue; - addCandidate(artistName, 0.38); - } - } - } - - if (candidateScoreByKey.isEmpty) return const []; - - final related = <_SmartQueueRelatedArtist>[]; - for (final entry in candidateScoreByKey.entries) { - related.add( - _SmartQueueRelatedArtist( - name: candidateNameByKey[entry.key] ?? entry.key, - provider: providerLabel, - score: entry.value.clamp(0.05, 1.0), - ), - ); - } - related.sort((a, b) => b.score.compareTo(a.score)); - return related.take(10).toList(growable: false); - } - - List _extractArtistNamesForSmartQueue(String rawArtists) { - final tokens = splitArtistNames(rawArtists); - if (tokens.isEmpty) return const []; - - final names = []; - final seen = {}; - for (final token in tokens) { - final name = token.trim(); - if (name.isEmpty) continue; - final key = _normalizeSmartQueueKey(name); - if (key.isEmpty || !seen.add(key)) continue; - names.add(name); - } - return names; - } - - Future> _searchArtistsForSmartQueue({ - required String query, - required String provider, - int limit = 8, - }) async { - final normalizedQuery = query.trim(); - if (normalizedQuery.isEmpty) return const []; - - final normalizedProvider = provider.trim().toLowerCase(); - if (normalizedProvider != 'spotify' && normalizedProvider != 'deezer') { - return const []; - } - - final pool = _buildOfflineTrackPoolForSmartQueue( - maxItems: _smartQueueOfflinePoolMaxItems, - ); - if (pool.isEmpty) return const []; - - final statsByArtist = {}; - for (final track in pool) { - for (final artistName in _extractArtistNamesForSmartQueue( - track.artistName, - )) { - final key = _normalizeSmartQueueKey(artistName); - if (key.isEmpty) continue; - final similarity = _artistNameSimilarity(normalizedQuery, artistName); - if (similarity <= 0.05 && - !key.contains(_normalizeSmartQueueKey(normalizedQuery))) { - continue; - } - - final current = statsByArtist[key]; - if (current == null) { - statsByArtist[key] = _OfflineSmartQueueArtistStats( - name: artistName, - count: 1, - scoreSum: similarity, - ); - } else { - statsByArtist[key] = _OfflineSmartQueueArtistStats( - name: current.name, - count: current.count + 1, - scoreSum: current.scoreSum + similarity, - ); - } - } - } - - if (statsByArtist.isEmpty) return const []; - final ranked = <_SmartQueueArtistSeed>[]; - for (final entry in statsByArtist.entries) { - final stats = entry.value; - final frequencyBoost = min(1.0, stats.count / 18.0); - final meanSimilarity = stats.scoreSum / max(1, stats.count); - final score = ((meanSimilarity * 0.78) + (frequencyBoost * 0.22)).clamp( - 0.0, - 1.0, - ); - ranked.add( - _SmartQueueArtistSeed( - id: '$normalizedProvider:${entry.key}', - name: stats.name, - provider: normalizedProvider, - score: score, - ), - ); - } - - ranked.sort((a, b) => b.score.compareTo(a.score)); - return ranked.take(max(1, limit)).toList(growable: false); - } - - double _artistNameSimilarity(String a, String b) { - final na = _normalizeSmartQueueKey(a); - final nb = _normalizeSmartQueueKey(b); - if (na.isEmpty || nb.isEmpty) return 0.0; - if (na == nb) return 1.0; - if (na.contains(nb) || nb.contains(na)) return 0.88; - - final tokensA = na - .split(RegExp(r'[^a-z0-9]+')) - .where((t) => t.isNotEmpty) - .toSet(); - final tokensB = nb - .split(RegExp(r'[^a-z0-9]+')) - .where((t) => t.isNotEmpty) - .toSet(); - if (tokensA.isEmpty || tokensB.isEmpty) return 0.0; - - final intersection = tokensA.intersection(tokensB).length; - final union = tokensA.union(tokensB).length; - if (union == 0) return 0.0; - return intersection / union; - } - - Future> _searchTracksForSmartQueue( - String query, { - int trackLimit = 20, - }) async { - final normalizedQuery = _normalizeSmartQueueKey(query); - if (normalizedQuery.isEmpty) return const []; - - final now = DateTime.now(); - final cached = _smartQueueSearchCache[normalizedQuery]; - if (cached != null && - now.difference(cached.fetchedAt) < _smartQueueSearchCacheTtl) { - return cached.tracks; - } - - final settings = ref.read(settingsProvider); - final preferSpotify = - settings.metadataSource.trim().toLowerCase() == 'spotify'; - final primaryLimit = max( - trackLimit, - (trackLimit * _smartQueuePrimarySourceRatio).round() + 5, - ); - final secondaryLimit = max(trackLimit ~/ 2, trackLimit - 2); - - final primaryResults = await (preferSpotify - ? _safeSmartQueueTrackSearch( - () => _searchSpotifyTracksForSmartQueue( - normalizedQuery, - trackLimit: primaryLimit, - ), - ) - : _safeSmartQueueTrackSearch( - () => _searchDeezerTracksForSmartQueue( - normalizedQuery, - trackLimit: primaryLimit, - ), - )); - final shouldQuerySecondary = - primaryResults.length < - max(8, (trackLimit * _smartQueuePrimarySourceRatio).round()); - final secondaryResults = shouldQuerySecondary - ? (preferSpotify - ? await _safeSmartQueueTrackSearch( - () => _searchDeezerTracksForSmartQueue( - normalizedQuery, - trackLimit: secondaryLimit, - ), - ) - : await _safeSmartQueueTrackSearch( - () => _searchSpotifyTracksForSmartQueue( - normalizedQuery, - trackLimit: secondaryLimit, - ), - )) - : const >[]; - - final blended = _blendSmartQueueTrackCandidates( - primary: primaryResults, - secondary: secondaryResults, - targetCount: max(10, trackLimit + 6), - primaryRatio: _smartQueuePrimarySourceRatio, - ); - - final parsedTracks = []; - final seenTrackKeys = {}; - for (final entry in blended) { - final track = _parseSearchTrackForSmartQueue(entry); - if (track.id.trim().isEmpty || track.name.trim().isEmpty) continue; - if (track.isCollection) continue; - final key = _trackKeyFromTrack(track); - if (key.isNotEmpty && !seenTrackKeys.add(key)) continue; - _registerSmartQueueTrackHints(track: track, raw: entry); - parsedTracks.add(track); - } - - _smartQueueSearchCache[normalizedQuery] = _SmartQueueCachedResult( - tracks: parsedTracks, - fetchedAt: now, - ); - return parsedTracks; - } - - Future>> _safeSmartQueueTrackSearch( - Future>> Function() resolver, - ) async { - try { - return await resolver(); - } catch (e) { - _log.d('Smart queue source search failed: $e'); - return const >[]; - } - } - - List> _blendSmartQueueTrackCandidates({ - required List> primary, - required List> secondary, - required int targetCount, - required double primaryRatio, - }) { - final merged = >[]; - final seen = {}; - var primaryIndex = 0; - var secondaryIndex = 0; - var primaryTaken = 0; - var secondaryTaken = 0; - final maxTarget = max(1, targetCount); - - void tryTakeFrom(List> source, bool isPrimary) { - while (true) { - final index = isPrimary ? primaryIndex : secondaryIndex; - if (index >= source.length) return; - final item = source[index]; - if (isPrimary) { - primaryIndex++; - } else { - secondaryIndex++; - } - final dedupKey = _smartQueueRawTrackDedupKey(item); - if (dedupKey.isEmpty || !seen.add(dedupKey)) { - continue; - } - merged.add(item); - if (isPrimary) { - primaryTaken++; - } else { - secondaryTaken++; - } - return; - } - } - - while (merged.length < maxTarget && - (primaryIndex < primary.length || secondaryIndex < secondary.length)) { - final expectedPrimary = ((merged.length + 1) * primaryRatio).round(); - final shouldTakePrimary = - secondaryIndex >= secondary.length || - (primaryIndex < primary.length && primaryTaken < expectedPrimary); - if (shouldTakePrimary) { - tryTakeFrom(primary, true); - } else { - tryTakeFrom(secondary, false); - } - if (merged.length >= maxTarget) break; - if (primaryIndex >= primary.length && secondaryIndex < secondary.length) { - tryTakeFrom(secondary, false); - } else if (secondaryIndex >= secondary.length && - primaryIndex < primary.length) { - tryTakeFrom(primary, true); - } - if (primaryTaken + secondaryTaken == 0) { - break; - } - } - return merged; - } - - String _smartQueueRawTrackDedupKey(Map raw) { - final id = (raw['spotify_id'] ?? raw['id'] ?? '').toString().trim(); - final source = (raw['source'] ?? raw['provider_id'] ?? '') - .toString() - .trim(); - if (id.isNotEmpty && source.isNotEmpty) { - return 'src:${_normalizeSmartQueueKey(source)}:${_normalizeSmartQueueKey(id)}'; - } - if (id.isNotEmpty) { - return 'id:${_normalizeSmartQueueKey(id)}'; - } - final title = (raw['name'] ?? '').toString().trim(); - final artist = (raw['artists'] ?? raw['artist'] ?? '').toString().trim(); - if (title.isEmpty && artist.isEmpty) return ''; - return 'name:${_normalizeSmartQueueKey(title)}|${_normalizeSmartQueueKey(artist)}'; - } - - Future>> _searchSpotifyTracksForSmartQueue( - String query, { - required int trackLimit, - }) async { - return _searchOfflineTracksForSmartQueue( - query, - trackLimit: trackLimit, - providerHint: 'spotify', - ); - } - - Future>> _searchDeezerTracksForSmartQueue( - String query, { - required int trackLimit, - }) async { - return _searchOfflineTracksForSmartQueue( - query, - trackLimit: trackLimit, - providerHint: 'deezer', - ); - } - - Future>> _searchOfflineTracksForSmartQueue( - String query, { - required int trackLimit, - required String providerHint, - }) async { - final normalizedQuery = _normalizeSmartQueueKey(query); - if (normalizedQuery.isEmpty || trackLimit <= 0) return const []; - - final terms = normalizedQuery - .split(RegExp(r'[^a-z0-9]+')) - .where((token) => token.isNotEmpty) - .toList(growable: false); - final pool = _buildOfflineTrackPoolForSmartQueue( - maxItems: _smartQueueOfflinePoolMaxItems, - ); - if (pool.isEmpty) return const []; - - final scored = <_OfflineSmartQueueTrackHit>[]; - for (final track in pool) { - var score = _searchScoreForOfflineTrack( - track, - normalizedQuery: normalizedQuery, - terms: terms, - ); - if (score <= 0) continue; - - if (providerHint == 'spotify' && _looksLikeSpotifyTrackId(track.id)) { - score += 0.22; - } else if (providerHint == 'deezer' && - _looksLikeDeezerTrackId(track.id, track.deezerId)) { - score += 0.22; - } - - final artistAffinity = - _smartQueueArtistAffinity[_normalizeSmartQueueKey( - track.artistName, - )] ?? - 0.0; - score += max(0.0, artistAffinity) * 0.25; - score += _smartQueueRandom.nextDouble() * 0.05; - scored.add(_OfflineSmartQueueTrackHit(track: track, score: score)); - } - - if (scored.isEmpty) return const []; - scored.sort((a, b) => b.score.compareTo(a.score)); - final target = max(1, trackLimit); - return scored - .take(target) - .map( - (entry) => _rawMapForOfflineSmartQueueTrack( - entry.track, - providerHint: providerHint, - ), - ) - .toList(growable: false); - } - - List _buildOfflineTrackPoolForSmartQueue({required int maxItems}) { - if (maxItems <= 0) return const []; - - final localItems = [...ref.read(localLibraryProvider).items]; - final historyItems = [...ref.read(downloadHistoryProvider).items]; - localItems.sort((a, b) => b.scannedAt.compareTo(a.scannedAt)); - historyItems.sort((a, b) => b.downloadedAt.compareTo(a.downloadedAt)); - - final pool = []; - final seen = {}; - final perSourceCap = max(40, maxItems ~/ 2); - - void addTrack(Track? track) { - if (track == null) return; - final name = track.name.trim(); - final artist = track.artistName.trim(); - if (name.isEmpty || artist.isEmpty) return; - final key = _trackKeyFromTrack(track); - if (key.isEmpty || !seen.add(key)) return; - pool.add(track); - } - - for (final item in historyItems.take(perSourceCap)) { - addTrack(_trackFromDownloadHistoryForSmartQueue(item)); - } - for (final item in localItems.take(perSourceCap)) { - addTrack(_trackFromLocalLibraryForSmartQueue(item)); - } - - if (pool.length <= maxItems) return pool; - return pool.take(maxItems).toList(growable: false); - } - - Track? _trackFromDownloadHistoryForSmartQueue(DownloadHistoryItem item) { - final path = item.filePath.trim(); - if (path.isEmpty) return null; - final title = item.trackName.trim(); - final artist = item.artistName.trim(); - if (title.isEmpty || artist.isEmpty) return null; - - final spotifyId = (item.spotifyId ?? '').trim(); - final id = spotifyId.isNotEmpty ? spotifyId : 'history:${item.id}'; - return Track( - id: id, - name: title, - artistName: artist, - albumName: item.albumName, - albumArtist: item.albumArtist, - coverUrl: item.coverUrl, - isrc: item.isrc, - duration: max(0, item.duration ?? 0), - trackNumber: item.trackNumber, - discNumber: item.discNumber, - releaseDate: item.releaseDate, - source: 'offline', - ); - } - - Track? _trackFromLocalLibraryForSmartQueue(LocalLibraryItem item) { - final path = item.filePath.trim(); - if (path.isEmpty) return null; - - final title = item.trackName.trim(); - final artist = item.artistName.trim(); - if (title.isEmpty || artist.isEmpty) return null; - - return Track( - id: 'local:${item.id}', - name: title, - artistName: artist, - albumName: item.albumName, - albumArtist: item.albumArtist, - coverUrl: item.coverPath, - isrc: item.isrc, - duration: max(0, item.duration ?? 0), - trackNumber: item.trackNumber, - discNumber: item.discNumber, - releaseDate: item.releaseDate, - source: 'local', - ); - } - - double _searchScoreForOfflineTrack( - Track track, { - required String normalizedQuery, - required List terms, - }) { - final title = _normalizeSmartQueueKey(track.name); - final artist = _normalizeSmartQueueKey(track.artistName); - final album = _normalizeSmartQueueKey(track.albumName); - final full = '$title $artist $album'; - if (full.trim().isEmpty) return 0; - - var score = 0.0; - if (title == normalizedQuery) { - score += 4.2; - } else if (title.startsWith(normalizedQuery)) { - score += 3.5; - } else if (title.contains(normalizedQuery)) { - score += 2.8; - } - - if (artist == normalizedQuery) { - score += 3.4; - } else if (artist.startsWith(normalizedQuery)) { - score += 2.7; - } else if (artist.contains(normalizedQuery)) { - score += 2.0; - } - - if (album == normalizedQuery) { - score += 1.6; - } else if (album.contains(normalizedQuery) && album.isNotEmpty) { - score += 1.0; - } - - var matchedTerms = 0; - for (final term in terms) { - if (term.length < 2) continue; - if (title.contains(term)) { - score += 0.85; - matchedTerms++; - } else if (artist.contains(term)) { - score += 0.72; - matchedTerms++; - } else if (album.contains(term)) { - score += 0.48; - matchedTerms++; - } - } - if (terms.isNotEmpty && matchedTerms == terms.length) { - score += 0.6; - } - return score; - } - - bool _looksLikeSpotifyTrackId(String rawId) { - final id = rawId.trim(); - if (id.isEmpty) return false; - final lowered = id.toLowerCase(); - if (lowered.startsWith('spotify:track:')) return true; - if (lowered.contains('open.spotify.com/track/')) return true; - return RegExp(r'^[a-z0-9]{22}$', caseSensitive: false).hasMatch(id); - } - - bool _looksLikeDeezerTrackId(String rawId, String? deezerId) { - if (deezerId != null && deezerId.trim().isNotEmpty) return true; - final id = rawId.trim(); - if (id.isEmpty) return false; - return RegExp(r'^\d{4,}$').hasMatch(id); - } - - Map _rawMapForOfflineSmartQueueTrack( - Track track, { - required String providerHint, - }) { - final rawId = track.id.trim(); - final map = { - 'id': rawId, - 'name': track.name, - 'artists': track.artistName, - 'artist': track.artistName, - 'album_name': track.albumName, - 'album': track.albumName, - 'album_artist': track.albumArtist, - 'cover_url': track.coverUrl, - 'isrc': track.isrc, - 'duration': track.duration, - 'duration_ms': track.duration > 0 ? track.duration * 1000 : 0, - 'track_number': track.trackNumber, - 'disc_number': track.discNumber, - 'release_date': track.releaseDate, - 'album_type': track.albumType, - 'item_type': 'track', - 'source': track.source ?? 'offline', - 'provider_id': providerHint, - 'deezer_id': track.deezerId, - }; - - if (_looksLikeSpotifyTrackId(rawId)) { - final spotifyId = rawId.toLowerCase().startsWith('spotify:track:') - ? rawId.split(':').last - : rawId; - map['spotify_id'] = spotifyId; - } - return map; - } - - String _resolveSmartQueueSourceLabel(Track track) { - final raw = (track.source ?? '').trim().toLowerCase(); - if (raw.isNotEmpty) return raw; - final id = track.id.trim().toLowerCase(); - if (id.startsWith('deezer:')) return 'deezer'; - if (id.startsWith('spotify:')) return 'spotify'; - return 'unknown'; - } - - Track _parseSearchTrackForSmartQueue( - Map data, { - String? source, - }) { - final durationMs = _extractDurationMsForSmartQueue(data); - final itemType = data['item_type']?.toString(); - return Track( - id: (data['spotify_id'] ?? data['id'] ?? '').toString(), - name: (data['name'] ?? '').toString(), - artistName: (data['artists'] ?? data['artist'] ?? '').toString(), - albumName: (data['album_name'] ?? data['album'] ?? '').toString(), - albumArtist: data['album_artist']?.toString(), - artistId: (data['artist_id'] ?? data['artistId'])?.toString(), - albumId: data['album_id']?.toString(), - coverUrl: (data['cover_url'] ?? data['images'])?.toString(), - isrc: data['isrc']?.toString(), - duration: (durationMs / 1000).round(), - trackNumber: data['track_number'] as int?, - discNumber: data['disc_number'] as int?, - releaseDate: data['release_date']?.toString(), - source: - source ?? - data['source']?.toString() ?? - data['provider_id']?.toString(), - albumType: data['album_type']?.toString(), - itemType: itemType, - deezerId: data['deezer_id']?.toString(), - ); - } - - int _extractDurationMsForSmartQueue(Map data) { - final durationMsRaw = data['duration_ms']; - if (durationMsRaw is num && durationMsRaw > 0) { - return durationMsRaw.toInt(); - } - if (durationMsRaw is String) { - final parsed = num.tryParse(durationMsRaw.trim()); - if (parsed != null && parsed > 0) { - return parsed.toInt(); - } - } - - final durationSecRaw = data['duration']; - if (durationSecRaw is num && durationSecRaw > 0) { - return (durationSecRaw * 1000).toInt(); - } - if (durationSecRaw is String) { - final parsed = num.tryParse(durationSecRaw.trim()); - if (parsed != null && parsed > 0) { - return (parsed * 1000).toInt(); - } - } - return 0; - } - - void _registerSmartQueueTrackHints({ - required Track track, - required Map raw, - }) { - final tempo = _extractTempoBpmForSmartQueue(raw); - if (tempo == null || tempo <= 0) return; - final key = _trackKeyFromTrack(track); - if (key.isEmpty) return; - _smartQueueTempoHintByTrackKey[key] = tempo; - if (_smartQueueTempoHintByTrackKey.length > _smartQueueMaxTempoHints) { - final removeCount = - _smartQueueTempoHintByTrackKey.length - _smartQueueMaxTempoHints; - final keys = _smartQueueTempoHintByTrackKey.keys - .take(removeCount) - .toList(growable: false); - for (final k in keys) { - _smartQueueTempoHintByTrackKey.remove(k); - } - } - } - - double? _extractTempoBpmForSmartQueue(Map raw) { - const keys = ['tempo', 'bpm', 'audio_tempo', 'track_bpm']; - for (final key in keys) { - final value = raw[key]; - if (value is num) { - final bpm = value.toDouble(); - if (bpm > 30 && bpm < 260) return bpm; - } else if (value is String) { - final bpm = double.tryParse(value.trim()); - if (bpm != null && bpm > 30 && bpm < 260) return bpm; - } - } - return null; - } - - _SmartQueueCandidate? _buildSmartQueueCandidate({ - required Track seed, - required Track candidate, - required Set existingTrackKeys, - }) { - final candidateKey = _trackKeyFromTrack(candidate); - if (candidateKey.isEmpty || existingTrackKeys.contains(candidateKey)) { - return null; - } - - final features = _buildSmartQueueFeatures( - seed: seed, - candidate: candidate, - existingTrackKeys: existingTrackKeys, - ); - final prediction = _smartQueuePredict(features); - final exploration = - _smartQueueRandom.nextDouble() * _sessionExplorationCeiling(); - final score = prediction + exploration; - return _SmartQueueCandidate( - track: candidate, - key: candidateKey, - features: features, - score: score, - ); - } - - Map _buildSmartQueueFeatures({ - required Track seed, - required Track candidate, - required Set existingTrackKeys, - }) { - final sameArtist = - _normalizeSmartQueueKey(seed.artistName) == - _normalizeSmartQueueKey(candidate.artistName) - ? 1.0 - : 0.0; - final sameAlbum = - _normalizeSmartQueueKey(seed.albumName) == - _normalizeSmartQueueKey(candidate.albumName) - ? 1.0 - : 0.0; - final durationSimilarity = _durationSimilarity( - seed.duration, - candidate.duration, - ); - final sourceMatch = - _sourceKey(seed.source ?? '') == _sourceKey(candidate.source ?? '') - ? 1.0 - : 0.0; - final releaseYearSimilarity = _releaseYearSimilarity( - seed.releaseDate, - candidate.releaseDate, - ); - final artistAffinityRaw = - _smartQueueArtistAffinity[_normalizeSmartQueueKey( - candidate.artistName, - )] ?? - 0.0; - final sourceAffinityRaw = - _smartQueueSourceAffinity[_sourceKey(candidate.source ?? '')] ?? 0.0; - final artistAffinity = ((artistAffinityRaw + 1.0) / 2.0).clamp(0.0, 1.0); - final sourceAffinity = ((sourceAffinityRaw + 1.0) / 2.0).clamp(0.0, 1.0); - final sessionAlignment = _smartQueueSessionAlignment( - profile: _smartQueueSessionProfile, - candidate: candidate, - ); - final hourAffinityRaw = - _smartQueueHourAffinity[_currentSmartQueueHourBucket()] ?? 0.0; - final hourAffinity = ((hourAffinityRaw + 1.0) / 2.0).clamp(0.0, 1.0); - final tempoContinuity = _smartQueueTempoContinuity( - seed: seed, - candidate: candidate, - ); - final yearCohesion = _smartQueueYearCohesion( - profile: _smartQueueSessionProfile, - candidate: candidate, - ); - - var artistRepetition = 0; - final candidateArtist = _normalizeSmartQueueKey(candidate.artistName); - if (candidateArtist.isNotEmpty) { - for (final key in _recentPlayedTrackKeys.take(10)) { - if (key.contains('|$candidateArtist')) { - artistRepetition++; - } - } - for (final queueItem in state.queue.reversed.take(6)) { - final artist = _normalizeSmartQueueKey(queueItem.artist); - if (artist.isNotEmpty && artist == candidateArtist) { - artistRepetition++; - } - } - } - final novelty = (1.0 - (artistRepetition / 3.0)).clamp(0.15, 1.0); - - final alreadySeen = existingTrackKeys.contains( - _trackKeyFromTrack(candidate), - ); - final noveltyAfterDuplicateCheck = alreadySeen ? 0.0 : novelty; - final skipPressure = (_smartQueueSkipStreak / _smartQueueMaxSkipStreak) - .clamp(0.0, 1.0); - final skipContext = (1.0 - (sameArtist * skipPressure)).clamp(0.05, 1.0); - - return { - 'same_artist': sameArtist, - 'same_album': sameAlbum, - 'duration_similarity': durationSimilarity, - 'source_match': sourceMatch, - 'release_year_similarity': releaseYearSimilarity, - 'artist_affinity': artistAffinity, - 'source_affinity': sourceAffinity, - 'novelty': noveltyAfterDuplicateCheck, - 'session_alignment': sessionAlignment, - 'hour_affinity': hourAffinity, - 'skip_context': skipContext, - 'tempo_continuity': tempoContinuity, - 'year_cohesion': yearCohesion, - }; - } - - double _durationSimilarity(int aSec, int bSec) { - if (aSec <= 0 || bSec <= 0) return 0.5; - final maxSec = max(aSec, bSec).toDouble(); - final diff = (aSec - bSec).abs().toDouble(); - final normalized = (1.0 - (diff / maxSec)).clamp(0.0, 1.0); - return normalized; - } - - double _releaseYearSimilarity(String? a, String? b) { - final yearA = _parseYear(a); - final yearB = _parseYear(b); - if (yearA == null || yearB == null) return 0.5; - final diff = (yearA - yearB).abs(); - if (diff == 0) return 1.0; - if (diff <= 1) return 0.85; - if (diff <= 3) return 0.65; - if (diff <= 6) return 0.45; - return 0.2; - } - - int? _parseYear(String? raw) { - if (raw == null || raw.trim().isEmpty) return null; - final match = RegExp(r'(\d{4})').firstMatch(raw); - if (match == null) return null; - return int.tryParse(match.group(1)!); - } - - double _sessionExplorationCeiling() { - return switch (_smartQueueSessionProfile.mode) { - _SmartQueueSessionMode.focus => 0.03, - _SmartQueueSessionMode.chill => 0.045, - _SmartQueueSessionMode.energetic => 0.08, - _SmartQueueSessionMode.balanced => 0.06, - }; - } - - double _smartQueueSessionAlignment({ - required _SmartQueueSessionProfile profile, - required Track candidate, - }) { - final targetDuration = max(1, profile.targetDurationSec); - final durationDiff = (candidate.duration - targetDuration).abs().toDouble(); - final durationMatch = - (1.0 - (durationDiff / max(90.0, targetDuration.toDouble()))).clamp( - 0.0, - 1.0, - ); - final yearMatch = _smartQueueYearCohesion( - profile: profile, - candidate: candidate, - ); - final preferredSource = _normalizeSmartQueueKey(profile.preferredSourceKey); - final candidateSource = _sourceKey(candidate.source ?? ''); - final sourceMatch = - preferredSource.isEmpty || candidateSource == preferredSource - ? 1.0 - : 0.45; - return ((durationMatch * 0.55) + (yearMatch * 0.25) + (sourceMatch * 0.20)) - .clamp(0.0, 1.0); - } - - double _smartQueueYearCohesion({ - required _SmartQueueSessionProfile profile, - required Track candidate, - }) { - final targetYear = profile.targetYear; - final candidateYear = _parseYear(candidate.releaseDate); - if (targetYear == null || candidateYear == null) return 0.55; - final diff = (targetYear - candidateYear).abs(); - if (diff == 0) return 1.0; - if (diff <= 2) return 0.88; - if (diff <= 5) return 0.72; - if (diff <= 10) return 0.5; - if (diff <= 15) return 0.3; - return 0.1; - } - - double _smartQueueTempoContinuity({ - required Track seed, - required Track candidate, - }) { - final seedTempo = _smartQueueTempoHintForTrack(seed); - final candidateTempo = _smartQueueTempoHintForTrack(candidate); - if (seedTempo == null || candidateTempo == null) { - return _durationSimilarity( - seed.duration, - candidate.duration, - ).clamp(0.2, 1.0); - } - final diff = (seedTempo - candidateTempo).abs(); - if (diff <= 8) return 1.0; - if (diff <= 16) return 0.82; - if (diff <= 26) return 0.62; - if (diff <= _smartQueueMaxTempoJumpBpm) return 0.38; - return 0.12; - } - - double? _smartQueueTempoHintForTrack(Track track) { - final key = _trackKeyFromTrack(track); - if (key.isEmpty) return null; - final raw = _smartQueueTempoHintByTrackKey[key]; - if (raw == null || raw <= 0) return null; - return raw; - } - - String _sourceKey(String sourceRaw) { - final normalized = _normalizeSmartQueueKey(sourceRaw); - if (normalized.isNotEmpty) return normalized; - return _resolveService( - ref.read(settingsProvider).defaultService, - ).toLowerCase(); - } - - List<_SmartQueueCandidate> _selectSmartQueueCandidates({ - required Track seed, - required _SmartQueueSessionProfile sessionProfile, - required List<_SmartQueueCandidate> scored, - required int targetCount, - }) { - if (targetCount <= 0 || scored.isEmpty) return const []; - - final poolSize = min(scored.length, max(14, targetCount * 3)); - final pool = scored.take(poolSize).toList(growable: true); - final selected = <_SmartQueueCandidate>[]; - final artistCounts = _buildSmartQueueArtistBaselineCounts(); - final selectedKeys = {}; - - while (pool.isNotEmpty && selected.length < targetCount) { - final picked = _pickWeightedCandidate(pool); - pool.remove(picked); - if (selectedKeys.contains(picked.key)) { - continue; - } - - final artistKey = _normalizeSmartQueueKey(picked.track.artistName); - final repeats = artistCounts[artistKey] ?? 0; - if (artistKey.isNotEmpty && repeats >= _smartQueueMaxArtistRepeats) { - continue; - } - - if (!_passesSmartQueueConstraints( - seed: seed, - candidate: picked.track, - profile: sessionProfile, - )) { - continue; - } - - selected.add(picked); - selectedKeys.add(picked.key); - if (artistKey.isNotEmpty) { - artistCounts[artistKey] = repeats + 1; - } - } - - if (selected.isEmpty) { - final relaxedArtistLimit = _smartQueueMaxArtistRepeats + 1; - for (final candidate in scored) { - if (selected.length >= targetCount) break; - if (selectedKeys.contains(candidate.key)) continue; - - final artistKey = _normalizeSmartQueueKey(candidate.track.artistName); - final repeats = artistCounts[artistKey] ?? 0; - if (artistKey.isNotEmpty && repeats >= relaxedArtistLimit) { - continue; - } - - selected.add(candidate); - selectedKeys.add(candidate.key); - if (artistKey.isNotEmpty) { - artistCounts[artistKey] = repeats + 1; - } - } - } - - return selected; - } - - Map _buildSmartQueueArtistBaselineCounts() { - final counts = {}; - for (final item in state.queue.reversed.take(8)) { - final artistKey = _normalizeSmartQueueKey(item.artist); - if (artistKey.isEmpty) continue; - counts[artistKey] = (counts[artistKey] ?? 0) + 1; - } - for (final signal in _smartQueueSessionSignals.reversed.take(8)) { - final artistKey = signal.artistKey; - if (artistKey.isEmpty) continue; - counts[artistKey] = (counts[artistKey] ?? 0) + 1; - } - return counts; - } - - bool _passesSmartQueueConstraints({ - required Track seed, - required Track candidate, - required _SmartQueueSessionProfile profile, - }) { - final seedYear = _parseYear(seed.releaseDate); - final candidateYear = _parseYear(candidate.releaseDate); - if (seedYear != null && - candidateYear != null && - (seedYear - candidateYear).abs() > _smartQueueMaxDecadeDriftYears) { - return false; - } - - if (profile.targetYear != null && - candidateYear != null && - (profile.targetYear! - candidateYear).abs() > - _smartQueueMaxDecadeDriftYears) { - return false; - } - - final seedTempo = _smartQueueTempoHintForTrack(seed); - final candidateTempo = _smartQueueTempoHintForTrack(candidate); - if (seedTempo != null && - candidateTempo != null && - (seedTempo - candidateTempo).abs() > _smartQueueMaxTempoJumpBpm) { - return false; - } - - final seedDuration = max(1, seed.duration); - final candidateDuration = max(1, candidate.duration); - final durationRatio = candidateDuration / seedDuration; - if (durationRatio > 2.25 || durationRatio < 0.45) { - return false; - } - return true; - } - - _SmartQueueCandidate _pickWeightedCandidate(List<_SmartQueueCandidate> pool) { - if (pool.length == 1) return pool.first; - - var total = 0.0; - for (final item in pool) { - total += max(0.0001, item.score); - } - var cursor = _smartQueueRandom.nextDouble() * total; - for (final item in pool) { - cursor -= max(0.0001, item.score); - if (cursor <= 0) return item; - } - return pool.last; - } - - void _pruneSmartQueueCaches() { - final now = DateTime.now(); - _smartQueueSearchCache.removeWhere( - (_, value) => now.difference(value.fetchedAt) > _smartQueueSearchCacheTtl, - ); - _smartQueueRelatedArtistsCache.removeWhere( - (_, value) => now.difference(value.fetchedAt) > _smartQueueSearchCacheTtl, - ); - _smartQueuePendingFeedbackByTrack.removeWhere( - (_, value) => now.difference(value.addedAt) > _smartQueueFeedbackMaxAge, - ); - } - - Uri _uriFromPath(String path) { - final input = path.trim(); - if (input.startsWith('http://') || - input.startsWith('https://') || - input.startsWith('content://') || - input.startsWith('file://')) { - return Uri.parse(input); - } - return Uri.file(input); - } - - String _resolvePrefetchServiceBucket(PlaybackItem item) { - final itemService = item.service.trim().toLowerCase(); - if (_isBuiltInStreamingService(itemService)) { - return itemService; - } - - final trackSource = (item.track?.source ?? '').trim().toLowerCase(); - if (_isBuiltInStreamingService(trackSource)) { - return trackSource; - } - - final defaultService = _resolveService( - ref.read(settingsProvider).defaultService, - ).toLowerCase(); - if (_isBuiltInStreamingService(defaultService)) { - return defaultService; - } - return 'other'; - } - - int _defaultPrefetchResolveLatencyMs(String serviceBucket) { - switch (serviceBucket) { - case 'tidal': - return 16000; - case 'amazon': - return 15000; - case 'qobuz': - return 10000; - case 'youtube': - return 12000; - default: - return 10000; - } - } - - int _prefetchSafetyMarginMs(String serviceBucket) { - switch (serviceBucket) { - case 'tidal': - return 9000; - case 'amazon': - return 7000; - case 'qobuz': - return 5000; - case 'youtube': - return 6000; - default: - return 5000; - } - } - - int _estimatePrefetchResolveLatencyMs(String serviceBucket) { - final samples = _prefetchLatencyByServiceMs[serviceBucket]; - if (samples == null || samples.isEmpty) { - return _defaultPrefetchResolveLatencyMs(serviceBucket); - } - - final sorted = [...samples]..sort(); - final percentileIndex = (((sorted.length - 1) * 0.95).round()).clamp( - 0, - sorted.length - 1, - ); - return sorted[percentileIndex]; - } - - Duration _adaptivePrefetchThresholdFor(PlaybackItem nextItem) { - final serviceBucket = _resolvePrefetchServiceBucket(nextItem); - var triggerMs = - _estimatePrefetchResolveLatencyMs(serviceBucket) + - _prefetchSafetyMarginMs(serviceBucket); - if (serviceBucket == 'tidal') { - // DASH manifest flow typically needs earlier warmup than direct URLs. - triggerMs = max(triggerMs, 22000); - } - final clamped = triggerMs.clamp( - _prefetchThresholdFloor.inMilliseconds, - _prefetchThresholdCeiling.inMilliseconds, - ); - return Duration(milliseconds: clamped.toInt()); - } - - bool _shouldTriggerPrefetchAttempt({ - required int attempts, - required Duration position, - required Duration remaining, - required Duration threshold, - }) { - if (attempts >= _maxPrefetchAttemptsPerTrack) { - return false; - } - if (position < const Duration(seconds: 1) || remaining.isNegative) { - return false; - } - - final inLateWindow = remaining <= threshold; - if (attempts == 0) { - return inLateWindow || position >= _prefetchEarlyKickoffPosition; - } - - // Retry only close to track end to avoid repeated resolver load. - return inLateWindow; - } - - void _maybePrefetchNext(Duration position) { - if (state.isLoading || state.currentIndex < 0 || state.queue.isEmpty) { - return; - } - final duration = state.duration; - if (duration <= Duration.zero) return; - - final nextIndex = _peekNextIndexForPrefetch(); - if (nextIndex == null) return; - if (nextIndex < 0 || nextIndex >= state.queue.length) return; - if (_prefetchingQueueIndex == nextIndex && - _lastPrefetchAttemptIndex == nextIndex) { - return; - } - - final nextItem = state.queue[nextIndex]; - if (nextItem.sourceUri.isNotEmpty || - nextItem.track == null || - nextItem.isLocal) { - return; - } - - final remaining = duration - position; - final adaptiveThreshold = _adaptivePrefetchThresholdFor(nextItem); - final attempts = _prefetchAttemptCounts[nextIndex] ?? 0; - if (!_shouldTriggerPrefetchAttempt( - attempts: attempts, - position: position, - remaining: remaining, - threshold: adaptiveThreshold, - )) { - return; - } - - final lastAttemptAt = _prefetchLastAttemptAt[nextIndex]; - if (lastAttemptAt != null && - DateTime.now().difference(lastAttemptAt) < _prefetchRetryCooldown) { - return; - } - - _prefetchAttemptCounts[nextIndex] = attempts + 1; - _prefetchLastAttemptAt[nextIndex] = DateTime.now(); - _lastPrefetchAttemptIndex = nextIndex; - unawaited(_prefetchQueueIndex(nextIndex)); - } - - int? _peekNextIndexForPrefetch() { - if (state.queue.isEmpty) return null; - - if (state.shuffle) { - final nextPos = _shufflePosition + 1; - if (nextPos < _shuffleOrder.length) { - return _shuffleOrder[nextPos]; - } - if (state.repeatMode == RepeatMode.all && _shuffleOrder.isNotEmpty) { - return _shuffleOrder.first; - } - return null; - } - - final next = state.currentIndex + 1; - if (next < state.queue.length) return next; - if (state.repeatMode == RepeatMode.all) return 0; - return null; - } - - Future _prefetchQueueIndex(int index) async { - if (index < 0) return; - } - - String _resolveService(String defaultService) { - final selected = defaultService.trim(); - if (selected.isEmpty) { - return 'tidal'; - } - final normalized = selected.toLowerCase(); - if (_isBuiltInStreamingService(normalized)) { - return normalized; - } - return selected; - } - - bool _isBuiltInStreamingService(String service) { - switch (service) { - case 'tidal': - case 'qobuz': - case 'amazon': - case 'youtube': - return true; - default: - return false; - } - } - - void _setPlaybackError(String message, {String type = 'resolve_failed'}) { - final trimmed = message.trim(); - state = state.copyWith( - isLoading: false, - isPlaying: false, - isBuffering: false, - error: trimmed.isEmpty ? 'Playback error' : trimmed, - errorType: type, - ); - } - - bool _shouldAutoSkipQueueItemOnFailure(String? failureType) { - final settings = ref.read(settingsProvider); - if (!settings.autoSkipUnavailableTracks) { - return false; - } - final normalized = (failureType ?? '').trim().toLowerCase(); - return normalized == 'not_found' || normalized == 'resolve_failed'; - } - - int? _resolveNextQueueIndexWithoutWrapAfterFailure(int failedIndex) { - if (failedIndex < 0 || failedIndex >= state.queue.length) return null; - - if (state.shuffle) { - final failedShufflePos = _shuffleOrder.indexOf(failedIndex); - if (failedShufflePos < 0) return null; - final nextShufflePos = failedShufflePos + 1; - if (nextShufflePos >= _shuffleOrder.length) return null; - return _shuffleOrder[nextShufflePos]; - } - - final nextIndex = failedIndex + 1; - if (nextIndex >= state.queue.length) return null; - return nextIndex; - } - - Future _handleQueueItemPlaybackFailure({ - required int failedIndex, - required int expectedRequestEpoch, - required Object error, - String fallbackType = 'resolve_failed', - }) async { - if (!_isPlayRequestCurrent(expectedRequestEpoch)) { - return false; - } - - final hasExistingError = (state.error ?? '').trim().isNotEmpty; - if (hasExistingError) { - state = state.copyWith( - isLoading: false, - isPlaying: false, - isBuffering: false, - ); - } else { - _setPlaybackError('Failed to play: $error', type: fallbackType); - } - - if (!_isPlayRequestCurrent(expectedRequestEpoch) || - state.currentIndex != failedIndex || - !_shouldAutoSkipQueueItemOnFailure(state.errorType)) { - return false; - } - - final nextIndex = _resolveNextQueueIndexWithoutWrapAfterFailure( - failedIndex, - ); - if (nextIndex == null || nextIndex == failedIndex) { - return false; - } - - final failureMessage = (state.error ?? '').trim(); - _log.w( - 'Auto-skip queue item $failedIndex -> $nextIndex ' - 'after ${state.errorType ?? fallbackType}: ' - '${failureMessage.isNotEmpty ? failureMessage : error}', - ); - await _playQueueIndex(nextIndex); - return true; - } - - bool _inferSeekSupportedForQueueItem(PlaybackItem item) { - if (item.isLocal) return true; - - final service = item.service.trim().toLowerCase(); - final trackSource = (item.track?.source ?? '').trim().toLowerCase(); - final resolvedService = service.isNotEmpty ? service : trackSource; - if (resolvedService == 'youtube') return false; - - final sourceUri = item.sourceUri.trim(); - if (sourceUri.isNotEmpty && - FFmpegService.isActiveLiveDecryptedUrl(sourceUri)) { - return false; - } - - return true; - } - - Duration? _pendingResumePositionForIndex(int index) { - final pendingPosition = _pendingResumePosition; - final pendingIndex = _pendingResumeIndex; - if (pendingPosition == null || - pendingPosition <= Duration.zero || - pendingIndex != index) { - return null; - } - return pendingPosition; - } - - void _clearPendingResumeForIndex(int index) { - if (_pendingResumeIndex != index) return; - _pendingResumePosition = null; - _pendingResumeIndex = null; - } - - void _scheduleSnapshotSaveForProgress(Duration position) { - if (state.queue.isEmpty || state.currentIndex < 0) return; - if (_player.processingState == ProcessingState.idle) return; - - final ms = position.inMilliseconds; - if (_lastProgressSnapshotMs >= 0 && - (ms - _lastProgressSnapshotMs).abs() < 1500) { - return; - } - _lastProgressSnapshotMs = ms; - - _snapshotSaveTimer?.cancel(); - _snapshotSaveTimer = Timer(const Duration(milliseconds: 300), () { - unawaited(_savePlaybackSnapshot()); - }); - } - - void _disposeInternal() { - _appLifecycleListener?.dispose(); - _appLifecycleListener = null; - _snapshotSaveTimer?.cancel(); - _smartQueueModelSaveTimer?.cancel(); - unawaited(_savePlaybackSnapshot()); - unawaited(_persistSmartQueueModel()); - unawaited(FFmpegService.stopLiveDecryptedStream()); - unawaited(FFmpegService.stopNativeDashManifestPlayback()); - for (final sub in _subscriptions) { - sub.cancel(); - } - _player.dispose(); - } -} - -class _SmartQueueLearningContext { - final Map features; - final DateTime addedAt; - - const _SmartQueueLearningContext({ - required this.features, - required this.addedAt, - }); -} - -enum _SmartQueueSessionMode { balanced, focus, chill, energetic } - -class _SmartQueueSessionProfile { - final _SmartQueueSessionMode mode; - final int targetDurationSec; - final int? targetYear; - final String preferredSourceKey; - - const _SmartQueueSessionProfile({ - required this.mode, - required this.targetDurationSec, - this.targetYear, - this.preferredSourceKey = '', - }); -} - -class _SmartQueueSessionSignal { - final String artistKey; - final String sourceKey; - final int durationSec; - final int? releaseYear; - final double listenRatio; - final bool skipped; - - const _SmartQueueSessionSignal({ - required this.artistKey, - required this.sourceKey, - required this.durationSec, - required this.releaseYear, - required this.listenRatio, - required this.skipped, - }); -} - -class _SmartQueueCachedResult { - final List tracks; - final DateTime fetchedAt; - - const _SmartQueueCachedResult({ - required this.tracks, - required this.fetchedAt, - }); -} - -class _SmartQueueRelatedArtistsCache { - final List<_SmartQueueRelatedArtist> artists; - final DateTime fetchedAt; - - const _SmartQueueRelatedArtistsCache({ - required this.artists, - required this.fetchedAt, - }); -} - -class _SmartQueueRelatedArtist { - final String name; - final String provider; - final double score; - - const _SmartQueueRelatedArtist({ - required this.name, - required this.provider, - required this.score, - }); -} - -class _SmartQueueArtistSeed { - final String id; - final String name; - final String provider; - final double score; - - const _SmartQueueArtistSeed({ - required this.id, - required this.name, - required this.provider, - required this.score, - }); -} - -class _SmartQueueCandidate { - final Track track; - final String key; - final Map features; - final double score; - - const _SmartQueueCandidate({ - required this.track, - required this.key, - required this.features, - required this.score, - }); -} - -class _OfflineSmartQueueTrackHit { - final Track track; - final double score; - - const _OfflineSmartQueueTrackHit({required this.track, required this.score}); -} - -class _OfflineSmartQueueArtistStats { - final String name; - final int count; - final double scoreSum; - - const _OfflineSmartQueueArtistStats({ - required this.name, - required this.count, - required this.scoreSum, - }); } final playbackProvider = NotifierProvider( diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 7bad99bc..133bdeec 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -279,22 +279,6 @@ class SettingsNotifier extends Notifier { _saveSettings(); } - void setAutoSkipUnavailableTracks(bool enabled) { - state = state.copyWith(autoSkipUnavailableTracks: enabled); - _saveSettings(); - } - - void setPlayerMode(String mode) { - final normalized = mode == 'external' ? 'external' : 'internal'; - state = state.copyWith(playerMode: normalized); - _saveSettings(); - } - - void setSmartQueueEnabled(bool enabled) { - state = state.copyWith(smartQueueEnabled: enabled); - _saveSettings(); - } - void setEmbedLyrics(bool enabled) { state = state.copyWith(embedLyrics: enabled); _saveSettings(); diff --git a/lib/screens/library_tracks_folder_screen.dart b/lib/screens/library_tracks_folder_screen.dart index 3d66b196..d2442c22 100644 --- a/lib/screens/library_tracks_folder_screen.dart +++ b/lib/screens/library_tracks_folder_screen.dart @@ -10,7 +10,6 @@ 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/local_library_provider.dart'; -import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; @@ -793,12 +792,11 @@ class _LibraryTracksFolderScreenState Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - if (widget.mode != - LibraryTracksFolderMode.wishlist) ...[ - _buildShuffleButton(entries), - const SizedBox(width: 12), - ], - _buildPlayAllCenterButton(entries), + _buildHeaderActionPlaceholder(), + const SizedBox(width: 12), + _buildDownloadAllCenterButton(entries), + const SizedBox(width: 12), + _buildHeaderActionPlaceholder(), ], ), ], @@ -831,35 +829,16 @@ class _LibraryTracksFolderScreenState ); } - // ── Shuffle / Play buttons ── + // ── Header actions ── - Widget _buildShuffleButton(List entries) { - return Container( - width: 48, - height: 48, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: Colors.white.withValues(alpha: 0.15), - border: Border.all( - color: Colors.white.withValues(alpha: 0.3), - width: 1, - ), - ), - child: IconButton( - onPressed: entries.isEmpty ? null : () => _shufflePlay(entries), - icon: const Icon(Icons.shuffle_rounded, size: 22, color: Colors.white), - tooltip: 'Shuffle Play', - padding: EdgeInsets.zero, - ), - ); - } + Widget _buildHeaderActionPlaceholder() => const SizedBox(width: 48, height: 48); - Widget _buildPlayAllCenterButton(List entries) { + Widget _buildDownloadAllCenterButton(List entries) { final tracks = entries.map((e) => e.track).toList(growable: false); return FilledButton.icon( - onPressed: tracks.isEmpty ? null : () => _playAll(tracks), - icon: const Icon(Icons.play_arrow_rounded, size: 18), - label: Text(context.l10n.playAllCount(tracks.length)), + onPressed: tracks.isEmpty ? null : () => _confirmDownloadAll(tracks), + icon: const Icon(Icons.download_rounded, size: 18), + label: Text(context.l10n.downloadAllCount(tracks.length)), style: FilledButton.styleFrom( backgroundColor: Colors.white, foregroundColor: Colors.black87, @@ -869,28 +848,70 @@ class _LibraryTracksFolderScreenState ); } - void _shufflePlay(List entries) { - final tracks = entries.map((e) => e.track).toList(growable: false); + void _confirmDownloadAll(List tracks) { if (tracks.isEmpty) return; - final shuffled = [...tracks]..shuffle(); - final messenger = ScaffoldMessenger.of(context); - ref.read(playbackProvider.notifier).playTrackList(shuffled).catchError((e) { - if (!mounted) return; - messenger.showSnackBar( - SnackBar(content: Text('Cannot shuffle play local tracks: $e')), - ); - }); + showDialog( + context: context, + builder: (dialogContext) { + final colorScheme = Theme.of(dialogContext).colorScheme; + return AlertDialog( + backgroundColor: colorScheme.surfaceContainerHigh, + title: const Text('Download All'), + content: Text('Download ${tracks.length} tracks?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(context.l10n.dialogCancel), + ), + FilledButton( + onPressed: () { + Navigator.pop(dialogContext); + _downloadAll(tracks); + }, + child: const Text('Download'), + ), + ], + ); + }, + ); } - void _playAll(List tracks) { + void _downloadAll(List tracks) { if (tracks.isEmpty) return; - final messenger = ScaffoldMessenger.of(context); - ref.read(playbackProvider.notifier).playTrackList(tracks).catchError((e) { - if (!mounted) return; - messenger.showSnackBar( - SnackBar(content: Text('Cannot play local tracks: $e')), + final settings = ref.read(settingsProvider); + if (settings.askQualityBeforeDownload) { + DownloadServicePicker.show( + context, + trackName: '${tracks.length} tracks', + artistName: switch (widget.mode) { + LibraryTracksFolderMode.wishlist => context.l10n.collectionWishlist, + LibraryTracksFolderMode.loved => context.l10n.collectionLoved, + LibraryTracksFolderMode.playlist => context.l10n.collectionPlaylist, + }, + onSelect: (quality, service) { + ref + .read(downloadQueueProvider.notifier) + .addMultipleToQueue(tracks, service, qualityOverride: quality); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.snackbarAddedTracksToQueue(tracks.length), + ), + ), + ); + }, ); - }); + } else { + ref + .read(downloadQueueProvider.notifier) + .addMultipleToQueue(tracks, settings.defaultService); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)), + ), + ); + } } void _showCoverOptionsSheet(BuildContext context, bool hasCustomCover) { @@ -1000,6 +1021,32 @@ class _CollectionTrackTile extends ConsumerWidget { final track = entry.track; final colorScheme = Theme.of(context).colorScheme; final effectiveCoverUrl = _resolveCoverUrl(track); + final isInHistory = ref.watch( + downloadHistoryProvider.select((state) { + if (state.isDownloaded(track.id)) return true; + final isrc = track.isrc?.trim(); + if (isrc != null && isrc.isNotEmpty && state.getByIsrc(isrc) != null) { + return true; + } + return state.findByTrackAndArtist(track.name, track.artistName) != null; + }), + ); + final showLocalLibraryIndicator = ref.watch( + settingsProvider.select( + (s) => s.localLibraryEnabled && s.localLibraryShowDuplicates, + ), + ); + final isInLocalLibrary = showLocalLibraryIndicator + ? ref.watch( + localLibraryProvider.select( + (state) => state.existsInLibrary( + isrc: track.isrc, + trackName: track.name, + artistName: track.artistName, + ), + ), + ) + : false; return Padding( padding: const EdgeInsets.symmetric(horizontal: 8), @@ -1059,10 +1106,48 @@ class _CollectionTrackTile extends ConsumerWidget { ], ), title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis), - subtitle: Text( - track.artistName, - maxLines: 1, - overflow: TextOverflow.ellipsis, + subtitle: Row( + children: [ + Flexible( + child: Text( + track.artistName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (isInLocalLibrary || isInHistory) ...[ + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: colorScheme.tertiaryContainer, + borderRadius: BorderRadius.circular(4), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.folder_outlined, + size: 10, + color: colorScheme.onTertiaryContainer, + ), + const SizedBox(width: 3), + Text( + context.l10n.libraryInLibrary, + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w500, + color: colorScheme.onTertiaryContainer, + ), + ), + ], + ), + ), + ], + ], ), trailing: isSelectionMode ? null diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index 42e20dd2..e103c6a1 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -20,7 +20,6 @@ import 'package:spotiflac_android/services/shell_navigation_service.dart'; import 'package:spotiflac_android/services/share_intent_service.dart'; import 'package:spotiflac_android/services/update_checker.dart'; import 'package:spotiflac_android/widgets/update_dialog.dart'; -import 'package:spotiflac_android/widgets/mini_player_bar.dart'; import 'package:spotiflac_android/utils/logger.dart'; final _log = AppLogger('MainShell'); @@ -375,7 +374,7 @@ class _MainShellState extends ConsumerState { if (_lastBackPress != null && now.difference(_lastBackPress!) < const Duration(seconds: 2)) { _log.i('Back: step 8 - double-tap exit'); - SystemNavigator.pop(); + unawaited(PlatformBridge.exitApp()); } else { _log.i('Back: step 7 - first tap, showing exit snackbar'); _lastBackPress = now; @@ -487,28 +486,17 @@ class _MainShellState extends ConsumerState { }); } - return PopScope( - canPop: false, - onPopInvokedWithResult: (didPop, result) async { - if (didPop) { - return; - } - + return BackButtonListener( + onBackButtonPressed: () async { _handleBackPress(); + return true; }, child: Scaffold( - body: Column( - children: [ - Expanded( - child: PageView( - controller: _pageController, - onPageChanged: _onPageChanged, - physics: const NeverScrollableScrollPhysics(), - children: tabs, - ), - ), - const MiniPlayerBar(), - ], + body: PageView( + controller: _pageController, + onPageChanged: _onPageChanged, + physics: const NeverScrollableScrollPhysics(), + children: tabs, ), bottomNavigationBar: NavigationBar( selectedIndex: _currentIndex.clamp(0, maxIndex), diff --git a/lib/screens/settings/about_page.dart b/lib/screens/settings/about_page.dart index ba8d7bc8..1765647c 100644 --- a/lib/screens/settings/about_page.dart +++ b/lib/screens/settings/about_page.dart @@ -149,6 +149,12 @@ class AboutPage extends StatelessWidget { subtitle: 'Partner lyrics proxy for Apple Music and QQ Music sources', onTap: () => _launchUrl('https://lyrics.paxsenix.org'), + showDivider: true, + ), + _ContributorItem( + name: 'Ruubiiiii', + description: 'Provided Qobuz API for the project', + githubUsername: 'Ruubiiiii', showDivider: false, ), ], diff --git a/lib/screens/settings/donate_page.dart b/lib/screens/settings/donate_page.dart index a1933808..4f20d118 100644 --- a/lib/screens/settings/donate_page.dart +++ b/lib/screens/settings/donate_page.dart @@ -166,19 +166,7 @@ class _RecentDonorsCard extends StatelessWidget { @override Widget build(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; - const donorNames = [ - 'NinoBrown', - '@nino_sandzak', - 'IMJ', - 'J', - 'Julian', - 'matt_3050', - 'Daniel', - '283Fabio', - 'laflame', - 'Elias el Autentico', - 'Faylyne', - ]; + const donorNames = []; // Match SettingsGroup color logic final cardColor = isDark @@ -221,16 +209,39 @@ class _RecentDonorsCard extends StatelessWidget { ), ), const SizedBox(height: 16), - Wrap( - spacing: 8, - runSpacing: 8, - children: donorNames - .map( - (name) => - _SupporterChip(name: name, colorScheme: colorScheme), - ) - .toList(), - ), + if (donorNames.isEmpty) + Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Column( + children: [ + Icon( + Icons.emoji_events_outlined, + size: 32, + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), + ), + const SizedBox(height: 8), + Text( + 'No supporters yet — be the first!', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.6), + ), + ), + ], + ), + ), + ) + else + Wrap( + spacing: 8, + runSpacing: 8, + children: donorNames + .map( + (name) => + _SupporterChip(name: name, colorScheme: colorScheme), + ) + .toList(), + ), ], ), ), @@ -463,8 +474,8 @@ int _cr(String v) { for (final c in v.codeUnits) { r = (r * 31 + c) & 0x7FFFFFFF; } return r; } -// Highlighted supporters (hashes of names): Julian, J, NinoBrown, @nino_sandzak, IMJ. -const _cv = {1825257268, 1035, 1497948283, 398058782, 996135}; +// Highlighted supporters (hashes of names): none for now. +const _cv = {}; class _SupporterChip extends StatelessWidget { final String name; diff --git a/lib/screens/settings/options_settings_page.dart b/lib/screens/settings/options_settings_page.dart index 6a33934c..a5a6e07c 100644 --- a/lib/screens/settings/options_settings_page.dart +++ b/lib/screens/settings/options_settings_page.dart @@ -152,40 +152,6 @@ class OptionsSettingsPage extends ConsumerWidget { onChanged: (v) => ref.read(settingsProvider.notifier).setAutoFallback(v), ), - SettingsSwitchItem( - icon: Icons.skip_next_rounded, - title: context.l10n.optionsAutoSkipUnavailableTracks, - subtitle: settings.autoSkipUnavailableTracks - ? context - .l10n - .optionsAutoSkipUnavailableTracksSubtitleOn - : context - .l10n - .optionsAutoSkipUnavailableTracksSubtitleOff, - value: settings.autoSkipUnavailableTracks, - onChanged: (v) => ref - .read(settingsProvider.notifier) - .setAutoSkipUnavailableTracks(v), - ), - SettingsItem( - icon: Icons.headphones, - title: 'Music Player', - subtitle: _playerModeLabel(settings.playerMode), - onTap: () => _showPlayerModePicker( - context, - ref, - settings.playerMode, - ), - ), - SettingsSwitchItem( - icon: Icons.queue_music_rounded, - title: context.l10n.settingsSmartQueueTitle, - subtitle: context.l10n.settingsSmartQueueSubtitle, - value: settings.smartQueueEnabled, - onChanged: (v) => ref - .read(settingsProvider.notifier) - .setSmartQueueEnabled(v), - ), if (hasExtensions) SettingsSwitchItem( icon: Icons.extension, @@ -328,74 +294,6 @@ class OptionsSettingsPage extends ConsumerWidget { ); } - String _playerModeLabel(String mode) { - if (mode == 'external') { - return 'External app (Poweramp, etc.)'; - } - return 'Internal player'; - } - - void _showPlayerModePicker( - BuildContext context, - WidgetRef ref, - String currentMode, - ) { - showModalBottomSheet( - context: context, - useRootNavigator: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), - builder: (sheetContext) { - final colorScheme = Theme.of(sheetContext).colorScheme; - return SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(height: 8), - Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: colorScheme.outlineVariant, - borderRadius: BorderRadius.circular(999), - ), - ), - const SizedBox(height: 12), - ListTile( - leading: const Icon(Icons.play_circle_outline), - title: const Text('Internal Player'), - subtitle: const Text('Use built-in app playback and queue'), - trailing: currentMode == 'internal' - ? Icon(Icons.check, color: colorScheme.primary) - : null, - onTap: () { - ref.read(settingsProvider.notifier).setPlayerMode('internal'); - Navigator.pop(sheetContext); - }, - ), - ListTile( - leading: const Icon(Icons.open_in_new), - title: const Text('External Player'), - subtitle: const Text( - 'Open songs with apps like Poweramp, Musicolet, etc.', - ), - trailing: currentMode == 'external' - ? Icon(Icons.check, color: colorScheme.primary) - : null, - onTap: () { - ref.read(settingsProvider.notifier).setPlayerMode('external'); - Navigator.pop(sheetContext); - }, - ), - const SizedBox(height: 8), - ], - ), - ); - }, - ); - } - void _showClearHistoryDialog( BuildContext context, WidgetRef ref, diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 6710df4b..a0b35d54 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -142,6 +142,10 @@ class PlatformBridge { }); } + static Future exitApp() async { + await _channel.invokeMethod('exitApp'); + } + static Future initItemProgress(String itemId) async { await _channel.invokeMethod('initItemProgress', {'item_id': itemId}); } diff --git a/lib/services/update_checker.dart b/lib/services/update_checker.dart index 3a2fb50f..bc9335c4 100644 --- a/lib/services/update_checker.dart +++ b/lib/services/update_checker.dart @@ -74,6 +74,13 @@ class UpdateChecker { return null; } + // Ignore releases from a different major version (e.g. v4.x when we + // rolled back to v3.x). Only offer updates within the same major line. + if (_majorVersion(latestVersion) != _majorVersion(AppInfo.version)) { + _log.i('Skipping update from different major version (current: ${AppInfo.version}, latest: $latestVersion)'); + return null; + } + final body = releaseData['body'] as String? ?? 'No changelog available'; final htmlUrl = releaseData['html_url'] as String? ?? '${AppInfo.githubUrl}/releases'; final publishedAt = DateTime.tryParse(releaseData['published_at'] as String? ?? '') ?? DateTime.now(); @@ -118,6 +125,14 @@ class UpdateChecker { } } + static int _majorVersion(String version) { + try { + return int.parse(version.split('-').first.split('.').first); + } catch (_) { + return -1; + } + } + static bool _isNewerVersion(String latest, String current) { try { final latestBase = latest.split('-').first; diff --git a/lib/widgets/mini_player_bar.dart b/lib/widgets/mini_player_bar.dart deleted file mode 100644 index 3a31bd2d..00000000 --- a/lib/widgets/mini_player_bar.dart +++ /dev/null @@ -1,2040 +0,0 @@ -import 'dart:io'; -import 'dart:async'; - -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:spotiflac_android/l10n/l10n.dart'; -import 'package:spotiflac_android/models/playback_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/playback_provider.dart'; -import 'package:spotiflac_android/providers/playback_provider.dart' - as playback_types - show RepeatMode; -import 'package:spotiflac_android/providers/settings_provider.dart'; -import 'package:spotiflac_android/services/cover_cache_manager.dart'; -import 'package:spotiflac_android/widgets/download_service_picker.dart'; -import 'package:spotiflac_android/utils/clickable_metadata.dart'; - -const Set _builtInPlaybackSources = { - 'deezer', - 'spotify', - 'tidal', - 'qobuz', - 'amazon', - 'youtube', - 'ytmusic', - 'local', -}; - -String? _playbackItemExtensionId(PlaybackItem item) { - final source = (item.track?.source ?? '').trim(); - if (source.isEmpty) return null; - if (_builtInPlaybackSources.contains(source.toLowerCase())) return null; - return source; -} - -// ─── Mini Player Bar ───────────────────────────────────────────────────────── -class MiniPlayerBar extends ConsumerWidget { - const MiniPlayerBar({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final stateSnapshot = ref.watch( - playbackProvider.select( - (s) => ( - currentItem: s.currentItem, - isPlaying: s.isPlaying, - isBuffering: s.isBuffering, - isLoading: s.isLoading, - hasNext: s.hasNext, - repeatMode: s.repeatMode, - error: s.error, - errorType: s.errorType, - ), - ), - ); - final playbackError = _localizedPlaybackErrorFromRaw( - context, - stateSnapshot.error, - stateSnapshot.errorType, - ); - final item = stateSnapshot.currentItem; - if (item == null) return const SizedBox.shrink(); - - final colorScheme = Theme.of(context).colorScheme; - - return Material( - color: colorScheme.surfaceContainerHighest, - child: InkWell( - onTap: () => _showExpandedPlayer(context), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const _MiniPlayerProgressBar(), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - child: Row( - children: [ - // Cover art - _CoverArt( - url: item.coverUrl, - isLocal: item.hasLocalCover, - size: 40, - borderRadius: 8, - ), - const SizedBox(width: 10), - // Track info - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodyMedium - ?.copyWith(fontWeight: FontWeight.w600), - ), - Text( - item.artist, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall - ?.copyWith(color: colorScheme.onSurfaceVariant), - ), - ], - ), - ), - // Error indicator - if (playbackError != null) - Padding( - padding: const EdgeInsets.only(right: 4), - child: Icon( - Icons.error_outline_rounded, - size: 20, - color: colorScheme.error, - ), - ), - // Loading indicator - if (stateSnapshot.isBuffering || stateSnapshot.isLoading) - const Padding( - padding: EdgeInsets.only(right: 8), - child: SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ), - // Play / Pause - IconButton( - icon: Icon( - stateSnapshot.isPlaying - ? Icons.pause_rounded - : Icons.play_arrow_rounded, - ), - onPressed: () => - ref.read(playbackProvider.notifier).togglePlayPause(), - ), - // Next - if (stateSnapshot.hasNext || - stateSnapshot.repeatMode == playback_types.RepeatMode.all) - IconButton( - icon: const Icon(Icons.skip_next_rounded, size: 22), - onPressed: () => - ref.read(playbackProvider.notifier).skipNext(), - ), - // Close - IconButton( - icon: const Icon(Icons.close_rounded, size: 20), - onPressed: () => - ref.read(playbackProvider.notifier).dismissPlayer(), - visualDensity: VisualDensity.compact, - ), - ], - ), - ), - ], - ), - ), - ); - } - - void _showExpandedPlayer(BuildContext context) { - Navigator.of(context).push( - PageRouteBuilder( - opaque: false, - pageBuilder: (context, animation, secondaryAnimation) => - const _FullScreenPlayer(), - transitionsBuilder: (context, animation, secondaryAnimation, child) { - return SlideTransition( - position: Tween(begin: const Offset(0, 1), end: Offset.zero) - .animate( - CurvedAnimation( - parent: animation, - curve: Curves.easeOutCubic, - ), - ), - child: child, - ); - }, - transitionDuration: const Duration(milliseconds: 350), - reverseTransitionDuration: const Duration(milliseconds: 300), - ), - ); - } -} - -class _MiniPlayerProgressBar extends ConsumerWidget { - const _MiniPlayerProgressBar(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final progressState = ref.watch( - playbackProvider.select( - (s) => (position: s.position, duration: s.duration), - ), - ); - final colorScheme = Theme.of(context).colorScheme; - final durationMs = progressState.duration.inMilliseconds; - final positionMs = progressState.position.inMilliseconds.clamp( - 0, - durationMs > 0 ? durationMs : 0, - ); - final progress = durationMs > 0 ? positionMs / durationMs : 0.0; - - return LinearProgressIndicator( - value: progress, - minHeight: 2, - backgroundColor: colorScheme.surfaceContainerHighest, - ); - } -} - -// ─── Full-Screen Player ────────────────────────────────────────────────────── -class _FullScreenPlayer extends ConsumerStatefulWidget { - const _FullScreenPlayer(); - - @override - ConsumerState<_FullScreenPlayer> createState() => _FullScreenPlayerState(); -} - -class _FullScreenPlayerState extends ConsumerState<_FullScreenPlayer> { - // 0 = cover art view, 1 = lyrics view - int _currentPage = 0; - late final PageController _pageController; - bool _isScrubbing = false; - double _scrubSeconds = 0; - double _topBarDragOffset = 0; - String? _lastLyricsPrefetchKey; - AppLifecycleListener? _appLifecycleListener; - bool _isAppResumed = true; - - @override - void initState() { - super.initState(); - _pageController = PageController(); - final initialState = WidgetsBinding.instance.lifecycleState; - _isAppResumed = - initialState == null || initialState == AppLifecycleState.resumed; - _appLifecycleListener = AppLifecycleListener( - onResume: () { - _isAppResumed = true; - if (!mounted) return; - final state = ref.read(playbackProvider); - _prefetchLyricsForCurrentTrack(state); - }, - onPause: () => _isAppResumed = false, - onHide: () => _isAppResumed = false, - onDetach: () => _isAppResumed = false, - onInactive: () => _isAppResumed = false, - ); - } - - @override - void dispose() { - _appLifecycleListener?.dispose(); - _appLifecycleListener = null; - _pageController.dispose(); - super.dispose(); - } - - String _lyricsPrefetchKey(PlaybackItem item) { - return '${item.id}|${item.title}|${item.artist}'; - } - - void _prefetchLyricsForCurrentTrack(PlaybackState state) { - if (!_isAppResumed) return; - final item = state.currentItem; - if (item == null) return; - - final key = _lyricsPrefetchKey(item); - if (_lastLyricsPrefetchKey == key) return; - _lastLyricsPrefetchKey = key; - - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - unawaited(ref.read(playbackProvider.notifier).ensureLyricsLoaded()); - }); - } - - void _switchToLyrics() { - setState(() => _currentPage = 1); - _pageController.animateToPage( - 1, - duration: const Duration(milliseconds: 350), - curve: Curves.easeOutCubic, - ); - } - - void _switchToCover() { - setState(() => _currentPage = 0); - _pageController.animateToPage( - 0, - duration: const Duration(milliseconds: 350), - curve: Curves.easeOutCubic, - ); - } - - void _handleTopBarDragUpdate(DragUpdateDetails details) { - final delta = details.primaryDelta ?? 0; - if (delta <= 0) return; - _topBarDragOffset += delta; - } - - void _handleTopBarDragEnd(DragEndDetails details) { - final swipeVelocity = details.primaryVelocity ?? 0; - final shouldDismiss = _topBarDragOffset > 72 || swipeVelocity > 900; - _topBarDragOffset = 0; - if (!shouldDismiss) return; - if (!mounted) return; - Navigator.of(context).pop(); - } - - @override - Widget build(BuildContext context) { - final state = ref.watch(playbackProvider); - final playbackNotifier = ref.read(playbackProvider.notifier); - final displayOrder = playbackNotifier.getQueueDisplayOrder(); - final displayPosition = playbackNotifier.getCurrentDisplayQueuePosition( - displayOrder: displayOrder, - ); - final queuePositionLabel = displayPosition >= 0 - ? displayPosition + 1 - : state.currentIndex + 1; - final playbackError = _localizedPlaybackError(context, state); - final item = state.currentItem; - if (item == null) { - _lastLyricsPrefetchKey = null; - // Track stopped, close the player - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) Navigator.of(context).pop(); - }); - return const SizedBox.shrink(); - } - _prefetchLyricsForCurrentTrack(state); - final extensionId = _playbackItemExtensionId(item); - - final colorScheme = Theme.of(context).colorScheme; - final textTheme = Theme.of(context).textTheme; - final screenSize = MediaQuery.sizeOf(context); - final isLandscape = screenSize.width > screenSize.height; - - final duration = state.duration; - final position = state.position; - final maxSeconds = duration.inMilliseconds > 0 - ? duration.inSeconds.toDouble() - : 0.0; - final currentSeconds = position.inSeconds.toDouble().clamp( - 0.0, - maxSeconds > 0 ? maxSeconds : 0.0, - ); - final sliderSeconds = _isScrubbing - ? _scrubSeconds.clamp(0.0, maxSeconds > 0 ? maxSeconds : 0.0) - : currentSeconds; - - return Scaffold( - backgroundColor: colorScheme.surface, - body: SafeArea( - child: LayoutBuilder( - builder: (context, constraints) { - final isCompactLayout = isLandscape || constraints.maxHeight < 620; - final mediaSectionHeight = - (constraints.maxHeight * (isCompactLayout ? 0.32 : 0.50)).clamp( - isCompactLayout ? 140.0 : 260.0, - isCompactLayout ? 280.0 : 560.0, - ); - final horizontalPadding = isCompactLayout ? 16.0 : 24.0; - final verticalGap = isCompactLayout ? 2.0 : 4.0; - final showAlbum = item.album.isNotEmpty && !isCompactLayout; - - return SingleChildScrollView( - physics: const ClampingScrollPhysics(), - child: ConstrainedBox( - constraints: BoxConstraints(minHeight: constraints.maxHeight), - child: Column( - children: [ - // ── Top bar (close + title + lyrics toggle) - GestureDetector( - behavior: HitTestBehavior.opaque, - onVerticalDragUpdate: _handleTopBarDragUpdate, - onVerticalDragEnd: _handleTopBarDragEnd, - child: Padding( - padding: EdgeInsets.symmetric( - horizontal: 8, - vertical: isCompactLayout ? 2 : 4, - ), - child: Row( - children: [ - // ── Left side - Expanded( - child: Align( - alignment: Alignment.centerLeft, - child: IconButton( - icon: const Icon( - Icons.keyboard_arrow_down_rounded, - size: 30, - ), - visualDensity: isCompactLayout - ? VisualDensity.compact - : VisualDensity.standard, - onPressed: () => Navigator.of(context).pop(), - tooltip: 'Close', - ), - ), - ), - // ── Center: Queue info - if (state.queue.length > 1) - GestureDetector( - onTap: () => _showQueueSheet(context, ref), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 4, - ), - decoration: BoxDecoration( - color: colorScheme.primaryContainer - .withValues(alpha: 0.5), - borderRadius: BorderRadius.circular(20), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.queue_music_rounded, - size: 16, - color: colorScheme.onPrimaryContainer, - ), - const SizedBox(width: 6), - Text( - '$queuePositionLabel / ${state.queue.length}', - style: textTheme.labelMedium?.copyWith( - color: colorScheme.onPrimaryContainer, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - ), - // ── Right side - Expanded( - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - if (!item.isLocal && item.track != null) - _DownloadButton( - item: item, - compact: isCompactLayout, - ), - IconButton( - visualDensity: isCompactLayout - ? VisualDensity.compact - : VisualDensity.standard, - icon: Icon( - Icons.lyrics_outlined, - color: _currentPage == 1 - ? colorScheme.primary - : colorScheme.onSurfaceVariant, - ), - onPressed: () { - if (_currentPage == 0) { - _switchToLyrics(); - } else { - _switchToCover(); - } - }, - tooltip: 'Lyrics', - ), - ], - ), - ), - ], - ), - ), - ), - - // ── Main content area (swipeable cover / lyrics) - SizedBox( - height: mediaSectionHeight, - child: PageView( - controller: _pageController, - onPageChanged: (page) => - setState(() => _currentPage = page), - children: [ - // Page 0: Cover art - _CoverArtPage(item: item, colorScheme: colorScheme), - // Page 1: Lyrics - _LyricsPage( - state: state, - colorScheme: colorScheme, - onRetry: () => ref - .read(playbackProvider.notifier) - .refetchLyrics(), - onSeek: state.seekSupported - ? (ms) => ref - .read(playbackProvider.notifier) - .seek(Duration(milliseconds: ms)) - : null, - ), - ], - ), - ), - - // ── Page indicator dots - Padding( - padding: EdgeInsets.only(top: isCompactLayout ? 4 : 8), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - _PageDot( - active: _currentPage == 0, - colorScheme: colorScheme, - ), - const SizedBox(width: 6), - _PageDot( - active: _currentPage == 1, - colorScheme: colorScheme, - ), - ], - ), - ), - SizedBox(height: isCompactLayout ? 4 : 8), - - // ── Track info - Padding( - padding: EdgeInsets.symmetric( - horizontal: horizontalPadding, - ), - child: Row( - children: [ - const SizedBox(width: 48), - Expanded( - child: Column( - children: [ - Text( - item.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - style: - (isCompactLayout - ? textTheme.titleMedium - : textTheme.titleLarge) - ?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - SizedBox(height: verticalGap), - ClickableArtistName( - artistName: item.artist, - artistId: item.track?.artistId, - extensionId: extensionId, - coverUrl: item.coverUrl.isNotEmpty - ? item.coverUrl - : null, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: - (isCompactLayout - ? textTheme.bodySmall - : textTheme.bodyMedium) - ?.copyWith( - color: colorScheme.primary, - ), - ), - if (showAlbum) ...[ - const SizedBox(height: 2), - ClickableAlbumName( - albumName: item.album, - albumId: item.track?.albumId, - artistName: item.artist, - extensionId: extensionId, - coverUrl: item.coverUrl.isNotEmpty - ? item.coverUrl - : null, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant - .withValues(alpha: 0.7), - ), - ), - ], - ], - ), - ), - SizedBox( - width: 48, - child: item.track != null - ? Consumer( - builder: (context, ref, child) { - final isLoved = ref.watch( - libraryCollectionsProvider.select( - (s) => s.isLoved(item.track!), - ), - ); - return IconButton( - icon: Icon( - isLoved - ? Icons.favorite - : Icons.favorite_border, - size: isCompactLayout ? 24 : 28, - ), - color: isLoved - ? Colors.redAccent - : colorScheme.onSurfaceVariant, - onPressed: () => ref - .read( - libraryCollectionsProvider - .notifier, - ) - .toggleLoved(item.track!), - ); - }, - ) - : const SizedBox.shrink(), - ), - ], - ), - ), - SizedBox(height: verticalGap), - - // ── Quality + Service badge row - _QualityServiceRow(item: item, colorScheme: colorScheme), - SizedBox(height: verticalGap), - - // ── Error message - if (playbackError != null) - Padding( - padding: EdgeInsets.symmetric( - horizontal: horizontalPadding, - vertical: verticalGap, - ), - child: Text( - playbackError, - maxLines: 2, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - style: textTheme.bodySmall?.copyWith( - color: colorScheme.error, - ), - ), - ), - - // ── Seek slider - Padding( - padding: EdgeInsets.symmetric( - horizontal: isCompactLayout ? 12 : 16, - ), - child: SliderTheme( - data: SliderThemeData( - trackHeight: 3, - thumbShape: const RoundSliderThumbShape( - enabledThumbRadius: 6, - ), - overlayShape: const RoundSliderOverlayShape( - overlayRadius: 14, - ), - activeTrackColor: colorScheme.primary, - inactiveTrackColor: colorScheme.primary.withValues( - alpha: 0.15, - ), - ), - child: Slider( - value: sliderSeconds, - max: maxSeconds > 0 ? maxSeconds : 1, - onChangeStart: state.seekSupported && maxSeconds > 0 - ? (value) { - setState(() { - _isScrubbing = true; - _scrubSeconds = value; - }); - } - : null, - onChanged: state.seekSupported - ? (value) { - if (!_isScrubbing) { - setState(() { - _isScrubbing = true; - }); - } - setState(() { - _scrubSeconds = value; - }); - } - : null, - onChangeEnd: state.seekSupported - ? (value) async { - setState(() { - _scrubSeconds = value; - _isScrubbing = false; - }); - await ref - .read(playbackProvider.notifier) - .seek( - Duration( - milliseconds: (value * 1000).round(), - ), - ); - } - : null, - ), - ), - ), - - // ── Duration labels - Padding( - padding: EdgeInsets.symmetric( - horizontal: horizontalPadding, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - _formatDuration(position), - style: textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - Text( - _formatDuration(duration), - style: textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - SizedBox(height: verticalGap), - - // ── Playback controls - _PlaybackControls(state: state, compact: isCompactLayout), - SizedBox(height: verticalGap), - ], - ), - ), - ); - }, - ), - ), - ); - } - - String _formatDuration(Duration duration) { - final totalSeconds = duration.inSeconds; - final minutes = (totalSeconds ~/ 60).toString().padLeft(2, '0'); - final seconds = (totalSeconds % 60).toString().padLeft(2, '0'); - return '$minutes:$seconds'; - } - - void _showQueueSheet(BuildContext context, WidgetRef ref) { - showModalBottomSheet( - context: context, - isScrollControlled: true, - useSafeArea: true, - backgroundColor: Theme.of(context).colorScheme.surface, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), - builder: (_) => _QueueBottomSheet(ref: ref), - ); - } -} - -String? _localizedPlaybackError(BuildContext context, PlaybackState state) { - return _localizedPlaybackErrorFromRaw(context, state.error, state.errorType); -} - -String? _localizedPlaybackErrorFromRaw( - BuildContext context, - String? error, - String? errorType, -) { - final raw = (error ?? '').trim(); - if (raw.isEmpty) { - return null; - } - if (errorType == 'seek_not_supported') { - return context.l10n.errorSeekNotSupported; - } - if (errorType == 'not_found') { - return context.l10n.errorNoTracksFound; - } - return raw; -} - -// ─── Page dot indicator ────────────────────────────────────────────────────── -class _PageDot extends StatelessWidget { - final bool active; - final ColorScheme colorScheme; - - const _PageDot({required this.active, required this.colorScheme}); - - @override - Widget build(BuildContext context) { - return AnimatedContainer( - duration: const Duration(milliseconds: 200), - width: active ? 16 : 6, - height: 6, - decoration: BoxDecoration( - color: active ? colorScheme.primary : colorScheme.outlineVariant, - borderRadius: BorderRadius.circular(3), - ), - ); - } -} - -// ─── Cover Art Page ────────────────────────────────────────────────────────── -class _CoverArtPage extends StatelessWidget { - final PlaybackItem item; - final ColorScheme colorScheme; - - const _CoverArtPage({required this.item, required this.colorScheme}); - - @override - Widget build(BuildContext context) { - return Center( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 32), - child: AspectRatio( - aspectRatio: 1, - child: _CoverArt( - url: item.coverUrl, - isLocal: item.hasLocalCover, - size: double.infinity, - borderRadius: 20, - ), - ), - ), - ); - } -} - -// ─── Lyrics Page ───────────────────────────────────────────────────────────── -class _LyricsPage extends StatelessWidget { - final PlaybackState state; - final ColorScheme colorScheme; - final VoidCallback onRetry; - final ValueChanged? onSeek; - - const _LyricsPage({ - required this.state, - required this.colorScheme, - required this.onRetry, - required this.onSeek, - }); - - @override - Widget build(BuildContext context) { - if (state.lyricsLoading) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator(strokeWidth: 2), - ), - const SizedBox(height: 12), - Text( - 'Loading lyrics...', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ); - } - - final lyrics = state.lyrics; - if (lyrics == null || lyrics.isEmpty) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.lyrics_outlined, - size: 48, - color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), - ), - const SizedBox(height: 12), - Text( - 'No lyrics available', - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 8), - TextButton.icon( - onPressed: onRetry, - icon: const Icon(Icons.refresh, size: 18), - label: const Text('Retry'), - ), - ], - ), - ); - } - - if (lyrics.instrumental) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.music_note_rounded, - size: 48, - color: colorScheme.primary.withValues(alpha: 0.6), - ), - const SizedBox(height: 12), - Text( - 'Instrumental', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ); - } - - if (lyrics.isSynced) { - return _SyncedLyricsView( - lyrics: lyrics, - positionMs: state.position.inMilliseconds, - colorScheme: colorScheme, - onSeek: onSeek, - ); - } - - // Unsynced lyrics: simple scrollable text - return _UnsyncedLyricsView(lyrics: lyrics, colorScheme: colorScheme); - } -} - -// ─── Synced Lyrics View (line + word-by-word) ──────────────────────────────── -class _SyncedLyricsView extends StatefulWidget { - final LyricsData lyrics; - final int positionMs; - final ColorScheme colorScheme; - final ValueChanged? onSeek; - - const _SyncedLyricsView({ - required this.lyrics, - required this.positionMs, - required this.colorScheme, - required this.onSeek, - }); - - @override - State<_SyncedLyricsView> createState() => _SyncedLyricsViewState(); -} - -class _SyncedLyricsViewState extends State<_SyncedLyricsView> { - final ScrollController _scrollController = ScrollController(); - final GlobalKey _currentLineKey = GlobalKey(); - int _lastScrolledLine = -1; - int _lastQueuedScrollLine = -1; - int? _pendingAutoScrollLine; - bool _userScrolling = false; - bool _isAutoScrolling = false; - Timer? _userScrollTimer; - double _viewHeight = 400; - - @override - void dispose() { - _scrollController.dispose(); - _userScrollTimer?.cancel(); - super.dispose(); - } - - int _findCurrentLineIndex() { - final pos = widget.positionMs; - final lines = widget.lyrics.lines; - if (lines.isEmpty) return -1; - - // Binary search: find the last line whose startMs <= current position. - var left = 0; - var right = lines.length - 1; - var result = -1; - while (left <= right) { - final mid = left + ((right - left) >> 1); - if (lines[mid].startMs <= pos) { - result = mid; - left = mid + 1; - } else { - right = mid - 1; - } - } - return result; - } - - double? _targetOffsetFromCurrentLineKey() { - if (!_scrollController.hasClients) return null; - final keyContext = _currentLineKey.currentContext; - if (keyContext == null) return null; - final renderObject = keyContext.findRenderObject(); - if (renderObject == null) return null; - final viewport = RenderAbstractViewport.of(renderObject); - final target = viewport.getOffsetToReveal(renderObject, 0.4).offset; - return target - .clamp(0.0, _scrollController.position.maxScrollExtent) - .toDouble(); - } - - Duration _autoScrollDuration(double distancePx) { - final clampedDistance = distancePx.clamp(80.0, 900.0); - var ms = (160 + (clampedDistance / 2.4)).round(); - if (ms < 180) ms = 180; - if (ms > 560) ms = 560; - return Duration(milliseconds: ms); - } - - Future _scrollToLine(int index) async { - if (_userScrolling || !_scrollController.hasClients) return; - if (_isAutoScrolling) { - _pendingAutoScrollLine = index; - return; - } - if (index == _lastScrolledLine) return; - _lastScrolledLine = index; - - double targetOffset; - final fromKey = _targetOffsetFromCurrentLineKey(); - if (fromKey != null) { - targetOffset = fromKey; - } else { - // Fallback: estimate-based scroll for off-screen items - const lineHeight = 44.0; - final topPad = _viewHeight * 0.4; - targetOffset = topPad + (index * lineHeight) - (_viewHeight * 0.4); - targetOffset = targetOffset - .clamp(0.0, _scrollController.position.maxScrollExtent) - .toDouble(); - } - - final distance = (targetOffset - _scrollController.offset).abs(); - if (distance < 1.0) return; - - _isAutoScrolling = true; - try { - await _scrollController.animateTo( - targetOffset, - duration: _autoScrollDuration(distance), - curve: Curves.easeInOutCubicEmphasized, - ); - } catch (_) { - // Ignore interrupted scroll animations; latest queued target will run next. - } finally { - _isAutoScrolling = false; - final pending = _pendingAutoScrollLine; - _pendingAutoScrollLine = null; - if (pending != null && pending != index && mounted) { - unawaited(_scrollToLine(pending)); - } - } - } - - @override - Widget build(BuildContext context) { - final currentLine = _findCurrentLineIndex(); - - // Auto-scroll only when the target line changes. - if (currentLine >= 0 && currentLine != _lastQueuedScrollLine) { - _lastQueuedScrollLine = currentLine; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - unawaited(_scrollToLine(currentLine)); - } - }); - } - - return LayoutBuilder( - builder: (context, constraints) { - _viewHeight = constraints.maxHeight; - - return NotificationListener( - onNotification: (notification) { - if (notification is ScrollStartNotification && - notification.dragDetails != null) { - _userScrolling = true; - _userScrollTimer?.cancel(); - _pendingAutoScrollLine = null; - } - if (notification is ScrollEndNotification && _userScrolling) { - _userScrollTimer = Timer(const Duration(seconds: 4), () { - _userScrolling = false; - _isAutoScrolling = false; - _lastScrolledLine = -1; // Force re-scroll - _lastQueuedScrollLine = -1; - _pendingAutoScrollLine = null; - }); - } - return false; - }, - child: ListView.builder( - controller: _scrollController, - padding: EdgeInsets.only( - left: 24, - right: 24, - top: _viewHeight * 0.4, - bottom: _viewHeight * 0.4, - ), - itemCount: widget.lyrics.lines.length, - itemBuilder: (context, index) { - final line = widget.lyrics.lines[index]; - final isCurrent = index == currentLine; - final isPast = index < currentLine; - - Widget lineWidget; - - if (line.text.isEmpty) { - // Empty line = interlude gap - lineWidget = const SizedBox(height: 32); - } else { - // Target style — AnimatedDefaultTextStyle will - // smoothly tween fontSize / fontWeight / color. - final targetStyle = TextStyle( - fontSize: isCurrent ? 24 : 19, - fontWeight: isCurrent ? FontWeight.w700 : FontWeight.w500, - color: isCurrent - ? widget.colorScheme.onSurface - : isPast - ? widget.colorScheme.onSurfaceVariant.withValues( - alpha: 0.35, - ) - : widget.colorScheme.onSurfaceVariant.withValues( - alpha: 0.55, - ), - height: 1.4, - ); - - lineWidget = GestureDetector( - onTap: widget.onSeek == null - ? null - : () => widget.onSeek!(line.startMs), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 6), - child: AnimatedDefaultTextStyle( - duration: const Duration(milliseconds: 350), - curve: Curves.easeOutCubic, - style: targetStyle, - child: line.hasWordSync - ? _WordByWordLine( - line: line, - positionMs: widget.positionMs, - colorScheme: widget.colorScheme, - isCurrent: isCurrent, - ) - : Text(line.text), - ), - ), - ); - } - - // Attach key to the current line for scroll targeting. - if (isCurrent && line.text.isNotEmpty) { - return KeyedSubtree(key: _currentLineKey, child: lineWidget); - } - return lineWidget; - }, - ), - ); - }, - ); - } -} - -// ─── Word-by-Word Highlighted Line ─────────────────────────────────────────── -class _WordByWordLine extends StatelessWidget { - final LyricsLine line; - final int positionMs; - final ColorScheme colorScheme; - final bool isCurrent; - - const _WordByWordLine({ - required this.line, - required this.positionMs, - required this.colorScheme, - required this.isCurrent, - }); - - @override - Widget build(BuildContext context) { - // When not the current line, render plain text that inherits the - // animated style from the parent AnimatedDefaultTextStyle. - if (!isCurrent) { - return Text(line.text); - } - - // Current line: word-by-word gradient sweep - final baseStyle = TextStyle( - fontSize: 24, - fontWeight: FontWeight.w700, - height: 1.4, - ); - final inactiveColor = colorScheme.onSurfaceVariant.withValues(alpha: 0.35); - final sungColor = colorScheme.onSurface; - final activeColor = colorScheme.primary; - - return Wrap( - children: line.words.map((word) { - final isCurrentWord = - positionMs >= word.startMs && positionMs < word.endMs; - final isSung = positionMs >= word.endMs; - final wordProgress = isSung - ? 1.0 - : isCurrentWord && word.endMs > word.startMs - ? ((positionMs - word.startMs) / (word.endMs - word.startMs)).clamp( - 0.0, - 1.0, - ) - : 0.0; - - return _AnimatedWordToken( - text: word.text, - progress: wordProgress, - isCurrentWord: isCurrentWord, - baseStyle: baseStyle, - inactiveColor: inactiveColor, - sungColor: sungColor, - activeColor: activeColor, - ); - }).toList(), - ); - } -} - -class _AnimatedWordToken extends StatelessWidget { - final String text; - final double progress; - final bool isCurrentWord; - final TextStyle baseStyle; - final Color inactiveColor; - final Color sungColor; - final Color activeColor; - - const _AnimatedWordToken({ - required this.text, - required this.progress, - required this.isCurrentWord, - required this.baseStyle, - required this.inactiveColor, - required this.sungColor, - required this.activeColor, - }); - - @override - Widget build(BuildContext context) { - final p = progress.clamp(0.0, 1.0); - final hasSweep = p > 0.0 && p < 1.0; - final settledColor = p >= 1.0 ? sungColor : inactiveColor; - - return AnimatedScale( - scale: isCurrentWord ? 1.04 : 1.0, - duration: const Duration(milliseconds: 120), - curve: Curves.easeOutCubic, - child: Stack( - children: [ - Text(text, style: baseStyle.copyWith(color: settledColor)), - if (hasSweep) - ClipRect( - child: Align( - alignment: Alignment.centerLeft, - widthFactor: p, - child: Text( - text, - style: baseStyle.copyWith(color: activeColor), - ), - ), - ), - ], - ), - ); - } -} - -// ─── Unsynced Lyrics View ──────────────────────────────────────────────────── -class _UnsyncedLyricsView extends StatelessWidget { - final LyricsData lyrics; - final ColorScheme colorScheme; - - const _UnsyncedLyricsView({required this.lyrics, required this.colorScheme}); - - @override - Widget build(BuildContext context) { - return ListView.builder( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24), - itemCount: lyrics.lines.length, - itemBuilder: (context, index) { - final line = lyrics.lines[index]; - if (line.text.isEmpty) return const SizedBox(height: 24); - - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Text( - line.text, - style: TextStyle( - fontSize: 19, - fontWeight: FontWeight.w500, - color: colorScheme.onSurface.withValues(alpha: 0.8), - height: 1.5, - ), - ), - ); - }, - ); - } -} - -// ─── Quality + Service Row ─────────────────────────────────────────────────── -class _QualityServiceRow extends StatelessWidget { - final PlaybackItem item; - final ColorScheme colorScheme; - - const _QualityServiceRow({required this.item, required this.colorScheme}); - - @override - Widget build(BuildContext context) { - final qualityLabel = item.qualityLabel; - final serviceLabel = _serviceDisplayName(item.service); - - if (qualityLabel.isEmpty && serviceLabel.isEmpty) { - return const SizedBox.shrink(); - } - - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Wrap( - spacing: 8, - runSpacing: 4, - alignment: WrapAlignment.center, - children: [ - if (serviceLabel.isNotEmpty) - _Chip( - icon: Icons.cloud_outlined, - label: serviceLabel, - colorScheme: colorScheme, - ), - if (qualityLabel.isNotEmpty) - _Chip( - icon: Icons.graphic_eq_rounded, - label: qualityLabel, - colorScheme: colorScheme, - ), - ], - ), - ); - } - - String _serviceDisplayName(String service) { - if (service.isEmpty) return ''; - switch (service.toLowerCase()) { - case 'tidal': - return 'Tidal'; - case 'qobuz': - return 'Qobuz'; - case 'amazon': - return 'Amazon Music'; - case 'youtube': - return 'YouTube'; - case 'offline': - return 'Local file'; - default: - if (service.isNotEmpty) { - return service[0].toUpperCase() + service.substring(1); - } - return service; - } - } -} - -class _Chip extends StatelessWidget { - final IconData icon; - final String label; - final ColorScheme colorScheme; - - const _Chip({ - required this.icon, - required this.label, - required this.colorScheme, - }); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: colorScheme.primaryContainer.withValues(alpha: 0.5), - borderRadius: BorderRadius.circular(20), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 14, color: colorScheme.onPrimaryContainer), - const SizedBox(width: 4), - Text( - label, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: colorScheme.onPrimaryContainer, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ); - } -} - -// ─── Download Button ───────────────────────────────────────────────────────── -class _DownloadButton extends ConsumerWidget { - final PlaybackItem item; - final bool compact; - - const _DownloadButton({required this.item, this.compact = false}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final track = item.track; - if (track == null) return const SizedBox.shrink(); - - final colorScheme = Theme.of(context).colorScheme; - final iconSize = compact ? 18.0 : 22.0; - - return IconButton( - visualDensity: compact ? VisualDensity.compact : VisualDensity.standard, - icon: Icon( - Icons.download_rounded, - color: colorScheme.onSurfaceVariant, - size: iconSize, - ), - onPressed: () => _onDownloadTap(context, ref, track), - tooltip: context.l10n.downloadTitle, - ); - } - - void _onDownloadTap(BuildContext context, WidgetRef ref, Track 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))), - ); - } - } -} - -// ─── Playback Controls ─────────────────────────────────────────────────────── -class _PlaybackControls extends ConsumerWidget { - final PlaybackState state; - final bool compact; - - const _PlaybackControls({required this.state, this.compact = false}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final colorScheme = Theme.of(context).colorScheme; - final notifier = ref.read(playbackProvider.notifier); - final hasPrev = - state.hasPrevious || state.repeatMode == playback_types.RepeatMode.all; - final hasNext = - state.hasNext || state.repeatMode == playback_types.RepeatMode.all; - final sideIconSize = compact ? 18.0 : 22.0; - final skipIconSize = compact ? 28.0 : 32.0; - final mainButtonSize = compact ? 54.0 : 64.0; - final mainIconSize = compact ? 30.0 : 36.0; - final loadingSize = compact ? 24.0 : 28.0; - - return Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // Shuffle - IconButton( - visualDensity: compact - ? VisualDensity.compact - : VisualDensity.standard, - icon: Icon( - Icons.shuffle_rounded, - color: state.shuffle - ? colorScheme.primary - : colorScheme.onSurfaceVariant, - size: sideIconSize, - ), - onPressed: notifier.toggleShuffle, - tooltip: 'Shuffle', - ), - SizedBox(width: compact ? 2 : 4), - - // Previous - IconButton( - iconSize: skipIconSize, - visualDensity: compact - ? VisualDensity.compact - : VisualDensity.standard, - onPressed: hasPrev ? notifier.skipPrevious : null, - icon: Icon( - Icons.skip_previous_rounded, - color: hasPrev - ? colorScheme.onSurface - : colorScheme.onSurfaceVariant.withValues(alpha: 0.3), - ), - tooltip: 'Previous', - ), - SizedBox(width: compact ? 4 : 8), - - // Play / Pause (large) - SizedBox( - width: mainButtonSize, - height: mainButtonSize, - child: IconButton.filled( - iconSize: mainIconSize, - onPressed: notifier.togglePlayPause, - icon: state.isBuffering || state.isLoading - ? SizedBox( - width: loadingSize, - height: loadingSize, - child: CircularProgressIndicator( - strokeWidth: 2.5, - color: colorScheme.onPrimary, - ), - ) - : Icon( - state.isPlaying - ? Icons.pause_rounded - : Icons.play_arrow_rounded, - ), - style: IconButton.styleFrom( - backgroundColor: colorScheme.primary, - foregroundColor: colorScheme.onPrimary, - ), - tooltip: state.isPlaying ? 'Pause' : 'Play', - ), - ), - SizedBox(width: compact ? 4 : 8), - - // Next - IconButton( - iconSize: skipIconSize, - visualDensity: compact - ? VisualDensity.compact - : VisualDensity.standard, - onPressed: hasNext ? notifier.skipNext : null, - icon: Icon( - Icons.skip_next_rounded, - color: hasNext - ? colorScheme.onSurface - : colorScheme.onSurfaceVariant.withValues(alpha: 0.3), - ), - tooltip: 'Next', - ), - SizedBox(width: compact ? 2 : 4), - - // Repeat - IconButton( - visualDensity: compact - ? VisualDensity.compact - : VisualDensity.standard, - icon: Icon( - state.repeatMode == playback_types.RepeatMode.one - ? Icons.repeat_one_rounded - : Icons.repeat_rounded, - color: state.repeatMode != playback_types.RepeatMode.off - ? colorScheme.primary - : colorScheme.onSurfaceVariant, - size: sideIconSize, - ), - onPressed: notifier.cycleRepeatMode, - tooltip: _repeatTooltip(state.repeatMode), - ), - ], - ); - } - - String _repeatTooltip(playback_types.RepeatMode mode) { - switch (mode) { - case playback_types.RepeatMode.off: - return 'Repeat: Off'; - case playback_types.RepeatMode.all: - return 'Repeat: All'; - case playback_types.RepeatMode.one: - return 'Repeat: One'; - } - } -} - -// ─── Cover Art Widget (supports both network and local) ────────────────────── -class _CoverArt extends StatelessWidget { - final String url; - final bool isLocal; - final double size; - final double borderRadius; - - const _CoverArt({ - required this.url, - required this.isLocal, - required this.size, - required this.borderRadius, - }); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - - if (url.trim().isEmpty) { - return _placeholder(colorScheme); - } - - if (isLocal) { - return ClipRRect( - borderRadius: BorderRadius.circular(borderRadius), - child: Image.file( - File(url), - width: size, - height: size, - fit: BoxFit.cover, - cacheWidth: size.isFinite ? (size * 3).toInt() : null, - cacheHeight: size.isFinite ? (size * 3).toInt() : null, - errorBuilder: (_, _, _) => _placeholder(colorScheme), - ), - ); - } - - return ClipRRect( - borderRadius: BorderRadius.circular(borderRadius), - child: CachedNetworkImage( - imageUrl: url, - width: size, - height: size, - fit: BoxFit.cover, - memCacheWidth: size.isFinite ? (size * 3).toInt() : null, - memCacheHeight: size.isFinite ? (size * 3).toInt() : null, - cacheManager: CoverCacheManager.instance, - errorWidget: (_, _, _) => _placeholder(colorScheme), - ), - ); - } - - Widget _placeholder(ColorScheme colorScheme) { - final iconSize = size.isFinite ? size * 0.4 : 48.0; - return Container( - width: size, - height: size, - decoration: BoxDecoration( - color: colorScheme.surfaceContainer, - borderRadius: BorderRadius.circular(borderRadius), - ), - child: Icon( - Icons.music_note_rounded, - size: iconSize, - color: colorScheme.onSurfaceVariant, - ), - ); - } -} - -// ─── Queue Bottom Sheet ────────────────────────────────────────────────────── -class _QueueBottomSheet extends ConsumerWidget { - final WidgetRef ref; - - const _QueueBottomSheet({required this.ref}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final state = ref.watch(playbackProvider); - final playbackNotifier = ref.read(playbackProvider.notifier); - final colorScheme = Theme.of(context).colorScheme; - final textTheme = Theme.of(context).textTheme; - final queue = state.queue; - final displayOrder = playbackNotifier.getQueueDisplayOrder(); - final currentDisplayIndex = playbackNotifier.getCurrentDisplayQueuePosition( - displayOrder: displayOrder, - ); - if (queue.isEmpty || displayOrder.isEmpty || currentDisplayIndex < 0) { - return const SizedBox.shrink(); - } - - return DraggableScrollableSheet( - initialChildSize: 0.65, - minChildSize: 0.3, - maxChildSize: 0.92, - expand: false, - builder: (context, scrollController) { - return Column( - children: [ - // Drag handle - Padding( - padding: const EdgeInsets.only(top: 12, bottom: 8), - child: Container( - width: 36, - height: 4, - decoration: BoxDecoration( - color: colorScheme.outlineVariant, - borderRadius: BorderRadius.circular(2), - ), - ), - ), - - // Header - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), - child: Row( - children: [ - Icon( - Icons.queue_music_rounded, - size: 22, - color: colorScheme.primary, - ), - const SizedBox(width: 10), - Text( - 'Queue', - style: textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - const Spacer(), - Text( - '${queue.length} tracks', - style: textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - - const Divider(height: 1), - - // Queue list - Expanded( - child: ListView.builder( - controller: scrollController, - padding: const EdgeInsets.only(bottom: 16), - itemCount: - queue.length + - _sectionHeaderCount(currentDisplayIndex, queue.length), - itemBuilder: (context, index) { - // Calculate real item index accounting for section headers - return _buildQueueListItem( - context, - ref, - index, - queue, - displayOrder, - currentDisplayIndex, - colorScheme, - textTheme, - ); - }, - ), - ), - ], - ); - }, - ); - } - - int _sectionHeaderCount(int currentIndex, int queueLength) { - int count = 0; - if (currentIndex > 0) count++; // "Already Played" header - count++; // "Now Playing" header - if (currentIndex < queueLength - 1) count++; // "Up Next" header - return count; - } - - Widget _buildQueueListItem( - BuildContext context, - WidgetRef ref, - int listIndex, - List queue, - List displayOrder, - int currentDisplayIndex, - ColorScheme colorScheme, - TextTheme textTheme, - ) { - // Build a flat list: [played header?, played items, now playing header, - // now playing item, up next header?, up next items] - int offset = 0; - - // Section: Already Played - if (currentDisplayIndex > 0) { - if (listIndex == offset) { - return _sectionHeader( - 'Played', - Icons.history_rounded, - colorScheme, - textTheme, - ); - } - offset++; - if (listIndex < offset + currentDisplayIndex) { - final displayIdx = listIndex - offset; - final queueIdx = displayOrder[displayIdx]; - return _queueTrackTile( - context, - ref, - queue[queueIdx], - queueIdx, - displayIdx, - colorScheme, - textTheme, - isPlayed: true, - ); - } - offset += currentDisplayIndex; - } - - // Section: Now Playing - if (listIndex == offset) { - return _sectionHeader( - 'Now Playing', - Icons.play_circle_filled_rounded, - colorScheme, - textTheme, - isPrimary: true, - ); - } - offset++; - if (listIndex == offset) { - final queueIdx = displayOrder[currentDisplayIndex]; - return _queueTrackTile( - context, - ref, - queue[queueIdx], - queueIdx, - currentDisplayIndex, - colorScheme, - textTheme, - isCurrent: true, - ); - } - offset++; - - // Section: Up Next - if (currentDisplayIndex < queue.length - 1) { - if (listIndex == offset) { - final upNextCount = queue.length - currentDisplayIndex - 1; - return _sectionHeader( - 'Up Next ($upNextCount)', - Icons.skip_next_rounded, - colorScheme, - textTheme, - ); - } - offset++; - final displayIdx = currentDisplayIndex + 1 + (listIndex - offset); - if (displayIdx < queue.length) { - final queueIdx = displayOrder[displayIdx]; - return _queueTrackTile( - context, - ref, - queue[queueIdx], - queueIdx, - displayIdx, - colorScheme, - textTheme, - ); - } - } - - return const SizedBox.shrink(); - } - - Widget _sectionHeader( - String title, - IconData icon, - ColorScheme colorScheme, - TextTheme textTheme, { - bool isPrimary = false, - }) { - return Padding( - padding: const EdgeInsets.fromLTRB(20, 16, 20, 4), - child: Row( - children: [ - Icon( - icon, - size: 16, - color: isPrimary - ? colorScheme.primary - : colorScheme.onSurfaceVariant, - ), - const SizedBox(width: 8), - Text( - title, - style: textTheme.labelLarge?.copyWith( - fontWeight: FontWeight.w600, - color: isPrimary - ? colorScheme.primary - : colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ); - } - - Widget _queueTrackTile( - BuildContext context, - WidgetRef ref, - PlaybackItem item, - int queueIndex, - int displayIndex, - ColorScheme colorScheme, - TextTheme textTheme, { - bool isCurrent = false, - bool isPlayed = false, - }) { - final opacity = isPlayed ? 0.5 : 1.0; - - return Material( - color: isCurrent - ? colorScheme.primaryContainer.withValues(alpha: 0.3) - : Colors.transparent, - child: InkWell( - onTap: isCurrent - ? null - : () { - ref.read(playbackProvider.notifier).playQueueIndex(queueIndex); - Navigator.of(context).pop(); - }, - child: Opacity( - opacity: opacity, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - child: Row( - children: [ - // Track number in queue - SizedBox( - width: 28, - child: Text( - '${displayIndex + 1}', - textAlign: TextAlign.center, - style: textTheme.bodySmall?.copyWith( - color: isCurrent - ? colorScheme.primary - : colorScheme.onSurfaceVariant, - fontWeight: isCurrent ? FontWeight.w700 : FontWeight.w400, - ), - ), - ), - const SizedBox(width: 8), - // Cover art - _CoverArt( - url: item.coverUrl, - isLocal: item.hasLocalCover, - size: 44, - borderRadius: 8, - ), - const SizedBox(width: 12), - // Track info - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: textTheme.bodyMedium?.copyWith( - fontWeight: isCurrent - ? FontWeight.w700 - : FontWeight.w500, - color: isCurrent - ? colorScheme.primary - : colorScheme.onSurface, - ), - ), - const SizedBox(height: 2), - Text( - item.artist, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - // Now playing indicator - if (isCurrent) - Padding( - padding: const EdgeInsets.only(left: 8), - child: Icon( - Icons.equalizer_rounded, - size: 20, - color: colorScheme.primary, - ), - ), - // Remove from queue button (for up next items only) - if (!isCurrent && !isPlayed) - IconButton( - icon: Icon( - Icons.close_rounded, - size: 18, - color: colorScheme.onSurfaceVariant.withValues( - alpha: 0.6, - ), - ), - onPressed: () { - ref - .read(playbackProvider.notifier) - .removeFromQueue(queueIndex); - }, - visualDensity: VisualDensity.compact, - tooltip: 'Remove', - ), - ], - ), - ), - ), - ), - ); - } -} diff --git a/lib/widgets/track_collection_quick_actions.dart b/lib/widgets/track_collection_quick_actions.dart index c45649f1..d21a67fe 100644 --- a/lib/widgets/track_collection_quick_actions.dart +++ b/lib/widgets/track_collection_quick_actions.dart @@ -1,18 +1,10 @@ -import 'dart:async'; - 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/download_queue_provider.dart'; import 'package:spotiflac_android/providers/library_collections_provider.dart'; -import 'package:spotiflac_android/providers/local_library_provider.dart'; -import 'package:spotiflac_android/providers/playback_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'; import 'package:spotiflac_android/utils/clickable_metadata.dart'; @@ -64,9 +56,6 @@ class _TrackOptionsSheet extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final colorScheme = Theme.of(context).colorScheme; - final settings = ref.watch(settingsProvider); - final rootContext = Navigator.of(context, rootNavigator: true).context; - final container = ProviderScope.containerOf(rootContext, listen: false); final isLoved = ref.watch( libraryCollectionsProvider.select((state) => state.isLoved(track)), @@ -174,46 +163,6 @@ class _TrackOptionsSheet extends ConsumerWidget { ), // Action items (matches _QualityOption style) - _OptionTile( - icon: Icons.download_rounded, - title: 'Download & Play', - onTap: () async { - Navigator.pop(context); - final playedLocal = await _playLocalIfAvailable( - container, - rootContext, - ); - if (playedLocal) { - return; - } - if (!rootContext.mounted) { - return; - } - - if (settings.askQualityBeforeDownload) { - DownloadServicePicker.show( - rootContext, - trackName: track.name, - artistName: track.artistName, - coverUrl: track.coverUrl, - onSelect: (quality, service) { - _enqueueDownloadAndAutoPlay( - container: container, - context: rootContext, - service: service, - quality: quality, - ); - }, - ); - } else { - _enqueueDownloadAndAutoPlay( - container: container, - context: rootContext, - service: settings.defaultService, - ); - } - }, - ), _OptionTile( icon: isLoved ? Icons.favorite : Icons.favorite_border, iconColor: isLoved ? colorScheme.error : null, @@ -282,138 +231,6 @@ class _TrackOptionsSheet extends ConsumerWidget { ), ); } - - Future _playLocalIfAvailable( - ProviderContainer container, - BuildContext context, - ) async { - final localState = container.read(localLibraryProvider); - final historyState = container.read(downloadHistoryProvider); - final historyNotifier = container.read(downloadHistoryProvider.notifier); - - try { - DownloadHistoryItem? historyItem = historyNotifier.getBySpotifyId( - track.id, - ); - final isrc = track.isrc?.trim(); - historyItem ??= (isrc != null && isrc.isNotEmpty) - ? historyNotifier.getByIsrc(isrc) - : null; - historyItem ??= historyState.findByTrackAndArtist( - track.name, - track.artistName, - ); - - if (historyItem != null) { - final exists = await fileExists(historyItem.filePath); - if (exists) { - await container - .read(playbackProvider.notifier) - .playLocalPath( - path: historyItem.filePath, - title: track.name, - artist: track.artistName, - album: track.albumName, - coverUrl: track.coverUrl ?? '', - ); - return true; - } - historyNotifier.removeFromHistory(historyItem.id); - } - - var localItem = (isrc != null && isrc.isNotEmpty) - ? localState.getByIsrc(isrc) - : null; - localItem ??= localState.findByTrackAndArtist( - track.name, - track.artistName, - ); - - if (localItem != null && await fileExists(localItem.filePath)) { - await container - .read(playbackProvider.notifier) - .playLocalPath( - path: localItem.filePath, - title: localItem.trackName, - artist: localItem.artistName, - album: localItem.albumName, - coverUrl: localItem.coverPath ?? track.coverUrl ?? '', - ); - return true; - } - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.snackbarCannotOpenFile('$e'))), - ); - } - return true; - } - - return false; - } - - void _enqueueDownloadAndAutoPlay({ - required ProviderContainer container, - required BuildContext context, - required String service, - String? quality, - }) { - container - .read(downloadQueueProvider.notifier) - .addToQueue(track, service, qualityOverride: quality); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))), - ); - } - unawaited(_waitForDownloadedFileAndPlay(container, context)); - } - - Future _waitForDownloadedFileAndPlay( - ProviderContainer container, - BuildContext context, - ) async { - const maxAttempts = 180; // up to ~3 minutes - for (var i = 0; i < maxAttempts; i++) { - final item = _findHistoryMatch(container); - if (item != null && await fileExists(item.filePath)) { - try { - await container - .read(playbackProvider.notifier) - .playLocalPath( - path: item.filePath, - title: track.name, - artist: track.artistName, - album: track.albumName, - coverUrl: track.coverUrl ?? '', - ); - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.snackbarCannotOpenFile('$e')), - ), - ); - } - } - return; - } - await Future.delayed(const Duration(seconds: 1)); - } - } - - DownloadHistoryItem? _findHistoryMatch(ProviderContainer container) { - final historyState = container.read(downloadHistoryProvider); - final historyNotifier = container.read(downloadHistoryProvider.notifier); - final isrc = track.isrc?.trim(); - - return historyNotifier.getBySpotifyId(track.id) ?? - ((isrc != null && isrc.isNotEmpty) - ? historyNotifier.getByIsrc(isrc) - : null) ?? - historyState.findByTrackAndArtist(track.name, track.artistName); - } } /// Styled like _QualityOption in download_service_picker.dart diff --git a/pubspec.lock b/pubspec.lock index 3492ac5c..967ee1bf 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -49,38 +49,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.0" - audio_service: - dependency: "direct main" - description: - name: audio_service - sha256: cb122c7c2639d2a992421ef96b67948ad88c5221da3365ccef1031393a76e044 - url: "https://pub.dev" - source: hosted - version: "0.18.18" - audio_service_platform_interface: - dependency: transitive - description: - name: audio_service_platform_interface - sha256: "6283782851f6c8b501b60904a32fc7199dc631172da0629d7301e66f672ab777" - url: "https://pub.dev" - source: hosted - version: "0.1.3" - audio_service_web: - dependency: transitive - description: - name: audio_service_web - sha256: b8ea9243201ee53383157fbccf13d5d2a866b5dda922ec19d866d1d5d70424df - url: "https://pub.dev" - source: hosted - version: "0.1.4" - audio_session: - dependency: "direct main" - description: - name: audio_session - sha256: "8f96a7fecbb718cb093070f868b4cdcb8a9b1053dce342ff8ab2fde10eb9afb7" - url: "https://pub.dev" - source: hosted - version: "0.2.2" boolean_selector: dependency: transitive description: @@ -613,30 +581,6 @@ packages: url: "https://pub.dev" source: hosted version: "6.11.2" - just_audio: - dependency: "direct main" - description: - name: just_audio - sha256: "9694e4734f515f2a052493d1d7e0d6de219ee0427c7c29492e246ff32a219908" - url: "https://pub.dev" - source: hosted - version: "0.10.5" - just_audio_platform_interface: - dependency: transitive - description: - name: just_audio_platform_interface - sha256: "2532c8d6702528824445921c5ff10548b518b13f808c2e34c2fd54793b999a6a" - url: "https://pub.dev" - source: hosted - version: "4.6.0" - just_audio_web: - dependency: transitive - description: - name: just_audio_web - sha256: "6ba8a2a7e87d57d32f0f7b42856ade3d6a9fbe0f1a11fabae0a4f00bb73f0663" - url: "https://pub.dev" - source: hosted - version: "0.4.16" leak_tracker: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index f6cefdfe..0e987f6b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music publish_to: "none" -version: 4.0.1+102 +version: 3.7.0+103 environment: sdk: ^3.10.0 @@ -61,9 +61,6 @@ dependencies: # Notifications flutter_local_notifications: 20.0.0 - just_audio: ^0.10.5 - audio_session: ^0.2.2 - audio_service: ^0.18.17 dev_dependencies: flutter_test: