diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3198bbcd..04ade01e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -71,7 +71,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v6 with: - go-version: "1.26" + go-version: "1.25.7" cache-dependency-path: go_backend/go.sum # Cache Gradle for faster builds @@ -174,7 +174,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v6 with: - go-version: "1.26" + go-version: "1.25.7" cache-dependency-path: go_backend/go.sum # Cache CocoaPods diff --git a/AndroidManifest.xml b/AndroidManifest.xml new file mode 100644 index 00000000..e8ef0445 Binary files /dev/null and b/AndroidManifest.xml differ diff --git a/CHANGELOG.md b/CHANGELOG.md index 439699d6..54308e14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,104 @@ # Changelog +## [4.0.1] - 2026-02-26 + +### 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 + +### 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 + +### Fixed + +- **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 + +--- + +## [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 ### Added diff --git a/README.md b/README.md index d18e8b58..f3e14cda 100644 --- a/README.md +++ b/README.md @@ -94,12 +94,7 @@ The software is provided "as is", without warranty of any kind. The author assum ## API Credits -- **Tidal**: [hifi-api](https://github.com/binimum/hifi-api), [music.binimum.org](https://music.binimum.org), [qqdl.site](https://qqdl.site), [squid.wtf](https://squid.wtf), [spotisaver.net](https://spotisaver.net) -- **Qobuz**: [dabmusic.xyz](https://dabmusic.xyz), [squid.wtf](https://squid.wtf), [jumo-dl](https://jumo-dl.pages.dev) -- **Amazon**: [AfkarXYZ](https://github.com/afkarxyz) -- **Lyrics**: [LRCLib](https://lrclib.net), [Paxsenix](https://lyrics.paxsenix.org) (Apple Music/QQ Music lyrics proxy) -- **YouTube Audio**: [Cobalt](https://cobalt.tools) via [qwkuns.me](https://qwkuns.me), [SpotubeDL](https://spotubedl.com) -- **Track Linking**: [SongLink / Odesli](https://odesli.co), [IDHS](https://github.com/sjdonado/idonthavespotify) +[hifi-api](https://github.com/binimum/hifi-api) · [music.binimum.org](https://music.binimum.org) · [qqdl.site](https://qqdl.site) · [squid.wtf](https://squid.wtf) · [spotisaver.net](https://spotisaver.net) · [dabmusic.xyz](https://dabmusic.xyz) · [AfkarXYZ](https://github.com/afkarxyz) · [LRCLib](https://lrclib.net) · [Paxsenix](https://lyrics.paxsenix.org) · [Cobalt](https://cobalt.tools) · [qwkuns.me](https://qwkuns.me) · [SpotubeDL](https://spotubedl.com) · [Song.link](https://song.link) · [IDHS](https://github.com/sjdonado/idonthavespotify) > [!TIP] diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro index 2bd4f8d5..d9752c2c 100644 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -80,6 +80,16 @@ -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/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 9043277b..13a82560 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -12,6 +12,7 @@ + @@ -21,6 +22,7 @@ android:name="${applicationName}" android:icon="@mipmap/ic_launcher" android:usesCleartextTraffic="false" + android:networkSecurityConfig="@xml/network_security_config" android:enableOnBackInvokedCallback="true" android:localeConfig="@xml/locale_config"> @@ -92,6 +94,24 @@ android:exported="false" android:foregroundServiceType="dataSync" /> + + + + + + + + + + + + + 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 bd3014c5..1c8769d2 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -4,20 +4,25 @@ import android.app.Activity import android.content.Intent import android.net.Uri 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.FlutterActivityLaunchConfigs.BackgroundMode import io.flutter.embedding.android.FlutterFragment -import io.flutter.embedding.android.FlutterFragmentActivity import io.flutter.embedding.android.RenderMode import io.flutter.embedding.android.TransparencyMode import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterShellArgs +import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.MethodChannel import gobackend.Gobackend import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.json.JSONArray @@ -27,13 +32,24 @@ import java.io.FileInputStream import java.io.FileOutputStream import java.util.Locale -class MainActivity: FlutterFragmentActivity() { +class MainActivity: AudioServiceFragmentActivity() { private val CHANNEL = "com.zarz.spotiflac/backend" + private val DOWNLOAD_PROGRESS_STREAM_CHANNEL = + "com.zarz.spotiflac/download_progress_stream" + private val LIBRARY_SCAN_PROGRESS_STREAM_CHANNEL = + "com.zarz.spotiflac/library_scan_progress_stream" + private val STREAM_POLLING_INTERVAL_MS = 800L private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) private var pendingSafTreeResult: MethodChannel.Result? = null private val safScanLock = Any() private val safDirLock = Any() private var safScanProgress = SafScanProgress() + private var downloadProgressStreamJob: Job? = null + private var downloadProgressEventSink: EventChannel.EventSink? = null + private var lastDownloadProgressPayload: String? = null + private var libraryScanProgressStreamJob: Job? = null + private var libraryScanProgressEventSink: EventChannel.EventSink? = null + private var lastLibraryScanProgressPayload: String? = null @Volatile private var safScanCancel = false @Volatile private var safScanActive = false private val safTreeLauncher = registerForActivityResult( @@ -380,6 +396,78 @@ class MainActivity: FlutterFragmentActivity() { return obj.toString() } + private fun readLibraryScanProgressJsonForStream(): String { + return if (safScanActive) { + safProgressToJson() + } else { + Gobackend.getLibraryScanProgressJSON() + } + } + + private fun startDownloadProgressStream(sink: EventChannel.EventSink) { + stopDownloadProgressStream() + downloadProgressEventSink = sink + lastDownloadProgressPayload = null + downloadProgressStreamJob = scope.launch { + while (isActive && downloadProgressEventSink === sink) { + try { + val payload = withContext(Dispatchers.IO) { + Gobackend.getAllDownloadProgress() + } + if (payload != lastDownloadProgressPayload) { + lastDownloadProgressPayload = payload + sink.success(payload) + } + } catch (e: Exception) { + android.util.Log.w( + "SpotiFLAC", + "Download progress stream poll failed: ${e.message}", + ) + } + delay(STREAM_POLLING_INTERVAL_MS) + } + } + } + + private fun stopDownloadProgressStream() { + downloadProgressStreamJob?.cancel() + downloadProgressStreamJob = null + downloadProgressEventSink = null + lastDownloadProgressPayload = null + } + + private fun startLibraryScanProgressStream(sink: EventChannel.EventSink) { + stopLibraryScanProgressStream() + libraryScanProgressEventSink = sink + lastLibraryScanProgressPayload = null + libraryScanProgressStreamJob = scope.launch { + while (isActive && libraryScanProgressEventSink === sink) { + try { + val payload = withContext(Dispatchers.IO) { + readLibraryScanProgressJsonForStream() + } + if (payload != lastLibraryScanProgressPayload) { + lastLibraryScanProgressPayload = payload + sink.success(payload) + } + } catch (e: Exception) { + android.util.Log.w( + "SpotiFLAC", + "Library scan progress stream poll failed: ${e.message}", + ) + } + delay(STREAM_POLLING_INTERVAL_MS) + } + } + } + + private fun stopLibraryScanProgressStream() { + libraryScanProgressStreamJob?.cancel() + libraryScanProgressStreamJob = null + libraryScanProgressEventSink = null + lastLibraryScanProgressPayload = null + } + private fun resolveSafFile(treeUriStr: String, relativeDir: String, fileName: String): String { val obj = JSONObject() if (treeUriStr.isBlank() || fileName.isBlank()) { @@ -1252,16 +1340,79 @@ class MainActivity: FlutterFragmentActivity() { return respObj.toString() } + // Disable Flutter's built-in deep linking so that incoming ACTION_VIEW URLs + // (Spotify, Deezer, Tidal, YouTube Music) are NOT forwarded to GoRouter. + // We handle these URLs ourselves via receive_sharing_intent + ShareIntentService. + override fun shouldHandleDeeplinking(): Boolean = false + override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) // Update the intent so receive_sharing_intent can access the new data setIntent(intent) } + override fun onDestroy() { + try { + Gobackend.cleanupExtensions() + } catch (e: Exception) { + android.util.Log.w("SpotiFLAC", "Failed to cleanup extensions on destroy: ${e.message}") + } + stopDownloadProgressStream() + stopLibraryScanProgressStream() + super.onDestroy() + } + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) - MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result -> + // Always-enabled back callback to ensure back presses reach Flutter. + // Nested tab navigators can incorrectly set frameworkHandlesBack(false), + // 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) { + override fun handleOnBackPressed() { + flutterEngine.navigationChannel.popRoute() + } + }) + + val messenger = flutterEngine.dartExecutor.binaryMessenger + + EventChannel( + messenger, + DOWNLOAD_PROGRESS_STREAM_CHANNEL, + ).setStreamHandler( + object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + if (events != null) { + startDownloadProgressStream(events) + } + } + + override fun onCancel(arguments: Any?) { + stopDownloadProgressStream() + } + }, + ) + + EventChannel( + messenger, + LIBRARY_SCAN_PROGRESS_STREAM_CHANNEL, + ).setStreamHandler( + object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + if (events != null) { + startLibraryScanProgressStream(events) + } + } + + override fun onCancel(arguments: Any?) { + stopLibraryScanProgressStream() + } + }, + ) + + MethodChannel(messenger, CHANNEL).setMethodCallHandler { call, result -> scope.launch { try { when (call.method) { @@ -1296,6 +1447,14 @@ class MainActivity: FlutterFragmentActivity() { } result.success(response) } + "getSpotifyRelatedArtists" -> { + val artistId = call.argument("artist_id") ?: "" + val limit = call.argument("limit") ?: 12 + val response = withContext(Dispatchers.IO) { + Gobackend.getSpotifyRelatedArtists(artistId, limit.toLong()) + } + result.success(response) + } "checkAvailability" -> { val spotifyId = call.argument("spotify_id") ?: "" val isrc = call.argument("isrc") ?: "" @@ -1973,6 +2132,14 @@ class MainActivity: FlutterFragmentActivity() { } result.success(response) } + "getDeezerRelatedArtists" -> { + val artistId = call.argument("artist_id") ?: "" + val limit = call.argument("limit") ?: 12 + val response = withContext(Dispatchers.IO) { + Gobackend.getDeezerRelatedArtists(artistId, limit.toLong()) + } + result.success(response) + } "getDeezerMetadata" -> { val resourceType = call.argument("resource_type") ?: "" val resourceId = call.argument("resource_id") ?: "" diff --git a/android/app/src/main/res/drawable/ic_stat_favorite.xml b/android/app/src/main/res/drawable/ic_stat_favorite.xml new file mode 100644 index 00000000..6ef85758 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_stat_favorite.xml @@ -0,0 +1,9 @@ + + + \ 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 new file mode 100644 index 00000000..7e803abf --- /dev/null +++ b/android/app/src/main/res/drawable/ic_stat_favorite_border.xml @@ -0,0 +1,9 @@ + + + \ 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 new file mode 100644 index 00000000..c71ae08e --- /dev/null +++ b/android/app/src/main/res/raw/keep.xml @@ -0,0 +1,3 @@ + + diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 00000000..cde84d83 --- /dev/null +++ b/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,10 @@ + + + + + + + localhost + 127.0.0.1 + + diff --git a/go_backend/amazon.go b/go_backend/amazon.go index 80fc9f3c..b30d669d 100644 --- a/go_backend/amazon.go +++ b/go_backend/amazon.go @@ -45,7 +45,7 @@ type AfkarXYZResponse struct { } `json:"data"` } -// AmazonStreamResponse is the new response format from amazon.afkarxyz.fun/api/track/{asin} +// AmazonStreamResponse is the new response format from amzn.afkarxyz.fun/api/track/{asin} type AmazonStreamResponse struct { StreamURL string `json:"streamUrl"` DecryptionKey string `json:"decryptionKey"` @@ -179,7 +179,7 @@ func (a *AmazonDownloader) doAfkarXYZRequestNew(asin string) (string, string, st ctx, cancel := context.WithTimeout(context.Background(), amazonAPITimeoutMobile) defer cancel() - apiURL := fmt.Sprintf("https://amazon.afkarxyz.fun/api/track/%s", asin) + apiURL := fmt.Sprintf("https://amzn.afkarxyz.fun/api/track/%s", asin) req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil) if err != nil { return "", "", "", fmt.Errorf("failed to create request: %w", err) @@ -193,13 +193,13 @@ func (a *AmazonDownloader) doAfkarXYZRequestNew(asin string) (string, string, st } defer resp.Body.Close() - if resp.StatusCode != 200 { - return "", "", "", fmt.Errorf("Amazon API returned status %d", resp.StatusCode) + body, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return "", "", "", fmt.Errorf("failed to read response: %w", readErr) } - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", "", "", fmt.Errorf("failed to read response: %w", err) + if resp.StatusCode != 200 { + return "", "", "", fmt.Errorf("Amazon API returned status %d", resp.StatusCode) } var apiResp AmazonStreamResponse @@ -219,7 +219,7 @@ func (a *AmazonDownloader) doAfkarXYZRequestLegacy(amazonURL string) (string, st ctx, cancel := context.WithTimeout(context.Background(), amazonAPITimeoutMobile) defer cancel() - apiURL := "https://amazon.afkarxyz.fun/convert?url=" + url.QueryEscape(amazonURL) + apiURL := "https://amzn.afkarxyz.fun/convert?url=" + url.QueryEscape(amazonURL) req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil) if err != nil { return "", "", "", fmt.Errorf("failed to create legacy request: %w", err) @@ -375,6 +375,57 @@ type AmazonDownloadResult struct { DecryptionKey string } +func resolveAmazonURLForRequest(req DownloadRequest, logPrefix string) (string, error) { + if strings.TrimSpace(logPrefix) == "" { + logPrefix = "Amazon" + } + + amazonURL := "" + if req.ISRC != "" { + if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.AmazonURL != "" { + amazonURL = cached.AmazonURL + GoLog("[%s] Cache hit! Using cached Amazon URL for ISRC %s\n", logPrefix, req.ISRC) + } + } + + if amazonURL != "" { + return amazonURL, nil + } + + songlink := NewSongLinkClient() + var availability *TrackAvailability + var err error + + deezerID := strings.TrimSpace(req.DeezerID) + if prefixedDeezerID, found := strings.CutPrefix(req.SpotifyID, "deezer:"); found && strings.TrimSpace(prefixedDeezerID) != "" { + deezerID = strings.TrimSpace(prefixedDeezerID) + } + + if deezerID != "" { + GoLog("[%s] Using Deezer ID for SongLink lookup: %s\n", logPrefix, deezerID) + availability, err = songlink.CheckAvailabilityFromDeezer(deezerID) + } else if req.SpotifyID != "" { + availability, err = songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC) + } else { + return "", fmt.Errorf("no valid Spotify or Deezer ID provided for Amazon lookup") + } + + if err != nil { + return "", fmt.Errorf("failed to check Amazon availability via SongLink: %w", err) + } + + if availability == nil || !availability.Amazon || availability.AmazonURL == "" { + return "", fmt.Errorf("track not available on Amazon Music (SongLink returned no Amazon URL)") + } + + amazonURL = availability.AmazonURL + if req.ISRC != "" { + GetTrackIDCache().SetAmazonURL(req.ISRC, amazonURL) + } + + return amazonURL, nil +} + func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { downloader := NewAmazonDownloader() @@ -385,40 +436,9 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { } } - amazonURL := "" - if req.ISRC != "" { - if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.AmazonURL != "" { - amazonURL = cached.AmazonURL - GoLog("[Amazon] Cache hit! Using cached Amazon URL for ISRC %s\n", req.ISRC) - } - } - - songlink := NewSongLinkClient() - var availability *TrackAvailability - var err error - - if amazonURL == "" { - if deezerID, found := strings.CutPrefix(req.SpotifyID, "deezer:"); found { - GoLog("[Amazon] Using Deezer ID for SongLink lookup: %s\n", deezerID) - availability, err = songlink.CheckAvailabilityFromDeezer(deezerID) - } else if req.SpotifyID != "" { - availability, err = songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC) - } else { - return AmazonDownloadResult{}, fmt.Errorf("no valid Spotify or Deezer ID provided for Amazon lookup") - } - - if err != nil { - return AmazonDownloadResult{}, fmt.Errorf("failed to check Amazon availability via SongLink: %w", err) - } - - if !availability.Amazon || availability.AmazonURL == "" { - return AmazonDownloadResult{}, fmt.Errorf("track not available on Amazon Music (SongLink returned no Amazon URL)") - } - - amazonURL = availability.AmazonURL - if req.ISRC != "" { - GetTrackIDCache().SetAmazonURL(req.ISRC, amazonURL) - } + amazonURL, err := resolveAmazonURLForRequest(req, "Amazon") + if err != nil { + return AmazonDownloadResult{}, err } if !isSafOutput && req.OutputDir != "." { @@ -467,13 +487,19 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { parallelDone := make(chan struct{}) go func() { defer close(parallelDone) + coverURL := req.CoverURL + embedLyrics := req.EmbedLyrics + if !req.EmbedMetadata { + coverURL = "" + embedLyrics = false + } parallelResult = FetchCoverAndLyricsParallel( - req.CoverURL, + coverURL, req.EmbedMaxQualityCover, req.SpotifyID, req.TrackName, req.ArtistName, - req.EmbedLyrics, + embedLyrics, int64(req.DurationMS), ) }() @@ -560,8 +586,12 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { } } - if isSafOutput || needsDecryption { - GoLog("[Amazon] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n") + if isSafOutput || needsDecryption || !req.EmbedMetadata { + if !req.EmbedMetadata { + GoLog("[Amazon] Metadata embedding disabled by settings, skipping in-backend metadata/lyrics embedding\n") + } else { + GoLog("[Amazon] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n") + } } else { isFlacOutput := strings.HasSuffix(strings.ToLower(actualOutputPath), ".flac") if isFlacOutput { @@ -641,7 +671,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { } lyricsLRC := "" - if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { + if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { lyricsLRC = parallelResult.LyricsLRC } diff --git a/go_backend/deezer.go b/go_backend/deezer.go index 09254fc2..568e61c4 100644 --- a/go_backend/deezer.go +++ b/go_backend/deezer.go @@ -13,12 +13,13 @@ import ( ) const ( - deezerBaseURL = "https://api.deezer.com/2.0" - deezerSearchURL = deezerBaseURL + "/search" - deezerTrackURL = deezerBaseURL + "/track/%s" - deezerAlbumURL = deezerBaseURL + "/album/%s" - deezerArtistURL = deezerBaseURL + "/artist/%s" - deezerPlaylistURL = deezerBaseURL + "/playlist/%s" + deezerBaseURL = "https://api.deezer.com/2.0" + deezerSearchURL = deezerBaseURL + "/search" + deezerTrackURL = deezerBaseURL + "/track/%s" + deezerAlbumURL = deezerBaseURL + "/album/%s" + deezerArtistURL = deezerBaseURL + "/artist/%s" + deezerArtistRelatedURL = deezerBaseURL + "/artist/%s/related" + deezerPlaylistURL = deezerBaseURL + "/playlist/%s" deezerCacheTTL = 10 * time.Minute @@ -234,6 +235,8 @@ func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata { DiscNumber: track.DiskNumber, ExternalURL: track.Link, ISRC: track.ISRC, + AlbumID: fmt.Sprintf("deezer:%d", track.Album.ID), + ArtistID: fmt.Sprintf("deezer:%d", track.Artist.ID), } } @@ -756,6 +759,66 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR return result, nil } +func (c *DeezerClient) GetRelatedArtists(ctx context.Context, artistID string, limit int) ([]SearchArtistResult, error) { + normalizedArtistID := strings.TrimSpace(strings.TrimPrefix(artistID, "deezer:")) + if normalizedArtistID == "" { + return nil, fmt.Errorf("invalid Deezer artist ID") + } + + effectiveLimit := limit + if effectiveLimit <= 0 { + effectiveLimit = 12 + } + + relatedURL := fmt.Sprintf("%s?limit=%d", fmt.Sprintf(deezerArtistRelatedURL, normalizedArtistID), effectiveLimit) + var relatedResp struct { + Data []struct { + ID int64 `json:"id"` + Name string `json:"name"` + Picture string `json:"picture"` + PictureMedium string `json:"picture_medium"` + PictureBig string `json:"picture_big"` + PictureXL string `json:"picture_xl"` + NbFan int `json:"nb_fan"` + } `json:"data"` + Error *struct { + Type string `json:"type"` + Message string `json:"message"` + Code int `json:"code"` + } `json:"error,omitempty"` + } + + if err := c.getJSON(ctx, relatedURL, &relatedResp); err != nil { + return nil, err + } + if relatedResp.Error != nil { + return nil, fmt.Errorf("deezer related artists error: %s", relatedResp.Error.Message) + } + + result := make([]SearchArtistResult, 0, len(relatedResp.Data)) + for _, artist := range relatedResp.Data { + imageURL := artist.PictureXL + if imageURL == "" { + imageURL = artist.PictureBig + } + if imageURL == "" { + imageURL = artist.PictureMedium + } + if imageURL == "" { + imageURL = artist.Picture + } + + result = append(result, SearchArtistResult{ + ID: fmt.Sprintf("deezer:%d", artist.ID), + Name: artist.Name, + Images: imageURL, + Followers: artist.NbFan, + Popularity: 0, + }) + } + return result, nil +} + func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*PlaylistResponsePayload, error) { playlistURL := fmt.Sprintf(deezerPlaylistURL, playlistID) diff --git a/go_backend/deezer_download.go b/go_backend/deezer_download.go new file mode 100644 index 00000000..fa613394 --- /dev/null +++ b/go_backend/deezer_download.go @@ -0,0 +1,352 @@ +package gobackend + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" +) + +const deezerYoinkifyURL = "https://yoinkify.lol/api/download" + +type YoinkifyRequest struct { + URL string `json:"url"` + Format string `json:"format"` + GenreSource string `json:"genreSource"` +} + +type DeezerDownloadResult struct { + FilePath string + BitDepth int + SampleRate int + Title string + Artist string + Album string + ReleaseDate string + TrackNumber int + DiscNumber int + ISRC string + LyricsLRC string +} + +func resolveSpotifyURLForYoinkify(req DownloadRequest) (string, error) { + rawSpotify := strings.TrimSpace(req.SpotifyID) + if rawSpotify != "" { + if isLikelySpotifyTrackID(rawSpotify) { + return fmt.Sprintf("https://open.spotify.com/track/%s", rawSpotify), nil + } + + if parsed, err := parseSpotifyURI(rawSpotify); err == nil && parsed.Type == "track" && parsed.ID != "" { + return fmt.Sprintf("https://open.spotify.com/track/%s", parsed.ID), nil + } + } + + deezerID := strings.TrimSpace(req.DeezerID) + if deezerID == "" { + if prefixed, found := strings.CutPrefix(rawSpotify, "deezer:"); found { + deezerID = strings.TrimSpace(prefixed) + } + } + + if deezerID != "" { + songlink := NewSongLinkClient() + spotifyID, err := songlink.GetSpotifyIDFromDeezer(deezerID) + if err != nil { + return "", fmt.Errorf("failed to map deezer:%s to Spotify ID: %w", deezerID, err) + } + spotifyID = strings.TrimSpace(spotifyID) + if spotifyID == "" { + return "", fmt.Errorf("SongLink returned empty Spotify ID for deezer:%s", deezerID) + } + return fmt.Sprintf("https://open.spotify.com/track/%s", spotifyID), nil + } + + return "", fmt.Errorf("missing Spotify track ID for Deezer Yoinkify") +} + +func isLikelySpotifyTrackID(value string) bool { + if len(value) != 22 { + return false + } + for _, r := range value { + switch { + case r >= 'A' && r <= 'Z': + case r >= 'a' && r <= 'z': + case r >= '0' && r <= '9': + default: + return false + } + } + return true +} + +func (c *DeezerClient) DownloadFromYoinkify(spotifyURL, outputPath string, outputFD int, itemID string) error { + payload := YoinkifyRequest{ + URL: spotifyURL, + Format: "flac", + GenreSource: "spotify", + } + + jsonData, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to encode Yoinkify request: %w", err) + } + + ctx := context.Background() + if itemID != "" { + StartItemProgress(itemID) + defer CompleteItemProgress(itemID) + ctx = initDownloadCancel(itemID) + defer clearDownloadCancel(itemID) + } + + if isDownloadCancelled(itemID) { + return ErrDownloadCancelled + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, deezerYoinkifyURL, bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("failed to create Yoinkify request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "*/*") + req.Header.Set("User-Agent", getRandomUserAgent()) + + resp, err := c.httpClient.Do(req) + if err != nil { + if isDownloadCancelled(itemID) { + return ErrDownloadCancelled + } + return fmt.Errorf("failed to call Yoinkify: %w", err) + } + defer resp.Body.Close() + + contentType := strings.ToLower(strings.TrimSpace(resp.Header.Get("Content-Type"))) + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + bodyText := strings.TrimSpace(string(bodyBytes)) + if bodyText != "" { + return fmt.Errorf("Yoinkify returned status %d: %s", resp.StatusCode, bodyText) + } + return fmt.Errorf("Yoinkify returned status %d", resp.StatusCode) + } + + if strings.Contains(contentType, "application/json") { + bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + bodyText := strings.TrimSpace(string(bodyBytes)) + if bodyText == "" { + bodyText = "empty JSON payload" + } + return fmt.Errorf("Yoinkify returned JSON instead of audio: %s", bodyText) + } + + expectedSize := resp.ContentLength + if expectedSize > 0 && itemID != "" { + SetItemBytesTotal(itemID, expectedSize) + } + + out, err := openOutputForWrite(outputPath, outputFD) + if err != nil { + return err + } + + bufWriter := bufio.NewWriterSize(out, 256*1024) + var written int64 + if itemID != "" { + pw := NewItemProgressWriter(bufWriter, itemID) + written, err = io.Copy(pw, resp.Body) + } else { + written, err = io.Copy(bufWriter, resp.Body) + } + + flushErr := bufWriter.Flush() + closeErr := out.Close() + + if err != nil { + cleanupOutputOnError(outputPath, outputFD) + if isDownloadCancelled(itemID) { + return ErrDownloadCancelled + } + return fmt.Errorf("download interrupted: %w", err) + } + if flushErr != nil { + cleanupOutputOnError(outputPath, outputFD) + return fmt.Errorf("failed to flush output: %w", flushErr) + } + if closeErr != nil { + cleanupOutputOnError(outputPath, outputFD) + return fmt.Errorf("failed to close output: %w", closeErr) + } + + if expectedSize > 0 && written != expectedSize { + cleanupOutputOnError(outputPath, outputFD) + return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written) + } + + GoLog("[Deezer] Downloaded via Yoinkify: %.2f MB\n", float64(written)/(1024*1024)) + return nil +} + +func downloadFromDeezer(req DownloadRequest) (DeezerDownloadResult, error) { + deezerClient := GetDeezerClient() + isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != "" + + if !isSafOutput { + if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists { + return DeezerDownloadResult{FilePath: "EXISTS:" + existingFile}, nil + } + } + + spotifyURL, err := resolveSpotifyURLForYoinkify(req) + if err != nil { + return DeezerDownloadResult{}, err + } + + filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{ + "title": req.TrackName, + "artist": req.ArtistName, + "album": req.AlbumName, + "track": req.TrackNumber, + "year": extractYear(req.ReleaseDate), + "date": req.ReleaseDate, + "disc": req.DiscNumber, + }) + + var outputPath string + if isSafOutput { + outputPath = strings.TrimSpace(req.OutputPath) + if outputPath == "" && isFDOutput(req.OutputFD) { + outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD) + } + } else { + filename = sanitizeFilename(filename) + ".flac" + outputPath = filepath.Join(req.OutputDir, filename) + if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 { + return DeezerDownloadResult{FilePath: "EXISTS:" + outputPath}, nil + } + } + + var parallelResult *ParallelDownloadResult + parallelDone := make(chan struct{}) + go func() { + defer close(parallelDone) + coverURL := req.CoverURL + embedLyrics := req.EmbedLyrics + if !req.EmbedMetadata { + coverURL = "" + embedLyrics = false + } + parallelResult = FetchCoverAndLyricsParallel( + coverURL, + req.EmbedMaxQualityCover, + req.SpotifyID, + req.TrackName, + req.ArtistName, + embedLyrics, + int64(req.DurationMS), + ) + }() + + if err := deezerClient.DownloadFromYoinkify(spotifyURL, outputPath, req.OutputFD, req.ItemID); err != nil { + if errors.Is(err, ErrDownloadCancelled) { + return DeezerDownloadResult{}, ErrDownloadCancelled + } + return DeezerDownloadResult{}, fmt.Errorf("deezer yoinkify failed: %w", err) + } + + <-parallelDone + + if req.ItemID != "" { + SetItemProgress(req.ItemID, 1.0, 0, 0) + SetItemFinalizing(req.ItemID) + } + + metadata := Metadata{ + Title: req.TrackName, + Artist: req.ArtistName, + Album: req.AlbumName, + AlbumArtist: req.AlbumArtist, + Date: req.ReleaseDate, + TrackNumber: req.TrackNumber, + TotalTracks: req.TotalTracks, + DiscNumber: req.DiscNumber, + ISRC: req.ISRC, + Genre: req.Genre, + Label: req.Label, + Copyright: req.Copyright, + } + + var coverData []byte + if parallelResult != nil && parallelResult.CoverData != nil { + coverData = parallelResult.CoverData + } + + if isSafOutput || !req.EmbedMetadata { + if !req.EmbedMetadata { + GoLog("[Deezer] Metadata embedding disabled by settings, skipping in-backend metadata/lyrics embedding\n") + } else { + GoLog("[Deezer] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n") + } + } else { + if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil { + GoLog("[Deezer] Warning: failed to embed metadata: %v\n", err) + } + + if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { + lyricsMode := req.LyricsMode + if lyricsMode == "" { + lyricsMode = "embed" + } + + if lyricsMode == "external" || lyricsMode == "both" { + if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil { + GoLog("[Deezer] Warning: failed to save LRC file: %v\n", lrcErr) + } else { + GoLog("[Deezer] LRC file saved: %s\n", lrcPath) + } + } + + if lyricsMode == "embed" || lyricsMode == "both" { + if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil { + GoLog("[Deezer] Warning: failed to embed lyrics: %v\n", embedErr) + } + } + } + } + + if !isSafOutput { + AddToISRCIndex(req.OutputDir, req.ISRC, outputPath) + } + + bitDepth, sampleRate := 0, 0 + if quality, qErr := GetAudioQuality(outputPath); qErr == nil { + bitDepth = quality.BitDepth + sampleRate = quality.SampleRate + } + + lyricsLRC := "" + if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { + lyricsLRC = parallelResult.LyricsLRC + } + + return DeezerDownloadResult{ + FilePath: outputPath, + BitDepth: bitDepth, + SampleRate: sampleRate, + Title: req.TrackName, + Artist: req.ArtistName, + Album: req.AlbumName, + ReleaseDate: req.ReleaseDate, + TrackNumber: req.TrackNumber, + DiscNumber: req.DiscNumber, + ISRC: req.ISRC, + LyricsLRC: lyricsLRC, + }, nil +} diff --git a/go_backend/exports.go b/go_backend/exports.go index 6f2412e4..3c05a0fd 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -123,6 +123,35 @@ func SearchSpotifyAll(query string, trackLimit, artistLimit int) (string, error) return string(jsonBytes), nil } +func GetSpotifyRelatedArtists(artistID string, limit int) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + client, err := NewSpotifyMetadataClient() + if err != nil { + return "", err + } + + normalizedArtistID := strings.TrimSpace(strings.TrimPrefix(artistID, "spotify:")) + if normalizedArtistID == "" { + return "", fmt.Errorf("invalid Spotify artist ID") + } + + artists, err := client.GetRelatedArtists(ctx, normalizedArtistID, limit) + if err != nil { + return "", err + } + + resp := map[string]interface{}{ + "artists": artists, + } + jsonBytes, err := json.Marshal(resp) + if err != nil { + return "", err + } + return string(jsonBytes), nil +} + func CheckAvailability(spotifyID, isrc string) (string, error) { client := NewSongLinkClient() availability, err := client.CheckTrackAvailability(spotifyID, isrc) @@ -159,6 +188,7 @@ type DownloadRequest struct { OutputExt string `json:"output_ext,omitempty"` FilenameFormat string `json:"filename_format"` Quality string `json:"quality"` + EmbedMetadata bool `json:"embed_metadata"` EmbedLyrics bool `json:"embed_lyrics"` EmbedMaxQualityCover bool `json:"embed_max_quality_cover"` TrackNumber int `json:"track_number"` @@ -467,6 +497,24 @@ func DownloadTrack(requestJSON string) (string, error) { } } err = amazonErr + case "deezer": + deezerResult, deezerErr := downloadFromDeezer(req) + if deezerErr == nil { + result = DownloadResult{ + FilePath: deezerResult.FilePath, + BitDepth: deezerResult.BitDepth, + SampleRate: deezerResult.SampleRate, + Title: deezerResult.Title, + Artist: deezerResult.Artist, + Album: deezerResult.Album, + ReleaseDate: deezerResult.ReleaseDate, + TrackNumber: deezerResult.TrackNumber, + DiscNumber: deezerResult.DiscNumber, + ISRC: deezerResult.ISRC, + LyricsLRC: deezerResult.LyricsLRC, + } + } + err = deezerErr case "youtube": youtubeResult, youtubeErr := downloadFromYouTube(req) if youtubeErr == nil { @@ -592,7 +640,7 @@ func DownloadWithFallback(requestJSON string) (string, error) { enrichRequestExtendedMetadata(&req) - allServices := []string{"tidal", "qobuz", "amazon"} + allServices := []string{"tidal", "qobuz", "amazon", "deezer"} preferredService := req.Service if preferredService == "" { preferredService = "tidal" @@ -680,6 +728,26 @@ func DownloadWithFallback(requestJSON string) (string, error) { GoLog("[DownloadWithFallback] Amazon error: %v\n", amazonErr) } err = amazonErr + case "deezer": + deezerResult, deezerErr := downloadFromDeezer(req) + if deezerErr == nil { + result = DownloadResult{ + FilePath: deezerResult.FilePath, + BitDepth: deezerResult.BitDepth, + SampleRate: deezerResult.SampleRate, + Title: deezerResult.Title, + Artist: deezerResult.Artist, + Album: deezerResult.Album, + ReleaseDate: deezerResult.ReleaseDate, + TrackNumber: deezerResult.TrackNumber, + DiscNumber: deezerResult.DiscNumber, + ISRC: deezerResult.ISRC, + LyricsLRC: deezerResult.LyricsLRC, + } + } else if !errors.Is(deezerErr, ErrDownloadCancelled) { + GoLog("[DownloadWithFallback] Deezer error: %v\n", deezerErr) + } + err = deezerErr } if err != nil && errors.Is(err, ErrDownloadCancelled) { @@ -1162,6 +1230,26 @@ func SearchDeezerAll(query string, trackLimit, artistLimit int, filter string) ( return string(jsonBytes), nil } +func GetDeezerRelatedArtists(artistID string, limit int) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + client := GetDeezerClient() + artists, err := client.GetRelatedArtists(ctx, artistID, limit) + if err != nil { + return "", err + } + + resp := map[string]interface{}{ + "artists": artists, + } + jsonBytes, err := json.Marshal(resp) + if err != nil { + return "", err + } + return string(jsonBytes), nil +} + func GetDeezerMetadata(resourceType, resourceID string) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() @@ -3145,7 +3233,10 @@ func callExtensionFunctionJSON(extensionID, functionName string, timeout time.Du return "", fmt.Errorf("extension '%s' is disabled", extensionID) } - provider := NewExtensionProviderWrapper(ext) + // Goja runtime is not thread-safe; guard direct extension.*() calls with VMMu + // to avoid races with other provider calls (e.g. getAlbum/getPlaylist). + ext.VMMu.Lock() + defer ext.VMMu.Unlock() script := fmt.Sprintf(` (function() { @@ -3156,7 +3247,7 @@ func callExtensionFunctionJSON(extensionID, functionName string, timeout time.Du })() `, functionName, functionName) - result, err := RunWithTimeoutAndRecover(provider.vm, script, timeout) + result, err := RunWithTimeoutAndRecover(ext.VM, script, timeout) if err != nil { return "", fmt.Errorf("%s failed: %w", functionName, err) } diff --git a/go_backend/extension_manager.go b/go_backend/extension_manager.go index 1b612691..b9b460c2 100644 --- a/go_backend/extension_manager.go +++ b/go_backend/extension_manager.go @@ -48,11 +48,12 @@ type LoadedExtension struct { Manifest *ExtensionManifest `json:"manifest"` VM *goja.Runtime `json:"-"` VMMu sync.Mutex `json:"-"` - Enabled bool `json:"enabled"` - Error string `json:"error,omitempty"` - DataDir string `json:"data_dir"` - SourceDir string `json:"source_dir"` - IconPath string `json:"icon_path"` + runtime *ExtensionRuntime + Enabled bool `json:"enabled"` + Error string `json:"error,omitempty"` + DataDir string `json:"data_dir"` + SourceDir string `json:"source_dir"` + IconPath string `json:"icon_path"` } type ExtensionManager struct { @@ -243,6 +244,7 @@ func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error { } runtime := NewExtensionRuntime(ext) + ext.runtime = runtime runtime.RegisterAPIs(vm) runtime.RegisterGoBackendAPIs(vm) @@ -295,6 +297,13 @@ func (m *ExtensionManager) UnloadExtension(extensionID string) error { GoLog("[Extension] Cleanup called for %s\n", extensionID) } } + if ext.runtime != nil { + if err := ext.runtime.flushStorageNow(); err != nil { + GoLog("[Extension] Failed to flush storage for %s: %v\n", extensionID, err) + } + ext.runtime.closeStorageFlusher() + ext.runtime = nil + } delete(m.extensions, extensionID) GoLog("[Extension] Unloaded extension: %s\n", extensionID) @@ -536,7 +545,6 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension, extDir := existing.SourceDir wasEnabled := existing.Enabled - m.CleanupExtension(existing.ID) m.UnloadExtension(existing.ID) if extDir != "" { @@ -909,7 +917,6 @@ func (m *ExtensionManager) UnloadAllExtensions() { m.mu.Unlock() for _, id := range extensionIDs { - m.CleanupExtension(id) m.UnloadExtension(id) } diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go index 694354b3..ceac1af4 100644 --- a/go_backend/extension_providers.go +++ b/go_backend/extension_providers.go @@ -631,7 +631,7 @@ func GetProviderPriority() []string { defer providerPriorityMu.RUnlock() if len(providerPriority) == 0 { - return []string{"tidal", "qobuz", "amazon"} + return []string{"tidal", "qobuz", "amazon", "deezer"} } result := make([]string, len(providerPriority)) @@ -815,7 +815,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro Copyright: req.Copyright, } - if req.Genre != "" || req.Label != "" { + if req.EmbedMetadata && (req.Genre != "" || req.Label != "") { if err := EmbedGenreLabel(result.FilePath, req.Genre, req.Label); err != nil { GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err) } else { @@ -1013,7 +1013,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro Copyright: req.Copyright, } - if req.Genre != "" || req.Label != "" { + if req.EmbedMetadata && (req.Genre != "" || req.Label != "") { if err := EmbedGenreLabel(result.FilePath, req.Genre, req.Label); err != nil { GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err) } else { @@ -1147,6 +1147,24 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon } } err = amazonErr + case "deezer": + deezerResult, deezerErr := downloadFromDeezer(req) + if deezerErr == nil { + result = DownloadResult{ + FilePath: deezerResult.FilePath, + BitDepth: deezerResult.BitDepth, + SampleRate: deezerResult.SampleRate, + Title: deezerResult.Title, + Artist: deezerResult.Artist, + Album: deezerResult.Album, + ReleaseDate: deezerResult.ReleaseDate, + TrackNumber: deezerResult.TrackNumber, + DiscNumber: deezerResult.DiscNumber, + ISRC: deezerResult.ISRC, + LyricsLRC: deezerResult.LyricsLRC, + } + } + err = deezerErr default: return nil, fmt.Errorf("unknown built-in provider: %s", providerID) } diff --git a/go_backend/extension_runtime.go b/go_backend/extension_runtime.go index ad2610c0..c45b52c1 100644 --- a/go_backend/extension_runtime.go +++ b/go_backend/extension_runtime.go @@ -88,18 +88,48 @@ type ExtensionRuntime struct { cookieJar http.CookieJar dataDir string vm *goja.Runtime + + storageMu sync.RWMutex + storageCache map[string]interface{} + storageLoaded bool + storageDirty bool + storageClosed bool + storageTimer *time.Timer + storageWriteMu sync.Mutex + + credentialsMu sync.RWMutex + credentialsCache map[string]interface{} + credentialsLoaded bool + storageFlushDelay time.Duration } +type privateIPCacheEntry struct { + isPrivate bool + expiresAt time.Time +} + +const ( + privateIPCacheTTL = 5 * time.Minute + privateIPErrorCacheTTL = 30 * time.Second + maxPrivateIPCacheSize = 1024 +) + +var ( + privateIPCache = make(map[string]privateIPCacheEntry) + privateIPCacheMu sync.RWMutex +) + func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime { jar, _ := newSimpleCookieJar() runtime := &ExtensionRuntime{ - extensionID: ext.ID, - manifest: ext.Manifest, - settings: make(map[string]interface{}), - cookieJar: jar, - dataDir: ext.DataDir, - vm: ext.VM, + extensionID: ext.ID, + manifest: ext.Manifest, + settings: make(map[string]interface{}), + cookieJar: jar, + dataDir: ext.DataDir, + vm: ext.VM, + storageFlushDelay: defaultStorageFlushDelay, } // Extension sandbox enforces HTTPS-only domains. Do not apply global @@ -166,18 +196,68 @@ func isPrivateIP(host string) bool { return isPrivateIPAddr(ip) } + if cached, ok := getPrivateIPCache(hostLower); ok { + return cached + } + ips, err := net.LookupIP(hostLower) if err != nil { + setPrivateIPCache(hostLower, false, privateIPErrorCacheTTL) return false } + isPrivate := false for _, ip := range ips { if isPrivateIPAddr(ip) { - return true + isPrivate = true + break } } - return false + setPrivateIPCache(hostLower, isPrivate, privateIPCacheTTL) + return isPrivate +} + +func getPrivateIPCache(host string) (bool, bool) { + now := time.Now() + + privateIPCacheMu.RLock() + entry, exists := privateIPCache[host] + privateIPCacheMu.RUnlock() + if !exists { + return false, false + } + + if now.Before(entry.expiresAt) { + return entry.isPrivate, true + } + + privateIPCacheMu.Lock() + delete(privateIPCache, host) + privateIPCacheMu.Unlock() + return false, false +} + +func setPrivateIPCache(host string, isPrivate bool, ttl time.Duration) { + expiresAt := time.Now().Add(ttl) + + privateIPCacheMu.Lock() + if len(privateIPCache) >= maxPrivateIPCacheSize { + now := time.Now() + for key, entry := range privateIPCache { + if now.After(entry.expiresAt) { + delete(privateIPCache, key) + } + } + if len(privateIPCache) >= maxPrivateIPCacheSize { + privateIPCache = make(map[string]privateIPCacheEntry) + } + } + privateIPCache[host] = privateIPCacheEntry{ + isPrivate: isPrivate, + expiresAt: expiresAt, + } + privateIPCacheMu.Unlock() } func isPrivateIPAddr(ip net.IP) bool { diff --git a/go_backend/extension_runtime_file.go b/go_backend/extension_runtime_file.go index ccd51308..9bb1191e 100644 --- a/go_backend/extension_runtime_file.go +++ b/go_backend/extension_runtime_file.go @@ -396,13 +396,14 @@ func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value { }) } - data, err := os.ReadFile(fullSrc) + srcFile, err := os.Open(fullSrc) if err != nil { return r.vm.ToValue(map[string]interface{}{ "success": false, "error": fmt.Sprintf("failed to read source: %v", err), }) } + defer srcFile.Close() dir := filepath.Dir(fullDst) if err := os.MkdirAll(dir, 0755); err != nil { @@ -412,10 +413,26 @@ func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value { }) } - if err := os.WriteFile(fullDst, data, 0644); err != nil { + dstFile, err := os.OpenFile(fullDst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) + if err != nil { return r.vm.ToValue(map[string]interface{}{ "success": false, - "error": fmt.Sprintf("failed to write destination: %v", err), + "error": fmt.Sprintf("failed to open destination: %v", err), + }) + } + + if _, err := io.Copy(dstFile, srcFile); err != nil { + _ = dstFile.Close() + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": fmt.Sprintf("failed to copy file: %v", err), + }) + } + + if err := dstFile.Close(); err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": fmt.Sprintf("failed to finalize destination: %v", err), }) } diff --git a/go_backend/extension_runtime_storage.go b/go_backend/extension_runtime_storage.go index 2e85033f..06cbdd33 100644 --- a/go_backend/extension_runtime_storage.go +++ b/go_backend/extension_runtime_storage.go @@ -11,42 +11,164 @@ import ( "io" "os" "path/filepath" + "reflect" + "time" "github.com/dop251/goja" ) // ==================== Storage API ==================== +const ( + defaultStorageFlushDelay = 400 * time.Millisecond + storageFlushRetryDelay = 2 * time.Second +) + func (r *ExtensionRuntime) getStoragePath() string { return filepath.Join(r.dataDir, "storage.json") } -func (r *ExtensionRuntime) loadStorage() (map[string]interface{}, error) { +func cloneInterfaceMap(src map[string]interface{}) map[string]interface{} { + if len(src) == 0 { + return make(map[string]interface{}) + } + dst := make(map[string]interface{}, len(src)) + for k, v := range src { + dst[k] = v + } + return dst +} + +func (r *ExtensionRuntime) ensureStorageLoaded() error { + r.storageMu.RLock() + if r.storageLoaded { + r.storageMu.RUnlock() + return nil + } + r.storageMu.RUnlock() + + r.storageMu.Lock() + defer r.storageMu.Unlock() + if r.storageLoaded { + return nil + } + storagePath := r.getStoragePath() data, err := os.ReadFile(storagePath) if err != nil { if os.IsNotExist(err) { - return make(map[string]interface{}), nil + r.storageCache = make(map[string]interface{}) + r.storageLoaded = true + return nil } - return nil, err + return err } var storage map[string]interface{} if err := json.Unmarshal(data, &storage); err != nil { + return err + } + if storage == nil { + storage = make(map[string]interface{}) + } + + r.storageCache = storage + r.storageLoaded = true + return nil +} + +func (r *ExtensionRuntime) loadStorage() (map[string]interface{}, error) { + if err := r.ensureStorageLoaded(); err != nil { return nil, err } - return storage, nil + r.storageMu.RLock() + defer r.storageMu.RUnlock() + return cloneInterfaceMap(r.storageCache), nil } -func (r *ExtensionRuntime) saveStorage(storage map[string]interface{}) error { - storagePath := r.getStoragePath() - data, err := json.MarshalIndent(storage, "", " ") +func (r *ExtensionRuntime) queueStorageFlushLocked(delay time.Duration) { + if r.storageClosed { + return + } + if r.storageTimer != nil { + return + } + r.storageTimer = time.AfterFunc(delay, r.flushStorageDirtyAsync) +} + +func (r *ExtensionRuntime) persistStorageSnapshot(storage map[string]interface{}) error { + data, err := json.Marshal(storage) if err != nil { return err } - return os.WriteFile(storagePath, data, 0600) + r.storageWriteMu.Lock() + defer r.storageWriteMu.Unlock() + + return os.WriteFile(r.getStoragePath(), data, 0600) +} + +func (r *ExtensionRuntime) flushStorageDirtyAsync() { + if err := r.flushStorageDirty(); err != nil { + GoLog("[Extension:%s] Storage flush error: %v\n", r.extensionID, err) + } +} + +func (r *ExtensionRuntime) flushStorageDirty() error { + r.storageMu.Lock() + if r.storageClosed { + r.storageTimer = nil + r.storageMu.Unlock() + return nil + } + if !r.storageDirty { + r.storageTimer = nil + r.storageMu.Unlock() + return nil + } + snapshot := cloneInterfaceMap(r.storageCache) + r.storageDirty = false + r.storageTimer = nil + r.storageMu.Unlock() + + if err := r.persistStorageSnapshot(snapshot); err != nil { + r.storageMu.Lock() + r.storageDirty = true + r.queueStorageFlushLocked(storageFlushRetryDelay) + r.storageMu.Unlock() + return err + } + + return nil +} + +func (r *ExtensionRuntime) flushStorageNow() error { + r.storageMu.Lock() + if r.storageTimer != nil { + r.storageTimer.Stop() + r.storageTimer = nil + } + if !r.storageLoaded || r.storageClosed { + r.storageMu.Unlock() + return nil + } + snapshot := cloneInterfaceMap(r.storageCache) + r.storageDirty = false + r.storageMu.Unlock() + + return r.persistStorageSnapshot(snapshot) +} + +func (r *ExtensionRuntime) closeStorageFlusher() { + r.storageMu.Lock() + r.storageClosed = true + r.storageDirty = false + if r.storageTimer != nil { + r.storageTimer.Stop() + r.storageTimer = nil + } + r.storageMu.Unlock() } func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value { @@ -56,13 +178,14 @@ func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value { key := call.Arguments[0].String() - storage, err := r.loadStorage() - if err != nil { + if err := r.ensureStorageLoaded(); err != nil { GoLog("[Extension:%s] Storage load error: %v\n", r.extensionID, err) return goja.Undefined() } - value, exists := storage[key] + r.storageMu.RLock() + value, exists := r.storageCache[key] + r.storageMu.RUnlock() if !exists { if len(call.Arguments) > 1 { return call.Arguments[1] @@ -81,18 +204,26 @@ func (r *ExtensionRuntime) storageSet(call goja.FunctionCall) goja.Value { key := call.Arguments[0].String() value := call.Arguments[1].Export() - storage, err := r.loadStorage() - if err != nil { + if err := r.ensureStorageLoaded(); err != nil { GoLog("[Extension:%s] Storage load error: %v\n", r.extensionID, err) return r.vm.ToValue(false) } - storage[key] = value - - if err := r.saveStorage(storage); err != nil { - GoLog("[Extension:%s] Storage save error: %v\n", r.extensionID, err) + r.storageMu.Lock() + if r.storageClosed { + r.storageMu.Unlock() return r.vm.ToValue(false) } + if existing, exists := r.storageCache[key]; exists { + if reflect.DeepEqual(existing, value) { + r.storageMu.Unlock() + return r.vm.ToValue(true) + } + } + r.storageCache[key] = value + r.storageDirty = true + r.queueStorageFlushLocked(r.storageFlushDelay) + r.storageMu.Unlock() return r.vm.ToValue(true) } @@ -104,18 +235,24 @@ func (r *ExtensionRuntime) storageRemove(call goja.FunctionCall) goja.Value { key := call.Arguments[0].String() - storage, err := r.loadStorage() - if err != nil { + if err := r.ensureStorageLoaded(); err != nil { GoLog("[Extension:%s] Storage load error: %v\n", r.extensionID, err) return r.vm.ToValue(false) } - delete(storage, key) - - if err := r.saveStorage(storage); err != nil { - GoLog("[Extension:%s] Storage save error: %v\n", r.extensionID, err) + r.storageMu.Lock() + if r.storageClosed { + r.storageMu.Unlock() return r.vm.ToValue(false) } + if _, exists := r.storageCache[key]; !exists { + r.storageMu.Unlock() + return r.vm.ToValue(true) + } + delete(r.storageCache, key) + r.storageDirty = true + r.queueStorageFlushLocked(r.storageFlushDelay) + r.storageMu.Unlock() return r.vm.ToValue(true) } @@ -159,31 +296,61 @@ func (r *ExtensionRuntime) getEncryptionKey() ([]byte, error) { return hash[:], nil } -func (r *ExtensionRuntime) loadCredentials() (map[string]interface{}, error) { +func (r *ExtensionRuntime) ensureCredentialsLoaded() error { + r.credentialsMu.RLock() + if r.credentialsLoaded { + r.credentialsMu.RUnlock() + return nil + } + r.credentialsMu.RUnlock() + + r.credentialsMu.Lock() + defer r.credentialsMu.Unlock() + if r.credentialsLoaded { + return nil + } + credPath := r.getCredentialsPath() data, err := os.ReadFile(credPath) if err != nil { if os.IsNotExist(err) { - return make(map[string]interface{}), nil + r.credentialsCache = make(map[string]interface{}) + r.credentialsLoaded = true + return nil } - return nil, err + return err } key, err := r.getEncryptionKey() if err != nil { - return nil, fmt.Errorf("failed to get encryption key: %w", err) + return fmt.Errorf("failed to get encryption key: %w", err) } decrypted, err := decryptAES(data, key) if err != nil { - return nil, fmt.Errorf("failed to decrypt credentials: %w", err) + return fmt.Errorf("failed to decrypt credentials: %w", err) } var creds map[string]interface{} if err := json.Unmarshal(decrypted, &creds); err != nil { + return err + } + if creds == nil { + creds = make(map[string]interface{}) + } + + r.credentialsCache = creds + r.credentialsLoaded = true + return nil +} + +func (r *ExtensionRuntime) loadCredentials() (map[string]interface{}, error) { + if err := r.ensureCredentialsLoaded(); err != nil { return nil, err } - return creds, nil + r.credentialsMu.RLock() + defer r.credentialsMu.RUnlock() + return cloneInterfaceMap(r.credentialsCache), nil } func (r *ExtensionRuntime) saveCredentials(creds map[string]interface{}) error { @@ -202,7 +369,15 @@ func (r *ExtensionRuntime) saveCredentials(creds map[string]interface{}) error { } credPath := r.getCredentialsPath() - return os.WriteFile(credPath, encrypted, 0600) + if err := os.WriteFile(credPath, encrypted, 0600); err != nil { + return err + } + + r.credentialsMu.Lock() + r.credentialsCache = cloneInterfaceMap(creds) + r.credentialsLoaded = true + r.credentialsMu.Unlock() + return nil } func (r *ExtensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value { @@ -216,8 +391,7 @@ func (r *ExtensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value { key := call.Arguments[0].String() value := call.Arguments[1].Export() - creds, err := r.loadCredentials() - if err != nil { + if err := r.ensureCredentialsLoaded(); err != nil { GoLog("[Extension:%s] Credentials load error: %v\n", r.extensionID, err) return r.vm.ToValue(map[string]interface{}{ "success": false, @@ -225,9 +399,12 @@ func (r *ExtensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value { }) } - creds[key] = value + r.credentialsMu.RLock() + nextCreds := cloneInterfaceMap(r.credentialsCache) + r.credentialsMu.RUnlock() + nextCreds[key] = value - if err := r.saveCredentials(creds); err != nil { + if err := r.saveCredentials(nextCreds); err != nil { GoLog("[Extension:%s] Credentials save error: %v\n", r.extensionID, err) return r.vm.ToValue(map[string]interface{}{ "success": false, @@ -247,13 +424,14 @@ func (r *ExtensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value { key := call.Arguments[0].String() - creds, err := r.loadCredentials() - if err != nil { + if err := r.ensureCredentialsLoaded(); err != nil { GoLog("[Extension:%s] Credentials load error: %v\n", r.extensionID, err) return goja.Undefined() } - value, exists := creds[key] + r.credentialsMu.RLock() + value, exists := r.credentialsCache[key] + r.credentialsMu.RUnlock() if !exists { if len(call.Arguments) > 1 { return call.Arguments[1] @@ -271,15 +449,17 @@ func (r *ExtensionRuntime) credentialsRemove(call goja.FunctionCall) goja.Value key := call.Arguments[0].String() - creds, err := r.loadCredentials() - if err != nil { + if err := r.ensureCredentialsLoaded(); err != nil { GoLog("[Extension:%s] Credentials load error: %v\n", r.extensionID, err) return r.vm.ToValue(false) } - delete(creds, key) + r.credentialsMu.RLock() + nextCreds := cloneInterfaceMap(r.credentialsCache) + r.credentialsMu.RUnlock() + delete(nextCreds, key) - if err := r.saveCredentials(creds); err != nil { + if err := r.saveCredentials(nextCreds); err != nil { GoLog("[Extension:%s] Credentials save error: %v\n", r.extensionID, err) return r.vm.ToValue(false) } @@ -294,12 +474,13 @@ func (r *ExtensionRuntime) credentialsHas(call goja.FunctionCall) goja.Value { key := call.Arguments[0].String() - creds, err := r.loadCredentials() - if err != nil { + if err := r.ensureCredentialsLoaded(); err != nil { return r.vm.ToValue(false) } - _, exists := creds[key] + r.credentialsMu.RLock() + _, exists := r.credentialsCache[key] + r.credentialsMu.RUnlock() return r.vm.ToValue(exists) } diff --git a/go_backend/extension_runtime_storage_test.go b/go_backend/extension_runtime_storage_test.go new file mode 100644 index 00000000..dad6781b --- /dev/null +++ b/go_backend/extension_runtime_storage_test.go @@ -0,0 +1,120 @@ +package gobackend + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/dop251/goja" +) + +func setStorageValue(t *testing.T, runtime *ExtensionRuntime, key string, value interface{}) { + t.Helper() + result := runtime.storageSet(goja.FunctionCall{ + Arguments: []goja.Value{ + runtime.vm.ToValue(key), + runtime.vm.ToValue(value), + }, + }) + if !result.ToBoolean() { + t.Fatalf("storage.set(%q) returned false", key) + } +} + +func readStorageMap(t *testing.T, storagePath string) map[string]interface{} { + t.Helper() + data, err := os.ReadFile(storagePath) + if err != nil { + t.Fatalf("failed to read storage file: %v", err) + } + + var parsed map[string]interface{} + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatalf("failed to unmarshal storage file: %v", err) + } + return parsed +} + +func TestExtensionRuntimeStorage_DebouncedWriteCompactJSON(t *testing.T) { + ext := &LoadedExtension{ + ID: "storage-test", + Manifest: &ExtensionManifest{ + Name: "storage-test", + }, + DataDir: t.TempDir(), + } + + runtime := NewExtensionRuntime(ext) + runtime.storageFlushDelay = 25 * time.Millisecond + runtime.RegisterAPIs(goja.New()) + + setStorageValue(t, runtime, "k1", "v1") + setStorageValue(t, runtime, "k2", 2) + + storagePath := filepath.Join(ext.DataDir, "storage.json") + deadline := time.Now().Add(1500 * time.Millisecond) + + var raw []byte + for time.Now().Before(deadline) { + data, err := os.ReadFile(storagePath) + if err == nil { + raw = data + break + } + time.Sleep(20 * time.Millisecond) + } + if len(raw) == 0 { + t.Fatalf("storage.json was not written within timeout") + } + + var parsed map[string]interface{} + if err := json.Unmarshal(raw, &parsed); err != nil { + t.Fatalf("failed to unmarshal storage file: %v", err) + } + if parsed["k1"] != "v1" { + t.Fatalf("expected k1=v1, got %v", parsed["k1"]) + } + if parsed["k2"] != float64(2) { + t.Fatalf("expected k2=2, got %v", parsed["k2"]) + } + if bytes.Contains(raw, []byte("\n")) { + t.Fatalf("expected compact JSON without indentation, got: %q", string(raw)) + } +} + +func TestUnloadExtension_FlushesPendingStorage(t *testing.T) { + ext := &LoadedExtension{ + ID: "unload-storage-test", + Manifest: &ExtensionManifest{ + Name: "unload-storage-test", + }, + DataDir: t.TempDir(), + VM: goja.New(), + } + + runtime := NewExtensionRuntime(ext) + runtime.storageFlushDelay = time.Hour + runtime.RegisterAPIs(ext.VM) + ext.runtime = runtime + + manager := &ExtensionManager{ + extensions: map[string]*LoadedExtension{ + ext.ID: ext, + }, + } + + setStorageValue(t, runtime, "persist_on_unload", true) + + if err := manager.UnloadExtension(ext.ID); err != nil { + t.Fatalf("UnloadExtension failed: %v", err) + } + + storagePath := filepath.Join(ext.DataDir, "storage.json") + parsed := readStorageMap(t, storagePath) + if parsed["persist_on_unload"] != true { + t.Fatalf("expected pending storage value to be flushed on unload, got %v", parsed["persist_on_unload"]) + } +} diff --git a/go_backend/lyrics.go b/go_backend/lyrics.go index 005a2ca9..3ec16555 100644 --- a/go_backend/lyrics.go +++ b/go_backend/lyrics.go @@ -22,6 +22,7 @@ const ( // Lyrics provider names (used in settings and cascade ordering) const ( + LyricsProviderSpotifyAPI = "spotify_api" LyricsProviderLRCLIB = "lrclib" LyricsProviderNetease = "netease" LyricsProviderMusixmatch = "musixmatch" @@ -33,6 +34,7 @@ const ( // LRCLIB first (no proxy dependency), then the others. var DefaultLyricsProviders = []string{ LyricsProviderLRCLIB, + LyricsProviderSpotifyAPI, LyricsProviderMusixmatch, LyricsProviderNetease, LyricsProviderAppleMusic, @@ -45,6 +47,11 @@ var ( lyricsProviders []string // ordered list of enabled providers ) +var ( + spotifyLyricsRateLimitMu sync.RWMutex + spotifyLyricsRateLimitedTil time.Time +) + // LyricsFetchOptions controls optional provider-specific enhancements. type LyricsFetchOptions struct { IncludeTranslationNetease bool `json:"include_translation_netease"` @@ -78,6 +85,7 @@ func SetLyricsProviderOrder(providers []string) { // Validate provider names validNames := map[string]bool{ + LyricsProviderSpotifyAPI: true, LyricsProviderLRCLIB: true, LyricsProviderNetease: true, LyricsProviderMusixmatch: true, @@ -114,6 +122,7 @@ func GetLyricsProviderOrder() []string { // GetAvailableLyricsProviders returns metadata about all available providers. func GetAvailableLyricsProviders() []map[string]interface{} { return []map[string]interface{}{ + {"id": LyricsProviderSpotifyAPI, "name": "Spotify Lyrics API", "has_proxy_dependency": true, "description": "Spotify-sourced synced lyrics via community API"}, {"id": LyricsProviderLRCLIB, "name": "LRCLIB", "has_proxy_dependency": false, "description": "Open-source synced lyrics database"}, {"id": LyricsProviderNetease, "name": "Netease", "has_proxy_dependency": false, "description": "NetEase Cloud Music (good for Asian songs)"}, {"id": LyricsProviderMusixmatch, "name": "Musixmatch", "has_proxy_dependency": true, "description": "Largest lyrics database (multi-language)"}, @@ -245,6 +254,18 @@ type LRCLibResponse struct { SyncedLyrics string `json:"syncedLyrics"` } +type SpotifyLyricsLine struct { + TimeTag string `json:"timeTag"` + Words string `json:"words"` +} + +type SpotifyLyricsAPIResponse struct { + Error bool `json:"error"` + Message string `json:"message"` + SyncType string `json:"syncType"` + Lines []SpotifyLyricsLine `json:"lines"` +} + type LyricsLine struct { StartTimeMs int64 `json:"startTimeMs"` Words string `json:"words"` @@ -352,6 +373,172 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string, durationSec flo return c.parseLRCLibResponse(&results[0]), nil } +func parseSpotifyLyricsTimeTagToMs(tag string) int64 { + raw := strings.TrimSpace(tag) + raw = strings.TrimPrefix(raw, "[") + raw = strings.TrimSuffix(raw, "]") + if raw == "" { + return 0 + } + + if ms, err := strconv.ParseInt(raw, 10, 64); err == nil { + return ms + } + + re := regexp.MustCompile(`^(\d{1,2}):(\d{2})\.(\d{1,3})$`) + matches := re.FindStringSubmatch(raw) + if len(matches) != 4 { + return 0 + } + + minutes, _ := strconv.ParseInt(matches[1], 10, 64) + seconds, _ := strconv.ParseInt(matches[2], 10, 64) + fraction := matches[3] + fractionInt, _ := strconv.ParseInt(fraction, 10, 64) + if len(fraction) == 2 { + fractionInt *= 10 + } else if len(fraction) == 1 { + fractionInt *= 100 + } + return minutes*60*1000 + seconds*1000 + fractionInt +} + +func getSpotifyLyricsRateLimitUntil() time.Time { + spotifyLyricsRateLimitMu.RLock() + defer spotifyLyricsRateLimitMu.RUnlock() + return spotifyLyricsRateLimitedTil +} + +func setSpotifyLyricsRateLimitUntil(until time.Time) { + spotifyLyricsRateLimitMu.Lock() + spotifyLyricsRateLimitedTil = until + spotifyLyricsRateLimitMu.Unlock() +} + +func parseSpotifyRetryAfter(retryAfter string, now time.Time) time.Time { + raw := strings.TrimSpace(retryAfter) + if raw == "" { + return now.Add(10 * time.Minute) + } + + if sec, err := strconv.Atoi(raw); err == nil && sec > 0 { + return now.Add(time.Duration(sec) * time.Second) + } + + if when, err := http.ParseTime(raw); err == nil && when.After(now) { + return when + } + + return now.Add(10 * time.Minute) +} + +func (c *LyricsClient) FetchLyricsFromSpotifyAPI(spotifyID string) (*LyricsResponse, error) { + now := time.Now() + if limitedUntil := getSpotifyLyricsRateLimitUntil(); limitedUntil.After(now) { + waitFor := int(math.Ceil(limitedUntil.Sub(now).Seconds())) + return nil, fmt.Errorf( + "Spotify Lyrics API cooldown active (%ds remaining after previous 429)", + waitFor, + ) + } + + spotifyID = strings.TrimSpace(spotifyID) + if spotifyID == "" { + return nil, fmt.Errorf("spotify ID is empty") + } + if parsed, err := parseSpotifyURI(spotifyID); err == nil && parsed.Type == "track" && parsed.ID != "" { + spotifyID = parsed.ID + } + + apiURL := fmt.Sprintf("https://spotify-lyrics-api-pi.vercel.app/?trackid=%s&format=lrc", url.QueryEscape(spotifyID)) + req, err := http.NewRequest("GET", apiURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("User-Agent", getRandomUserAgent()) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to fetch from Spotify Lyrics API: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + if resp.StatusCode == http.StatusTooManyRequests { + retryUntil := parseSpotifyRetryAfter(resp.Header.Get("Retry-After"), now) + setSpotifyLyricsRateLimitUntil(retryUntil) + } + var payload map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&payload); err == nil { + if msg, ok := payload["message"].(string); ok && strings.TrimSpace(msg) != "" { + return nil, fmt.Errorf("Spotify Lyrics API returned status %d: %s", resp.StatusCode, strings.TrimSpace(msg)) + } + if msg, ok := payload["error"].(string); ok && strings.TrimSpace(msg) != "" { + return nil, fmt.Errorf("Spotify Lyrics API returned status %d: %s", resp.StatusCode, strings.TrimSpace(msg)) + } + } + return nil, fmt.Errorf("Spotify Lyrics API returned status %d", resp.StatusCode) + } + + var apiResp SpotifyLyricsAPIResponse + if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil { + return nil, fmt.Errorf("failed to parse Spotify Lyrics API response: %w", err) + } + + if apiResp.Error { + msg := strings.TrimSpace(apiResp.Message) + if msg == "" { + msg = "Spotify Lyrics API returned error" + } + return nil, fmt.Errorf("%s", msg) + } + + result := &LyricsResponse{ + Lines: make([]LyricsLine, 0, len(apiResp.Lines)), + SyncType: apiResp.SyncType, + Instrumental: false, + PlainLyrics: "", + Provider: "Spotify Lyrics API", + Source: "Spotify Lyrics API", + } + + for _, line := range apiResp.Lines { + words := strings.TrimSpace(line.Words) + if words == "" { + continue + } + startMs := parseSpotifyLyricsTimeTagToMs(line.TimeTag) + result.Lines = append(result.Lines, LyricsLine{ + StartTimeMs: startMs, + Words: words, + EndTimeMs: 0, + }) + } + + if len(result.Lines) > 1 { + for i := 0; i < len(result.Lines)-1; i++ { + nextStart := result.Lines[i+1].StartTimeMs + if nextStart > result.Lines[i].StartTimeMs { + result.Lines[i].EndTimeMs = nextStart + } + } + last := len(result.Lines) - 1 + if result.Lines[last].EndTimeMs == 0 { + result.Lines[last].EndTimeMs = result.Lines[last].StartTimeMs + 5000 + } + } + + if len(result.Lines) == 0 { + return nil, fmt.Errorf("Spotify Lyrics API returned empty lines") + } + + if result.SyncType == "" { + result.SyncType = "LINE_SYNCED" + } + + return result, nil +} + func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec float64) *LRCLibResponse { var bestSynced *LRCLibResponse var bestPlain *LRCLibResponse @@ -448,6 +635,9 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st var err error switch providerName { + case LyricsProviderSpotifyAPI: + lyrics, err = c.FetchLyricsFromSpotifyAPI(spotifyID) + case LyricsProviderLRCLIB: lyrics, err = c.tryLRCLIB(primaryArtist, artistName, trackName, simplifiedTrack, durationSec) diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index f8f0fcbc..044e1315 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -3,7 +3,6 @@ package gobackend import ( "bufio" "context" - "encoding/base64" "encoding/json" "errors" "fmt" @@ -28,6 +27,11 @@ var ( qobuzDownloaderOnce sync.Once ) +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=" +) + type QobuzTrack struct { ID int64 `json:"id"` Title string `json:"title"` @@ -185,13 +189,19 @@ func qobuzTitlesMatch(expectedTitle, foundTitle string) bool { } } - // Some tracks are symbol/emoji-heavy and providers can return textual - // aliases. If artist/duration already matched upstream, avoid false rejects. + // Emoji/symbol-only titles must be matched strictly to avoid false positives + // like mapping "🪐" to unrelated textual tracks. if (!hasAlphaNumericRunes(expectedTitle) || !hasAlphaNumericRunes(foundTitle)) && strings.TrimSpace(expectedTitle) != "" && strings.TrimSpace(foundTitle) != "" { - GoLog("[Qobuz] Symbol-heavy title detected, relaxing match: '%s' vs '%s'\n", expectedTitle, foundTitle) - return true + expectedSymbols := normalizeSymbolOnlyTitle(expectedTitle) + foundSymbols := normalizeSymbolOnlyTitle(foundTitle) + if expectedSymbols != "" && foundSymbols != "" && expectedSymbols == foundSymbols { + GoLog("[Qobuz] Symbol-heavy title matched strictly: '%s' vs '%s'\n", expectedTitle, foundTitle) + return true + } + GoLog("[Qobuz] Symbol-heavy title mismatch: '%s' vs '%s'\n", expectedTitle, foundTitle) + return false } expectedLatin := qobuzIsLatinScript(expectedTitle) @@ -331,8 +341,7 @@ func NewQobuzDownloader() *QobuzDownloader { } func (q *QobuzDownloader) GetTrackByID(trackID int64) (*QobuzTrack, error) { - apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9nZXQ/dHJhY2tfaWQ9") - trackURL := fmt.Sprintf("%s%d&app_id=%s", string(apiBase), trackID, q.appID) + trackURL := fmt.Sprintf("%s%d&app_id=%s", qobuzTrackGetBaseURL, trackID, q.appID) req, err := http.NewRequest("GET", trackURL, nil) if err != nil { @@ -358,46 +367,10 @@ func (q *QobuzDownloader) GetTrackByID(trackID int64) (*QobuzTrack, error) { } func (q *QobuzDownloader) GetAvailableAPIs() []string { - encodedAPIs := []string{ - "ZGFiLnllZXQuc3UvYXBpL3N0cmVhbT90cmFja0lkPQ==", - "ZGFibXVzaWMueHl6L2FwaS9zdHJlYW0/dHJhY2tJZD0=", - "cW9idXouc3F1aWQud3RmL2FwaS9kb3dubG9hZC1tdXNpYz90cmFja19pZD0=", + return []string{ + "https://dab.yeet.su/api/stream?trackId=", + "https://dabmusic.xyz/api/stream?trackId=", } - - var apis []string - for _, encoded := range encodedAPIs { - decoded, err := base64.StdEncoding.DecodeString(encoded) - if err != nil { - continue - } - apis = append(apis, "https://"+string(decoded)) - } - - return apis -} - -func mapJumoQuality(quality string) int { - switch quality { - case "6": - return 6 - case "7": - return 7 - case "27": - return 27 - default: - return 6 - } -} - -func decodeXOR(data []byte) string { - text := string(data) - runes := []rune(text) - result := make([]rune, len(runes)) - for i, char := range runes { - key := rune((i * 17) % 128) - result[i] = char ^ 253 ^ key - } - return string(result) } func extractQobuzDownloadURLFromBody(body []byte) (string, error) { @@ -436,67 +409,8 @@ func extractQobuzDownloadURLFromBody(body []byte) (string, error) { return "", fmt.Errorf("no download URL in response") } -func (q *QobuzDownloader) downloadFromJumo(trackID int64, quality string) (string, error) { - formatID := mapJumoQuality(quality) - region := "US" - jumoURL := fmt.Sprintf("https://jumo-dl.pages.dev/get?track_id=%d&format_id=%d®ion=%s", trackID, formatID, region) - - GoLog("[Qobuz] Trying Jumo API fallback...\n") - - client := NewHTTPClientWithTimeout(30 * time.Second) - req, err := http.NewRequest("GET", jumoURL, nil) - if err != nil { - return "", err - } - req.Header.Set("User-Agent", getRandomUserAgent()) - req.Header.Set("Referer", "https://jumo-dl.pages.dev/") - - resp, err := client.Do(req) - if err != nil { - return "", err - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return "", fmt.Errorf("Jumo API returned HTTP %d", resp.StatusCode) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", err - } - - var result map[string]any - if err := json.Unmarshal(body, &result); err != nil { - decoded := decodeXOR(body) - if err := json.Unmarshal([]byte(decoded), &result); err != nil { - return "", fmt.Errorf("failed to parse Jumo response (plain or XOR): %w", err) - } - } - - if urlVal, ok := result["url"].(string); ok && urlVal != "" { - GoLog("[Qobuz] Jumo API returned URL successfully\n") - return urlVal, nil - } - - if data, ok := result["data"].(map[string]any); ok { - if urlVal, ok := data["url"].(string); ok && urlVal != "" { - GoLog("[Qobuz] Jumo API returned URL successfully (from data)\n") - return urlVal, nil - } - } - - if linkVal, ok := result["link"].(string); ok && linkVal != "" { - GoLog("[Qobuz] Jumo API returned URL successfully (from link)\n") - return linkVal, nil - } - - return "", fmt.Errorf("URL not found in Jumo response") -} - func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) { - apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9") - searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(isrc), q.appID) + searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", qobuzTrackSearchBaseURL, url.QueryEscape(isrc), q.appID) req, err := http.NewRequest("GET", searchURL, nil) if err != nil { @@ -538,8 +452,7 @@ func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) { func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDurationSec int) (*QobuzTrack, error) { GoLog("[Qobuz] Searching by ISRC: %s\n", isrc) - apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9") - searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(isrc), q.appID) + searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", qobuzTrackSearchBaseURL, url.QueryEscape(isrc), q.appID) req, err := http.NewRequest("GET", searchURL, nil) if err != nil { @@ -621,8 +534,6 @@ func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (* } func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) { - apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9") - queries := []string{} if artistName != "" && trackName != "" { @@ -674,7 +585,7 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam GoLog("[Qobuz] Searching for: %s\n", cleanQuery) - searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(cleanQuery), q.appID) + searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", qobuzTrackSearchBaseURL, url.QueryEscape(cleanQuery), q.appID) req, err := http.NewRequest("GET", searchURL, nil) if err != nil { @@ -799,26 +710,8 @@ func getQobuzAPITimeout() time.Duration { return qobuzAPITimeoutMobile } -// qobuzSquidCountries defines the region fallback order for squid.wtf API -var qobuzSquidCountries = []string{"US", "FR"} - // fetchQobuzURLWithRetry fetches download URL from a single Qobuz API with retry logic -// For squid.wtf APIs, it tries US region first, then falls back to FR func fetchQobuzURLWithRetry(api string, trackID int64, quality string, timeout time.Duration) (string, error) { - isSquid := strings.Contains(api, "squid.wtf") - - if isSquid { - for _, country := range qobuzSquidCountries { - GoLog("[Qobuz] Trying squid.wtf with country=%s\n", country) - result, err := fetchQobuzURLSingleAttempt(api, trackID, quality, timeout, country) - if err == nil { - return result, nil - } - GoLog("[Qobuz] squid.wtf country=%s failed: %v\n", country, err) - } - return "", fmt.Errorf("squid.wtf failed for all regions (US, FR)") - } - return fetchQobuzURLSingleAttempt(api, trackID, quality, timeout, "") } @@ -964,34 +857,43 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string, return "", fmt.Errorf("no Qobuz API available") } - _, downloadURL, err := getQobuzDownloadURLParallel(apis, trackID, quality) + qualityCode := strings.TrimSpace(quality) + if qualityCode == "" || qualityCode == "5" { + qualityCode = "6" + } + + downloadFunc := func(qual string) (string, error) { + _, downloadURL, err := getQobuzDownloadURLParallel(apis, trackID, qual) + if err != nil { + return "", err + } + return downloadURL, nil + } + + downloadURL, err := downloadFunc(qualityCode) if err == nil { return downloadURL, nil } - GoLog("[Qobuz] Standard APIs failed, trying Jumo fallback...\n") - jumoURL, jumoErr := q.downloadFromJumo(trackID, quality) - if jumoErr == nil { - return jumoURL, nil - } - - if quality == "27" { + currentQuality := qualityCode + if currentQuality == "27" { GoLog("[Qobuz] Hi-res (27) failed, trying 24-bit (7)...\n") - jumoURL, jumoErr = q.downloadFromJumo(trackID, "7") - if jumoErr == nil { - return jumoURL, nil + downloadURL, err = downloadFunc("7") + if err == nil { + return downloadURL, nil } + currentQuality = "7" } - if quality == "27" || quality == "7" { + if currentQuality == "7" { GoLog("[Qobuz] 24-bit failed, trying 16-bit (6)...\n") - jumoURL, jumoErr = q.downloadFromJumo(trackID, "6") - if jumoErr == nil { - return jumoURL, nil + downloadURL, err = downloadFunc("6") + if err == nil { + return downloadURL, nil } } - return "", fmt.Errorf("all Qobuz APIs and Jumo fallback failed: %w", err) + return "", fmt.Errorf("all Qobuz APIs failed: %w", err) } func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error { @@ -1087,14 +989,12 @@ type QobuzDownloadResult struct { LyricsLRC string } -func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { - downloader := NewQobuzDownloader() - - isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != "" - if !isSafOutput { - if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists { - return QobuzDownloadResult{FilePath: "EXISTS:" + existingFile}, nil - } +func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloader, logPrefix string) (*QobuzTrack, error) { + if downloader == nil { + downloader = NewQobuzDownloader() + } + if strings.TrimSpace(logPrefix) == "" { + logPrefix = "Qobuz" } expectedDurationSec := req.DurationMS / 1000 @@ -1104,15 +1004,15 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { // Strategy 1: Use Qobuz ID from Odesli enrichment (fastest, most accurate) if req.QobuzID != "" { - GoLog("[Qobuz] Using Qobuz ID from Odesli enrichment: %s\n", req.QobuzID) + GoLog("[%s] Using Qobuz ID from Odesli enrichment: %s\n", logPrefix, req.QobuzID) var trackID int64 if _, parseErr := fmt.Sscanf(req.QobuzID, "%d", &trackID); parseErr == nil && trackID > 0 { track, err = downloader.GetTrackByID(trackID) if err != nil { - GoLog("[Qobuz] Failed to get track by Odesli ID %d: %v\n", trackID, err) + GoLog("[%s] Failed to get track by Odesli ID %d: %v\n", logPrefix, trackID, err) track = nil } else if track != nil { - GoLog("[Qobuz] Successfully found track via Odesli ID: '%s' by '%s'\n", track.Title, track.Performer.Name) + GoLog("[%s] Successfully found track via Odesli ID: '%s' by '%s'\n", logPrefix, track.Title, track.Performer.Name) } } } @@ -1120,10 +1020,10 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { // Strategy 2: Use cached Qobuz Track ID (fast, no search needed) if track == nil && req.ISRC != "" { if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.QobuzTrackID > 0 { - GoLog("[Qobuz] Cache hit! Using cached track ID: %d\n", cached.QobuzTrackID) + GoLog("[%s] Cache hit! Using cached track ID: %d\n", logPrefix, cached.QobuzTrackID) track, err = downloader.GetTrackByID(cached.QobuzTrackID) if err != nil { - GoLog("[Qobuz] Cache hit but GetTrackByID failed: %v\n", err) + GoLog("[%s] Cache hit but GetTrackByID failed: %v\n", logPrefix, err) track = nil } } @@ -1131,19 +1031,19 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { // Strategy 3: Try to get QobuzID from SongLink if we have SpotifyID if track == nil && req.SpotifyID != "" && req.QobuzID == "" { - GoLog("[Qobuz] Trying to get Qobuz ID from SongLink for Spotify ID: %s\n", req.SpotifyID) + GoLog("[%s] Trying to get Qobuz ID from SongLink for Spotify ID: %s\n", logPrefix, req.SpotifyID) songLinkClient := NewSongLinkClient() availability, slErr := songLinkClient.CheckTrackAvailability(req.SpotifyID, req.ISRC) if slErr == nil && availability != nil && availability.QobuzID != "" { var trackID int64 if _, parseErr := fmt.Sscanf(availability.QobuzID, "%d", &trackID); parseErr == nil && trackID > 0 { - GoLog("[Qobuz] Got Qobuz ID %d from SongLink\n", trackID) + GoLog("[%s] Got Qobuz ID %d from SongLink\n", logPrefix, trackID) track, err = downloader.GetTrackByID(trackID) if err != nil { - GoLog("[Qobuz] Failed to get track by SongLink ID %d: %v\n", trackID, err) + GoLog("[%s] Failed to get track by SongLink ID %d: %v\n", logPrefix, trackID, err) track = nil } else if track != nil { - GoLog("[Qobuz] Successfully found track via SongLink ID: '%s' by '%s'\n", track.Title, track.Performer.Name) + GoLog("[%s] Successfully found track via SongLink ID: '%s' by '%s'\n", logPrefix, track.Title, track.Performer.Name) // Cache for future use if req.ISRC != "" { GetTrackIDCache().SetQobuz(req.ISRC, track.ID) @@ -1155,16 +1055,16 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { // Strategy 4: ISRC search with duration verification if track == nil && req.ISRC != "" { - GoLog("[Qobuz] Trying ISRC search: %s\n", req.ISRC) + GoLog("[%s] Trying ISRC search: %s\n", logPrefix, req.ISRC) track, err = downloader.SearchTrackByISRCWithDuration(req.ISRC, expectedDurationSec) if track != nil { if !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) { - GoLog("[Qobuz] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n", - req.ArtistName, track.Performer.Name) + GoLog("[%s] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n", + logPrefix, req.ArtistName, track.Performer.Name) track = nil } else if !qobuzTitlesMatch(req.TrackName, track.Title) { - GoLog("[Qobuz] Title mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n", - req.TrackName, track.Title) + GoLog("[%s] Title mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n", + logPrefix, req.TrackName, track.Title) track = nil } } @@ -1172,11 +1072,11 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { // Strategy 5: Metadata search with strict matching (duration tolerance: 10 seconds) if track == nil { - GoLog("[Qobuz] Trying metadata search: '%s' by '%s'\n", req.TrackName, req.ArtistName) + GoLog("[%s] Trying metadata search: '%s' by '%s'\n", logPrefix, req.TrackName, req.ArtistName) track, err = downloader.SearchTrackByMetadataWithDuration(req.TrackName, req.ArtistName, expectedDurationSec) if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) { - GoLog("[Qobuz] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n", - req.ArtistName, track.Performer.Name) + GoLog("[%s] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n", + logPrefix, req.ArtistName, track.Performer.Name) track = nil } } @@ -1186,14 +1086,32 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { if err != nil { errMsg = err.Error() } - return QobuzDownloadResult{}, fmt.Errorf("qobuz search failed: %s", errMsg) + return nil, fmt.Errorf("qobuz search failed: %s", errMsg) } - GoLog("[Qobuz] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, track.Performer.Name, track.Duration) + GoLog("[%s] Match found: '%s' by '%s' (duration: %ds)\n", logPrefix, track.Title, track.Performer.Name, track.Duration) if req.ISRC != "" { GetTrackIDCache().SetQobuz(req.ISRC, track.ID) } + return track, nil +} + +func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { + downloader := NewQobuzDownloader() + + isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != "" + if !isSafOutput { + if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists { + return QobuzDownloadResult{FilePath: "EXISTS:" + existingFile}, nil + } + } + + track, err := resolveQobuzTrackForRequest(req, downloader, "Qobuz") + if err != nil { + return QobuzDownloadResult{}, err + } + filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{ "title": req.TrackName, "artist": req.ArtistName, @@ -1241,13 +1159,19 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { parallelDone := make(chan struct{}) go func() { defer close(parallelDone) + coverURL := req.CoverURL + embedLyrics := req.EmbedLyrics + if !req.EmbedMetadata { + coverURL = "" + embedLyrics = false + } parallelResult = FetchCoverAndLyricsParallel( - req.CoverURL, + coverURL, req.EmbedMaxQualityCover, req.SpotifyID, req.TrackName, req.ArtistName, - req.EmbedLyrics, + embedLyrics, int64(req.DurationMS), ) }() @@ -1297,8 +1221,12 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { GoLog("[Qobuz] Using parallel-fetched cover (%d bytes)\n", len(coverData)) } - if isSafOutput { - GoLog("[Qobuz] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n") + if isSafOutput || !req.EmbedMetadata { + if !req.EmbedMetadata { + GoLog("[Qobuz] Metadata embedding disabled by settings, skipping in-backend metadata/lyrics embedding\n") + } else { + GoLog("[Qobuz] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n") + } } else { if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil { fmt.Printf("Warning: failed to embed metadata: %v\n", err) @@ -1337,7 +1265,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { } lyricsLRC := "" - if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { + if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { lyricsLRC = parallelResult.LyricsLRC } diff --git a/go_backend/songlink.go b/go_backend/songlink.go index 43cca7aa..975573df 100644 --- a/go_backend/songlink.go +++ b/go_backend/songlink.go @@ -561,16 +561,17 @@ func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID strin availability.DeezerURL = deezerLink.URL } - if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" { + // Prefer youtubeMusic URLs — they are usually closer to music catalog matches. + if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" { availability.YouTube = true - availability.YouTubeURL = youtubeLink.URL - availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL) + availability.YouTubeURL = ytMusicLink.URL + availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL) } if !availability.YouTube { - if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" { + if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" { availability.YouTube = true - availability.YouTubeURL = ytMusicLink.URL - availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL) + availability.YouTubeURL = youtubeLink.URL + availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL) } } @@ -658,16 +659,17 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL) } - if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" { + // Prefer youtubeMusic URLs — they are usually closer to music catalog matches. + if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" { availability.YouTube = true - availability.YouTubeURL = youtubeLink.URL - availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL) + availability.YouTubeURL = ytMusicLink.URL + availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL) } if !availability.YouTube { - if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" { + if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" { availability.YouTube = true - availability.YouTubeURL = ytMusicLink.URL - availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL) + availability.YouTubeURL = youtubeLink.URL + availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL) } } @@ -805,16 +807,17 @@ func (s *SongLinkClient) CheckAvailabilityFromURL(inputURL string) (*TrackAvaila availability.DeezerURL = deezerLink.URL availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL) } - if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" { + // Prefer youtubeMusic URLs — they are usually closer to music catalog matches. + if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" { availability.YouTube = true - availability.YouTubeURL = youtubeLink.URL - availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL) + availability.YouTubeURL = ytMusicLink.URL + availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL) } if !availability.YouTube { - if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" { + if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" { availability.YouTube = true - availability.YouTubeURL = ytMusicLink.URL - availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL) + availability.YouTubeURL = youtubeLink.URL + availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL) } } diff --git a/go_backend/spotify.go b/go_backend/spotify.go index 6501f952..9728a72f 100644 --- a/go_backend/spotify.go +++ b/go_backend/spotify.go @@ -16,13 +16,14 @@ import ( ) const ( - spotifyTokenURL = "https://accounts.spotify.com/api/token" - playlistBaseURL = "https://api.spotify.com/v1/playlists/%s" - albumBaseURL = "https://api.spotify.com/v1/albums/%s" - trackBaseURL = "https://api.spotify.com/v1/tracks/%s" - artistBaseURL = "https://api.spotify.com/v1/artists/%s" - artistAlbumsURL = "https://api.spotify.com/v1/artists/%s/albums" - searchBaseURL = "https://api.spotify.com/v1/search" + spotifyTokenURL = "https://accounts.spotify.com/api/token" + playlistBaseURL = "https://api.spotify.com/v1/playlists/%s" + albumBaseURL = "https://api.spotify.com/v1/albums/%s" + trackBaseURL = "https://api.spotify.com/v1/tracks/%s" + artistBaseURL = "https://api.spotify.com/v1/artists/%s" + artistAlbumsURL = "https://api.spotify.com/v1/artists/%s/albums" + artistRelatedURL = "https://api.spotify.com/v1/artists/%s/related-artists" + searchBaseURL = "https://api.spotify.com/v1/search" artistCacheTTL = 10 * time.Minute searchCacheTTL = 5 * time.Minute @@ -140,6 +141,8 @@ type TrackMetadata struct { DiscNumber int `json:"disc_number,omitempty"` ExternalURL string `json:"external_urls"` ISRC string `json:"isrc"` + AlbumID string `json:"album_id,omitempty"` + ArtistID string `json:"artist_id,omitempty"` AlbumType string `json:"album_type,omitempty"` } @@ -361,6 +364,10 @@ func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string, } for _, track := range response.Tracks.Items { + var firstArtistID string + if len(track.Artists) > 0 { + firstArtistID = track.Artists[0].ID + } result.Tracks = append(result.Tracks, TrackMetadata{ SpotifyID: track.ID, Artists: joinArtists(track.Artists), @@ -375,6 +382,8 @@ func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string, DiscNumber: track.DiscNumber, ExternalURL: track.ExternalURL.Spotify, ISRC: track.ExternalID.ISRC, + AlbumID: track.Album.ID, + ArtistID: firstArtistID, AlbumType: track.Album.AlbumType, }) } @@ -426,6 +435,10 @@ func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, tra } for _, track := range response.Tracks.Items { + var firstArtistID string + if len(track.Artists) > 0 { + firstArtistID = track.Artists[0].ID + } result.Tracks = append(result.Tracks, TrackMetadata{ SpotifyID: track.ID, Artists: joinArtists(track.Artists), @@ -440,6 +453,8 @@ func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, tra DiscNumber: track.DiscNumber, ExternalURL: track.ExternalURL.Spotify, ISRC: track.ExternalID.ISRC, + AlbumID: track.Album.ID, + ArtistID: firstArtistID, AlbumType: track.Album.AlbumType, }) } @@ -838,6 +853,47 @@ func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token return result, nil } +func (c *SpotifyMetadataClient) GetRelatedArtists(ctx context.Context, artistID string, limit int) ([]SearchArtistResult, error) { + token, err := c.getAccessToken(ctx) + if err != nil { + return nil, err + } + + var data struct { + Artists []struct { + ID string `json:"id"` + Name string `json:"name"` + Images []image `json:"images"` + Followers struct { + Total int `json:"total"` + } `json:"followers"` + Popularity int `json:"popularity"` + } `json:"artists"` + } + + if err := c.getJSON(ctx, fmt.Sprintf(artistRelatedURL, artistID), token, &data); err != nil { + return nil, err + } + + maxItems := len(data.Artists) + if limit > 0 && limit < maxItems { + maxItems = limit + } + + result := make([]SearchArtistResult, 0, maxItems) + for i := 0; i < maxItems; i++ { + artist := data.Artists[i] + result = append(result, SearchArtistResult{ + ID: artist.ID, + Name: artist.Name, + Images: firstImageURL(artist.Images), + Followers: artist.Followers.Total, + Popularity: artist.Popularity, + }) + } + return result, nil +} + func (c *SpotifyMetadataClient) fetchTrackISRC(ctx context.Context, trackID, token string) string { var data struct { ExternalID externalID `json:"external_ids"` diff --git a/go_backend/tidal.go b/go_backend/tidal.go index bc0ca7ba..22fd2377 100644 --- a/go_backend/tidal.go +++ b/go_backend/tidal.go @@ -20,13 +20,8 @@ import ( ) type TidalDownloader struct { - client *http.Client - clientID string - clientSecret string - apiURL string - cachedToken string - tokenExpiresAt time.Time - tokenMu sync.Mutex + client *http.Client + apiURL string } var ( @@ -34,6 +29,11 @@ var ( tidalDownloaderOnce sync.Once ) +const ( + spotifyTrackBaseURL = "https://open.spotify.com/track/" + songLinkLookupBaseURL = "https://api.song.link/v1-alpha.1/links?url=" +) + type TidalTrack struct { ID int64 `json:"id"` Title string `json:"title"` @@ -102,13 +102,8 @@ type MPD struct { func NewTidalDownloader() *TidalDownloader { tidalDownloaderOnce.Do(func() { - clientID, _ := base64.StdEncoding.DecodeString("NkJEU1JkcEs5aHFFQlRnVQ==") - clientSecret, _ := base64.StdEncoding.DecodeString("eGV1UG1ZN25icFo5SUliTEFjUTkzc2hrYTFWTmhlVUFxTjZJY3N6alRHOD0=") - globalTidalDownloader = &TidalDownloader{ - client: NewHTTPClientWithTimeout(DefaultTimeout), // 60s timeout - clientID: string(clientID), - clientSecret: string(clientSecret), + client: NewHTTPClientWithTimeout(DefaultTimeout), // 60s timeout } apis := globalTidalDownloader.GetAvailableAPIs() @@ -120,85 +115,27 @@ func NewTidalDownloader() *TidalDownloader { } func (t *TidalDownloader) GetAvailableAPIs() []string { - encodedAPIs := []string{ - "dGlkYWwtYXBpLmJpbmltdW0ub3Jn", // tidal-api.binimum.org (priority) - "dGlkYWwua2lub3BsdXMub25saW5l", // tidal.kinoplus.online - "dHJpdG9uLnNxdWlkLnd0Zg==", // triton.squid.wtf - "dm9nZWwucXFkbC5zaXRl", // vogel.qqdl.site - "bWF1cy5xcWRsLnNpdGU=", // maus.qqdl.site - "aHVuZC5xcWRsLnNpdGU=", // hund.qqdl.site - "a2F0emUucXFkbC5zaXRl", // katze.qqdl.site - "d29sZi5xcWRsLnNpdGU=", // wolf.qqdl.site - "aGlmaS1vbmUuc3BvdGlzYXZlci5uZXQ=", // hifi-one.spotisaver.net - "aGlmaS10d28uc3BvdGlzYXZlci5uZXQ=", // hifi-two.spotisaver.net + return []string{ + "https://tidal-api.binimum.org", // priority + "https://tidal.kinoplus.online", + "https://triton.squid.wtf", + "https://vogel.qqdl.site", + "https://maus.qqdl.site", + "https://hund.qqdl.site", + "https://katze.qqdl.site", + "https://wolf.qqdl.site", + "https://hifi-one.spotisaver.net", + "https://hifi-two.spotisaver.net", } - - var apis []string - for _, encoded := range encodedAPIs { - decoded, err := base64.StdEncoding.DecodeString(encoded) - if err != nil { - continue - } - apis = append(apis, "https://"+string(decoded)) - } - - return apis } func (t *TidalDownloader) GetAccessToken() (string, error) { - t.tokenMu.Lock() - defer t.tokenMu.Unlock() - - if t.cachedToken != "" && time.Now().Add(60*time.Second).Before(t.tokenExpiresAt) { - return t.cachedToken, nil - } - - data := fmt.Sprintf("client_id=%s&grant_type=client_credentials", t.clientID) - - authURL, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hdXRoLnRpZGFsLmNvbS92MS9vYXV0aDIvdG9rZW4=") - req, err := http.NewRequest("POST", string(authURL), strings.NewReader(data)) - if err != nil { - return "", err - } - - req.SetBasicAuth(t.clientID, t.clientSecret) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - - resp, err := DoRequestWithUserAgent(t.client, req) - if err != nil { - return "", err - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return "", fmt.Errorf("failed to get access token: HTTP %d", resp.StatusCode) - } - - var result struct { - AccessToken string `json:"access_token"` - ExpiresIn int `json:"expires_in"` - } - - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return "", err - } - - t.cachedToken = result.AccessToken - if result.ExpiresIn > 0 { - t.tokenExpiresAt = time.Now().Add(time.Duration(result.ExpiresIn) * time.Second) - } else { - t.tokenExpiresAt = time.Now().Add(55 * time.Minute) // Default 55 min - } - - return result.AccessToken, nil + return "", fmt.Errorf("tidal official metadata API disabled: no client credentials mode") } func (t *TidalDownloader) GetTidalURLFromSpotify(spotifyTrackID string) (string, error) { - spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==") - spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID) - - apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==") - apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL)) + spotifyURL := fmt.Sprintf("%s%s", spotifyTrackBaseURL, spotifyTrackID) + apiURL := fmt.Sprintf("%s%s", songLinkLookupBaseURL, url.QueryEscape(spotifyURL)) req, err := http.NewRequest("GET", apiURL, nil) if err != nil { @@ -251,321 +188,20 @@ func (t *TidalDownloader) GetTrackIDFromURL(tidalURL string) (int64, error) { } func (t *TidalDownloader) GetTrackInfoByID(trackID int64) (*TidalTrack, error) { - token, err := t.GetAccessToken() - if err != nil { - return nil, fmt.Errorf("failed to get access token: %w", err) - } - - trackBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkudGlkYWwuY29tL3YxL3RyYWNrcy8=") - trackURL := fmt.Sprintf("%s%d?countryCode=US", string(trackBase), trackID) - - req, err := http.NewRequest("GET", trackURL, nil) - if err != nil { - return nil, err - } - - req.Header.Set("Authorization", "Bearer "+token) - - resp, err := DoRequestWithUserAgent(t.client, req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return nil, fmt.Errorf("failed to get track info: HTTP %d", resp.StatusCode) - } - - var trackInfo TidalTrack - if err := json.NewDecoder(resp.Body).Decode(&trackInfo); err != nil { - return nil, err - } - - return &trackInfo, nil + return nil, fmt.Errorf("tidal track lookup API disabled: no client credentials mode") } func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) { - token, err := t.GetAccessToken() - if err != nil { - return nil, err - } - - searchBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkudGlkYWwuY29tL3YxL3NlYXJjaC90cmFja3M/cXVlcnk9") - searchURL := fmt.Sprintf("%s%s&limit=50&countryCode=US", string(searchBase), url.QueryEscape(isrc)) - - req, err := http.NewRequest("GET", searchURL, nil) - if err != nil { - return nil, err - } - - req.Header.Set("Authorization", "Bearer "+token) - - resp, err := DoRequestWithUserAgent(t.client, req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return nil, fmt.Errorf("search failed: HTTP %d", resp.StatusCode) - } - - var result struct { - Items []TidalTrack `json:"items"` - } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, err - } - - for i := range result.Items { - if result.Items[i].ISRC == isrc { - return &result.Items[i], nil - } - } - - if len(result.Items) == 0 { - return nil, fmt.Errorf("no tracks found for ISRC: %s", isrc) - } - - return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc) + return nil, fmt.Errorf("tidal ISRC search API disabled: no client credentials mode") } // Now includes romaji conversion for Japanese text (4 search strategies like PC) -func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, spotifyISRC string, expectedDuration int) (*TidalTrack, error) { - token, err := t.GetAccessToken() - if err != nil { - return nil, err - } - - // Build search queries - multiple strategies (same as PC version) - queries := []string{} - - if artistName != "" && trackName != "" { - queries = append(queries, artistName+" "+trackName) - } - - if trackName != "" { - queries = append(queries, trackName) - } - - if ContainsJapanese(trackName) || ContainsJapanese(artistName) { - romajiTrack := JapaneseToRomaji(trackName) - romajiArtist := JapaneseToRomaji(artistName) - - cleanRomajiTrack := CleanToASCII(romajiTrack) - cleanRomajiArtist := CleanToASCII(romajiArtist) - - if cleanRomajiArtist != "" && cleanRomajiTrack != "" { - romajiQuery := cleanRomajiArtist + " " + cleanRomajiTrack - if !containsQuery(queries, romajiQuery) { - queries = append(queries, romajiQuery) - GoLog("[Tidal] Japanese detected, adding romaji query: %s\n", romajiQuery) - } - } - - if cleanRomajiTrack != "" && cleanRomajiTrack != trackName { - if !containsQuery(queries, cleanRomajiTrack) { - queries = append(queries, cleanRomajiTrack) - } - } - - if artistName != "" && cleanRomajiTrack != "" { - partialQuery := artistName + " " + cleanRomajiTrack - if !containsQuery(queries, partialQuery) { - queries = append(queries, partialQuery) - } - } - } - - if artistName != "" { - artistOnly := CleanToASCII(JapaneseToRomaji(artistName)) - if artistOnly != "" && !containsQuery(queries, artistOnly) { - queries = append(queries, artistOnly) - } - } - - searchBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkudGlkYWwuY29tL3YxL3NlYXJjaC90cmFja3M/cXVlcnk9") - - var allTracks []TidalTrack - searchedQueries := make(map[string]bool) - - for _, query := range queries { - cleanQuery := strings.TrimSpace(query) - if cleanQuery == "" || searchedQueries[cleanQuery] { - continue - } - searchedQueries[cleanQuery] = true - - GoLog("[Tidal] Searching for: %s\n", cleanQuery) - - searchURL := fmt.Sprintf("%s%s&limit=100&countryCode=US", string(searchBase), url.QueryEscape(cleanQuery)) - - req, err := http.NewRequest("GET", searchURL, nil) - if err != nil { - continue - } - - req.Header.Set("Authorization", "Bearer "+token) - - resp, err := DoRequestWithUserAgent(t.client, req) - if err != nil { - GoLog("[Tidal] Search error for '%s': %v\n", cleanQuery, err) - continue - } - - if resp.StatusCode != 200 { - resp.Body.Close() - continue - } - - var result struct { - Items []TidalTrack `json:"items"` - } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - resp.Body.Close() - continue - } - resp.Body.Close() - - if len(result.Items) > 0 { - GoLog("[Tidal] Found %d results for '%s'\n", len(result.Items), cleanQuery) - - if spotifyISRC != "" { - for i := range result.Items { - if result.Items[i].ISRC == spotifyISRC { - track := &result.Items[i] - if expectedDuration > 0 { - durationDiff := track.Duration - expectedDuration - if durationDiff < 0 { - durationDiff = -durationDiff - } - if durationDiff <= 3 { - GoLog("[Tidal] ISRC match: '%s' (duration verified)\n", track.Title) - return track, nil - } - GoLog("[Tidal] ISRC match but duration mismatch (expected %ds, got %ds), continuing...\n", - expectedDuration, track.Duration) - } else { - GoLog("[Tidal] ISRC match: '%s'\n", track.Title) - return track, nil - } - } - } - } - - allTracks = append(allTracks, result.Items...) - } - } - - if len(allTracks) == 0 { - return nil, fmt.Errorf("no tracks found for any search query") - } - - if spotifyISRC != "" { - GoLog("[Tidal] Looking for ISRC match: %s\n", spotifyISRC) - var isrcMatches []*TidalTrack - for i := range allTracks { - track := &allTracks[i] - if track.ISRC == spotifyISRC { - isrcMatches = append(isrcMatches, track) - } - } - - if len(isrcMatches) > 0 { - if expectedDuration > 0 { - var durationVerifiedMatches []*TidalTrack - for _, track := range isrcMatches { - durationDiff := track.Duration - expectedDuration - if durationDiff < 0 { - durationDiff = -durationDiff - } - if durationDiff <= 3 { - durationVerifiedMatches = append(durationVerifiedMatches, track) - } - } - - if len(durationVerifiedMatches) > 0 { - GoLog("[Tidal] ISRC match with duration verification: '%s' (expected %ds, found %ds)\n", - durationVerifiedMatches[0].Title, expectedDuration, durationVerifiedMatches[0].Duration) - return durationVerifiedMatches[0], nil - } - - GoLog("[Tidal] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n", - spotifyISRC, expectedDuration, isrcMatches[0].Duration) - return nil, fmt.Errorf("ISRC found but duration mismatch: expected %ds, found %ds (likely different version/edit)", - expectedDuration, isrcMatches[0].Duration) - } - - GoLog("[Tidal] ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title) - return isrcMatches[0], nil - } - - GoLog("[Tidal] No ISRC match found for: %s\n", spotifyISRC) - return nil, fmt.Errorf("ISRC mismatch: no track found with ISRC %s on Tidal", spotifyISRC) - } - - if expectedDuration > 0 { - tolerance := 3 // 3 seconds tolerance - var durationMatches []*TidalTrack - - for i := range allTracks { - track := &allTracks[i] - durationDiff := track.Duration - expectedDuration - if durationDiff < 0 { - durationDiff = -durationDiff - } - if durationDiff <= tolerance { - durationMatches = append(durationMatches, track) - } - } - - if len(durationMatches) > 0 { - bestMatch := durationMatches[0] - for _, track := range durationMatches { - for _, tag := range track.MediaMetadata.Tags { - if tag == "HIRES_LOSSLESS" { - bestMatch = track - break - } - } - } - GoLog("[Tidal] Found via duration match: %s - %s (%s)\n", - bestMatch.Artist.Name, bestMatch.Title, bestMatch.AudioQuality) - return bestMatch, nil - } - } - - bestMatch := &allTracks[0] - for i := range allTracks { - track := &allTracks[i] - for _, tag := range track.MediaMetadata.Tags { - if tag == "HIRES_LOSSLESS" { - bestMatch = track - break - } - } - if bestMatch != &allTracks[0] { - break - } - } - - GoLog("[Tidal] Found via search (no ISRC provided): %s - %s (ISRC: %s, Quality: %s)\n", - bestMatch.Artist.Name, bestMatch.Title, bestMatch.ISRC, bestMatch.AudioQuality) - - return bestMatch, nil -} - -func containsQuery(queries []string, query string) bool { - for _, q := range queries { - if q == query { - return true - } - } - return false +func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, albumName, spotifyISRC string, expectedDuration int) (*TidalTrack, error) { + return nil, fmt.Errorf("tidal metadata search API disabled: no client credentials mode") } func (t *TidalDownloader) SearchTrackByMetadata(trackName, artistName string) (*TidalTrack, error) { - return t.SearchTrackByMetadataWithISRC(trackName, artistName, "", 0) + return nil, fmt.Errorf("tidal metadata search API disabled: no client credentials mode") } // TidalDownloadInfo contains download URL and quality info @@ -1300,13 +936,19 @@ func titlesMatch(expectedTitle, foundTitle string) bool { } } - // Some tracks are symbol/emoji-heavy and providers can return textual - // aliases. If artist/duration already matched upstream, avoid false rejects. + // Emoji/symbol-only titles must be matched strictly to avoid false positives + // like mapping "🪐" to "Higher Power". if (!hasAlphaNumericRunes(expectedTitle) || !hasAlphaNumericRunes(foundTitle)) && strings.TrimSpace(expectedTitle) != "" && strings.TrimSpace(foundTitle) != "" { - GoLog("[Tidal] Symbol-heavy title detected, relaxing match: '%s' vs '%s'\n", expectedTitle, foundTitle) - return true + expectedSymbols := normalizeSymbolOnlyTitle(expectedTitle) + foundSymbols := normalizeSymbolOnlyTitle(foundTitle) + if expectedSymbols != "" && foundSymbols != "" && expectedSymbols == foundSymbols { + GoLog("[Tidal] Symbol-heavy title matched strictly: '%s' vs '%s'\n", expectedTitle, foundTitle) + return true + } + GoLog("[Tidal] Symbol-heavy title mismatch: '%s' vs '%s'\n", expectedTitle, foundTitle) + return false } expectedLatin := isLatinScript(expectedTitle) @@ -1426,182 +1068,9 @@ func isLatinScript(s string) bool { return true } -func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { - downloader := NewTidalDownloader() - - isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != "" - if !isSafOutput { - if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists { - return TidalDownloadResult{FilePath: "EXISTS:" + existingFile}, nil - } - } - - expectedDurationSec := req.DurationMS / 1000 - - var track *TidalTrack - var err error - - if req.TidalID != "" { - GoLog("[Tidal] Using Tidal ID from Odesli enrichment: %s\n", req.TidalID) - var trackID int64 - if _, parseErr := fmt.Sscanf(req.TidalID, "%d", &trackID); parseErr == nil && trackID > 0 { - track, err = downloader.GetTrackInfoByID(trackID) - if err != nil { - GoLog("[Tidal] Failed to get track by Odesli ID %d: %v\n", trackID, err) - track = nil - } else if track != nil { - GoLog("[Tidal] Successfully found track via Odesli ID: '%s' by '%s'\n", track.Title, track.Artist.Name) - } - } - } - - if track == nil && req.ISRC != "" { - if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.TidalTrackID > 0 { - GoLog("[Tidal] Cache hit! Using cached track ID: %d\n", cached.TidalTrackID) - track, err = downloader.GetTrackInfoByID(cached.TidalTrackID) - if err != nil { - GoLog("[Tidal] Cache hit but failed to get track info: %v\n", err) - track = nil // Fall through to normal search - } - } - } - - if track == nil && req.ISRC != "" { - GoLog("[Tidal] Trying ISRC search: %s\n", req.ISRC) - track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, req.ISRC, expectedDurationSec) - if track != nil { - // Verify artist only (ISRC match is already accurate) - tidalArtist := track.Artist.Name - if len(track.Artists) > 0 { - var artistNames []string - for _, a := range track.Artists { - artistNames = append(artistNames, a.Name) - } - tidalArtist = strings.Join(artistNames, ", ") - } - if !artistsMatch(req.ArtistName, tidalArtist) { - GoLog("[Tidal] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n", - req.ArtistName, tidalArtist) - track = nil - } - } - } - - if track == nil && req.SpotifyID != "" { - GoLog("[Tidal] ISRC search failed, trying SongLink...\n") - - var trackID int64 - var gotTidalID bool - - if strings.HasPrefix(req.SpotifyID, "deezer:") { - deezerID := strings.TrimPrefix(req.SpotifyID, "deezer:") - GoLog("[Tidal] Using Deezer ID for SongLink lookup: %s\n", deezerID) - songlink := NewSongLinkClient() - availability, slErr := songlink.CheckAvailabilityFromDeezer(deezerID) - if slErr == nil && availability != nil && availability.TidalID != "" { - if _, parseErr := fmt.Sscanf(availability.TidalID, "%d", &trackID); parseErr == nil && trackID > 0 { - GoLog("[Tidal] Got Tidal ID %d directly from SongLink\n", trackID) - gotTidalID = true - } - } - // Fallback to URL parsing if TidalID not in struct - if !gotTidalID && availability != nil && availability.TidalURL != "" { - var idErr error - trackID, idErr = downloader.GetTrackIDFromURL(availability.TidalURL) - if idErr == nil && trackID > 0 { - GoLog("[Tidal] Got Tidal ID %d from URL parsing\n", trackID) - gotTidalID = true - } - } - } else { - songlink := NewSongLinkClient() - availability, slErr := songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC) - if slErr == nil && availability != nil && availability.TidalID != "" { - if _, parseErr := fmt.Sscanf(availability.TidalID, "%d", &trackID); parseErr == nil && trackID > 0 { - GoLog("[Tidal] Got Tidal ID %d directly from SongLink\n", trackID) - gotTidalID = true - } - } - // Fallback to URL parsing if TidalID not in struct - if !gotTidalID && availability != nil && availability.TidalURL != "" { - var idErr error - trackID, idErr = downloader.GetTrackIDFromURL(availability.TidalURL) - if idErr == nil && trackID > 0 { - GoLog("[Tidal] Got Tidal ID %d from URL parsing\n", trackID) - gotTidalID = true - } - } - } - - if gotTidalID && trackID > 0 { - track, err = downloader.GetTrackInfoByID(trackID) - if track != nil { - tidalArtist := track.Artist.Name - if len(track.Artists) > 0 { - var artistNames []string - for _, a := range track.Artists { - artistNames = append(artistNames, a.Name) - } - tidalArtist = strings.Join(artistNames, ", ") - } - - if !artistsMatch(req.ArtistName, tidalArtist) { - GoLog("[Tidal] Artist mismatch from SongLink: expected '%s', got '%s'. Rejecting.\n", - req.ArtistName, tidalArtist) - track = nil - } - - if track != nil && expectedDurationSec > 0 { - durationDiff := track.Duration - expectedDurationSec - if durationDiff < 0 { - durationDiff = -durationDiff - } - if durationDiff > 3 { - GoLog("[Tidal] Duration mismatch from SongLink: expected %ds, got %ds. Rejecting.\n", - expectedDurationSec, track.Duration) - track = nil // Reject this match - } - } - - // Cache for future use - if track != nil && req.ISRC != "" { - GetTrackIDCache().SetTidal(req.ISRC, track.ID) - } - } - } - } - +func tidalTrackArtistsDisplay(track *TidalTrack) string { if track == nil { - GoLog("[Tidal] Trying metadata search as last resort...\n") - track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, "", expectedDurationSec) - if track != nil { - tidalArtist := track.Artist.Name - if len(track.Artists) > 0 { - var artistNames []string - for _, a := range track.Artists { - artistNames = append(artistNames, a.Name) - } - tidalArtist = strings.Join(artistNames, ", ") - } - - if !titlesMatch(req.TrackName, track.Title) { - GoLog("[Tidal] Title mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n", - req.TrackName, track.Title) - track = nil - } else if !artistsMatch(req.ArtistName, tidalArtist) { - GoLog("[Tidal] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n", - req.ArtistName, tidalArtist) - track = nil - } - } - } - - if track == nil { - errMsg := "could not find matching track on Tidal (artist/duration mismatch)" - if err != nil { - errMsg = err.Error() - } - return TidalDownloadResult{}, fmt.Errorf("tidal search failed: %s", errMsg) + return "" } tidalArtist := track.Artist.Name @@ -1612,10 +1081,130 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { } tidalArtist = strings.Join(artistNames, ", ") } - GoLog("[Tidal] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, tidalArtist, track.Duration) + return tidalArtist +} + +func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloader, logPrefix string) (*TidalTrack, error) { + if downloader == nil { + downloader = NewTidalDownloader() + } + if strings.TrimSpace(logPrefix) == "" { + logPrefix = "Tidal" + } + + expectedDurationSec := req.DurationMS / 1000 + var trackID int64 + var gotTidalID bool + + if req.TidalID != "" { + GoLog("[%s] Using Tidal ID from Odesli enrichment: %s\n", logPrefix, req.TidalID) + if _, parseErr := fmt.Sscanf(req.TidalID, "%d", &trackID); parseErr == nil && trackID > 0 { + gotTidalID = true + } + } + + if !gotTidalID && req.ISRC != "" { + if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.TidalTrackID > 0 { + GoLog("[%s] Cache hit! Using cached track ID: %d\n", logPrefix, cached.TidalTrackID) + trackID = cached.TidalTrackID + gotTidalID = true + } + } + + if !gotTidalID && (req.SpotifyID != "" || req.DeezerID != "") { + GoLog("[%s] Trying SongLink for Tidal ID...\n", logPrefix) + + resolveFromAvailability := func(availability *TrackAvailability) { + if availability == nil || gotTidalID { + return + } + if availability.TidalID != "" { + if _, parseErr := fmt.Sscanf(availability.TidalID, "%d", &trackID); parseErr == nil && trackID > 0 { + GoLog("[%s] Got Tidal ID %d directly from SongLink\n", logPrefix, trackID) + gotTidalID = true + return + } + } + if availability.TidalURL != "" { + var idErr error + trackID, idErr = downloader.GetTrackIDFromURL(availability.TidalURL) + if idErr == nil && trackID > 0 { + GoLog("[%s] Got Tidal ID %d from URL parsing\n", logPrefix, trackID) + gotTidalID = true + } + } + } + + // Prefer Deezer-based SongLink lookup when DeezerID is available. + if req.DeezerID != "" { + GoLog("[%s] Using Deezer ID for SongLink lookup: %s\n", logPrefix, req.DeezerID) + songlink := NewSongLinkClient() + availability, slErr := songlink.CheckAvailabilityFromDeezer(req.DeezerID) + if slErr == nil { + resolveFromAvailability(availability) + } else { + GoLog("[%s] SongLink Deezer lookup failed: %v\n", logPrefix, slErr) + } + } + + if !gotTidalID && req.SpotifyID != "" { + if strings.HasPrefix(req.SpotifyID, "deezer:") { + deezerID := strings.TrimPrefix(req.SpotifyID, "deezer:") + GoLog("[%s] Using Deezer ID for SongLink lookup: %s\n", logPrefix, deezerID) + songlink := NewSongLinkClient() + availability, slErr := songlink.CheckAvailabilityFromDeezer(deezerID) + if slErr == nil { + resolveFromAvailability(availability) + } else { + GoLog("[%s] SongLink Deezer lookup failed: %v\n", logPrefix, slErr) + } + } + } + + if !gotTidalID && req.SpotifyID != "" && !strings.HasPrefix(req.SpotifyID, "deezer:") { + songlink := NewSongLinkClient() + availability, slErr := songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC) + if slErr == nil { + resolveFromAvailability(availability) + } + } + } + + if !gotTidalID || trackID <= 0 { + return nil, fmt.Errorf("failed to find tidal track id from request/cache/songlink") + } + + track := &TidalTrack{ + ID: trackID, + Title: strings.TrimSpace(req.TrackName), + ISRC: strings.TrimSpace(req.ISRC), + Duration: expectedDurationSec, + TrackNumber: req.TrackNumber, + VolumeNumber: req.DiscNumber, + } + track.Artist.Name = strings.TrimSpace(req.ArtistName) + track.Album.Title = strings.TrimSpace(req.AlbumName) + track.Album.ReleaseDate = strings.TrimSpace(req.ReleaseDate) if req.ISRC != "" { - GetTrackIDCache().SetTidal(req.ISRC, track.ID) + GetTrackIDCache().SetTidal(req.ISRC, trackID) + } + return track, nil +} + +func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { + downloader := NewTidalDownloader() + + isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != "" + if !isSafOutput { + if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists { + return TidalDownloadResult{FilePath: "EXISTS:" + existingFile}, nil + } + } + + track, err := resolveTidalTrackForRequest(req, downloader, "Tidal") + if err != nil { + return TidalDownloadResult{}, err } quality := req.Quality @@ -1694,13 +1283,19 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { parallelDone := make(chan struct{}) go func() { defer close(parallelDone) + coverURL := req.CoverURL + embedLyrics := req.EmbedLyrics + if !req.EmbedMetadata { + coverURL = "" + embedLyrics = false + } parallelResult = FetchCoverAndLyricsParallel( - req.CoverURL, + coverURL, req.EmbedMaxQualityCover, req.SpotifyID, req.TrackName, req.ArtistName, - req.EmbedLyrics, + embedLyrics, int64(req.DurationMS), ) }() @@ -1784,11 +1379,15 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { } if (isSafOutput && actualExt == ".flac") || (!isSafOutput && strings.HasSuffix(actualOutputPath, ".flac")) { - if err := EmbedMetadataWithCoverData(actualOutputPath, metadata, coverData); err != nil { - fmt.Printf("Warning: failed to embed metadata: %v\n", err) + if req.EmbedMetadata { + if err := EmbedMetadataWithCoverData(actualOutputPath, metadata, coverData); err != nil { + fmt.Printf("Warning: failed to embed metadata: %v\n", err) + } + } else { + GoLog("[Tidal] Metadata embedding disabled by settings, skipping FLAC metadata/lyrics embedding\n") } - if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { + if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { lyricsMode := req.LyricsMode if lyricsMode == "" { lyricsMode = "embed" @@ -1811,14 +1410,14 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { fmt.Println("[Tidal] Lyrics embedded successfully") } } - } else if req.EmbedLyrics { + } else if req.EmbedMetadata && req.EmbedLyrics { fmt.Println("[Tidal] No lyrics available from parallel fetch") } } else if (isSafOutput && actualExt == ".m4a") || (!isSafOutput && strings.HasSuffix(actualOutputPath, ".m4a")) { if quality == "HIGH" { GoLog("[Tidal] HIGH quality M4A - skipping metadata embedding (file from server is already valid)\n") - if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { + if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { lyricsMode := req.LyricsMode if lyricsMode == "" { lyricsMode = "embed" @@ -1849,7 +1448,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { bitDepth = 0 sampleRate = 44100 } - if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { + if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { lyricsLRC = parallelResult.LyricsLRC } diff --git a/go_backend/title_match_utils.go b/go_backend/title_match_utils.go index a7bb186b..039ff434 100644 --- a/go_backend/title_match_utils.go +++ b/go_backend/title_match_utils.go @@ -41,3 +41,30 @@ func hasAlphaNumericRunes(value string) bool { } return false } + +// normalizeSymbolOnlyTitle keeps symbol/emoji runes while dropping letters, +// digits, spaces and punctuation. This is useful for emoji-only titles such as +// "🪐", "🌎" etc, so we can compare them strictly and avoid false matches. +func normalizeSymbolOnlyTitle(title string) string { + trimmed := strings.TrimSpace(strings.ToLower(title)) + if trimmed == "" { + return "" + } + + var b strings.Builder + b.Grow(len(trimmed)) + + for _, r := range trimmed { + switch { + case unicode.IsLetter(r), unicode.IsNumber(r), unicode.IsSpace(r), unicode.IsPunct(r): + continue + // Drop combining marks such as emoji variation selectors. + case unicode.Is(unicode.Mn, r), unicode.Is(unicode.Mc, r), unicode.Is(unicode.Me, r): + continue + default: + b.WriteRune(r) + } + } + + return b.String() +} diff --git a/go_backend/title_match_utils_test.go b/go_backend/title_match_utils_test.go index b9064c91..edc63058 100644 --- a/go_backend/title_match_utils_test.go +++ b/go_backend/title_match_utils_test.go @@ -27,8 +27,26 @@ func TestTitlesMatch_SeparatorVariants(t *testing.T) { } } +func TestTitlesMatch_EmojiStrict(t *testing.T) { + if titlesMatch("🪐", "Higher Power") { + t.Fatal("expected emoji title not to match unrelated textual title") + } + if !titlesMatch("🪐", "🪐") { + t.Fatal("expected identical emoji titles to match") + } +} + func TestQobuzTitlesMatch_SeparatorVariants(t *testing.T) { if !qobuzTitlesMatch("Doctor / Cops", "Doctor _ Cops") { t.Fatal("expected qobuzTitlesMatch to accept / vs _ variant") } } + +func TestQobuzTitlesMatch_EmojiStrict(t *testing.T) { + if qobuzTitlesMatch("🪐", "Higher Power") { + t.Fatal("expected emoji title not to match unrelated textual title") + } + if !qobuzTitlesMatch("🪐", "🪐") { + t.Fatal("expected identical emoji titles to match") + } +} diff --git a/go_backend/youtube.go b/go_backend/youtube.go index e5ff2c29..fdbadc63 100644 --- a/go_backend/youtube.go +++ b/go_backend/youtube.go @@ -276,11 +276,11 @@ func (y *YouTubeDownloader) requestCobaltDirect(videoURL, audioFormat, audioBitr } // requestSpotubeDL uses SpotubeDL as a Cobalt proxy (they handle auth to yt-dl.click instances). -// Note: engine v2 currently serves MP3-oriented outputs, so we only use v2 for MP3 requests. +// Engines v3/v2 are MP3-oriented outputs, so we only use them for MP3 requests. func (y *YouTubeDownloader) requestSpotubeDL(videoID, audioFormat, audioBitrate string) (*CobaltResponse, error) { engines := []string{"v1"} if strings.EqualFold(audioFormat, "mp3") { - engines = append(engines, "v2") + engines = append(engines, "v3", "v2") } var lastErr error diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 2314d37f..8dfaa378 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -5,6 +5,15 @@ import Gobackend // Import Go framework @main @objc class AppDelegate: FlutterAppDelegate { private let CHANNEL = "com.zarz.spotiflac/backend" + private let DOWNLOAD_PROGRESS_STREAM_CHANNEL = "com.zarz.spotiflac/download_progress_stream" + private let LIBRARY_SCAN_PROGRESS_STREAM_CHANNEL = "com.zarz.spotiflac/library_scan_progress_stream" + private let streamQueue = DispatchQueue(label: "com.zarz.spotiflac.progress_stream", qos: .utility) + private var downloadProgressTimer: DispatchSourceTimer? + private var downloadProgressEventSink: FlutterEventSink? + private var lastDownloadProgressPayload: String? + private var libraryScanProgressTimer: DispatchSourceTimer? + private var libraryScanProgressEventSink: FlutterEventSink? + private var lastLibraryScanProgressPayload: String? override func application( _ application: UIApplication, @@ -16,14 +25,111 @@ import Gobackend // Import Go framework name: CHANNEL, binaryMessenger: controller.binaryMessenger ) + let downloadProgressEvents = FlutterEventChannel( + name: DOWNLOAD_PROGRESS_STREAM_CHANNEL, + binaryMessenger: controller.binaryMessenger + ) + let libraryScanProgressEvents = FlutterEventChannel( + name: LIBRARY_SCAN_PROGRESS_STREAM_CHANNEL, + binaryMessenger: controller.binaryMessenger + ) channel.setMethodCallHandler { [weak self] call, result in self?.handleMethodCall(call: call, result: result) } + downloadProgressEvents.setStreamHandler( + ClosureStreamHandler( + onListen: { [weak self] _, events in + self?.startDownloadProgressStream(events) + return nil + }, + onCancel: { [weak self] _ in + self?.stopDownloadProgressStream() + return nil + } + ) + ) + libraryScanProgressEvents.setStreamHandler( + ClosureStreamHandler( + onListen: { [weak self] _, events in + self?.startLibraryScanProgressStream(events) + return nil + }, + onCancel: { [weak self] _ in + self?.stopLibraryScanProgressStream() + return nil + } + ) + ) GeneratedPluginRegistrant.register(with: self) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + + deinit { + stopDownloadProgressStream() + stopLibraryScanProgressStream() + } + + private func startDownloadProgressStream(_ eventSink: @escaping FlutterEventSink) { + stopDownloadProgressStream() + downloadProgressEventSink = eventSink + lastDownloadProgressPayload = nil + + let timer = DispatchSource.makeTimerSource(queue: streamQueue) + timer.schedule(deadline: .now(), repeating: .milliseconds(800)) + timer.setEventHandler { [weak self] in + guard let self else { return } + let payload = GobackendGetAllDownloadProgress() as String? ?? "{}" + if payload == self.lastDownloadProgressPayload { + return + } + self.lastDownloadProgressPayload = payload + DispatchQueue.main.async { [weak self] in + self?.downloadProgressEventSink?(payload) + } + } + downloadProgressTimer = timer + timer.resume() + } + + private func stopDownloadProgressStream() { + downloadProgressTimer?.setEventHandler {} + downloadProgressTimer?.cancel() + downloadProgressTimer = nil + downloadProgressEventSink = nil + lastDownloadProgressPayload = nil + } + + private func startLibraryScanProgressStream(_ eventSink: @escaping FlutterEventSink) { + stopLibraryScanProgressStream() + libraryScanProgressEventSink = eventSink + lastLibraryScanProgressPayload = nil + + let timer = DispatchSource.makeTimerSource(queue: streamQueue) + timer.schedule(deadline: .now(), repeating: .milliseconds(800)) + timer.setEventHandler { [weak self] in + guard let self else { return } + let payload = GobackendGetLibraryScanProgressJSON() as String? ?? "{}" + if payload == self.lastLibraryScanProgressPayload { + return + } + self.lastLibraryScanProgressPayload = payload + DispatchQueue.main.async { [weak self] in + self?.libraryScanProgressEventSink?(payload) + } + } + libraryScanProgressTimer = timer + timer.resume() + } + + private func stopLibraryScanProgressStream() { + libraryScanProgressTimer?.setEventHandler {} + libraryScanProgressTimer?.cancel() + libraryScanProgressTimer = nil + libraryScanProgressEventSink = nil + lastLibraryScanProgressPayload = nil + } private func handleMethodCall(call: FlutterMethodCall, result: @escaping FlutterResult) { DispatchQueue.global(qos: .userInitiated).async { @@ -74,6 +180,14 @@ import Gobackend // Import Go framework let response = GobackendSearchSpotifyAll(query, Int(trackLimit), Int(artistLimit), &error) if let error = error { throw error } return response + + case "getSpotifyRelatedArtists": + let args = call.arguments as! [String: Any] + let artistId = args["artist_id"] as! String + let limit = args["limit"] as? Int ?? 12 + let response = GobackendGetSpotifyRelatedArtists(artistId, Int(limit), &error) + if let error = error { throw error } + return response case "checkAvailability": let args = call.arguments as! [String: Any] @@ -282,6 +396,14 @@ import Gobackend // Import Go framework if let error = error { throw error } return response + case "getDeezerRelatedArtists": + let args = call.arguments as! [String: Any] + let artistId = args["artist_id"] as! String + let limit = args["limit"] as? Int ?? 12 + let response = GobackendGetDeezerRelatedArtists(artistId, Int(limit), &error) + if let error = error { throw error } + return response + case "getDeezerMetadata": let args = call.arguments as! [String: Any] let resourceType = args["resource_type"] as! String @@ -840,3 +962,27 @@ import Gobackend // Import Go framework } } } + +private final class ClosureStreamHandler: NSObject, FlutterStreamHandler { + typealias ListenHandler = (_ arguments: Any?, _ events: @escaping FlutterEventSink) -> FlutterError? + typealias CancelHandler = (_ arguments: Any?) -> FlutterError? + + private let onListenHandler: ListenHandler + private let onCancelHandler: CancelHandler + + init( + onListen: @escaping ListenHandler, + onCancel: @escaping CancelHandler = { _ in nil } + ) { + self.onListenHandler = onListen + self.onCancelHandler = onCancel + } + + func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { + onListenHandler(arguments, events) + } + + func onCancel(withArguments arguments: Any?) -> FlutterError? { + onCancelHandler(arguments) + } +} diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 4439fd20..5b1d9f87 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -105,5 +105,11 @@ tidal youtube-music + + + UIBackgroundModes + + audio + diff --git a/lib/app.dart b/lib/app.dart index 0e005e54..981c3bae 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -37,6 +37,9 @@ final _routerProvider = Provider((ref) { builder: (context, state) => const TutorialScreen(), ), ], + // Safety net: if a deep link URL (e.g. Spotify/Deezer) somehow reaches + // GoRouter, redirect to home instead of showing "Page Not Found". + errorBuilder: (context, state) => const MainShell(), ); }); @@ -54,10 +57,14 @@ class SpotiFLACApp extends ConsumerWidget { : null; Locale? locale; - if (localeString != 'system') { + if (localeString != 'system' && localeString.isNotEmpty) { if (localeString.contains('_')) { final parts = localeString.split('_'); - locale = Locale(parts[0], parts[1]); + if (parts.length == 2) { + locale = Locale(parts[0], parts[1]); + } else { + locale = Locale(parts[0]); + } } else { locale = Locale(localeString); } @@ -76,6 +83,25 @@ class SpotiFLACApp extends ConsumerWidget { themeAnimationCurve: Curves.easeInOut, routerConfig: router, locale: locale, + localeResolutionCallback: (deviceLocale, supportedLocales) { + if (locale != null) return locale; + if (deviceLocale == null) return supportedLocales.first; + + for (var supportedLocale in supportedLocales) { + if (supportedLocale.languageCode == deviceLocale.languageCode && + supportedLocale.countryCode == deviceLocale.countryCode) { + return supportedLocale; + } + } + + for (var supportedLocale in supportedLocales) { + if (supportedLocale.languageCode == deviceLocale.languageCode) { + return supportedLocale; + } + } + + return supportedLocales.first; + }, localizationsDelegates: const [ AppLocalizations.delegate, GlobalMaterialLocalizations.delegate, diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart index a8639cc8..121bd07e 100644 --- a/lib/constants/app_info.dart +++ b/lib/constants/app_info.dart @@ -1,8 +1,8 @@ /// App version and info constants /// Update version here only - all other files will reference this class AppInfo { - static const String version = '3.7.0'; - static const String buildNumber = '83'; + static const String version = '4.0.1'; + static const String buildNumber = '102'; static const String fullVersion = '$version+$buildNumber'; diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 8333786e..bccbf1fc 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -544,6 +544,54 @@ abstract class AppLocalizations { /// **'Try other services if download fails'** String get optionsAutoFallbackSubtitle; + /// Toggle to skip to the next queue track when current track stream resolution fails + /// + /// In en, this message translates to: + /// **'Auto Skip Unavailable Tracks'** + String get optionsAutoSkipUnavailableTracks; + + /// Subtitle when auto skip on resolve failure is enabled + /// + /// In en, this message translates to: + /// **'Automatically skip to the next queue track when a stream cannot be resolved.'** + String get optionsAutoSkipUnavailableTracksSubtitleOn; + + /// Subtitle when auto skip on resolve failure is disabled + /// + /// In en, this message translates to: + /// **'Stop on failed track resolution and show an error.'** + String get optionsAutoSkipUnavailableTracksSubtitleOff; + + /// Tap behavior mode for track lists + /// + /// In en, this message translates to: + /// **'Interaction Mode'** + String get optionsInteractionMode; + + /// Interaction mode where taps queue downloads + /// + /// In en, this message translates to: + /// **'Downloader Mode'** + String get modeDownloader; + + /// Subtitle for downloader interaction mode + /// + /// In en, this message translates to: + /// **'Tap tracks to add them to download queue'** + String get modeDownloaderSubtitle; + + /// Interaction mode where taps start playback + /// + /// In en, this message translates to: + /// **'Streaming Mode'** + String get modeStreaming; + + /// Subtitle for streaming interaction mode + /// + /// In en, this message translates to: + /// **'Tap tracks to play instantly'** + String get modeStreamingSubtitle; + /// Enable extension download providers /// /// In en, this message translates to: @@ -1906,6 +1954,12 @@ abstract class AppLocalizations { /// **'No tracks found'** String get errorNoTracksFound; + /// Error - seek disabled for live decrypted stream + /// + /// In en, this message translates to: + /// **'Seeking is not supported for this live stream'** + String get errorSeekNotSupported; + /// Error - extension source not available /// /// In en, this message translates to: @@ -2842,6 +2896,12 @@ abstract class AppLocalizations { /// **'Download All ({count})'** String downloadAllCount(int count); + /// Play all button with count + /// + /// In en, this message translates to: + /// **'Play All ({count})'** + String playAllCount(int count); + /// Track count display /// /// In en, this message translates to: @@ -4048,12 +4108,24 @@ abstract class AppLocalizations { /// **'Download Discography'** String get discographyDownload; + /// Button - play artist discography + /// + /// In en, this message translates to: + /// **'Play Discography'** + String get discographyPlay; + /// Option - download entire discography /// /// In en, this message translates to: /// **'Download All'** String get discographyDownloadAll; + /// Option - play entire discography + /// + /// In en, this message translates to: + /// **'Play All'** + String get discographyPlayAll; + /// Subtitle showing total tracks and albums /// /// In en, this message translates to: @@ -4120,6 +4192,12 @@ abstract class AppLocalizations { /// **'Download Selected'** String get discographyDownloadSelected; + /// Button - play selected albums + /// + /// In en, this message translates to: + /// **'Play Selected'** + String get discographyPlaySelected; + /// Snackbar - tracks added from discography /// /// In en, this message translates to: @@ -5555,6 +5633,384 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Converted {success} of {total} tracks to {format}'** String selectionBatchConvertSuccess(int success, int total, String format); + + /// Title for mode selection step in setup wizard + /// + /// In en, this message translates to: + /// **'Choose Your Mode'** + String get setupModeSelectionTitle; + + /// Description for mode selection step + /// + /// In en, this message translates to: + /// **'How would you like to use SpotiFLAC? You can always change this later in Settings.'** + String get setupModeSelectionDescription; + + /// Title for downloader mode option + /// + /// In en, this message translates to: + /// **'Downloader'** + String get setupModeDownloaderTitle; + + /// Downloader mode feature 1 + /// + /// In en, this message translates to: + /// **'Download tracks in lossless FLAC quality'** + String get setupModeDownloaderFeature1; + + /// Downloader mode feature 2 + /// + /// In en, this message translates to: + /// **'Save music to your device for offline listening'** + String get setupModeDownloaderFeature2; + + /// Downloader mode feature 3 + /// + /// In en, this message translates to: + /// **'Manage your local music library'** + String get setupModeDownloaderFeature3; + + /// Title for streaming mode option + /// + /// In en, this message translates to: + /// **'Streaming'** + String get setupModeStreamingTitle; + + /// Streaming mode feature 1 + /// + /// In en, this message translates to: + /// **'Stream tracks instantly without downloading'** + String get setupModeStreamingFeature1; + + /// Streaming mode feature 2 + /// + /// In en, this message translates to: + /// **'Smart Queue auto-discovers new music for you'** + String get setupModeStreamingFeature2; + + /// Streaming mode feature 3 + /// + /// In en, this message translates to: + /// **'Play any track on demand with playback controls'** + String get setupModeStreamingFeature3; + + /// Hint that mode can be changed later + /// + /// 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 3663483d..30bea9cf 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -251,6 +251,33 @@ class AppLocalizationsDe extends AppLocalizations { String get optionsAutoFallbackSubtitle => 'Andere Dienste versuchen, wenn Download fehlschlägt'; + @override + String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks'; + + @override + String get optionsAutoSkipUnavailableTracksSubtitleOn => + 'Automatically skip to the next queue track when a stream cannot be resolved.'; + + @override + String get optionsAutoSkipUnavailableTracksSubtitleOff => + 'Stop on failed track resolution and show an error.'; + + @override + String get optionsInteractionMode => 'Interaction Mode'; + + @override + String get modeDownloader => 'Downloader Mode'; + + @override + String get modeDownloaderSubtitle => + 'Tap tracks to add them to download queue'; + + @override + String get modeStreaming => 'Streaming Mode'; + + @override + String get modeStreamingSubtitle => 'Tap tracks to play instantly'; + @override String get optionsUseExtensionProviders => 'Erweiterungs-Anbieter verwenden'; @@ -1057,6 +1084,10 @@ class AppLocalizationsDe extends AppLocalizations { @override String get errorNoTracksFound => 'Keine Titel gefunden'; + @override + String get errorSeekNotSupported => + 'Seeking is not supported for this live stream'; + @override String errorMissingExtensionSource(String item) { return 'Kann $item nicht lade wegen fehlender Erweiterungsquelle'; @@ -1578,6 +1609,11 @@ class AppLocalizationsDe extends AppLocalizations { return 'Download All ($count)'; } + @override + String playAllCount(int count) { + return 'Play All ($count)'; + } + @override String tracksCount(int count) { String _temp0 = intl.Intl.pluralLogic( @@ -2256,9 +2292,15 @@ class AppLocalizationsDe extends AppLocalizations { @override String get discographyDownload => 'Download Discography'; + @override + String get discographyPlay => 'Play Discography'; + @override String get discographyDownloadAll => 'Download All'; + @override + String get discographyPlayAll => 'Play All'; + @override String discographyDownloadAllSubtitle(int count, int albumCount) { return '$count tracks from $albumCount releases'; @@ -2303,6 +2345,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get discographyDownloadSelected => 'Download Selected'; + @override + String get discographyPlaySelected => 'Play Selected'; + @override String discographyAddedToQueue(int count) { return 'Added $count tracks to queue'; @@ -3202,4 +3247,218 @@ class AppLocalizationsDe extends AppLocalizations { String selectionBatchConvertSuccess(int success, int total, String format) { return 'Converted $success of $total tracks to $format'; } + + @override + String get setupModeSelectionTitle => 'Wähle deinen Modus'; + + @override + String get setupModeSelectionDescription => + 'Wie möchtest du SpotiFLAC nutzen? Du kannst dies später jederzeit in den Einstellungen ändern.'; + + @override + String get setupModeDownloaderTitle => 'Downloader'; + + @override + String get setupModeDownloaderFeature1 => + 'Lade Titel in verlustfreier FLAC-Qualität herunter'; + + @override + String get setupModeDownloaderFeature2 => + 'Speichere Musik auf deinem Gerät zum Offline-Hören'; + + @override + String get setupModeDownloaderFeature3 => + 'Verwalte deine lokale Musikbibliothek'; + + @override + String get setupModeStreamingTitle => 'Streaming'; + + @override + String get setupModeStreamingFeature1 => + 'Streame Titel sofort ohne Herunterladen'; + + @override + String get setupModeStreamingFeature2 => + 'Smart Queue entdeckt automatisch neue Musik für dich'; + + @override + String get setupModeStreamingFeature3 => + 'Spiele jeden Titel auf Abruf mit Wiedergabesteuerung'; + + @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 3864a2a6..e9544e94 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -248,6 +248,33 @@ class AppLocalizationsEn extends AppLocalizations { String get optionsAutoFallbackSubtitle => 'Try other services if download fails'; + @override + String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks'; + + @override + String get optionsAutoSkipUnavailableTracksSubtitleOn => + 'Automatically skip to the next queue track when a stream cannot be resolved.'; + + @override + String get optionsAutoSkipUnavailableTracksSubtitleOff => + 'Stop on failed track resolution and show an error.'; + + @override + String get optionsInteractionMode => 'Interaction Mode'; + + @override + String get modeDownloader => 'Downloader Mode'; + + @override + String get modeDownloaderSubtitle => + 'Tap tracks to add them to download queue'; + + @override + String get modeStreaming => 'Streaming Mode'; + + @override + String get modeStreamingSubtitle => 'Tap tracks to play instantly'; + @override String get optionsUseExtensionProviders => 'Use Extension Providers'; @@ -1041,6 +1068,10 @@ class AppLocalizationsEn extends AppLocalizations { @override String get errorNoTracksFound => 'No tracks found'; + @override + String get errorSeekNotSupported => + 'Seeking is not supported for this live stream'; + @override String errorMissingExtensionSource(String item) { return 'Cannot load $item: missing extension source'; @@ -1557,6 +1588,11 @@ class AppLocalizationsEn extends AppLocalizations { return 'Download All ($count)'; } + @override + String playAllCount(int count) { + return 'Play All ($count)'; + } + @override String tracksCount(int count) { String _temp0 = intl.Intl.pluralLogic( @@ -2235,9 +2271,15 @@ class AppLocalizationsEn extends AppLocalizations { @override String get discographyDownload => 'Download Discography'; + @override + String get discographyPlay => 'Play Discography'; + @override String get discographyDownloadAll => 'Download All'; + @override + String get discographyPlayAll => 'Play All'; + @override String discographyDownloadAllSubtitle(int count, int albumCount) { return '$count tracks from $albumCount releases'; @@ -2282,6 +2324,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get discographyDownloadSelected => 'Download Selected'; + @override + String get discographyPlaySelected => 'Play Selected'; + @override String discographyAddedToQueue(int count) { return 'Added $count tracks to queue'; @@ -3181,4 +3226,217 @@ class AppLocalizationsEn extends AppLocalizations { String selectionBatchConvertSuccess(int success, int total, String format) { return 'Converted $success of $total tracks to $format'; } + + @override + String get setupModeSelectionTitle => 'Choose Your Mode'; + + @override + String get setupModeSelectionDescription => + 'How would you like to use SpotiFLAC? You can always change this later in Settings.'; + + @override + String get setupModeDownloaderTitle => 'Downloader'; + + @override + String get setupModeDownloaderFeature1 => + 'Download tracks in lossless FLAC quality'; + + @override + String get setupModeDownloaderFeature2 => + 'Save music to your device for offline listening'; + + @override + String get setupModeDownloaderFeature3 => 'Manage your local music library'; + + @override + String get setupModeStreamingTitle => 'Streaming'; + + @override + String get setupModeStreamingFeature1 => + 'Stream tracks instantly without downloading'; + + @override + String get setupModeStreamingFeature2 => + 'Smart Queue auto-discovers new music for you'; + + @override + String get setupModeStreamingFeature3 => + 'Play any track on demand with playback controls'; + + @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 4184f353..5f805c85 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -248,6 +248,33 @@ class AppLocalizationsEs extends AppLocalizations { String get optionsAutoFallbackSubtitle => 'Try other services if download fails'; + @override + String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks'; + + @override + String get optionsAutoSkipUnavailableTracksSubtitleOn => + 'Automatically skip to the next queue track when a stream cannot be resolved.'; + + @override + String get optionsAutoSkipUnavailableTracksSubtitleOff => + 'Stop on failed track resolution and show an error.'; + + @override + String get optionsInteractionMode => 'Interaction Mode'; + + @override + String get modeDownloader => 'Downloader Mode'; + + @override + String get modeDownloaderSubtitle => + 'Tap tracks to add them to download queue'; + + @override + String get modeStreaming => 'Streaming Mode'; + + @override + String get modeStreamingSubtitle => 'Tap tracks to play instantly'; + @override String get optionsUseExtensionProviders => 'Use Extension Providers'; @@ -1041,6 +1068,10 @@ class AppLocalizationsEs extends AppLocalizations { @override String get errorNoTracksFound => 'No tracks found'; + @override + String get errorSeekNotSupported => + 'Seeking is not supported for this live stream'; + @override String errorMissingExtensionSource(String item) { return 'Cannot load $item: missing extension source'; @@ -1557,6 +1588,11 @@ class AppLocalizationsEs extends AppLocalizations { return 'Download All ($count)'; } + @override + String playAllCount(int count) { + return 'Play All ($count)'; + } + @override String tracksCount(int count) { String _temp0 = intl.Intl.pluralLogic( @@ -2235,9 +2271,15 @@ class AppLocalizationsEs extends AppLocalizations { @override String get discographyDownload => 'Download Discography'; + @override + String get discographyPlay => 'Play Discography'; + @override String get discographyDownloadAll => 'Download All'; + @override + String get discographyPlayAll => 'Play All'; + @override String discographyDownloadAllSubtitle(int count, int albumCount) { return '$count tracks from $albumCount releases'; @@ -2282,6 +2324,9 @@ class AppLocalizationsEs extends AppLocalizations { @override String get discographyDownloadSelected => 'Download Selected'; + @override + String get discographyPlaySelected => 'Play Selected'; + @override String discographyAddedToQueue(int count) { return 'Added $count tracks to queue'; @@ -3181,6 +3226,220 @@ class AppLocalizationsEs extends AppLocalizations { String selectionBatchConvertSuccess(int success, int total, String format) { return 'Converted $success of $total tracks to $format'; } + + @override + String get setupModeSelectionTitle => 'Elige tu modo'; + + @override + String get setupModeSelectionDescription => + '¿Cómo te gustaría usar SpotiFLAC? Puedes cambiarlo más tarde en Ajustes.'; + + @override + String get setupModeDownloaderTitle => 'Descargador'; + + @override + String get setupModeDownloaderFeature1 => + 'Descarga pistas en calidad FLAC sin pérdida'; + + @override + String get setupModeDownloaderFeature2 => + 'Guarda música en tu dispositivo para escuchar sin conexión'; + + @override + String get setupModeDownloaderFeature3 => + 'Gestiona tu biblioteca de música local'; + + @override + String get setupModeStreamingTitle => 'Streaming'; + + @override + String get setupModeStreamingFeature1 => + 'Transmite pistas al instante sin descargar'; + + @override + String get setupModeStreamingFeature2 => + 'Smart Queue descubre automáticamente nueva música para ti'; + + @override + String get setupModeStreamingFeature3 => + 'Reproduce cualquier pista bajo demanda con controles de reproducción'; + + @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`). @@ -6147,4 +6406,52 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get trackConvertFailed => 'Conversion failed'; + + @override + String get setupModeSelectionTitle => 'Elige tu modo'; + + @override + String get setupModeSelectionDescription => + '¿Cómo te gustaría usar SpotiFLAC? Puedes cambiarlo más tarde en Ajustes.'; + + @override + String get setupModeDownloaderTitle => 'Descargador'; + + @override + String get setupModeDownloaderFeature1 => + 'Descarga pistas en calidad FLAC sin pérdida'; + + @override + String get setupModeDownloaderFeature2 => + 'Guarda música en tu dispositivo para escuchar sin conexión'; + + @override + String get setupModeDownloaderFeature3 => + 'Gestiona tu biblioteca de música local'; + + @override + String get setupModeStreamingTitle => 'Streaming'; + + @override + String get setupModeStreamingFeature1 => + 'Transmite pistas al instante sin descargar'; + + @override + String get setupModeStreamingFeature2 => + 'Smart Queue descubre automáticamente nueva música para ti'; + + @override + String get setupModeStreamingFeature3 => + 'Reproduce cualquier pista bajo demanda con controles de reproducción'; + + @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 76b5227c..2909fc46 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -253,6 +253,33 @@ class AppLocalizationsFr extends AppLocalizations { String get optionsAutoFallbackSubtitle => 'Essayez d\'autres services si le téléchargement échoue'; + @override + String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks'; + + @override + String get optionsAutoSkipUnavailableTracksSubtitleOn => + 'Automatically skip to the next queue track when a stream cannot be resolved.'; + + @override + String get optionsAutoSkipUnavailableTracksSubtitleOff => + 'Stop on failed track resolution and show an error.'; + + @override + String get optionsInteractionMode => 'Interaction Mode'; + + @override + String get modeDownloader => 'Downloader Mode'; + + @override + String get modeDownloaderSubtitle => + 'Tap tracks to add them to download queue'; + + @override + String get modeStreaming => 'Streaming Mode'; + + @override + String get modeStreamingSubtitle => 'Tap tracks to play instantly'; + @override String get optionsUseExtensionProviders => 'Utiliser des fournisseurs d\'extension'; @@ -1047,6 +1074,10 @@ class AppLocalizationsFr extends AppLocalizations { @override String get errorNoTracksFound => 'No tracks found'; + @override + String get errorSeekNotSupported => + 'Seeking is not supported for this live stream'; + @override String errorMissingExtensionSource(String item) { return 'Cannot load $item: missing extension source'; @@ -1563,6 +1594,11 @@ class AppLocalizationsFr extends AppLocalizations { return 'Download All ($count)'; } + @override + String playAllCount(int count) { + return 'Play All ($count)'; + } + @override String tracksCount(int count) { String _temp0 = intl.Intl.pluralLogic( @@ -2241,9 +2277,15 @@ class AppLocalizationsFr extends AppLocalizations { @override String get discographyDownload => 'Download Discography'; + @override + String get discographyPlay => 'Play Discography'; + @override String get discographyDownloadAll => 'Download All'; + @override + String get discographyPlayAll => 'Play All'; + @override String discographyDownloadAllSubtitle(int count, int albumCount) { return '$count tracks from $albumCount releases'; @@ -2288,6 +2330,9 @@ class AppLocalizationsFr extends AppLocalizations { @override String get discographyDownloadSelected => 'Download Selected'; + @override + String get discographyPlaySelected => 'Play Selected'; + @override String discographyAddedToQueue(int count) { return 'Added $count tracks to queue'; @@ -3187,4 +3232,218 @@ class AppLocalizationsFr extends AppLocalizations { String selectionBatchConvertSuccess(int success, int total, String format) { return 'Converted $success of $total tracks to $format'; } + + @override + String get setupModeSelectionTitle => 'Choisissez votre mode'; + + @override + String get setupModeSelectionDescription => + 'Comment souhaitez-vous utiliser SpotiFLAC ? Vous pouvez toujours changer cela plus tard dans les Paramètres.'; + + @override + String get setupModeDownloaderTitle => 'Téléchargeur'; + + @override + String get setupModeDownloaderFeature1 => + 'Téléchargez des pistes en qualité FLAC sans perte'; + + @override + String get setupModeDownloaderFeature2 => + 'Enregistrez de la musique sur votre appareil pour une écoute hors ligne'; + + @override + String get setupModeDownloaderFeature3 => + 'Gérez votre bibliothèque musicale locale'; + + @override + String get setupModeStreamingTitle => 'Streaming'; + + @override + String get setupModeStreamingFeature1 => + 'Diffusez des pistes instantanément sans télécharger'; + + @override + String get setupModeStreamingFeature2 => + 'Smart Queue découvre automatiquement de nouvelle musique pour vous'; + + @override + String get setupModeStreamingFeature3 => + 'Écoutez n\'importe quelle piste à la demande avec les contrôles de lecture'; + + @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 36ae71f0..8b90147c 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -248,6 +248,33 @@ class AppLocalizationsHi extends AppLocalizations { String get optionsAutoFallbackSubtitle => 'Try other services if download fails'; + @override + String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks'; + + @override + String get optionsAutoSkipUnavailableTracksSubtitleOn => + 'Automatically skip to the next queue track when a stream cannot be resolved.'; + + @override + String get optionsAutoSkipUnavailableTracksSubtitleOff => + 'Stop on failed track resolution and show an error.'; + + @override + String get optionsInteractionMode => 'Interaction Mode'; + + @override + String get modeDownloader => 'Downloader Mode'; + + @override + String get modeDownloaderSubtitle => + 'Tap tracks to add them to download queue'; + + @override + String get modeStreaming => 'Streaming Mode'; + + @override + String get modeStreamingSubtitle => 'Tap tracks to play instantly'; + @override String get optionsUseExtensionProviders => 'Use Extension Providers'; @@ -1041,6 +1068,10 @@ class AppLocalizationsHi extends AppLocalizations { @override String get errorNoTracksFound => 'No tracks found'; + @override + String get errorSeekNotSupported => + 'Seeking is not supported for this live stream'; + @override String errorMissingExtensionSource(String item) { return 'Cannot load $item: missing extension source'; @@ -1557,6 +1588,11 @@ class AppLocalizationsHi extends AppLocalizations { return 'Download All ($count)'; } + @override + String playAllCount(int count) { + return 'Play All ($count)'; + } + @override String tracksCount(int count) { String _temp0 = intl.Intl.pluralLogic( @@ -2235,9 +2271,15 @@ class AppLocalizationsHi extends AppLocalizations { @override String get discographyDownload => 'Download Discography'; + @override + String get discographyPlay => 'Play Discography'; + @override String get discographyDownloadAll => 'Download All'; + @override + String get discographyPlayAll => 'Play All'; + @override String discographyDownloadAllSubtitle(int count, int albumCount) { return '$count tracks from $albumCount releases'; @@ -2282,6 +2324,9 @@ class AppLocalizationsHi extends AppLocalizations { @override String get discographyDownloadSelected => 'Download Selected'; + @override + String get discographyPlaySelected => 'Play Selected'; + @override String discographyAddedToQueue(int count) { return 'Added $count tracks to queue'; @@ -3181,4 +3226,218 @@ class AppLocalizationsHi extends AppLocalizations { String selectionBatchConvertSuccess(int success, int total, String format) { return 'Converted $success of $total tracks to $format'; } + + @override + String get setupModeSelectionTitle => 'अपना मोड चुनें'; + + @override + String get setupModeSelectionDescription => + 'आप SpotiFLAC का उपयोग कैसे करना चाहेंगे? आप इसे बाद में सेटिंग्स में कभी भी बदल सकते हैं।'; + + @override + String get setupModeDownloaderTitle => 'डाउनलोडर'; + + @override + String get setupModeDownloaderFeature1 => + 'लॉसलेस FLAC गुणवत्ता में ट्रैक डाउनलोड करें'; + + @override + String get setupModeDownloaderFeature2 => + 'ऑफ़लाइन सुनने के लिए संगीत अपने डिवाइस में सहेजें'; + + @override + String get setupModeDownloaderFeature3 => + 'अपनी स्थानीय संगीत लाइब्रेरी प्रबंधित करें'; + + @override + String get setupModeStreamingTitle => 'स्ट्रीमिंग'; + + @override + String get setupModeStreamingFeature1 => + 'बिना डाउनलोड किए तुरंत ट्रैक स्ट्रीम करें'; + + @override + String get setupModeStreamingFeature2 => + 'Smart Queue स्वचालित रूप से आपके लिए नया संगीत खोजता है'; + + @override + String get setupModeStreamingFeature3 => + 'प्लेबैक नियंत्रण के साथ किसी भी ट्रैक को मांग पर चलाएं'; + + @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 a6820bb7..7a9fad81 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -251,6 +251,34 @@ class AppLocalizationsId extends AppLocalizations { String get optionsAutoFallbackSubtitle => 'Coba layanan lain jika unduhan gagal'; + @override + String get optionsAutoSkipUnavailableTracks => + 'Lewati Otomatis Lagu yang Tidak Tersedia'; + + @override + String get optionsAutoSkipUnavailableTracksSubtitleOn => + 'Otomatis lanjut ke lagu berikutnya di antrean jika stream lagu tidak bisa ditemukan.'; + + @override + String get optionsAutoSkipUnavailableTracksSubtitleOff => + 'Berhenti di lagu yang gagal dan tampilkan pesan error.'; + + @override + String get optionsInteractionMode => 'Mode Interaksi'; + + @override + String get modeDownloader => 'Mode Downloader'; + + @override + String get modeDownloaderSubtitle => + 'Ketuk lagu untuk menambah ke antrean unduhan'; + + @override + String get modeStreaming => 'Mode Streaming'; + + @override + String get modeStreamingSubtitle => 'Ketuk lagu untuk langsung memutar'; + @override String get optionsUseExtensionProviders => 'Gunakan Provider Ekstensi'; @@ -1047,6 +1075,10 @@ class AppLocalizationsId extends AppLocalizations { @override String get errorNoTracksFound => 'Tidak ada lagu ditemukan'; + @override + String get errorSeekNotSupported => + 'Menggeser posisi lagu tidak didukung untuk live stream ini'; + @override String errorMissingExtensionSource(String item) { return 'Tidak dapat memuat $item: sumber ekstensi tidak ada'; @@ -1567,6 +1599,11 @@ class AppLocalizationsId extends AppLocalizations { return 'Unduh Semua ($count)'; } + @override + String playAllCount(int count) { + return 'Putar Semua ($count)'; + } + @override String tracksCount(int count) { String _temp0 = intl.Intl.pluralLogic( @@ -2248,9 +2285,15 @@ class AppLocalizationsId extends AppLocalizations { @override String get discographyDownload => 'Download Discography'; + @override + String get discographyPlay => 'Putar Diskografi'; + @override String get discographyDownloadAll => 'Unduh Semua'; + @override + String get discographyPlayAll => 'Putar Semua'; + @override String discographyDownloadAllSubtitle(int count, int albumCount) { return '$count tracks from $albumCount releases'; @@ -2295,6 +2338,9 @@ class AppLocalizationsId extends AppLocalizations { @override String get discographyDownloadSelected => 'Download Selected'; + @override + String get discographyPlaySelected => 'Putar Terpilih'; + @override String discographyAddedToQueue(int count) { return 'Added $count tracks to queue'; @@ -3144,31 +3190,32 @@ class AppLocalizationsId extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'tracks', - one: 'track', + other: 'trek', + one: 'trek', ); - return 'Share $count $_temp0'; + return 'Bagikan $count $_temp0'; } @override - String get selectionShareNoFiles => 'No shareable files found'; + String get selectionShareNoFiles => 'Tidak ada file yang dapat dibagikan'; @override String selectionConvertCount(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'tracks', - one: 'track', + other: 'trek', + one: 'trek', ); - return 'Convert $count $_temp0'; + return 'Konversi $count $_temp0'; } @override - String get selectionConvertNoConvertible => 'No convertible tracks selected'; + String get selectionConvertNoConvertible => + 'Tidak ada trek yang dapat dikonversi dipilih'; @override - String get selectionBatchConvertConfirmTitle => 'Batch Convert'; + String get selectionBatchConvertConfirmTitle => 'Konversi Massal'; @override String selectionBatchConvertConfirmMessage( @@ -3179,19 +3226,242 @@ class AppLocalizationsId extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'tracks', - one: 'track', + other: 'trek', + one: 'trek', ); - return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.'; + return 'Konversi $count $_temp0 ke $format pada $bitrate?\n\nFile asli akan dihapus setelah konversi.'; } @override String selectionBatchConvertProgress(int current, int total) { - return 'Converting $current of $total...'; + return 'Mengonversi $current dari $total...'; } @override String selectionBatchConvertSuccess(int success, int total, String format) { - return 'Converted $success of $total tracks to $format'; + return 'Berhasil mengonversi $success dari $total trek ke $format'; + } + + @override + String get setupModeSelectionTitle => 'Pilih Mode Anda'; + + @override + String get setupModeSelectionDescription => + 'Bagaimana Anda ingin menggunakan SpotiFLAC? Anda dapat mengubahnya nanti di Pengaturan.'; + + @override + String get setupModeDownloaderTitle => 'Pengunduh'; + + @override + String get setupModeDownloaderFeature1 => + 'Unduh trek dalam kualitas FLAC lossless'; + + @override + String get setupModeDownloaderFeature2 => + 'Simpan musik ke perangkat Anda untuk mendengarkan offline'; + + @override + String get setupModeDownloaderFeature3 => + 'Kelola perpustakaan musik lokal Anda'; + + @override + String get setupModeStreamingTitle => 'Streaming'; + + @override + String get setupModeStreamingFeature1 => + 'Streaming trek secara instan tanpa mengunduh'; + + @override + String get setupModeStreamingFeature2 => + 'Smart Queue secara otomatis menemukan musik baru untuk Anda'; + + @override + String get setupModeStreamingFeature3 => + 'Putar trek apa pun sesuai permintaan dengan kontrol pemutaran'; + + @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 2e64efd3..7628d16b 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -248,6 +248,33 @@ class AppLocalizationsJa extends AppLocalizations { String get optionsAutoFallbackSubtitle => 'Try other services if download fails'; + @override + String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks'; + + @override + String get optionsAutoSkipUnavailableTracksSubtitleOn => + 'Automatically skip to the next queue track when a stream cannot be resolved.'; + + @override + String get optionsAutoSkipUnavailableTracksSubtitleOff => + 'Stop on failed track resolution and show an error.'; + + @override + String get optionsInteractionMode => 'Interaction Mode'; + + @override + String get modeDownloader => 'Downloader Mode'; + + @override + String get modeDownloaderSubtitle => + 'Tap tracks to add them to download queue'; + + @override + String get modeStreaming => 'Streaming Mode'; + + @override + String get modeStreamingSubtitle => 'Tap tracks to play instantly'; + @override String get optionsUseExtensionProviders => '拡張のプロバイダーを使用する'; @@ -1035,6 +1062,10 @@ class AppLocalizationsJa extends AppLocalizations { @override String get errorNoTracksFound => 'トラックがありません'; + @override + String get errorSeekNotSupported => + 'Seeking is not supported for this live stream'; + @override String errorMissingExtensionSource(String item) { return '$item を読み込めません: 拡張ソースがありません'; @@ -1550,6 +1581,11 @@ class AppLocalizationsJa extends AppLocalizations { return 'すべてダウンロード ($count)'; } + @override + String playAllCount(int count) { + return 'Play All ($count)'; + } + @override String tracksCount(int count) { String _temp0 = intl.Intl.pluralLogic( @@ -2221,9 +2257,15 @@ class AppLocalizationsJa extends AppLocalizations { @override String get discographyDownload => 'ディスコグラフィをダウンロード'; + @override + String get discographyPlay => 'Play Discography'; + @override String get discographyDownloadAll => 'すべてダウンロード'; + @override + String get discographyPlayAll => 'Play All'; + @override String discographyDownloadAllSubtitle(int count, int albumCount) { return '$albumCount 個のリリースから $count 個のトラック'; @@ -2268,6 +2310,9 @@ class AppLocalizationsJa extends AppLocalizations { @override String get discographyDownloadSelected => '選択済みをダウンロード'; + @override + String get discographyPlaySelected => 'Play Selected'; + @override String discographyAddedToQueue(int count) { return 'Added $count tracks to queue'; @@ -3167,4 +3212,210 @@ class AppLocalizationsJa extends AppLocalizations { String selectionBatchConvertSuccess(int success, int total, String format) { return 'Converted $success of $total tracks to $format'; } + + @override + String get setupModeSelectionTitle => 'モードを選択'; + + @override + String get setupModeSelectionDescription => + 'SpotiFLACをどのように使いますか?この設定は後からいつでも変更できます。'; + + @override + String get setupModeDownloaderTitle => 'ダウンローダー'; + + @override + String get setupModeDownloaderFeature1 => 'ロスレスFLAC品質でトラックをダウンロード'; + + @override + String get setupModeDownloaderFeature2 => 'オフライン再生用に音楽をデバイスに保存'; + + @override + String get setupModeDownloaderFeature3 => 'ローカル音楽ライブラリを管理'; + + @override + String get setupModeStreamingTitle => 'ストリーミング'; + + @override + String get setupModeStreamingFeature1 => 'ダウンロードせずにトラックを即座にストリーミング'; + + @override + String get setupModeStreamingFeature2 => 'Smart Queueが自動的に新しい音楽を見つけます'; + + @override + String get setupModeStreamingFeature3 => '再生コントロールで任意のトラックをオンデマンド再生'; + + @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 32d7f843..c9aa8d92 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -247,6 +247,33 @@ class AppLocalizationsKo extends AppLocalizations { @override String get optionsAutoFallbackSubtitle => '다운로드가 실패한 경우, 다른 서비스로 재시도'; + @override + String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks'; + + @override + String get optionsAutoSkipUnavailableTracksSubtitleOn => + 'Automatically skip to the next queue track when a stream cannot be resolved.'; + + @override + String get optionsAutoSkipUnavailableTracksSubtitleOff => + 'Stop on failed track resolution and show an error.'; + + @override + String get optionsInteractionMode => 'Interaction Mode'; + + @override + String get modeDownloader => 'Downloader Mode'; + + @override + String get modeDownloaderSubtitle => + 'Tap tracks to add them to download queue'; + + @override + String get modeStreaming => 'Streaming Mode'; + + @override + String get modeStreamingSubtitle => 'Tap tracks to play instantly'; + @override String get optionsUseExtensionProviders => 'Use Extension Providers'; @@ -1040,6 +1067,10 @@ class AppLocalizationsKo extends AppLocalizations { @override String get errorNoTracksFound => 'No tracks found'; + @override + String get errorSeekNotSupported => + 'Seeking is not supported for this live stream'; + @override String errorMissingExtensionSource(String item) { return 'Cannot load $item: missing extension source'; @@ -1556,6 +1587,11 @@ class AppLocalizationsKo extends AppLocalizations { return 'Download All ($count)'; } + @override + String playAllCount(int count) { + return 'Play All ($count)'; + } + @override String tracksCount(int count) { String _temp0 = intl.Intl.pluralLogic( @@ -2234,9 +2270,15 @@ class AppLocalizationsKo extends AppLocalizations { @override String get discographyDownload => 'Download Discography'; + @override + String get discographyPlay => 'Play Discography'; + @override String get discographyDownloadAll => 'Download All'; + @override + String get discographyPlayAll => 'Play All'; + @override String discographyDownloadAllSubtitle(int count, int albumCount) { return '$count tracks from $albumCount releases'; @@ -2281,6 +2323,9 @@ class AppLocalizationsKo extends AppLocalizations { @override String get discographyDownloadSelected => 'Download Selected'; + @override + String get discographyPlaySelected => 'Play Selected'; + @override String discographyAddedToQueue(int count) { return 'Added $count tracks to queue'; @@ -3180,4 +3225,210 @@ class AppLocalizationsKo extends AppLocalizations { String selectionBatchConvertSuccess(int success, int total, String format) { return 'Converted $success of $total tracks to $format'; } + + @override + String get setupModeSelectionTitle => '모드 선택'; + + @override + String get setupModeSelectionDescription => + 'SpotiFLAC을 어떻게 사용하시겠습니까? 나중에 설정에서 언제든지 변경할 수 있습니다.'; + + @override + String get setupModeDownloaderTitle => '다운로더'; + + @override + String get setupModeDownloaderFeature1 => '무손실 FLAC 품질로 트랙 다운로드'; + + @override + String get setupModeDownloaderFeature2 => '오프라인 감상을 위해 기기에 음악 저장'; + + @override + String get setupModeDownloaderFeature3 => '로컬 음악 라이브러리 관리'; + + @override + String get setupModeStreamingTitle => '스트리밍'; + + @override + String get setupModeStreamingFeature1 => '다운로드 없이 트랙을 즉시 스트리밍'; + + @override + String get setupModeStreamingFeature2 => 'Smart Queue가 자동으로 새로운 음악을 발견합니다'; + + @override + String get setupModeStreamingFeature3 => '재생 컨트롤로 원하는 트랙을 온디맨드 재생'; + + @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 4f263f2d..ed4810d0 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -248,6 +248,33 @@ class AppLocalizationsNl extends AppLocalizations { String get optionsAutoFallbackSubtitle => 'Try other services if download fails'; + @override + String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks'; + + @override + String get optionsAutoSkipUnavailableTracksSubtitleOn => + 'Automatically skip to the next queue track when a stream cannot be resolved.'; + + @override + String get optionsAutoSkipUnavailableTracksSubtitleOff => + 'Stop on failed track resolution and show an error.'; + + @override + String get optionsInteractionMode => 'Interaction Mode'; + + @override + String get modeDownloader => 'Downloader Mode'; + + @override + String get modeDownloaderSubtitle => + 'Tap tracks to add them to download queue'; + + @override + String get modeStreaming => 'Streaming Mode'; + + @override + String get modeStreamingSubtitle => 'Tap tracks to play instantly'; + @override String get optionsUseExtensionProviders => 'Use Extension Providers'; @@ -1041,6 +1068,10 @@ class AppLocalizationsNl extends AppLocalizations { @override String get errorNoTracksFound => 'No tracks found'; + @override + String get errorSeekNotSupported => + 'Seeking is not supported for this live stream'; + @override String errorMissingExtensionSource(String item) { return 'Cannot load $item: missing extension source'; @@ -1557,6 +1588,11 @@ class AppLocalizationsNl extends AppLocalizations { return 'Download All ($count)'; } + @override + String playAllCount(int count) { + return 'Play All ($count)'; + } + @override String tracksCount(int count) { String _temp0 = intl.Intl.pluralLogic( @@ -2235,9 +2271,15 @@ class AppLocalizationsNl extends AppLocalizations { @override String get discographyDownload => 'Download Discography'; + @override + String get discographyPlay => 'Play Discography'; + @override String get discographyDownloadAll => 'Download All'; + @override + String get discographyPlayAll => 'Play All'; + @override String discographyDownloadAllSubtitle(int count, int albumCount) { return '$count tracks from $albumCount releases'; @@ -2282,6 +2324,9 @@ class AppLocalizationsNl extends AppLocalizations { @override String get discographyDownloadSelected => 'Download Selected'; + @override + String get discographyPlaySelected => 'Play Selected'; + @override String discographyAddedToQueue(int count) { return 'Added $count tracks to queue'; @@ -3181,4 +3226,218 @@ class AppLocalizationsNl extends AppLocalizations { String selectionBatchConvertSuccess(int success, int total, String format) { return 'Converted $success of $total tracks to $format'; } + + @override + String get setupModeSelectionTitle => 'Kies je modus'; + + @override + String get setupModeSelectionDescription => + 'Hoe wil je SpotiFLAC gebruiken? Je kunt dit later altijd wijzigen in Instellingen.'; + + @override + String get setupModeDownloaderTitle => 'Downloader'; + + @override + String get setupModeDownloaderFeature1 => + 'Download nummers in lossless FLAC-kwaliteit'; + + @override + String get setupModeDownloaderFeature2 => + 'Sla muziek op je apparaat op om offline te luisteren'; + + @override + String get setupModeDownloaderFeature3 => + 'Beheer je lokale muziekbibliotheek'; + + @override + String get setupModeStreamingTitle => 'Streaming'; + + @override + String get setupModeStreamingFeature1 => + 'Stream nummers direct zonder te downloaden'; + + @override + String get setupModeStreamingFeature2 => + 'Smart Queue ontdekt automatisch nieuwe muziek voor je'; + + @override + String get setupModeStreamingFeature3 => + 'Speel elk nummer op aanvraag af met afspeelbediening'; + + @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 2ab77278..9a341fad 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -248,6 +248,33 @@ class AppLocalizationsPt extends AppLocalizations { String get optionsAutoFallbackSubtitle => 'Try other services if download fails'; + @override + String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks'; + + @override + String get optionsAutoSkipUnavailableTracksSubtitleOn => + 'Automatically skip to the next queue track when a stream cannot be resolved.'; + + @override + String get optionsAutoSkipUnavailableTracksSubtitleOff => + 'Stop on failed track resolution and show an error.'; + + @override + String get optionsInteractionMode => 'Interaction Mode'; + + @override + String get modeDownloader => 'Downloader Mode'; + + @override + String get modeDownloaderSubtitle => + 'Tap tracks to add them to download queue'; + + @override + String get modeStreaming => 'Streaming Mode'; + + @override + String get modeStreamingSubtitle => 'Tap tracks to play instantly'; + @override String get optionsUseExtensionProviders => 'Use Extension Providers'; @@ -1041,6 +1068,10 @@ class AppLocalizationsPt extends AppLocalizations { @override String get errorNoTracksFound => 'No tracks found'; + @override + String get errorSeekNotSupported => + 'Seeking is not supported for this live stream'; + @override String errorMissingExtensionSource(String item) { return 'Cannot load $item: missing extension source'; @@ -1557,6 +1588,11 @@ class AppLocalizationsPt extends AppLocalizations { return 'Download All ($count)'; } + @override + String playAllCount(int count) { + return 'Play All ($count)'; + } + @override String tracksCount(int count) { String _temp0 = intl.Intl.pluralLogic( @@ -2235,9 +2271,15 @@ class AppLocalizationsPt extends AppLocalizations { @override String get discographyDownload => 'Download Discography'; + @override + String get discographyPlay => 'Play Discography'; + @override String get discographyDownloadAll => 'Download All'; + @override + String get discographyPlayAll => 'Play All'; + @override String discographyDownloadAllSubtitle(int count, int albumCount) { return '$count tracks from $albumCount releases'; @@ -2282,6 +2324,9 @@ class AppLocalizationsPt extends AppLocalizations { @override String get discographyDownloadSelected => 'Download Selected'; + @override + String get discographyPlaySelected => 'Play Selected'; + @override String discographyAddedToQueue(int count) { return 'Added $count tracks to queue'; @@ -3181,6 +3226,220 @@ class AppLocalizationsPt extends AppLocalizations { String selectionBatchConvertSuccess(int success, int total, String format) { return 'Converted $success of $total tracks to $format'; } + + @override + String get setupModeSelectionTitle => 'Escolha seu modo'; + + @override + String get setupModeSelectionDescription => + 'Como você gostaria de usar o SpotiFLAC? Você pode alterar isso depois nas Configurações.'; + + @override + String get setupModeDownloaderTitle => 'Downloader'; + + @override + String get setupModeDownloaderFeature1 => + 'Baixe faixas em qualidade FLAC lossless'; + + @override + String get setupModeDownloaderFeature2 => + 'Salve músicas no seu dispositivo para ouvir offline'; + + @override + String get setupModeDownloaderFeature3 => + 'Gerencie sua biblioteca de músicas local'; + + @override + String get setupModeStreamingTitle => 'Streaming'; + + @override + String get setupModeStreamingFeature1 => + 'Transmita faixas instantaneamente sem baixar'; + + @override + String get setupModeStreamingFeature2 => + 'Smart Queue descobre automaticamente novas músicas para você'; + + @override + String get setupModeStreamingFeature3 => + 'Reproduza qualquer faixa sob demanda com controles de reprodução'; + + @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`). @@ -6141,4 +6400,52 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get trackConvertFailed => 'Conversion failed'; + + @override + String get setupModeSelectionTitle => 'Escolha o seu modo'; + + @override + String get setupModeSelectionDescription => + 'Como gostaria de utilizar o SpotiFLAC? Pode alterar isto mais tarde nas Definições.'; + + @override + String get setupModeDownloaderTitle => 'Transferência'; + + @override + String get setupModeDownloaderFeature1 => + 'Transfira faixas em qualidade FLAC sem perdas'; + + @override + String get setupModeDownloaderFeature2 => + 'Guarde música no seu dispositivo para ouvir offline'; + + @override + String get setupModeDownloaderFeature3 => + 'Faça a gestão da sua biblioteca de música local'; + + @override + String get setupModeStreamingTitle => 'Streaming'; + + @override + String get setupModeStreamingFeature1 => + 'Transmita faixas instantaneamente sem transferir'; + + @override + String get setupModeStreamingFeature2 => + 'Smart Queue descobre automaticamente novas músicas para si'; + + @override + String get setupModeStreamingFeature3 => + 'Reproduza qualquer faixa a pedido com controlos de reprodução'; + + @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 dbd95cc2..3df13ff8 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -255,6 +255,33 @@ class AppLocalizationsRu extends AppLocalizations { String get optionsAutoFallbackSubtitle => 'Попробовать другие сервисы при сбое загрузки'; + @override + String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks'; + + @override + String get optionsAutoSkipUnavailableTracksSubtitleOn => + 'Automatically skip to the next queue track when a stream cannot be resolved.'; + + @override + String get optionsAutoSkipUnavailableTracksSubtitleOff => + 'Stop on failed track resolution and show an error.'; + + @override + String get optionsInteractionMode => 'Interaction Mode'; + + @override + String get modeDownloader => 'Downloader Mode'; + + @override + String get modeDownloaderSubtitle => + 'Tap tracks to add them to download queue'; + + @override + String get modeStreaming => 'Streaming Mode'; + + @override + String get modeStreamingSubtitle => 'Tap tracks to play instantly'; + @override String get optionsUseExtensionProviders => 'Использовать провайдера расширений'; @@ -1066,6 +1093,10 @@ class AppLocalizationsRu extends AppLocalizations { @override String get errorNoTracksFound => 'Треки не найдены'; + @override + String get errorSeekNotSupported => + 'Seeking is not supported for this live stream'; + @override String errorMissingExtensionSource(String item) { return 'Невозможно загрузить $item: отсутствует источник расширения'; @@ -1587,6 +1618,11 @@ class AppLocalizationsRu extends AppLocalizations { return 'Скачать все ($count)'; } + @override + String playAllCount(int count) { + return 'Play All ($count)'; + } + @override String tracksCount(int count) { String _temp0 = intl.Intl.pluralLogic( @@ -2283,9 +2319,15 @@ class AppLocalizationsRu extends AppLocalizations { @override String get discographyDownload => 'Скачать дискографию'; + @override + String get discographyPlay => 'Play Discography'; + @override String get discographyDownloadAll => 'Скачать всё'; + @override + String get discographyPlayAll => 'Play All'; + @override String discographyDownloadAllSubtitle(int count, int albumCount) { return '$count треков из $albumCount релизов'; @@ -2330,6 +2372,9 @@ class AppLocalizationsRu extends AppLocalizations { @override String get discographyDownloadSelected => 'Скачать выбранное'; + @override + String get discographyPlaySelected => 'Play Selected'; + @override String discographyAddedToQueue(int count) { return 'Добавлено $count треков в очередь'; @@ -3279,4 +3324,218 @@ class AppLocalizationsRu extends AppLocalizations { String selectionBatchConvertSuccess(int success, int total, String format) { return 'Converted $success of $total tracks to $format'; } + + @override + String get setupModeSelectionTitle => 'Выберите режим'; + + @override + String get setupModeSelectionDescription => + 'Как вы хотите использовать SpotiFLAC? Вы всегда можете изменить это позже в Настройках.'; + + @override + String get setupModeDownloaderTitle => 'Загрузчик'; + + @override + String get setupModeDownloaderFeature1 => + 'Скачивайте треки в качестве FLAC без потерь'; + + @override + String get setupModeDownloaderFeature2 => + 'Сохраняйте музыку на устройство для прослушивания офлайн'; + + @override + String get setupModeDownloaderFeature3 => + 'Управляйте своей локальной музыкальной библиотекой'; + + @override + String get setupModeStreamingTitle => 'Стриминг'; + + @override + String get setupModeStreamingFeature1 => + 'Слушайте треки мгновенно без скачивания'; + + @override + String get setupModeStreamingFeature2 => + 'Smart Queue автоматически подбирает новую музыку для вас'; + + @override + String get setupModeStreamingFeature3 => + 'Воспроизводите любой трек по запросу с элементами управления'; + + @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 dda62af2..935c31d4 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -252,6 +252,33 @@ class AppLocalizationsTr extends AppLocalizations { String get optionsAutoFallbackSubtitle => 'İndirme başarısız olursa diğer hizmetleri dene'; + @override + String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks'; + + @override + String get optionsAutoSkipUnavailableTracksSubtitleOn => + 'Automatically skip to the next queue track when a stream cannot be resolved.'; + + @override + String get optionsAutoSkipUnavailableTracksSubtitleOff => + 'Stop on failed track resolution and show an error.'; + + @override + String get optionsInteractionMode => 'Interaction Mode'; + + @override + String get modeDownloader => 'Downloader Mode'; + + @override + String get modeDownloaderSubtitle => + 'Tap tracks to add them to download queue'; + + @override + String get modeStreaming => 'Streaming Mode'; + + @override + String get modeStreamingSubtitle => 'Tap tracks to play instantly'; + @override String get optionsUseExtensionProviders => 'Eklenti sağlayıcılarını kullan'; @@ -1048,6 +1075,10 @@ class AppLocalizationsTr extends AppLocalizations { @override String get errorNoTracksFound => 'Parça bulunamadı'; + @override + String get errorSeekNotSupported => + 'Seeking is not supported for this live stream'; + @override String errorMissingExtensionSource(String item) { return '$item yüklenemedi: Eksik eklenti kaynağı'; @@ -1570,6 +1601,11 @@ class AppLocalizationsTr extends AppLocalizations { return 'Tümünü İndir ($count)'; } + @override + String playAllCount(int count) { + return 'Play All ($count)'; + } + @override String tracksCount(int count) { String _temp0 = intl.Intl.pluralLogic( @@ -2250,9 +2286,15 @@ class AppLocalizationsTr extends AppLocalizations { @override String get discographyDownload => 'Download Discography'; + @override + String get discographyPlay => 'Play Discography'; + @override String get discographyDownloadAll => 'Download All'; + @override + String get discographyPlayAll => 'Play All'; + @override String discographyDownloadAllSubtitle(int count, int albumCount) { return '$count tracks from $albumCount releases'; @@ -2297,6 +2339,9 @@ class AppLocalizationsTr extends AppLocalizations { @override String get discographyDownloadSelected => 'Download Selected'; + @override + String get discographyPlaySelected => 'Play Selected'; + @override String discographyAddedToQueue(int count) { return '$count şarkı kuyruğa eklendi'; @@ -3196,4 +3241,217 @@ class AppLocalizationsTr extends AppLocalizations { String selectionBatchConvertSuccess(int success, int total, String format) { return 'Converted $success of $total tracks to $format'; } + + @override + String get setupModeSelectionTitle => 'Modunuzu Seçin'; + + @override + String get setupModeSelectionDescription => + 'SpotiFLAC\'ı nasıl kullanmak istersiniz? Bunu daha sonra Ayarlar\'dan değiştirebilirsiniz.'; + + @override + String get setupModeDownloaderTitle => 'İndirici'; + + @override + String get setupModeDownloaderFeature1 => + 'Kayıpsız FLAC kalitesinde parça indirin'; + + @override + String get setupModeDownloaderFeature2 => + 'Çevrimdışı dinlemek için müziği cihazınıza kaydedin'; + + @override + String get setupModeDownloaderFeature3 => 'Yerel müzik kütüphanenizi yönetin'; + + @override + String get setupModeStreamingTitle => 'Yayın Akışı'; + + @override + String get setupModeStreamingFeature1 => + 'İndirmeden parçaları anında yayınlayın'; + + @override + String get setupModeStreamingFeature2 => + 'Smart Queue sizin için otomatik olarak yeni müzik keşfeder'; + + @override + String get setupModeStreamingFeature3 => + 'İstediğiniz parçayı oynatma kontrolleriyle çalın'; + + @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 9021f5dc..c4a6f4aa 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -248,6 +248,33 @@ class AppLocalizationsZh extends AppLocalizations { String get optionsAutoFallbackSubtitle => 'Try other services if download fails'; + @override + String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks'; + + @override + String get optionsAutoSkipUnavailableTracksSubtitleOn => + 'Automatically skip to the next queue track when a stream cannot be resolved.'; + + @override + String get optionsAutoSkipUnavailableTracksSubtitleOff => + 'Stop on failed track resolution and show an error.'; + + @override + String get optionsInteractionMode => 'Interaction Mode'; + + @override + String get modeDownloader => 'Downloader Mode'; + + @override + String get modeDownloaderSubtitle => + 'Tap tracks to add them to download queue'; + + @override + String get modeStreaming => 'Streaming Mode'; + + @override + String get modeStreamingSubtitle => 'Tap tracks to play instantly'; + @override String get optionsUseExtensionProviders => 'Use Extension Providers'; @@ -1041,6 +1068,10 @@ class AppLocalizationsZh extends AppLocalizations { @override String get errorNoTracksFound => 'No tracks found'; + @override + String get errorSeekNotSupported => + 'Seeking is not supported for this live stream'; + @override String errorMissingExtensionSource(String item) { return 'Cannot load $item: missing extension source'; @@ -1557,6 +1588,11 @@ class AppLocalizationsZh extends AppLocalizations { return 'Download All ($count)'; } + @override + String playAllCount(int count) { + return 'Play All ($count)'; + } + @override String tracksCount(int count) { String _temp0 = intl.Intl.pluralLogic( @@ -2235,9 +2271,15 @@ class AppLocalizationsZh extends AppLocalizations { @override String get discographyDownload => 'Download Discography'; + @override + String get discographyPlay => 'Play Discography'; + @override String get discographyDownloadAll => 'Download All'; + @override + String get discographyPlayAll => 'Play All'; + @override String discographyDownloadAllSubtitle(int count, int albumCount) { return '$count tracks from $albumCount releases'; @@ -2282,6 +2324,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get discographyDownloadSelected => 'Download Selected'; + @override + String get discographyPlaySelected => 'Play Selected'; + @override String discographyAddedToQueue(int count) { return 'Added $count tracks to queue'; @@ -3181,6 +3226,211 @@ class AppLocalizationsZh extends AppLocalizations { String selectionBatchConvertSuccess(int success, int total, String format) { return 'Converted $success of $total tracks to $format'; } + + @override + String get setupModeSelectionTitle => '选择您的模式'; + + @override + String get setupModeSelectionDescription => '您想如何使用 SpotiFLAC?您可以稍后在设置中随时更改。'; + + @override + String get setupModeDownloaderTitle => '下载器'; + + @override + String get setupModeDownloaderFeature1 => '以无损 FLAC 品质下载曲目'; + + @override + String get setupModeDownloaderFeature2 => '将音乐保存到设备以供离线收听'; + + @override + String get setupModeDownloaderFeature3 => '管理您的本地音乐库'; + + @override + String get setupModeStreamingTitle => '流媒体'; + + @override + String get setupModeStreamingFeature1 => '无需下载即可即时播放曲目'; + + @override + String get setupModeStreamingFeature2 => 'Smart Queue 自动为您发现新音乐'; + + @override + String get setupModeStreamingFeature3 => '通过播放控件随时点播任意曲目'; + + @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`). @@ -6114,6 +6364,45 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get trackConvertFailed => 'Conversion failed'; + + @override + String get setupModeSelectionTitle => '选择您的模式'; + + @override + String get setupModeSelectionDescription => '您想如何使用 SpotiFLAC?您可以稍后在设置中随时更改。'; + + @override + String get setupModeDownloaderTitle => '下载器'; + + @override + String get setupModeDownloaderFeature1 => '以无损 FLAC 品质下载曲目'; + + @override + String get setupModeDownloaderFeature2 => '将音乐保存到设备以供离线收听'; + + @override + String get setupModeDownloaderFeature3 => '管理您的本地音乐库'; + + @override + String get setupModeStreamingTitle => '流媒体'; + + @override + String get setupModeStreamingFeature1 => '无需下载即可即时播放曲目'; + + @override + String get setupModeStreamingFeature2 => 'Smart Queue 自动为您发现新音乐'; + + @override + String get setupModeStreamingFeature3 => '通过播放控件随时点播任意曲目'; + + @override + String get setupModeChangeableLater => '您可以随时在设置中切换模式。'; + + @override + String get settingsSmartQueueTitle => 'Smart Queue'; + + @override + String get settingsSmartQueueSubtitle => '自动发现并将相似曲目添加到您的队列中'; } /// The translations for Chinese, as used in Taiwan (`zh_TW`). @@ -9047,4 +9336,43 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get trackConvertFailed => 'Conversion failed'; + + @override + String get setupModeSelectionTitle => '選擇您的模式'; + + @override + String get setupModeSelectionDescription => '您想如何使用 SpotiFLAC?您可以稍後在設定中隨時變更。'; + + @override + String get setupModeDownloaderTitle => '下載器'; + + @override + String get setupModeDownloaderFeature1 => '以無損 FLAC 品質下載曲目'; + + @override + String get setupModeDownloaderFeature2 => '將音樂儲存到裝置以供離線收聽'; + + @override + String get setupModeDownloaderFeature3 => '管理您的本機音樂庫'; + + @override + String get setupModeStreamingTitle => '串流'; + + @override + String get setupModeStreamingFeature1 => '無需下載即可即時串流曲目'; + + @override + String get setupModeStreamingFeature2 => 'Smart Queue 自動為您探索新音樂'; + + @override + String get setupModeStreamingFeature3 => '透過播放控制項隨時點播任意曲目'; + + @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 e8dba80d..cf06c2ed 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -3868,5 +3868,18 @@ "trackConvertFailed": "Conversion failed", "@trackConvertFailed": { "description": "Snackbar when conversion fails" - } + }, + "setupModeSelectionTitle": "Wähle deinen Modus", + "setupModeSelectionDescription": "Wie möchtest du SpotiFLAC nutzen? Du kannst dies später jederzeit in den Einstellungen ändern.", + "setupModeDownloaderTitle": "Downloader", + "setupModeDownloaderFeature1": "Lade Titel in verlustfreier FLAC-Qualität herunter", + "setupModeDownloaderFeature2": "Speichere Musik auf deinem Gerät zum Offline-Hören", + "setupModeDownloaderFeature3": "Verwalte deine lokale Musikbibliothek", + "setupModeStreamingTitle": "Streaming", + "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" } \ No newline at end of file diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 1e0f3572..562073ff 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -175,6 +175,22 @@ "@optionsAutoFallback": {"description": "Auto-retry with other services"}, "optionsAutoFallbackSubtitle": "Try other services if download fails", "@optionsAutoFallbackSubtitle": {"description": "Subtitle for auto fallback"}, + "optionsAutoSkipUnavailableTracks": "Auto Skip Unavailable Tracks", + "@optionsAutoSkipUnavailableTracks": {"description": "Toggle to skip to the next queue track when current track stream resolution fails"}, + "optionsAutoSkipUnavailableTracksSubtitleOn": "Automatically skip to the next queue track when a stream cannot be resolved.", + "@optionsAutoSkipUnavailableTracksSubtitleOn": {"description": "Subtitle when auto skip on resolve failure is enabled"}, + "optionsAutoSkipUnavailableTracksSubtitleOff": "Stop on failed track resolution and show an error.", + "@optionsAutoSkipUnavailableTracksSubtitleOff": {"description": "Subtitle when auto skip on resolve failure is disabled"}, + "optionsInteractionMode": "Interaction Mode", + "@optionsInteractionMode": {"description": "Tap behavior mode for track lists"}, + "modeDownloader": "Downloader Mode", + "@modeDownloader": {"description": "Interaction mode where taps queue downloads"}, + "modeDownloaderSubtitle": "Tap tracks to add them to download queue", + "@modeDownloaderSubtitle": {"description": "Subtitle for downloader interaction mode"}, + "modeStreaming": "Streaming Mode", + "@modeStreaming": {"description": "Interaction mode where taps start playback"}, + "modeStreamingSubtitle": "Tap tracks to play instantly", + "@modeStreamingSubtitle": {"description": "Subtitle for streaming interaction mode"}, "optionsUseExtensionProviders": "Use Extension Providers", "@optionsUseExtensionProviders": {"description": "Enable extension download providers"}, "optionsUseExtensionProvidersOn": "Extensions will be tried first", @@ -759,6 +775,8 @@ }, "errorNoTracksFound": "No tracks found", "@errorNoTracksFound": {"description": "Error - search returned no results"}, + "errorSeekNotSupported": "Seeking is not supported for this live stream", + "@errorSeekNotSupported": {"description": "Error - seek disabled for live decrypted stream"}, "errorMissingExtensionSource": "Cannot load {item}: missing extension source", "@errorMissingExtensionSource": { "description": "Error - extension source not available", @@ -1151,6 +1169,13 @@ "count": {"type": "int"} } }, + "playAllCount": "Play All ({count})", + "@playAllCount": { + "description": "Play all button with count", + "placeholders": { + "count": {"type": "int"} + } + }, "tracksCount": "{count, plural, =1{1 track} other{{count} tracks}}", "@tracksCount": { "description": "Track count display", @@ -1669,8 +1694,12 @@ "discographyDownload": "Download Discography", "@discographyDownload": {"description": "Button - download artist discography"}, + "discographyPlay": "Play Discography", + "@discographyPlay": {"description": "Button - play artist discography"}, "discographyDownloadAll": "Download All", "@discographyDownloadAll": {"description": "Option - download entire discography"}, + "discographyPlayAll": "Play All", + "@discographyPlayAll": {"description": "Option - play entire discography"}, "discographyDownloadAllSubtitle": "{count} tracks from {albumCount} releases", "@discographyDownloadAllSubtitle": { "description": "Subtitle showing total tracks and albums", @@ -1722,6 +1751,8 @@ }, "discographyDownloadSelected": "Download Selected", "@discographyDownloadSelected": {"description": "Button - download selected albums"}, + "discographyPlaySelected": "Play Selected", + "@discographyPlaySelected": {"description": "Button - play selected albums"}, "discographyAddedToQueue": "Added {count} tracks to queue", "@discographyAddedToQueue": { "description": "Snackbar - tracks added from discography", @@ -2441,5 +2472,140 @@ "total": {"type": "int"}, "format": {"type": "String"} } + }, + + "setupModeSelectionTitle": "Choose Your Mode", + "@setupModeSelectionTitle": {"description": "Title for mode selection step in setup wizard"}, + "setupModeSelectionDescription": "How would you like to use SpotiFLAC? You can always change this later in Settings.", + "@setupModeSelectionDescription": {"description": "Description for mode selection step"}, + "setupModeDownloaderTitle": "Downloader", + "@setupModeDownloaderTitle": {"description": "Title for downloader mode option"}, + "setupModeDownloaderFeature1": "Download tracks in lossless FLAC quality", + "@setupModeDownloaderFeature1": {"description": "Downloader mode feature 1"}, + "setupModeDownloaderFeature2": "Save music to your device for offline listening", + "@setupModeDownloaderFeature2": {"description": "Downloader mode feature 2"}, + "setupModeDownloaderFeature3": "Manage your local music library", + "@setupModeDownloaderFeature3": {"description": "Downloader mode feature 3"}, + "setupModeStreamingTitle": "Streaming", + "@setupModeStreamingTitle": {"description": "Title for streaming mode option"}, + "setupModeStreamingFeature1": "Stream tracks instantly without downloading", + "@setupModeStreamingFeature1": {"description": "Streaming mode feature 1"}, + "setupModeStreamingFeature2": "Smart Queue auto-discovers new music for you", + "@setupModeStreamingFeature2": {"description": "Streaming mode feature 2"}, + "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"} + } } } diff --git a/lib/l10n/arb/app_es.arb b/lib/l10n/arb/app_es.arb index f499cb14..8d9a8fb9 100644 --- a/lib/l10n/arb/app_es.arb +++ b/lib/l10n/arb/app_es.arb @@ -2565,5 +2565,18 @@ "utilityFunctions": "Utility Functions", "@utilityFunctions": { "description": "Extension capability - utility functions" - } + }, + "setupModeSelectionTitle": "Elige tu modo", + "setupModeSelectionDescription": "¿Cómo te gustaría usar SpotiFLAC? Puedes cambiarlo más tarde en Ajustes.", + "setupModeDownloaderTitle": "Descargador", + "setupModeDownloaderFeature1": "Descarga pistas en calidad FLAC sin pérdida", + "setupModeDownloaderFeature2": "Guarda música en tu dispositivo para escuchar sin conexión", + "setupModeDownloaderFeature3": "Gestiona tu biblioteca de música local", + "setupModeStreamingTitle": "Streaming", + "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" } \ 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 5643ebe2..ccb341db 100644 --- a/lib/l10n/arb/app_es_ES.arb +++ b/lib/l10n/arb/app_es_ES.arb @@ -3868,5 +3868,18 @@ "trackConvertFailed": "Conversion failed", "@trackConvertFailed": { "description": "Snackbar when conversion fails" - } + }, + "setupModeSelectionTitle": "Elige tu modo", + "setupModeSelectionDescription": "¿Cómo te gustaría usar SpotiFLAC? Puedes cambiarlo más tarde en Ajustes.", + "setupModeDownloaderTitle": "Descargador", + "setupModeDownloaderFeature1": "Descarga pistas en calidad FLAC sin pérdida", + "setupModeDownloaderFeature2": "Guarda música en tu dispositivo para escuchar sin conexión", + "setupModeDownloaderFeature3": "Gestiona tu biblioteca de música local", + "setupModeStreamingTitle": "Streaming", + "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" } \ No newline at end of file diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index 41034185..351c4531 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -3868,5 +3868,18 @@ "trackConvertFailed": "Conversion failed", "@trackConvertFailed": { "description": "Snackbar when conversion fails" - } + }, + "setupModeSelectionTitle": "Choisissez votre mode", + "setupModeSelectionDescription": "Comment souhaitez-vous utiliser SpotiFLAC ? Vous pouvez toujours changer cela plus tard dans les Paramètres.", + "setupModeDownloaderTitle": "Téléchargeur", + "setupModeDownloaderFeature1": "Téléchargez des pistes en qualité FLAC sans perte", + "setupModeDownloaderFeature2": "Enregistrez de la musique sur votre appareil pour une écoute hors ligne", + "setupModeDownloaderFeature3": "Gérez votre bibliothèque musicale locale", + "setupModeStreamingTitle": "Streaming", + "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" } \ No newline at end of file diff --git a/lib/l10n/arb/app_hi.arb b/lib/l10n/arb/app_hi.arb index 71d38aab..7517340d 100644 --- a/lib/l10n/arb/app_hi.arb +++ b/lib/l10n/arb/app_hi.arb @@ -3868,5 +3868,18 @@ "trackConvertFailed": "Conversion failed", "@trackConvertFailed": { "description": "Snackbar when conversion fails" - } + }, + "setupModeSelectionTitle": "अपना मोड चुनें", + "setupModeSelectionDescription": "आप SpotiFLAC का उपयोग कैसे करना चाहेंगे? आप इसे बाद में सेटिंग्स में कभी भी बदल सकते हैं।", + "setupModeDownloaderTitle": "डाउनलोडर", + "setupModeDownloaderFeature1": "लॉसलेस FLAC गुणवत्ता में ट्रैक डाउनलोड करें", + "setupModeDownloaderFeature2": "ऑफ़लाइन सुनने के लिए संगीत अपने डिवाइस में सहेजें", + "setupModeDownloaderFeature3": "अपनी स्थानीय संगीत लाइब्रेरी प्रबंधित करें", + "setupModeStreamingTitle": "स्ट्रीमिंग", + "setupModeStreamingFeature1": "बिना डाउनलोड किए तुरंत ट्रैक स्ट्रीम करें", + "setupModeStreamingFeature2": "Smart Queue स्वचालित रूप से आपके लिए नया संगीत खोजता है", + "setupModeStreamingFeature3": "प्लेबैक नियंत्रण के साथ किसी भी ट्रैक को मांग पर चलाएं", + "setupModeChangeableLater": "आप सेटिंग्स में कभी भी मोड बदल सकते हैं।", + "settingsSmartQueueTitle": "Smart Queue", + "settingsSmartQueueSubtitle": "स्वचालित रूप से समान ट्रैक खोजें और अपनी कतार में जोड़ें" } \ No newline at end of file diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index b74ffdb1..c2d4b7d8 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -300,15 +300,47 @@ "@optionsSwitchBack": { "description": "Hint to switch back to built-in providers" }, - "optionsAutoFallback": "Auto Fallback", - "@optionsAutoFallback": { - "description": "Auto-retry with other services" - }, - "optionsAutoFallbackSubtitle": "Coba layanan lain jika unduhan gagal", - "@optionsAutoFallbackSubtitle": { - "description": "Subtitle for auto fallback" - }, - "optionsUseExtensionProviders": "Gunakan Provider Ekstensi", + "optionsAutoFallback": "Auto Fallback", + "@optionsAutoFallback": { + "description": "Auto-retry with other services" + }, + "optionsAutoFallbackSubtitle": "Coba layanan lain jika unduhan gagal", + "@optionsAutoFallbackSubtitle": { + "description": "Subtitle for auto fallback" + }, + "optionsAutoSkipUnavailableTracks": "Lewati Otomatis Lagu yang Tidak Tersedia", + "@optionsAutoSkipUnavailableTracks": { + "description": "Toggle to skip to the next queue track when current track stream resolution fails" + }, + "optionsAutoSkipUnavailableTracksSubtitleOn": "Otomatis lanjut ke lagu berikutnya di antrean jika stream lagu tidak bisa ditemukan.", + "@optionsAutoSkipUnavailableTracksSubtitleOn": { + "description": "Subtitle when auto skip on resolve failure is enabled" + }, + "optionsAutoSkipUnavailableTracksSubtitleOff": "Berhenti di lagu yang gagal dan tampilkan pesan error.", + "@optionsAutoSkipUnavailableTracksSubtitleOff": { + "description": "Subtitle when auto skip on resolve failure is disabled" + }, + "optionsInteractionMode": "Mode Interaksi", + "@optionsInteractionMode": { + "description": "Tap behavior mode for track lists" + }, + "modeDownloader": "Mode Downloader", + "@modeDownloader": { + "description": "Interaction mode where taps queue downloads" + }, + "modeDownloaderSubtitle": "Ketuk lagu untuk menambah ke antrean unduhan", + "@modeDownloaderSubtitle": { + "description": "Subtitle for downloader interaction mode" + }, + "modeStreaming": "Mode Streaming", + "@modeStreaming": { + "description": "Interaction mode where taps start playback" + }, + "modeStreamingSubtitle": "Ketuk lagu untuk langsung memutar", + "@modeStreamingSubtitle": { + "description": "Subtitle for streaming interaction mode" + }, + "optionsUseExtensionProviders": "Gunakan Provider Ekstensi", "@optionsUseExtensionProviders": { "description": "Enable extension download providers" }, @@ -1336,11 +1368,15 @@ } } }, - "errorNoTracksFound": "Tidak ada lagu ditemukan", - "@errorNoTracksFound": { - "description": "Error - search returned no results" - }, - "errorMissingExtensionSource": "Tidak dapat memuat {item}: sumber ekstensi tidak ada", + "errorNoTracksFound": "Tidak ada lagu ditemukan", + "@errorNoTracksFound": { + "description": "Error - search returned no results" + }, + "errorSeekNotSupported": "Menggeser posisi lagu tidak didukung untuk live stream ini", + "@errorSeekNotSupported": { + "description": "Error - seek disabled for live decrypted stream" + }, + "errorMissingExtensionSource": "Tidak dapat memuat {item}: sumber ekstensi tidak ada", "@errorMissingExtensionSource": { "description": "Error - extension source not available", "placeholders": { @@ -2013,16 +2049,25 @@ "@tracksHeader": { "description": "Section header for track list" }, - "downloadAllCount": "Unduh Semua ({count})", - "@downloadAllCount": { - "description": "Download all button with count", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "tracksCount": "{count, plural, =1{1 lagu} other{{count} lagu}}", + "downloadAllCount": "Unduh Semua ({count})", + "@downloadAllCount": { + "description": "Download all button with count", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "playAllCount": "Putar Semua ({count})", + "@playAllCount": { + "description": "Play all button with count", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "tracksCount": "{count, plural, =1{1 lagu} other{{count} lagu}}", "@tracksCount": { "description": "Track count display", "placeholders": { @@ -2927,14 +2972,22 @@ } } }, - "discographyDownload": "Download Discography", - "@discographyDownload": { - "description": "Button - download artist discography" - }, - "discographyDownloadAll": "Unduh Semua", - "@discographyDownloadAll": { - "description": "Option - download entire discography" - }, + "discographyDownload": "Download Discography", + "@discographyDownload": { + "description": "Button - download artist discography" + }, + "discographyPlay": "Putar Diskografi", + "@discographyPlay": { + "description": "Button - play artist discography" + }, + "discographyDownloadAll": "Unduh Semua", + "@discographyDownloadAll": { + "description": "Option - download entire discography" + }, + "discographyPlayAll": "Putar Semua", + "@discographyPlayAll": { + "description": "Option - play entire discography" + }, "discographyDownloadAllSubtitle": "{count} tracks from {albumCount} releases", "@discographyDownloadAllSubtitle": { "description": "Subtitle showing total tracks and albums", @@ -3012,10 +3065,14 @@ } } }, - "discographyDownloadSelected": "Download Selected", - "@discographyDownloadSelected": { - "description": "Button - download selected albums" - }, + "discographyDownloadSelected": "Download Selected", + "@discographyDownloadSelected": { + "description": "Button - download selected albums" + }, + "discographyPlaySelected": "Putar Terpilih", + "@discographyPlaySelected": { + "description": "Button - play selected albums" + }, "discographyAddedToQueue": "Added {count} tracks to queue", "@discographyAddedToQueue": { "description": "Snackbar - tracks added from discography", @@ -4132,5 +4189,172 @@ "collectionPlaylistChangeCover": "Ubah gambar sampul", "@collectionPlaylistChangeCover": {"description": "Bottom sheet action to pick a custom cover image for a playlist"}, "collectionPlaylistRemoveCover": "Hapus gambar sampul", - "@collectionPlaylistRemoveCover": {"description": "Bottom sheet action to remove custom cover image from a playlist"} + "@collectionPlaylistRemoveCover": {"description": "Bottom sheet action to remove custom cover image from a playlist"}, + "setupModeSelectionTitle": "Pilih Mode Anda", + "setupModeSelectionDescription": "Bagaimana Anda ingin menggunakan SpotiFLAC? Anda dapat mengubahnya nanti di Pengaturan.", + "setupModeDownloaderTitle": "Pengunduh", + "setupModeDownloaderFeature1": "Unduh trek dalam kualitas FLAC lossless", + "setupModeDownloaderFeature2": "Simpan musik ke perangkat Anda untuk mendengarkan offline", + "setupModeDownloaderFeature3": "Kelola perpustakaan musik lokal Anda", + "setupModeStreamingTitle": "Streaming", + "setupModeStreamingFeature1": "Streaming trek secara instan tanpa mengunduh", + "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", + "placeholders": { + "count": {"type": "int"} + } + }, + "selectionShareNoFiles": "Tidak ada file yang dapat dibagikan", + "@selectionShareNoFiles": {"description": "Snackbar when no selected files exist on disk"}, + "selectionConvertCount": "Konversi {count} {count, plural, =1{trek} other{trek}}", + "@selectionConvertCount": { + "description": "Convert button text with count in selection mode", + "placeholders": { + "count": {"type": "int"} + } + }, + "selectionConvertNoConvertible": "Tidak ada trek yang dapat dikonversi dipilih", + "@selectionConvertNoConvertible": {"description": "Snackbar when no selected tracks support conversion"}, + "selectionBatchConvertConfirmTitle": "Konversi Massal", + "@selectionBatchConvertConfirmTitle": {"description": "Confirmation dialog title for batch conversion"}, + "selectionBatchConvertConfirmMessage": "Konversi {count} {count, plural, =1{trek} other{trek}} ke {format} pada {bitrate}?\n\nFile asli akan dihapus setelah konversi.", + "@selectionBatchConvertConfirmMessage": { + "description": "Confirmation dialog message for batch conversion", + "placeholders": { + "count": {"type": "int"}, + "format": {"type": "String"}, + "bitrate": {"type": "String"} + } + }, + "selectionBatchConvertProgress": "Mengonversi {current} dari {total}...", + "@selectionBatchConvertProgress": { + "description": "Snackbar during batch conversion progress", + "placeholders": { + "current": {"type": "int"}, + "total": {"type": "int"} + } + }, + "selectionBatchConvertSuccess": "Berhasil mengonversi {success} dari {total} trek ke {format}", + "@selectionBatchConvertSuccess": { + "description": "Snackbar after batch conversion completes", + "placeholders": { + "success": {"type": "int"}, + "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 cef5e33a..12f1259b 100644 --- a/lib/l10n/arb/app_ja.arb +++ b/lib/l10n/arb/app_ja.arb @@ -3868,5 +3868,18 @@ "trackConvertFailed": "Conversion failed", "@trackConvertFailed": { "description": "Snackbar when conversion fails" - } + }, + "setupModeSelectionTitle": "モードを選択", + "setupModeSelectionDescription": "SpotiFLACをどのように使いますか?この設定は後からいつでも変更できます。", + "setupModeDownloaderTitle": "ダウンローダー", + "setupModeDownloaderFeature1": "ロスレスFLAC品質でトラックをダウンロード", + "setupModeDownloaderFeature2": "オフライン再生用に音楽をデバイスに保存", + "setupModeDownloaderFeature3": "ローカル音楽ライブラリを管理", + "setupModeStreamingTitle": "ストリーミング", + "setupModeStreamingFeature1": "ダウンロードせずにトラックを即座にストリーミング", + "setupModeStreamingFeature2": "Smart Queueが自動的に新しい音楽を見つけます", + "setupModeStreamingFeature3": "再生コントロールで任意のトラックをオンデマンド再生", + "setupModeChangeableLater": "設定からいつでもモードを切り替えられます。", + "settingsSmartQueueTitle": "Smart Queue", + "settingsSmartQueueSubtitle": "類似トラックを自動的に検出してキューに追加" } \ No newline at end of file diff --git a/lib/l10n/arb/app_ko.arb b/lib/l10n/arb/app_ko.arb index 5627cd07..a350b7a2 100644 --- a/lib/l10n/arb/app_ko.arb +++ b/lib/l10n/arb/app_ko.arb @@ -3868,5 +3868,18 @@ "trackConvertFailed": "Conversion failed", "@trackConvertFailed": { "description": "Snackbar when conversion fails" - } + }, + "setupModeSelectionTitle": "모드 선택", + "setupModeSelectionDescription": "SpotiFLAC을 어떻게 사용하시겠습니까? 나중에 설정에서 언제든지 변경할 수 있습니다.", + "setupModeDownloaderTitle": "다운로더", + "setupModeDownloaderFeature1": "무손실 FLAC 품질로 트랙 다운로드", + "setupModeDownloaderFeature2": "오프라인 감상을 위해 기기에 음악 저장", + "setupModeDownloaderFeature3": "로컬 음악 라이브러리 관리", + "setupModeStreamingTitle": "스트리밍", + "setupModeStreamingFeature1": "다운로드 없이 트랙을 즉시 스트리밍", + "setupModeStreamingFeature2": "Smart Queue가 자동으로 새로운 음악을 발견합니다", + "setupModeStreamingFeature3": "재생 컨트롤로 원하는 트랙을 온디맨드 재생", + "setupModeChangeableLater": "설정에서 언제든지 모드를 전환할 수 있습니다.", + "settingsSmartQueueTitle": "Smart Queue", + "settingsSmartQueueSubtitle": "유사한 트랙을 자동으로 검색하여 대기열에 추가" } \ No newline at end of file diff --git a/lib/l10n/arb/app_nl.arb b/lib/l10n/arb/app_nl.arb index e331bf27..34ceb20a 100644 --- a/lib/l10n/arb/app_nl.arb +++ b/lib/l10n/arb/app_nl.arb @@ -3868,5 +3868,18 @@ "trackConvertFailed": "Conversion failed", "@trackConvertFailed": { "description": "Snackbar when conversion fails" - } + }, + "setupModeSelectionTitle": "Kies je modus", + "setupModeSelectionDescription": "Hoe wil je SpotiFLAC gebruiken? Je kunt dit later altijd wijzigen in Instellingen.", + "setupModeDownloaderTitle": "Downloader", + "setupModeDownloaderFeature1": "Download nummers in lossless FLAC-kwaliteit", + "setupModeDownloaderFeature2": "Sla muziek op je apparaat op om offline te luisteren", + "setupModeDownloaderFeature3": "Beheer je lokale muziekbibliotheek", + "setupModeStreamingTitle": "Streaming", + "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" } \ No newline at end of file diff --git a/lib/l10n/arb/app_pt.arb b/lib/l10n/arb/app_pt.arb index 654f6c08..391b81c3 100644 --- a/lib/l10n/arb/app_pt.arb +++ b/lib/l10n/arb/app_pt.arb @@ -2565,5 +2565,18 @@ "utilityFunctions": "Utility Functions", "@utilityFunctions": { "description": "Extension capability - utility functions" - } + }, + "setupModeSelectionTitle": "Escolha seu modo", + "setupModeSelectionDescription": "Como você gostaria de usar o SpotiFLAC? Você pode alterar isso depois nas Configurações.", + "setupModeDownloaderTitle": "Downloader", + "setupModeDownloaderFeature1": "Baixe faixas em qualidade FLAC lossless", + "setupModeDownloaderFeature2": "Salve músicas no seu dispositivo para ouvir offline", + "setupModeDownloaderFeature3": "Gerencie sua biblioteca de músicas local", + "setupModeStreamingTitle": "Streaming", + "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" } \ 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 75810ad5..c3e621f9 100644 --- a/lib/l10n/arb/app_pt_PT.arb +++ b/lib/l10n/arb/app_pt_PT.arb @@ -3868,5 +3868,18 @@ "trackConvertFailed": "Conversion failed", "@trackConvertFailed": { "description": "Snackbar when conversion fails" - } + }, + "setupModeSelectionTitle": "Escolha o seu modo", + "setupModeSelectionDescription": "Como gostaria de utilizar o SpotiFLAC? Pode alterar isto mais tarde nas Definições.", + "setupModeDownloaderTitle": "Transferência", + "setupModeDownloaderFeature1": "Transfira faixas em qualidade FLAC sem perdas", + "setupModeDownloaderFeature2": "Guarde música no seu dispositivo para ouvir offline", + "setupModeDownloaderFeature3": "Faça a gestão da sua biblioteca de música local", + "setupModeStreamingTitle": "Streaming", + "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" } \ No newline at end of file diff --git a/lib/l10n/arb/app_ru.arb b/lib/l10n/arb/app_ru.arb index f29925ea..818424cf 100644 --- a/lib/l10n/arb/app_ru.arb +++ b/lib/l10n/arb/app_ru.arb @@ -3868,5 +3868,18 @@ "trackConvertFailed": "Conversion failed", "@trackConvertFailed": { "description": "Snackbar when conversion fails" - } + }, + "setupModeSelectionTitle": "Выберите режим", + "setupModeSelectionDescription": "Как вы хотите использовать SpotiFLAC? Вы всегда можете изменить это позже в Настройках.", + "setupModeDownloaderTitle": "Загрузчик", + "setupModeDownloaderFeature1": "Скачивайте треки в качестве FLAC без потерь", + "setupModeDownloaderFeature2": "Сохраняйте музыку на устройство для прослушивания офлайн", + "setupModeDownloaderFeature3": "Управляйте своей локальной музыкальной библиотекой", + "setupModeStreamingTitle": "Стриминг", + "setupModeStreamingFeature1": "Слушайте треки мгновенно без скачивания", + "setupModeStreamingFeature2": "Smart Queue автоматически подбирает новую музыку для вас", + "setupModeStreamingFeature3": "Воспроизводите любой трек по запросу с элементами управления", + "setupModeChangeableLater": "Вы можете переключаться между режимами в любое время в Настройках.", + "settingsSmartQueueTitle": "Smart Queue", + "settingsSmartQueueSubtitle": "Автоматически находите и добавляйте похожие треки в очередь воспроизведения" } \ No newline at end of file diff --git a/lib/l10n/arb/app_tr.arb b/lib/l10n/arb/app_tr.arb index e51150d8..0b736169 100644 --- a/lib/l10n/arb/app_tr.arb +++ b/lib/l10n/arb/app_tr.arb @@ -3868,5 +3868,18 @@ "trackConvertFailed": "Conversion failed", "@trackConvertFailed": { "description": "Snackbar when conversion fails" - } + }, + "setupModeSelectionTitle": "Modunuzu Seçin", + "setupModeSelectionDescription": "SpotiFLAC'ı nasıl kullanmak istersiniz? Bunu daha sonra Ayarlar'dan değiştirebilirsiniz.", + "setupModeDownloaderTitle": "İndirici", + "setupModeDownloaderFeature1": "Kayıpsız FLAC kalitesinde parça indirin", + "setupModeDownloaderFeature2": "Çevrimdışı dinlemek için müziği cihazınıza kaydedin", + "setupModeDownloaderFeature3": "Yerel müzik kütüphanenizi yönetin", + "setupModeStreamingTitle": "Yayın Akışı", + "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" } \ No newline at end of file diff --git a/lib/l10n/arb/app_zh.arb b/lib/l10n/arb/app_zh.arb index aad9e509..bc4c4b4e 100644 --- a/lib/l10n/arb/app_zh.arb +++ b/lib/l10n/arb/app_zh.arb @@ -2565,5 +2565,18 @@ "utilityFunctions": "Utility Functions", "@utilityFunctions": { "description": "Extension capability - utility functions" - } + }, + "setupModeSelectionTitle": "选择您的模式", + "setupModeSelectionDescription": "您想如何使用 SpotiFLAC?您可以稍后在设置中随时更改。", + "setupModeDownloaderTitle": "下载器", + "setupModeDownloaderFeature1": "以无损 FLAC 品质下载曲目", + "setupModeDownloaderFeature2": "将音乐保存到设备以供离线收听", + "setupModeDownloaderFeature3": "管理您的本地音乐库", + "setupModeStreamingTitle": "流媒体", + "setupModeStreamingFeature1": "无需下载即可即时播放曲目", + "setupModeStreamingFeature2": "Smart Queue 自动为您发现新音乐", + "setupModeStreamingFeature3": "通过播放控件随时点播任意曲目", + "setupModeChangeableLater": "您可以随时在设置中切换模式。", + "settingsSmartQueueTitle": "Smart Queue", + "settingsSmartQueueSubtitle": "自动发现并将相似曲目添加到您的队列中" } \ 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 f55e8b80..6f34877c 100644 --- a/lib/l10n/arb/app_zh_CN.arb +++ b/lib/l10n/arb/app_zh_CN.arb @@ -3868,5 +3868,18 @@ "trackConvertFailed": "Conversion failed", "@trackConvertFailed": { "description": "Snackbar when conversion fails" - } + }, + "setupModeSelectionTitle": "选择您的模式", + "setupModeSelectionDescription": "您想如何使用 SpotiFLAC?您可以稍后在设置中随时更改。", + "setupModeDownloaderTitle": "下载器", + "setupModeDownloaderFeature1": "以无损 FLAC 品质下载曲目", + "setupModeDownloaderFeature2": "将音乐保存到设备以供离线收听", + "setupModeDownloaderFeature3": "管理您的本地音乐库", + "setupModeStreamingTitle": "流媒体", + "setupModeStreamingFeature1": "无需下载即可即时播放曲目", + "setupModeStreamingFeature2": "Smart Queue 自动为您发现新音乐", + "setupModeStreamingFeature3": "通过播放控件随时点播任意曲目", + "setupModeChangeableLater": "您可以随时在设置中切换模式。", + "settingsSmartQueueTitle": "Smart Queue", + "settingsSmartQueueSubtitle": "自动发现并将相似曲目添加到您的队列中" } \ 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 954569ce..91fb2903 100644 --- a/lib/l10n/arb/app_zh_TW.arb +++ b/lib/l10n/arb/app_zh_TW.arb @@ -3868,5 +3868,18 @@ "trackConvertFailed": "Conversion failed", "@trackConvertFailed": { "description": "Snackbar when conversion fails" - } + }, + "setupModeSelectionTitle": "選擇您的模式", + "setupModeSelectionDescription": "您想如何使用 SpotiFLAC?您可以稍後在設定中隨時變更。", + "setupModeDownloaderTitle": "下載器", + "setupModeDownloaderFeature1": "以無損 FLAC 品質下載曲目", + "setupModeDownloaderFeature2": "將音樂儲存到裝置以供離線收聽", + "setupModeDownloaderFeature3": "管理您的本機音樂庫", + "setupModeStreamingTitle": "串流", + "setupModeStreamingFeature1": "無需下載即可即時串流曲目", + "setupModeStreamingFeature2": "Smart Queue 自動為您探索新音樂", + "setupModeStreamingFeature3": "透過播放控制項隨時點播任意曲目", + "setupModeChangeableLater": "您可以隨時在設定中切換模式。", + "settingsSmartQueueTitle": "Smart Queue", + "settingsSmartQueueSubtitle": "自動探索並將相似曲目新增到您的佇列中" } \ No newline at end of file diff --git a/lib/models/playback_item.dart b/lib/models/playback_item.dart new file mode 100644 index 00000000..877674d0 --- /dev/null +++ b/lib/models/playback_item.dart @@ -0,0 +1,91 @@ +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 d499bf01..4b4a10b3 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -11,6 +11,9 @@ class AppSettings { final String storageMode; // 'app' or 'saf' final String downloadTreeUri; // SAF persistable tree URI final bool autoFallback; + final bool autoSkipUnavailableTracks; + final bool smartQueueEnabled; // Enable smart curated autoplay queue + final bool embedMetadata; // Master switch for metadata/cover/lyrics embedding final bool embedLyrics; final bool maxQualityCover; final bool isFirstLaunch; @@ -76,6 +79,10 @@ class AppSettings { final String musixmatchLanguage; // Optional ISO language code for Musixmatch localized lyrics + // Version upgrade tracking + final String + lastSeenVersion; // Last app version the user has acknowledged (e.g. '3.7.0') + const AppSettings({ this.defaultService = 'tidal', this.audioQuality = 'LOSSLESS', @@ -84,6 +91,9 @@ class AppSettings { this.storageMode = 'app', this.downloadTreeUri = '', this.autoFallback = true, + this.autoSkipUnavailableTracks = true, + this.smartQueueEnabled = true, + this.embedMetadata = true, this.embedLyrics = true, this.maxQualityCover = true, this.isFirstLaunch = true, @@ -127,6 +137,7 @@ class AppSettings { // Lyrics providers default order this.lyricsProviders = const [ 'lrclib', + 'spotify_api', 'musixmatch', 'netease', 'apple_music', @@ -136,6 +147,8 @@ class AppSettings { this.lyricsIncludeRomanizationNetease = false, this.lyricsMultiPersonWordByWord = false, this.musixmatchLanguage = '', + // Version upgrade tracking + this.lastSeenVersion = '', }); AppSettings copyWith({ @@ -146,6 +159,9 @@ class AppSettings { String? storageMode, String? downloadTreeUri, bool? autoFallback, + bool? autoSkipUnavailableTracks, + bool? smartQueueEnabled, + bool? embedMetadata, bool? embedLyrics, bool? maxQualityCover, bool? isFirstLaunch, @@ -193,6 +209,8 @@ class AppSettings { bool? lyricsIncludeRomanizationNetease, bool? lyricsMultiPersonWordByWord, String? musixmatchLanguage, + // Version upgrade tracking + String? lastSeenVersion, }) { return AppSettings( defaultService: defaultService ?? this.defaultService, @@ -202,6 +220,10 @@ class AppSettings { storageMode: storageMode ?? this.storageMode, downloadTreeUri: downloadTreeUri ?? this.downloadTreeUri, autoFallback: autoFallback ?? this.autoFallback, + autoSkipUnavailableTracks: + autoSkipUnavailableTracks ?? this.autoSkipUnavailableTracks, + smartQueueEnabled: smartQueueEnabled ?? this.smartQueueEnabled, + embedMetadata: embedMetadata ?? this.embedMetadata, embedLyrics: embedLyrics ?? this.embedLyrics, maxQualityCover: maxQualityCover ?? this.maxQualityCover, isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch, @@ -264,6 +286,8 @@ class AppSettings { lyricsMultiPersonWordByWord: lyricsMultiPersonWordByWord ?? this.lyricsMultiPersonWordByWord, musixmatchLanguage: musixmatchLanguage ?? this.musixmatchLanguage, + // Version upgrade tracking + lastSeenVersion: lastSeenVersion ?? this.lastSeenVersion, ); } diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index fd02b464..853d34aa 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -14,6 +14,9 @@ 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, + smartQueueEnabled: json['smartQueueEnabled'] as bool? ?? true, + embedMetadata: json['embedMetadata'] as bool? ?? true, embedLyrics: json['embedLyrics'] as bool? ?? true, maxQualityCover: json['maxQualityCover'] as bool? ?? true, isFirstLaunch: json['isFirstLaunch'] as bool? ?? true, @@ -50,10 +53,7 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( autoExportFailedDownloads: json['autoExportFailedDownloads'] as bool? ?? false, downloadNetworkMode: json['downloadNetworkMode'] as String? ?? 'any', - networkCompatibilityMode: - json['networkCompatibilityMode'] as bool? ?? - json['songLinkCompatibilityMode'] as bool? ?? - false, + networkCompatibilityMode: json['networkCompatibilityMode'] as bool? ?? false, songLinkRegion: json['songLinkRegion'] as String? ?? 'US', localLibraryEnabled: json['localLibraryEnabled'] as bool? ?? false, localLibraryPath: json['localLibraryPath'] as String? ?? '', @@ -64,7 +64,14 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( (json['lyricsProviders'] as List?) ?.map((e) => e as String) .toList() ?? - const ['lrclib', 'musixmatch', 'netease', 'apple_music', 'qqmusic'], + const [ + 'lrclib', + 'spotify_api', + 'musixmatch', + 'netease', + 'apple_music', + 'qqmusic', + ], lyricsIncludeTranslationNetease: json['lyricsIncludeTranslationNetease'] as bool? ?? false, lyricsIncludeRomanizationNetease: @@ -72,6 +79,7 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( lyricsMultiPersonWordByWord: json['lyricsMultiPersonWordByWord'] as bool? ?? false, musixmatchLanguage: json['musixmatchLanguage'] as String? ?? '', + lastSeenVersion: json['lastSeenVersion'] as String? ?? '', ); Map _$AppSettingsToJson( @@ -84,6 +92,9 @@ Map _$AppSettingsToJson( 'storageMode': instance.storageMode, 'downloadTreeUri': instance.downloadTreeUri, 'autoFallback': instance.autoFallback, + 'autoSkipUnavailableTracks': instance.autoSkipUnavailableTracks, + 'smartQueueEnabled': instance.smartQueueEnabled, + 'embedMetadata': instance.embedMetadata, 'embedLyrics': instance.embedLyrics, 'maxQualityCover': instance.maxQualityCover, 'isFirstLaunch': instance.isFirstLaunch, @@ -128,4 +139,5 @@ Map _$AppSettingsToJson( 'lyricsIncludeRomanizationNetease': instance.lyricsIncludeRomanizationNetease, 'lyricsMultiPersonWordByWord': instance.lyricsMultiPersonWordByWord, 'musixmatchLanguage': instance.musixmatchLanguage, + 'lastSeenVersion': instance.lastSeenVersion, }; diff --git a/lib/models/track.dart b/lib/models/track.dart index d2ab69fe..244a7a65 100644 --- a/lib/models/track.dart +++ b/lib/models/track.dart @@ -9,6 +9,8 @@ class Track { final String artistName; final String albumName; final String? albumArtist; + final String? artistId; + final String? albumId; final String? coverUrl; final String? isrc; final int duration; @@ -27,6 +29,8 @@ class Track { required this.artistName, required this.albumName, this.albumArtist, + this.artistId, + this.albumId, this.coverUrl, this.isrc, required this.duration, diff --git a/lib/models/track.g.dart b/lib/models/track.g.dart index 1d2277b7..f640cfe7 100644 --- a/lib/models/track.g.dart +++ b/lib/models/track.g.dart @@ -12,6 +12,8 @@ Track _$TrackFromJson(Map json) => Track( artistName: json['artistName'] as String, albumName: json['albumName'] as String, albumArtist: json['albumArtist'] as String?, + artistId: json['artistId'] as String?, + albumId: json['albumId'] as String?, coverUrl: json['coverUrl'] as String?, isrc: json['isrc'] as String?, duration: (json['duration'] as num).toInt(), @@ -35,6 +37,8 @@ Map _$TrackToJson(Track instance) => { 'artistName': instance.artistName, 'albumName': instance.albumName, 'albumArtist': instance.albumArtist, + 'artistId': instance.artistId, + 'albumId': instance.albumId, 'coverUrl': instance.coverUrl, 'isrc': instance.isrc, 'duration': instance.duration, diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 8c3c37a0..9f61f4e2 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -702,10 +702,13 @@ class _ProgressUpdate { class DownloadQueueNotifier extends Notifier { Timer? _progressTimer; + Timer? _progressStreamBootstrapTimer; Timer? _queuePersistDebounce; + StreamSubscription>? _progressStreamSub; int _downloadCount = 0; static const _cleanupInterval = 50; static const _progressPollingInterval = Duration(milliseconds: 800); + static const _idleProgressPollEveryTicks = 3; static const _queueSchedulingInterval = Duration(milliseconds: 250); static const _queuePersistDebounceDuration = Duration(milliseconds: 350); static const _bytesUiStep = 104857; // ~0.1 MiB, matches one-decimal MB UI. @@ -718,6 +721,9 @@ class DownloadQueueNotifier extends Notifier { final Set _ensuredDirs = {}; int _progressPollingErrorCount = 0; bool _isProgressPollingInFlight = false; + int _idleProgressPollTick = 0; + bool _hasReceivedProgressStreamEvent = false; + bool _usingProgressStream = false; String? _lastServiceTrackName; String? _lastServiceArtistName; int _lastServicePercent = -1; @@ -788,7 +794,11 @@ class DownloadQueueNotifier extends Notifier { ref.onDispose(() { _progressTimer?.cancel(); + _progressStreamBootstrapTimer?.cancel(); + _progressStreamSub?.cancel(); _progressTimer = null; + _progressStreamBootstrapTimer = null; + _progressStreamSub = null; if (_queuePersistDebounce?.isActive == true) { _queuePersistDebounce?.cancel(); unawaited(_flushQueueToStorage()); @@ -894,213 +904,105 @@ class DownloadQueueNotifier extends Notifier { } void _startMultiProgressPolling() { + _progressTimer?.cancel(); + _progressStreamBootstrapTimer?.cancel(); + _progressStreamBootstrapTimer = null; + _progressStreamSub?.cancel(); + _progressStreamSub = null; + _hasReceivedProgressStreamEvent = false; + _usingProgressStream = false; + _idleProgressPollTick = 0; + + if (Platform.isAndroid || Platform.isIOS) { + _attachDownloadProgressStream(); + return; + } + + _startMultiProgressPollingTimer(); + } + + void _attachDownloadProgressStream() { + _progressStreamSub = PlatformBridge.downloadProgressStream().listen( + (allProgress) { + _hasReceivedProgressStreamEvent = true; + _usingProgressStream = true; + _progressStreamBootstrapTimer?.cancel(); + _progressStreamBootstrapTimer = null; + if (_isProgressPollingInFlight) return; + _isProgressPollingInFlight = true; + try { + _processAllDownloadProgress(allProgress); + _progressPollingErrorCount = 0; + } catch (e) { + _progressPollingErrorCount++; + if (_progressPollingErrorCount <= 3) { + _log.w('Progress stream processing failed: $e'); + } + } finally { + _isProgressPollingInFlight = false; + } + }, + onError: (Object error, StackTrace stackTrace) { + if (_usingProgressStream) { + _log.w( + 'Download progress stream failed, fallback to polling: $error', + ); + } + _progressStreamSub?.cancel(); + _progressStreamSub = null; + _usingProgressStream = false; + _progressStreamBootstrapTimer?.cancel(); + _progressStreamBootstrapTimer = null; + _startMultiProgressPollingTimer(); + }, + cancelOnError: false, + ); + + _progressStreamBootstrapTimer = Timer(const Duration(seconds: 3), () { + if (_hasReceivedProgressStreamEvent) { + return; + } + _log.w('Download progress stream timeout, fallback to polling'); + _progressStreamSub?.cancel(); + _progressStreamSub = null; + _usingProgressStream = false; + _startMultiProgressPollingTimer(); + }); + } + + void _startMultiProgressPollingTimer() { _progressTimer?.cancel(); _progressTimer = Timer.periodic(_progressPollingInterval, (timer) async { if (_isProgressPollingInFlight) return; _isProgressPollingInFlight = true; try { - final allProgress = await PlatformBridge.getAllDownloadProgress(); - final items = allProgress['items'] as Map? ?? {}; final currentItems = state.items; - final itemsById = {}; - final itemIndexById = {}; - int queuedCount = 0; - int downloadingCount = 0; - DownloadItem? firstDownloading; - for (int i = 0; i < currentItems.length; i++) { - final item = currentItems[i]; - itemsById[item.id] = item; - itemIndexById[item.id] = i; - if (item.status == DownloadStatus.downloading) { - downloadingCount++; - firstDownloading ??= item; - } - if (item.status == DownloadStatus.queued || - item.status == DownloadStatus.downloading) { - queuedCount++; - } - } - final progressUpdates = {}; + final hasQueuedItems = currentItems.any( + (item) => item.status == DownloadStatus.queued, + ); + final hasActiveItems = currentItems.any( + (item) => + item.status == DownloadStatus.downloading || + item.status == DownloadStatus.finalizing, + ); - bool hasFinalizingItem = false; - String? finalizingTrackName; - String? finalizingArtistName; - - for (final entry in items.entries) { - final itemId = entry.key; - final localItem = itemsById[itemId]; - if (localItem == null) { - continue; - } - if (localItem.status == DownloadStatus.skipped) { - PlatformBridge.clearItemProgress(itemId).catchError((_) {}); - continue; - } - if (localItem.status == DownloadStatus.completed || - localItem.status == DownloadStatus.failed) { - continue; - } - final itemProgress = entry.value as Map; - final bytesReceived = itemProgress['bytes_received'] as int? ?? 0; - final bytesTotal = itemProgress['bytes_total'] as int? ?? 0; - final speedMBps = - (itemProgress['speed_mbps'] as num?)?.toDouble() ?? 0.0; - final isDownloading = - itemProgress['is_downloading'] as bool? ?? false; - final status = itemProgress['status'] as String? ?? 'downloading'; - - if (status == 'finalizing' && bytesTotal > 0) { - progressUpdates[itemId] = const _ProgressUpdate( - status: DownloadStatus.finalizing, - progress: 1.0, - ); - hasFinalizingItem = true; - finalizingTrackName = localItem.track.name; - finalizingArtistName = localItem.track.artistName; - continue; + if (!hasActiveItems) { + if (state.isPaused || !hasQueuedItems) { + _idleProgressPollTick = 0; + return; } - final progressFromBackend = - (itemProgress['progress'] as num?)?.toDouble() ?? 0.0; - - if (isDownloading) { - double percentage = 0.0; - if (bytesTotal > 0) { - percentage = bytesReceived / bytesTotal; - } else { - percentage = progressFromBackend; - } - final normalizedProgress = _normalizeProgressForUi(percentage); - final normalizedSpeed = _normalizeSpeedForUi(speedMBps); - final normalizedBytes = _normalizeBytesForUi(bytesReceived); - - progressUpdates[itemId] = _ProgressUpdate( - status: DownloadStatus.downloading, - progress: normalizedProgress, - speedMBps: normalizedSpeed, - bytesReceived: normalizedBytes, - ); - - if (LogBuffer.loggingEnabled) { - final mbReceived = bytesReceived / (1024 * 1024); - final mbTotal = bytesTotal / (1024 * 1024); - if (bytesTotal > 0) { - _log.d( - 'Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (${mbReceived.toStringAsFixed(2)}/${mbTotal.toStringAsFixed(2)} MB) @ ${speedMBps.toStringAsFixed(2)} MB/s', - ); - } else { - _log.d( - 'Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (DASH segments/unknown size) @ ${speedMBps.toStringAsFixed(2)} MB/s', - ); - } - } + _idleProgressPollTick = + (_idleProgressPollTick + 1) % _idleProgressPollEveryTicks; + if (_idleProgressPollTick != 0) { + return; } + } else { + _idleProgressPollTick = 0; } - if (progressUpdates.isNotEmpty) { - var updatedItems = currentItems; - bool changed = false; - - for (final entry in progressUpdates.entries) { - final index = itemIndexById[entry.key]; - if (index == null) continue; - final current = updatedItems[index]; - if (current.status == DownloadStatus.skipped || - current.status == DownloadStatus.completed || - current.status == DownloadStatus.failed) { - continue; - } - final update = entry.value; - final next = current.copyWith( - status: update.status, - progress: update.progress, - speedMBps: update.speedMBps ?? current.speedMBps, - bytesReceived: update.bytesReceived ?? current.bytesReceived, - ); - if (current.status != next.status || - current.progress != next.progress || - current.speedMBps != next.speedMBps || - current.bytesReceived != next.bytesReceived) { - if (!changed) { - updatedItems = List.from(updatedItems); - changed = true; - } - updatedItems[index] = next; - } - } - - if (changed) { - state = state.copyWith(items: updatedItems); - } - } - - if (hasFinalizingItem && finalizingTrackName != null) { - final safeArtistName = finalizingArtistName ?? ''; - if (finalizingTrackName != _lastFinalizingTrackName || - safeArtistName != _lastFinalizingArtistName) { - _notificationService.showDownloadFinalizing( - trackName: finalizingTrackName, - artistName: safeArtistName, - ); - _lastFinalizingTrackName = finalizingTrackName; - _lastFinalizingArtistName = safeArtistName; - } - return; - } - _lastFinalizingTrackName = null; - _lastFinalizingArtistName = null; - - if (items.isNotEmpty) { - final firstEntry = items.entries.first; - final firstProgress = firstEntry.value as Map; - final bytesReceived = firstProgress['bytes_received'] as int? ?? 0; - final bytesTotal = firstProgress['bytes_total'] as int? ?? 0; - - if (downloadingCount > 0 && firstDownloading != null) { - final trackName = downloadingCount == 1 - ? firstDownloading.track.name - : '$downloadingCount downloads'; - final artistName = downloadingCount == 1 - ? firstDownloading.track.artistName - : 'Downloading...'; - - int notifProgress = bytesReceived; - int notifTotal = bytesTotal; - - if (bytesTotal <= 0) { - final progressPercent = - (firstProgress['progress'] as num?)?.toDouble() ?? 0.0; - notifProgress = (progressPercent * 100).toInt(); - notifTotal = 100; - } - - final safeNotifTotal = notifTotal > 0 ? notifTotal : 1; - if (_shouldUpdateProgressNotification( - trackName: trackName, - artistName: artistName, - progress: notifProgress, - total: safeNotifTotal, - queueCount: queuedCount, - )) { - _notificationService.showDownloadProgress( - trackName: trackName, - artistName: artistName, - progress: notifProgress, - total: safeNotifTotal, - ); - } - - if (Platform.isAndroid) { - _maybeUpdateAndroidDownloadService( - trackName: firstDownloading.track.name, - artistName: firstDownloading.track.artistName, - progress: notifProgress, - total: safeNotifTotal, - queueCount: queuedCount, - ); - } - } - } + final allProgress = await PlatformBridge.getAllDownloadProgress(); + _processAllDownloadProgress(allProgress); _progressPollingErrorCount = 0; } catch (e) { _progressPollingErrorCount++; @@ -1113,6 +1015,221 @@ class DownloadQueueNotifier extends Notifier { }); } + void _processAllDownloadProgress(Map allProgress) { + final rawItems = allProgress['items']; + final items = rawItems is Map + ? rawItems.map((key, value) => MapEntry(key.toString(), value)) + : const {}; + final currentItems = state.items; + final itemsById = {}; + final itemIndexById = {}; + int queuedCount = 0; + int downloadingCount = 0; + DownloadItem? firstDownloading; + for (int i = 0; i < currentItems.length; i++) { + final item = currentItems[i]; + itemsById[item.id] = item; + itemIndexById[item.id] = i; + if (item.status == DownloadStatus.downloading) { + downloadingCount++; + firstDownloading ??= item; + } + if (item.status == DownloadStatus.queued || + item.status == DownloadStatus.downloading) { + queuedCount++; + } + } + final progressUpdates = {}; + + bool hasFinalizingItem = false; + String? finalizingTrackName; + String? finalizingArtistName; + + for (final entry in items.entries) { + final itemId = entry.key; + final localItem = itemsById[itemId]; + if (localItem == null) { + continue; + } + if (localItem.status == DownloadStatus.skipped) { + PlatformBridge.clearItemProgress(itemId).catchError((_) {}); + continue; + } + if (localItem.status == DownloadStatus.completed || + localItem.status == DownloadStatus.failed) { + continue; + } + final rawItemProgress = entry.value; + if (rawItemProgress is! Map) { + continue; + } + final itemProgress = Map.from(rawItemProgress); + final bytesReceived = + (itemProgress['bytes_received'] as num?)?.toInt() ?? 0; + final bytesTotal = (itemProgress['bytes_total'] as num?)?.toInt() ?? 0; + final speedMBps = (itemProgress['speed_mbps'] as num?)?.toDouble() ?? 0.0; + final isDownloading = itemProgress['is_downloading'] as bool? ?? false; + final status = itemProgress['status'] as String? ?? 'downloading'; + + if (status == 'finalizing' && bytesTotal > 0) { + progressUpdates[itemId] = const _ProgressUpdate( + status: DownloadStatus.finalizing, + progress: 1.0, + ); + hasFinalizingItem = true; + finalizingTrackName = localItem.track.name; + finalizingArtistName = localItem.track.artistName; + continue; + } + + final progressFromBackend = + (itemProgress['progress'] as num?)?.toDouble() ?? 0.0; + + if (isDownloading) { + double percentage = 0.0; + if (bytesTotal > 0) { + percentage = bytesReceived / bytesTotal; + } else { + percentage = progressFromBackend; + } + final normalizedProgress = _normalizeProgressForUi(percentage); + final normalizedSpeed = _normalizeSpeedForUi(speedMBps); + final normalizedBytes = _normalizeBytesForUi(bytesReceived); + + progressUpdates[itemId] = _ProgressUpdate( + status: DownloadStatus.downloading, + progress: normalizedProgress, + speedMBps: normalizedSpeed, + bytesReceived: normalizedBytes, + ); + + if (LogBuffer.loggingEnabled) { + final mbReceived = bytesReceived / (1024 * 1024); + final mbTotal = bytesTotal / (1024 * 1024); + if (bytesTotal > 0) { + _log.d( + 'Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (${mbReceived.toStringAsFixed(2)}/${mbTotal.toStringAsFixed(2)} MB) @ ${speedMBps.toStringAsFixed(2)} MB/s', + ); + } else { + _log.d( + 'Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (DASH segments/unknown size) @ ${speedMBps.toStringAsFixed(2)} MB/s', + ); + } + } + } + } + + if (progressUpdates.isNotEmpty) { + var updatedItems = currentItems; + bool changed = false; + + for (final entry in progressUpdates.entries) { + final index = itemIndexById[entry.key]; + if (index == null) continue; + final current = updatedItems[index]; + if (current.status == DownloadStatus.skipped || + current.status == DownloadStatus.completed || + current.status == DownloadStatus.failed) { + continue; + } + final update = entry.value; + final next = current.copyWith( + status: update.status, + progress: update.progress, + speedMBps: update.speedMBps ?? current.speedMBps, + bytesReceived: update.bytesReceived ?? current.bytesReceived, + ); + if (current.status != next.status || + current.progress != next.progress || + current.speedMBps != next.speedMBps || + current.bytesReceived != next.bytesReceived) { + if (!changed) { + updatedItems = List.from(updatedItems); + changed = true; + } + updatedItems[index] = next; + } + } + + if (changed) { + state = state.copyWith(items: updatedItems); + } + } + + if (hasFinalizingItem && finalizingTrackName != null) { + final safeArtistName = finalizingArtistName ?? ''; + if (finalizingTrackName != _lastFinalizingTrackName || + safeArtistName != _lastFinalizingArtistName) { + _notificationService.showDownloadFinalizing( + trackName: finalizingTrackName, + artistName: safeArtistName, + ); + _lastFinalizingTrackName = finalizingTrackName; + _lastFinalizingArtistName = safeArtistName; + } + return; + } + _lastFinalizingTrackName = null; + _lastFinalizingArtistName = null; + + if (items.isNotEmpty) { + final firstEntry = items.entries.first; + final rawFirstProgress = firstEntry.value; + if (rawFirstProgress is! Map) { + return; + } + final firstProgress = Map.from(rawFirstProgress); + final bytesReceived = + (firstProgress['bytes_received'] as num?)?.toInt() ?? 0; + final bytesTotal = (firstProgress['bytes_total'] as num?)?.toInt() ?? 0; + + if (downloadingCount > 0 && firstDownloading != null) { + final trackName = downloadingCount == 1 + ? firstDownloading.track.name + : '$downloadingCount downloads'; + final artistName = downloadingCount == 1 + ? firstDownloading.track.artistName + : 'Downloading...'; + + int notifProgress = bytesReceived; + int notifTotal = bytesTotal; + + if (bytesTotal <= 0) { + final progressPercent = + (firstProgress['progress'] as num?)?.toDouble() ?? 0.0; + notifProgress = (progressPercent * 100).toInt(); + notifTotal = 100; + } + + final safeNotifTotal = notifTotal > 0 ? notifTotal : 1; + if (_shouldUpdateProgressNotification( + trackName: trackName, + artistName: artistName, + progress: notifProgress, + total: safeNotifTotal, + queueCount: queuedCount, + )) { + _notificationService.showDownloadProgress( + trackName: trackName, + artistName: artistName, + progress: notifProgress, + total: safeNotifTotal, + ); + } + + if (Platform.isAndroid) { + _maybeUpdateAndroidDownloadService( + trackName: firstDownloading.track.name, + artistName: firstDownloading.track.artistName, + progress: notifProgress, + total: safeNotifTotal, + queueCount: queuedCount, + ); + } + } + } + } + void _maybeUpdateAndroidDownloadService({ required String trackName, required String artistName, @@ -1156,9 +1273,16 @@ class DownloadQueueNotifier extends Notifier { void _stopProgressPolling() { _progressTimer?.cancel(); + _progressStreamBootstrapTimer?.cancel(); + _progressStreamSub?.cancel(); _progressTimer = null; + _progressStreamBootstrapTimer = null; + _progressStreamSub = null; _progressPollingErrorCount = 0; _isProgressPollingInFlight = false; + _idleProgressPollTick = 0; + _hasReceivedProgressStreamEvent = false; + _usingProgressStream = false; _lastServiceTrackName = null; _lastServiceArtistName = null; _lastServicePercent = -1; @@ -1926,6 +2050,8 @@ class DownloadQueueNotifier extends Notifier { artistName: baseTrack.artistName, albumName: backendAlbum ?? baseTrack.albumName, albumArtist: resolvedAlbumArtist, + artistId: baseTrack.artistId, + albumId: baseTrack.albumId, coverUrl: baseTrack.coverUrl, duration: baseTrack.duration, isrc: baseTrack.isrc, @@ -1945,8 +2071,13 @@ class DownloadQueueNotifier extends Notifier { String? genre, String? label, String? copyright, + bool writeExternalLrc = true, }) async { final settings = ref.read(settingsProvider); + if (!settings.embedMetadata) { + _log.d('Metadata embedding disabled, skipping FLAC metadata/cover embed'); + return; + } String? coverPath; var coverUrl = track.coverUrl; @@ -2030,12 +2161,17 @@ class DownloadQueueNotifier extends Notifier { final shouldEmbedLyrics = settings.embedLyrics && (lyricsMode == 'embed' || lyricsMode == 'both'); + final shouldSaveExternalLyrics = + settings.embedLyrics && + (lyricsMode == 'external' || lyricsMode == 'both'); + final shouldFetchLyrics = shouldEmbedLyrics || shouldSaveExternalLyrics; + String? lrcContent; - if (shouldEmbedLyrics) { + if (shouldFetchLyrics) { try { final durationMs = track.duration * 1000; - final lrcContent = await PlatformBridge.getLyricsLRC( + final fetchedLrc = await PlatformBridge.getLyricsLRC( track.id, track.name, track.artistName, @@ -2043,20 +2179,46 @@ class DownloadQueueNotifier extends Notifier { durationMs: durationMs, ); - if (lrcContent.isNotEmpty && lrcContent != '[instrumental:true]') { - metadata['LYRICS'] = lrcContent; - metadata['UNSYNCEDLYRICS'] = lrcContent; - _log.d('Lyrics fetched for embedding (${lrcContent.length} chars)'); - } else if (lrcContent == '[instrumental:true]') { - _log.d('Track is instrumental, skipping lyrics embedding'); + if (fetchedLrc.isNotEmpty && fetchedLrc != '[instrumental:true]') { + lrcContent = fetchedLrc; + _log.d('Lyrics fetched for FLAC (${fetchedLrc.length} chars)'); + } else if (fetchedLrc == '[instrumental:true]') { + _log.d('Track is instrumental, skipping lyrics handling'); + } else { + _log.d('No lyrics returned for FLAC download'); } } catch (e) { - _log.w('Failed to fetch lyrics for embedding: $e'); + _log.w('Failed to fetch lyrics for FLAC: $e'); + } + } + + if (shouldEmbedLyrics) { + if (lrcContent != null) { + metadata['LYRICS'] = lrcContent; + metadata['UNSYNCEDLYRICS'] = lrcContent; + _log.d('Lyrics added to FLAC metadata'); + } else { + _log.d('No lyrics available for FLAC embedding'); } } else { metadata['LYRICS'] = ''; metadata['UNSYNCEDLYRICS'] = ''; - _log.d('Lyrics embedding disabled by settings, skipping lyric fetch'); + _log.d( + 'Lyrics embedding disabled by settings, skipping lyric embedding', + ); + } + + if (writeExternalLrc && shouldSaveExternalLyrics && lrcContent != null) { + try { + final replacedPath = flacPath.replaceAll(RegExp(r'\.[^.]+$'), '.lrc'); + final lrcPath = replacedPath == flacPath + ? '$flacPath.lrc' + : replacedPath; + await File(lrcPath).writeAsString(lrcContent); + _log.d('External LRC file saved: $lrcPath'); + } catch (e) { + _log.w('Failed to save external LRC file for FLAC: $e'); + } } _log.d('Generating tags for FLAC: $metadata'); @@ -2098,6 +2260,10 @@ class DownloadQueueNotifier extends Notifier { String? copyright, }) async { final settings = ref.read(settingsProvider); + if (!settings.embedMetadata) { + _log.d('Metadata embedding disabled, skipping MP3 metadata/cover embed'); + return; + } String? coverPath; var coverUrl = track.coverUrl; @@ -2262,6 +2428,10 @@ class DownloadQueueNotifier extends Notifier { String? copyright, }) async { final settings = ref.read(settingsProvider); + if (!settings.embedMetadata) { + _log.d('Metadata embedding disabled, skipping Opus metadata/cover embed'); + return; + } String? coverPath; var coverUrl = track.coverUrl; @@ -2743,6 +2913,7 @@ class DownloadQueueNotifier extends Notifier { try { final settings = ref.read(settingsProvider); + final metadataEmbeddingEnabled = settings.embedMetadata; Track trackToDownload = item.track; final needsEnrichment = @@ -2785,6 +2956,11 @@ class DownloadQueueNotifier extends Notifier { (data['album_name'] as String?) ?? trackToDownload.albumName, albumArtist: data['album_artist'] as String?, + artistId: + (data['artist_id'] ?? data['artistId'])?.toString() ?? + trackToDownload.artistId, + albumId: + data['album_id']?.toString() ?? trackToDownload.albumId, coverUrl: data['images'] as String?, duration: ((data['duration_ms'] as int?) ?? @@ -2991,6 +3167,8 @@ class DownloadQueueNotifier extends Notifier { artistName: trackToDownload.artistName, albumName: trackToDownload.albumName, albumArtist: trackToDownload.albumArtist, + artistId: trackToDownload.artistId, + albumId: trackToDownload.albumId, coverUrl: trackToDownload.coverUrl, duration: trackToDownload.duration, isrc: (deezerIsrc != null && _isValidISRC(deezerIsrc)) @@ -3101,12 +3279,16 @@ class DownloadQueueNotifier extends Notifier { artistName: trackToDownload.artistName, albumName: trackToDownload.albumName, albumArtist: resolvedAlbumArtist, - coverUrl: trackToDownload.coverUrl ?? '', + coverUrl: metadataEmbeddingEnabled + ? (trackToDownload.coverUrl ?? '') + : '', outputDir: outputDir, filenameFormat: state.filenameFormat, quality: quality, - embedLyrics: settings.embedLyrics, - embedMaxQualityCover: settings.maxQualityCover, + embedMetadata: metadataEmbeddingEnabled, + embedLyrics: metadataEmbeddingEnabled && settings.embedLyrics, + embedMaxQualityCover: + metadataEmbeddingEnabled && settings.maxQualityCover, trackNumber: normalizedTrackNumber, discNumber: normalizedDiscNumber, releaseDate: trackToDownload.releaseDate ?? '', @@ -3501,6 +3683,7 @@ class DownloadQueueNotifier extends Notifier { genre: backendGenre ?? genre, label: backendLabel ?? label, copyright: backendCopyright, + writeExternalLrc: false, ); final newFileName = '${safBaseName ?? 'track'}.flac'; @@ -3692,7 +3875,8 @@ class DownloadQueueNotifier extends Notifier { } } } - } else if (isContentUriPath && + } else if (metadataEmbeddingEnabled && + isContentUriPath && effectiveSafMode && isFlacFile && !wasExisting) { @@ -3724,6 +3908,7 @@ class DownloadQueueNotifier extends Notifier { genre: backendGenre ?? genre, label: backendLabel ?? label, copyright: backendCopyright, + writeExternalLrc: false, ); final newFileName = '${safBaseName ?? 'track'}.flac'; @@ -3753,7 +3938,8 @@ class DownloadQueueNotifier extends Notifier { } catch (_) {} } } - } else if (!isContentUriPath && + } else if (metadataEmbeddingEnabled && + !isContentUriPath && !effectiveSafMode && isFlacFile && !wasExisting && @@ -3792,7 +3978,10 @@ class DownloadQueueNotifier extends Notifier { } // YouTube downloads: embed metadata to raw Opus/MP3 files from Cobalt - if (!wasExisting && item.service == 'youtube' && filePath != null) { + if (metadataEmbeddingEnabled && + !wasExisting && + item.service == 'youtube' && + filePath != null) { final isOpusFile = filePath.endsWith('.opus'); final isMp3File = filePath.endsWith('.mp3'); @@ -3957,6 +4146,7 @@ class DownloadQueueNotifier extends Notifier { final lyricsMode = settings.lyricsMode; final shouldSaveExternalLrc = + metadataEmbeddingEnabled && settings.embedLyrics && (lyricsMode == 'external' || lyricsMode == 'both'); if (shouldSaveExternalLrc && diff --git a/lib/providers/extension_provider.dart b/lib/providers/extension_provider.dart index 31722a7b..1963e4cc 100644 --- a/lib/providers/extension_provider.dart +++ b/lib/providers/extension_provider.dart @@ -1,5 +1,9 @@ +import 'dart:async'; import 'dart:convert'; +import 'dart:io'; +import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/logger.dart'; @@ -9,6 +13,7 @@ final _log = AppLogger('ExtensionProvider'); const _metadataProviderPriorityKey = 'metadata_provider_priority'; const _providerPriorityKey = 'provider_priority'; +const _spotifyWebExtensionId = 'spotify-web'; class Extension { final String id; @@ -27,12 +32,14 @@ class Extension { final bool hasMetadataProvider; final bool hasDownloadProvider; final bool hasLyricsProvider; - final bool skipMetadataEnrichment; // If true, use metadata from extension instead of enriching + final bool + skipMetadataEnrichment; // If true, use metadata from extension instead of enriching final SearchBehavior? searchBehavior; final URLHandler? urlHandler; final TrackMatching? trackMatching; final PostProcessing? postProcessing; - final Map capabilities; // Extension capabilities (homeFeed, browseCategories, etc.) + final Map + capabilities; // Extension capabilities (homeFeed, browseCategories, etc.) const Extension({ required this.id, @@ -63,7 +70,8 @@ class Extension { return Extension( id: json['id'] as String? ?? '', name: json['name'] as String? ?? '', - displayName: json['display_name'] as String? ?? json['name'] as String? ?? '', + displayName: + json['display_name'] as String? ?? json['name'] as String? ?? '', version: json['version'] as String? ?? '0.0.0', author: json['author'] as String? ?? 'Unknown', description: json['description'] as String? ?? '', @@ -71,28 +79,40 @@ class Extension { status: json['status'] as String? ?? 'loaded', errorMessage: json['error_message'] as String?, iconPath: json['icon_path'] as String?, - permissions: (json['permissions'] as List?)?.cast() ?? [], - settings: (json['settings'] as List?) - ?.map((s) => ExtensionSetting.fromJson(s as Map)) - .toList() ?? [], - qualityOptions: (json['quality_options'] as List?) - ?.map((q) => QualityOption.fromJson(q as Map)) - .toList() ?? [], + permissions: + (json['permissions'] as List?)?.cast() ?? [], + settings: + (json['settings'] as List?) + ?.map((s) => ExtensionSetting.fromJson(s as Map)) + .toList() ?? + [], + qualityOptions: + (json['quality_options'] as List?) + ?.map((q) => QualityOption.fromJson(q as Map)) + .toList() ?? + [], hasMetadataProvider: json['has_metadata_provider'] as bool? ?? false, hasDownloadProvider: json['has_download_provider'] as bool? ?? false, hasLyricsProvider: json['has_lyrics_provider'] as bool? ?? false, - skipMetadataEnrichment: json['skip_metadata_enrichment'] as bool? ?? false, - searchBehavior: json['search_behavior'] != null - ? SearchBehavior.fromJson(json['search_behavior'] as Map) + skipMetadataEnrichment: + json['skip_metadata_enrichment'] as bool? ?? false, + searchBehavior: json['search_behavior'] != null + ? SearchBehavior.fromJson( + json['search_behavior'] as Map, + ) : null, urlHandler: json['url_handler'] != null ? URLHandler.fromJson(json['url_handler'] as Map) : null, trackMatching: json['track_matching'] != null - ? TrackMatching.fromJson(json['track_matching'] as Map) + ? TrackMatching.fromJson( + json['track_matching'] as Map, + ) : null, postProcessing: json['post_processing'] != null - ? PostProcessing.fromJson(json['post_processing'] as Map) + ? PostProcessing.fromJson( + json['post_processing'] as Map, + ) : null, capabilities: (json['capabilities'] as Map?) ?? const {}, ); @@ -139,7 +159,8 @@ class Extension { hasMetadataProvider: hasMetadataProvider ?? this.hasMetadataProvider, hasDownloadProvider: hasDownloadProvider ?? this.hasDownloadProvider, hasLyricsProvider: hasLyricsProvider ?? this.hasLyricsProvider, - skipMetadataEnrichment: skipMetadataEnrichment ?? this.skipMetadataEnrichment, + skipMetadataEnrichment: + skipMetadataEnrichment ?? this.skipMetadataEnrichment, searchBehavior: searchBehavior ?? this.searchBehavior, urlHandler: urlHandler ?? this.urlHandler, trackMatching: trackMatching ?? this.trackMatching, @@ -161,11 +182,7 @@ class SearchFilter { final String? label; final String? icon; - const SearchFilter({ - required this.id, - this.label, - this.icon, - }); + const SearchFilter({required this.id, this.label, this.icon}); factory SearchFilter.fromJson(Map json) { return SearchFilter( @@ -181,10 +198,12 @@ class SearchBehavior { final String? placeholder; final bool primary; final String? icon; - final String? thumbnailRatio; // "square" (1:1), "wide" (16:9), "portrait" (2:3) + final String? + thumbnailRatio; // "square" (1:1), "wide" (16:9), "portrait" (2:3) final int? thumbnailWidth; final int? thumbnailHeight; - final List filters; // Available search filters (e.g., track, album, artist, playlist) + final List + filters; // Available search filters (e.g., track, album, artist, playlist) const SearchBehavior({ required this.enabled, @@ -206,9 +225,11 @@ class SearchBehavior { thumbnailRatio: json['thumbnailRatio'] as String?, thumbnailWidth: json['thumbnailWidth'] as int?, thumbnailHeight: json['thumbnailHeight'] as int?, - filters: (json['filters'] as List?) - ?.map((f) => SearchFilter.fromJson(f as Map)) - .toList() ?? [], + filters: + (json['filters'] as List?) + ?.map((f) => SearchFilter.fromJson(f as Map)) + .toList() ?? + [], ); } @@ -216,7 +237,7 @@ class SearchBehavior { if (thumbnailWidth != null && thumbnailHeight != null) { return (thumbnailWidth!.toDouble(), thumbnailHeight!.toDouble()); } - + switch (thumbnailRatio) { case 'wide': // 16:9 - YouTube style return (defaultSize * 16 / 9, defaultSize); @@ -253,17 +274,18 @@ class PostProcessing { final bool enabled; final List hooks; - const PostProcessing({ - required this.enabled, - this.hooks = const [], - }); + const PostProcessing({required this.enabled, this.hooks = const []}); factory PostProcessing.fromJson(Map json) { return PostProcessing( enabled: json['enabled'] as bool? ?? false, - hooks: (json['hooks'] as List?) - ?.map((h) => PostProcessingHook.fromJson(h as Map)) - .toList() ?? [], + hooks: + (json['hooks'] as List?) + ?.map( + (h) => PostProcessingHook.fromJson(h as Map), + ) + .toList() ?? + [], ); } } @@ -273,10 +295,7 @@ class URLHandler { final bool enabled; final List patterns; - const URLHandler({ - required this.enabled, - this.patterns = const [], - }); + const URLHandler({required this.enabled, this.patterns = const []}); factory URLHandler.fromJson(Map json) { return URLHandler( @@ -319,7 +338,8 @@ class PostProcessingHook { name: json['name'] as String? ?? '', description: json['description'] as String?, defaultEnabled: json['defaultEnabled'] as bool? ?? false, - supportedFormats: (json['supportedFormats'] as List?)?.cast() ?? [], + supportedFormats: + (json['supportedFormats'] as List?)?.cast() ?? [], ); } } @@ -342,9 +362,14 @@ class QualityOption { id: json['id'] as String? ?? '', label: json['label'] as String? ?? '', description: json['description'] as String?, - settings: (json['settings'] as List?) - ?.map((s) => QualitySpecificSetting.fromJson(s as Map)) - .toList() ?? [], + settings: + (json['settings'] as List?) + ?.map( + (s) => + QualitySpecificSetting.fromJson(s as Map), + ) + .toList() ?? + [], ); } } @@ -447,7 +472,8 @@ class ExtensionState { return ExtensionState( extensions: extensions ?? this.extensions, providerPriority: providerPriority ?? this.providerPriority, - metadataProviderPriority: metadataProviderPriority ?? this.metadataProviderPriority, + metadataProviderPriority: + metadataProviderPriority ?? this.metadataProviderPriority, isLoading: isLoading ?? this.isLoading, error: error, isInitialized: isInitialized ?? this.isInitialized, @@ -455,18 +481,44 @@ class ExtensionState { } } - class ExtensionNotifier extends Notifier { + AppLifecycleListener? _appLifecycleListener; + bool _cleanupInFlight = false; + @override ExtensionState build() { + _appLifecycleListener ??= AppLifecycleListener( + onDetach: _scheduleLifecycleCleanup, + ); + ref.onDispose(() { + _appLifecycleListener?.dispose(); + _appLifecycleListener = null; + }); return const ExtensionState(); } + void _scheduleLifecycleCleanup() { + if (_cleanupInFlight) return; + _cleanupInFlight = true; + unawaited(_cleanupExtensions(reason: 'lifecycle detach')); + } + + Future _cleanupExtensions({required String reason}) async { + try { + await PlatformBridge.cleanupExtensions(); + _log.d('Extensions cleaned up ($reason)'); + } catch (e) { + _log.w('Extension cleanup failed ($reason): $e'); + } finally { + _cleanupInFlight = false; + } + } + Future initialize(String extensionsDir, String dataDir) async { if (state.isInitialized) return; - + state = state.copyWith(isLoading: true, error: null); - + try { await PlatformBridge.initExtensionSystem(extensionsDir, dataDir); await loadExtensions(extensionsDir); @@ -482,7 +534,7 @@ class ExtensionNotifier extends Notifier { Future loadExtensions(String dirPath) async { state = state.copyWith(isLoading: true, error: null); - + try { final result = await PlatformBridge.loadExtensionsFromDir(dirPath); _log.d('Load extensions result: $result'); @@ -500,10 +552,12 @@ class ExtensionNotifier extends Notifier { final extensions = list.map((e) => Extension.fromJson(e)).toList(); state = state.copyWith(extensions: extensions); _log.d('Loaded ${extensions.length} extensions'); - + for (final ext in extensions) { if (ext.searchBehavior != null) { - _log.d('Extension ${ext.id}: thumbnailRatio=${ext.searchBehavior!.thumbnailRatio}'); + _log.d( + 'Extension ${ext.id}: thumbnailRatio=${ext.searchBehavior!.thumbnailRatio}', + ); } } } catch (e) { @@ -512,14 +566,13 @@ class ExtensionNotifier extends Notifier { } } - void clearError() { state = state.copyWith(error: null); } Future installExtension(String filePath) async { state = state.copyWith(isLoading: true, error: null); - + try { final result = await PlatformBridge.loadExtensionFromPath(filePath); _log.i('Installed extension: ${result['name']}'); @@ -544,10 +597,12 @@ class ExtensionNotifier extends Notifier { Future upgradeExtension(String filePath) async { state = state.copyWith(isLoading: true, error: null); - + try { final result = await PlatformBridge.upgradeExtension(filePath); - _log.i('Upgraded extension: ${result['display_name']} to v${result['version']}'); + _log.i( + 'Upgraded extension: ${result['display_name']} to v${result['version']}', + ); await refreshExtensions(); state = state.copyWith(isLoading: false); return true; @@ -560,7 +615,7 @@ class ExtensionNotifier extends Notifier { Future removeExtension(String extensionId) async { state = state.copyWith(isLoading: true, error: null); - + try { await PlatformBridge.removeExtension(extensionId); _log.i('Removed extension: $extensionId'); @@ -574,35 +629,40 @@ class ExtensionNotifier extends Notifier { } } - Future setExtensionEnabled(String extensionId, bool enabled) async { try { await PlatformBridge.setExtensionEnabled(extensionId, enabled); _log.d('Set extension $extensionId enabled: $enabled'); - - final ext = state.extensions.where((e) => e.id == extensionId).firstOrNull; - + + final ext = state.extensions + .where((e) => e.id == extensionId) + .firstOrNull; + final extensions = state.extensions.map((e) { if (e.id == extensionId) { return e.copyWith(enabled: enabled); } return e; }).toList(); - + state = state.copyWith(extensions: extensions); - + if (!enabled && ext != null) { final settings = ref.read(settingsProvider); - + if (settings.searchProvider == extensionId) { ref.read(settingsProvider.notifier).setSearchProvider(null); ref.read(settingsProvider.notifier).setMetadataSource('deezer'); - _log.d('Cleared search provider and reset to Deezer because extension $extensionId was disabled'); + _log.d( + 'Cleared search provider and reset to Deezer because extension $extensionId was disabled', + ); } - + if (ext.hasDownloadProvider && settings.defaultService == extensionId) { ref.read(settingsProvider.notifier).setDefaultService('tidal'); - _log.d('Reset default service to Tidal because extension $extensionId was disabled'); + _log.d( + 'Reset default service to Tidal because extension $extensionId was disabled', + ); } } } catch (e) { @@ -611,6 +671,68 @@ class ExtensionNotifier extends Notifier { } } + Future ensureSpotifyWebExtensionReady({ + bool setAsSearchProvider = true, + }) async { + try { + await refreshExtensions(); + + var ext = state.extensions + .where((e) => e.id == _spotifyWebExtensionId) + .firstOrNull; + + if (ext == null) { + final cacheDir = await getTemporaryDirectory(); + await PlatformBridge.initExtensionStore(cacheDir.path); + + final tempRoot = await getTemporaryDirectory(); + final installDir = await Directory( + '${tempRoot.path}/spotiflac_bootstrap_spotify_web', + ).create(recursive: true); + + final downloadPath = await PlatformBridge.downloadStoreExtension( + _spotifyWebExtensionId, + installDir.path, + ); + + final installed = await installExtension(downloadPath); + if (!installed) { + _log.w('Failed to install spotify-web extension from store'); + return false; + } + + await refreshExtensions(); + ext = state.extensions + .where((e) => e.id == _spotifyWebExtensionId) + .firstOrNull; + } + + if (ext == null) { + _log.w('spotify-web extension is still not available after install'); + return false; + } + + if (!ext.enabled) { + await setExtensionEnabled(_spotifyWebExtensionId, true); + } + + if (setAsSearchProvider) { + final settings = ref.read(settingsProvider); + if (settings.searchProvider != _spotifyWebExtensionId) { + ref + .read(settingsProvider.notifier) + .setSearchProvider(_spotifyWebExtensionId); + } + } + + _log.i('spotify-web extension is ready'); + return true; + } catch (e) { + _log.w('Failed to ensure spotify-web extension is ready: $e'); + return false; + } + } + Future> getExtensionSettings(String extensionId) async { try { return await PlatformBridge.getExtensionSettings(extensionId); @@ -620,7 +742,10 @@ class ExtensionNotifier extends Notifier { } } - Future setExtensionSettings(String extensionId, Map settings) async { + Future setExtensionSettings( + String extensionId, + Map settings, + ) async { try { await PlatformBridge.setExtensionSettings(extensionId, settings); _log.d('Updated settings for extension: $extensionId'); @@ -635,49 +760,72 @@ class ExtensionNotifier extends Notifier { // Load from SharedPreferences first (persisted) final prefs = await SharedPreferences.getInstance(); final savedJson = prefs.getString(_providerPriorityKey); - + List priority; if (savedJson != null) { final saved = jsonDecode(savedJson) as List; priority = saved.map((e) => e as String).toList(); + priority = _sanitizeDownloadProviderPriority(priority); _log.d('Loaded provider priority from prefs: $priority'); + await prefs.setString(_providerPriorityKey, jsonEncode(priority)); // Sync to Go backend await PlatformBridge.setProviderPriority(priority); } else { // Fallback to Go backend default priority = await PlatformBridge.getProviderPriority(); + priority = _sanitizeDownloadProviderPriority(priority); + await PlatformBridge.setProviderPriority(priority); _log.d('Using default provider priority: $priority'); } - + state = state.copyWith(providerPriority: priority); } catch (e) { _log.e('Failed to load provider priority: $e'); } } - Future setProviderPriority(List priority) async { try { + final sanitized = _sanitizeDownloadProviderPriority(priority); // Save to SharedPreferences for persistence final prefs = await SharedPreferences.getInstance(); - await prefs.setString(_providerPriorityKey, jsonEncode(priority)); - + await prefs.setString(_providerPriorityKey, jsonEncode(sanitized)); + // Sync to Go backend - await PlatformBridge.setProviderPriority(priority); - state = state.copyWith(providerPriority: priority); - _log.d('Saved provider priority: $priority'); + await PlatformBridge.setProviderPriority(sanitized); + state = state.copyWith(providerPriority: sanitized); + _log.d('Saved provider priority: $sanitized'); } catch (e) { _log.e('Failed to set provider priority: $e'); state = state.copyWith(error: e.toString()); } } + List _sanitizeDownloadProviderPriority(List input) { + final allowed = getAllDownloadProviders().toSet(); + final result = []; + + for (final provider in input) { + if (allowed.contains(provider) && !result.contains(provider)) { + result.add(provider); + } + } + + for (final provider in const ['tidal', 'qobuz', 'amazon']) { + if (!result.contains(provider)) { + result.add(provider); + } + } + + return result; + } + Future loadMetadataProviderPriority() async { try { // Load from SharedPreferences first (persisted) final prefs = await SharedPreferences.getInstance(); final savedJson = prefs.getString(_metadataProviderPriorityKey); - + List priority; if (savedJson != null) { final saved = jsonDecode(savedJson) as List; @@ -690,7 +838,7 @@ class ExtensionNotifier extends Notifier { priority = await PlatformBridge.getMetadataProviderPriority(); _log.d('Using default metadata provider priority: $priority'); } - + state = state.copyWith(metadataProviderPriority: priority); } catch (e) { _log.e('Failed to load metadata provider priority: $e'); @@ -702,7 +850,7 @@ class ExtensionNotifier extends Notifier { // Save to SharedPreferences for persistence final prefs = await SharedPreferences.getInstance(); await prefs.setString(_metadataProviderPriorityKey, jsonEncode(priority)); - + // Sync to Go backend await PlatformBridge.setMetadataProviderPriority(priority); state = state.copyWith(metadataProviderPriority: priority); @@ -714,12 +862,9 @@ class ExtensionNotifier extends Notifier { } Future cleanup() async { - try { - await PlatformBridge.cleanupExtensions(); - _log.d('Extensions cleaned up'); - } catch (e) { - _log.e('Failed to cleanup extensions: $e'); - } + if (_cleanupInFlight) return; + _cleanupInFlight = true; + await _cleanupExtensions(reason: 'manual'); } Extension? getExtension(String extensionId) { @@ -755,7 +900,9 @@ class ExtensionNotifier extends Notifier { } List get searchProviders { - return state.extensions.where((ext) => ext.enabled && ext.hasCustomSearch).toList(); + return state.extensions + .where((ext) => ext.enabled && ext.hasCustomSearch) + .toList(); } } diff --git a/lib/providers/local_library_provider.dart b/lib/providers/local_library_provider.dart index 2f4ce8f6..30e8500b 100644 --- a/lib/providers/local_library_provider.dart +++ b/lib/providers/local_library_provider.dart @@ -121,15 +121,25 @@ class LocalLibraryNotifier extends Notifier { final NotificationService _notificationService = NotificationService(); static const _progressPollingInterval = Duration(milliseconds: 800); Timer? _progressTimer; + Timer? _progressStreamBootstrapTimer; + StreamSubscription>? _progressStreamSub; bool _isLoaded = false; bool _scanCancelRequested = false; int _progressPollingErrorCount = 0; bool _isProgressPollingInFlight = false; + bool _hasReceivedProgressStreamEvent = false; + bool _usingProgressStream = false; + static const _scanNotificationHeartbeat = Duration(seconds: 4); + int _lastScanNotificationPercent = -1; + int _lastScanNotificationTotalFiles = -1; + DateTime _lastScanNotificationAt = DateTime.fromMillisecondsSinceEpoch(0); @override LocalLibraryState build() { ref.onDispose(() { _progressTimer?.cancel(); + _progressStreamBootstrapTimer?.cancel(); + _progressStreamSub?.cancel(); }); Future.microtask(() async { @@ -257,12 +267,19 @@ class LocalLibraryNotifier extends Notifier { scanErrorCount: 0, scanWasCancelled: false, ); - await _showScanProgressNotification( + _resetScanNotificationTracking(); + if (_shouldShowScanProgressNotification( progress: 0, - scannedFiles: 0, totalFiles: 0, - currentFile: null, - ); + isComplete: false, + )) { + await _showScanProgressNotification( + progress: 0, + scannedFiles: 0, + totalFiles: 0, + currentFile: null, + ); + } try { final appSupportDir = await getApplicationSupportDirectory(); @@ -499,49 +516,75 @@ class LocalLibraryNotifier extends Notifier { } void _startProgressPolling() { + _progressTimer?.cancel(); + _progressStreamBootstrapTimer?.cancel(); + _progressStreamBootstrapTimer = null; + _progressStreamSub?.cancel(); + _progressStreamSub = null; + _hasReceivedProgressStreamEvent = false; + _usingProgressStream = false; + + if (Platform.isAndroid || Platform.isIOS) { + _progressStreamSub = PlatformBridge.libraryScanProgressStream().listen( + (progress) async { + _hasReceivedProgressStreamEvent = true; + _usingProgressStream = true; + _progressStreamBootstrapTimer?.cancel(); + _progressStreamBootstrapTimer = null; + if (_isProgressPollingInFlight) return; + _isProgressPollingInFlight = true; + try { + await _handleLibraryScanProgress(progress); + _progressPollingErrorCount = 0; + } catch (e) { + _progressPollingErrorCount++; + if (_progressPollingErrorCount <= 3) { + _log.w('Library scan progress stream processing failed: $e'); + } + } finally { + _isProgressPollingInFlight = false; + } + }, + onError: (Object error, StackTrace stackTrace) { + if (_usingProgressStream) { + _log.w( + 'Library scan progress stream failed, fallback to polling: $error', + ); + } + _progressStreamSub?.cancel(); + _progressStreamSub = null; + _usingProgressStream = false; + _progressStreamBootstrapTimer?.cancel(); + _progressStreamBootstrapTimer = null; + _startProgressPollingTimer(); + }, + cancelOnError: false, + ); + + _progressStreamBootstrapTimer = Timer(const Duration(seconds: 3), () { + if (_hasReceivedProgressStreamEvent) { + return; + } + _log.w('Library scan progress stream timeout, fallback to polling'); + _progressStreamSub?.cancel(); + _progressStreamSub = null; + _usingProgressStream = false; + _startProgressPollingTimer(); + }); + return; + } + + _startProgressPollingTimer(); + } + + void _startProgressPollingTimer() { _progressTimer?.cancel(); _progressTimer = Timer.periodic(_progressPollingInterval, (_) async { if (_isProgressPollingInFlight) return; _isProgressPollingInFlight = true; try { final progress = await PlatformBridge.getLibraryScanProgress(); - final nextProgress = - (progress['progress_pct'] as num?)?.toDouble() ?? 0; - final normalizedProgress = ((nextProgress * 10).round() / 10).clamp( - 0.0, - 100.0, - ); - final currentFile = progress['current_file'] as String?; - final totalFiles = progress['total_files'] as int? ?? 0; - final scannedFiles = progress['scanned_files'] as int? ?? 0; - final errorCount = progress['error_count'] as int? ?? 0; - - final shouldUpdateState = - state.scanProgress != normalizedProgress || - state.scanCurrentFile != currentFile || - state.scanTotalFiles != totalFiles || - state.scannedFiles != scannedFiles || - state.scanErrorCount != errorCount; - - if (shouldUpdateState) { - state = state.copyWith( - scanProgress: normalizedProgress, - scanCurrentFile: currentFile, - scanTotalFiles: totalFiles, - scannedFiles: scannedFiles, - scanErrorCount: errorCount, - ); - await _showScanProgressNotification( - progress: normalizedProgress, - scannedFiles: scannedFiles, - totalFiles: totalFiles, - currentFile: currentFile, - ); - } - - if (progress['is_complete'] == true) { - _stopProgressPolling(); - } + await _handleLibraryScanProgress(progress); _progressPollingErrorCount = 0; } catch (e) { _progressPollingErrorCount++; @@ -554,11 +597,93 @@ class LocalLibraryNotifier extends Notifier { }); } + Future _handleLibraryScanProgress(Map progress) async { + final nextProgress = (progress['progress_pct'] as num?)?.toDouble() ?? 0; + final normalizedProgress = ((nextProgress * 10).round() / 10).clamp( + 0.0, + 100.0, + ); + final currentFile = progress['current_file'] as String?; + final totalFiles = (progress['total_files'] as num?)?.toInt() ?? 0; + final scannedFiles = (progress['scanned_files'] as num?)?.toInt() ?? 0; + final errorCount = (progress['error_count'] as num?)?.toInt() ?? 0; + final isComplete = progress['is_complete'] == true; + + final shouldUpdateState = + state.scanProgress != normalizedProgress || + state.scanCurrentFile != currentFile || + state.scanTotalFiles != totalFiles || + state.scannedFiles != scannedFiles || + state.scanErrorCount != errorCount; + + if (shouldUpdateState) { + state = state.copyWith( + scanProgress: normalizedProgress, + scanCurrentFile: currentFile, + scanTotalFiles: totalFiles, + scannedFiles: scannedFiles, + scanErrorCount: errorCount, + ); + } + + if (_shouldShowScanProgressNotification( + progress: normalizedProgress, + totalFiles: totalFiles, + isComplete: isComplete, + )) { + await _showScanProgressNotification( + progress: normalizedProgress, + scannedFiles: scannedFiles, + totalFiles: totalFiles, + currentFile: currentFile, + ); + } + + if (isComplete) { + _stopProgressPolling(); + } + } + void _stopProgressPolling() { _progressTimer?.cancel(); + _progressStreamBootstrapTimer?.cancel(); + _progressStreamSub?.cancel(); _progressTimer = null; + _progressStreamBootstrapTimer = null; + _progressStreamSub = null; _progressPollingErrorCount = 0; _isProgressPollingInFlight = false; + _hasReceivedProgressStreamEvent = false; + _usingProgressStream = false; + _resetScanNotificationTracking(); + } + + void _resetScanNotificationTracking() { + _lastScanNotificationPercent = -1; + _lastScanNotificationTotalFiles = -1; + _lastScanNotificationAt = DateTime.fromMillisecondsSinceEpoch(0); + } + + bool _shouldShowScanProgressNotification({ + required double progress, + required int totalFiles, + required bool isComplete, + }) { + final now = DateTime.now(); + final percent = progress.round().clamp(0, 100); + final percentChanged = percent != _lastScanNotificationPercent; + final totalFilesChanged = totalFiles != _lastScanNotificationTotalFiles; + final heartbeatDue = + now.difference(_lastScanNotificationAt) >= _scanNotificationHeartbeat; + + if (!percentChanged && !totalFilesChanged && !isComplete && !heartbeatDue) { + return false; + } + + _lastScanNotificationPercent = percent; + _lastScanNotificationTotalFiles = totalFiles; + _lastScanNotificationAt = now; + return true; } Future cancelScan() async { diff --git a/lib/providers/playback_provider.dart b/lib/providers/playback_provider.dart new file mode 100644 index 00000000..c52bc6e2 --- /dev/null +++ b/lib/providers/playback_provider.dart @@ -0,0 +1,3880 @@ +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/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/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, + ); + } +} + +// ─── 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 _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(); + } + + void _init() { + unawaited(_configureAudioSession()); + unawaited(_initAudioService()); + unawaited(_restorePlaybackSnapshot()); + unawaited(_restoreSmartQueueModel()); + _appLifecycleListener ??= AppLifecycleListener( + onInactive: () => unawaited(_savePlaybackSnapshot()), + onPause: () => unawaited(_savePlaybackSnapshot()), + onDetach: () => unawaited(_savePlaybackSnapshot()), + onHide: () => unawaited(_savePlaybackSnapshot()), + ); + + 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); + } + } + }); + + _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); + return; + } + await _player.play(); + } + + 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; + } + + 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, + ]; + + 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}) { + // Invalidate any in-flight lyrics fetch from previous track. + _lyricsGeneration++; + state = state.copyWith( + currentItem: upcomingItem ?? state.currentItem, + 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) { + final localState = ref.read(localLibraryProvider); + final isLocalSource = (track.source ?? '').toLowerCase() == 'local'; + + LocalLibraryItem? localItem; + if (isLocalSource) { + for (final item in localState.items) { + if (item.id == track.id) { + localItem = item; + break; + } + } + } + + if (localItem == null) { + final isrc = track.isrc?.trim(); + if (isrc != null && isrc.isNotEmpty) { + localItem = localState.getByIsrc(isrc); + } + } + + localItem ??= localState.findByTrackAndArtist(track.name, track.artistName); + + 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, + ); + } + + return PlaybackItem( + id: track.id, + title: track.name, + artist: track.artistName, + album: track.albumName, + coverUrl: track.coverUrl ?? '', + sourceUri: '', + durationMs: _trackDurationMs(track), + track: track, + ); + } + + 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 = '', + }) async { + final requestEpoch = _startNewPlayRequest(); + _resetPrefetchCycleState(); + _resetSmartQueueSessionState(clearRecent: true); + _pendingResumePosition = null; + _pendingResumeIndex = null; + final uri = _uriFromPath(path); + final item = PlaybackItem( + id: path, + title: title, + artist: artist, + album: album, + coverUrl: coverUrl, + sourceUri: uri.toString(), + isLocal: true, + service: 'offline', + ); + + _clearLyricsForTrackChange(upcomingItem: item); + + // 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); + } + _clearLyricsForTrackChange(upcomingItem: item); + state = state.copyWith( + currentIndex: index, + currentItem: item, + isLoading: true, + isBuffering: true, + isPlaying: false, + seekSupported: _inferSeekSupportedForQueueItem(item), + position: + pendingResumePosition != null && pendingResumePosition > Duration.zero + ? pendingResumePosition + : Duration.zero, + bufferedPosition: Duration.zero, + duration: _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) || + 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 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; + } + } + + // ─── 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 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); + } + + /// 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); + } + + /// 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); + } + return merged; + } + + 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 { + try { + if (seed.provider == 'spotify') { + return await _fetchSpotifyRelatedArtistsForSmartQueue(seed); + } else if (seed.provider == 'deezer') { + final response = await PlatformBridge.getDeezerRelatedArtists( + seed.id, + limit: 10, + ); + final rawList = response['artists'] as List? ?? const []; + final result = <_SmartQueueRelatedArtist>[]; + for (final entry in rawList) { + if (entry is! Map) continue; + final map = Map.from(entry); + final name = (map['name'] as String?)?.trim() ?? ''; + if (name.isEmpty) continue; + final popularity = (map['popularity'] as num?)?.toDouble() ?? 0.0; + final followers = (map['followers'] as num?)?.toDouble() ?? 0.0; + final score = + ((popularity / 100.0) * 0.65) + + (min(followers, 2000000) / 2000000.0) * 0.35; + result.add( + _SmartQueueRelatedArtist( + name: name, + provider: seed.provider, + score: score.clamp(0.05, 1.0), + ), + ); + } + return result; + } + return const []; + } catch (_) { + return const []; + } + } + + Future> + _fetchSpotifyRelatedArtistsForSmartQueue(_SmartQueueArtistSeed seed) async { + final seedArtistKey = _normalizeSmartQueueKey(seed.name); + if (seedArtistKey.isEmpty) return const []; + + final relatedScores = {}; + final relatedNames = {}; + + void addRelatedName(String rawName, double score) { + final name = rawName.trim(); + final key = _normalizeSmartQueueKey(name); + if (key.isEmpty || key == seedArtistKey || score <= 0) return; + relatedNames[key] = name; + relatedScores[key] = (relatedScores[key] ?? 0.0) + score; + } + + try { + final artist = await PlatformBridge.getArtistWithExtension( + _smartQueueSpotifyExtensionId, + seed.id, + ); + if (artist != null) { + final topTracks = artist['top_tracks'] as List? ?? const []; + for (var index = 0; index < topTracks.length && index < 20; index++) { + final entry = topTracks[index]; + if (entry is! Map) continue; + final map = Map.from(entry); + final artistsText = (map['artists'] ?? map['artist'] ?? '') + .toString() + .trim(); + if (artistsText.isEmpty) continue; + final rankWeight = (1.0 - (index / 18.0)).clamp(0.18, 1.0); + for (final artistName in _extractArtistNamesForSmartQueue( + artistsText, + )) { + addRelatedName(artistName, 0.42 * rankWeight); + } + } + } + } catch (_) {} + + try { + final searchResults = await PlatformBridge.customSearchWithExtension( + _smartQueueSpotifyExtensionId, + seed.name, + options: { + 'filter': 'artists', + 'limit': 12, + 'offset': 0, + }, + ); + for (var index = 0; index < searchResults.length; index++) { + final map = searchResults[index]; + final itemType = (map['item_type'] ?? '').toString().toLowerCase(); + if (itemType.isNotEmpty && itemType != 'artist') continue; + final id = (map['id'] ?? '').toString().trim(); + final name = (map['name'] ?? '').toString().trim(); + if (name.isEmpty) continue; + final normalizedName = _normalizeSmartQueueKey(name); + if (normalizedName == seedArtistKey || id == seed.id) continue; + + final similarity = _artistNameSimilarity(seed.name, name); + final rankWeight = (1.0 - (index / 12.0)).clamp(0.1, 1.0); + addRelatedName(name, (rankWeight * 0.24) + (similarity * 0.12)); + } + } catch (_) {} + + if (relatedScores.isEmpty) return const []; + + final related = <_SmartQueueRelatedArtist>[]; + for (final entry in relatedScores.entries) { + related.add( + _SmartQueueRelatedArtist( + name: relatedNames[entry.key] ?? entry.key, + provider: _smartQueueSpotifyExtensionId, + 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 []; + } + + try { + final List> artistsRaw; + if (normalizedProvider == 'spotify') { + final response = await PlatformBridge.customSearchWithExtension( + _smartQueueSpotifyExtensionId, + normalizedQuery, + options: { + 'filter': 'artists', + 'limit': min(30, max(4, limit)), + 'offset': 0, + }, + ); + artistsRaw = response + .where( + (item) => + (item['item_type'] ?? 'artist').toString().toLowerCase() == + 'artist', + ) + .toList(growable: false); + } else { + final result = await PlatformBridge.searchDeezerAll( + normalizedQuery, + trackLimit: 1, + artistLimit: limit, + filter: 'artist', + ); + final raw = result['artists'] as List? ?? const []; + artistsRaw = raw + .whereType() + .map((entry) => Map.from(entry)) + .toList(growable: false); + } + + final seeds = <_SmartQueueArtistSeed>[]; + final seen = {}; + for (var index = 0; index < artistsRaw.length; index++) { + final map = artistsRaw[index]; + final id = (map['id'] ?? '').toString().trim(); + final name = (map['name'] ?? '').toString().trim(); + if (id.isEmpty || name.isEmpty) continue; + final key = '$normalizedProvider:${_normalizeSmartQueueKey(id)}'; + if (!seen.add(key)) continue; + + final popularity = (map['popularity'] as num?)?.toDouble() ?? 0.0; + final similarity = _artistNameSimilarity(query, name); + final rankScore = (1.0 - (index / max(1, artistsRaw.length))).clamp( + 0.05, + 1.0, + ); + final score = normalizedProvider == 'spotify' + ? (similarity * 0.82) + (rankScore * 0.18) + : (similarity * 0.72) + ((popularity / 100.0) * 0.28); + seeds.add( + _SmartQueueArtistSeed( + id: id, + name: name, + provider: normalizedProvider, + score: score.clamp(0.0, 1.0), + ), + ); + } + return seeds; + } catch (_) { + return const []; + } + } + + 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 { + final response = await PlatformBridge.customSearchWithExtension( + _smartQueueSpotifyExtensionId, + query, + options: { + 'filter': 'tracks', + 'limit': min(50, max(1, trackLimit)), + 'offset': 0, + }, + ); + return response + .where( + (item) => + (item['item_type'] ?? 'track').toString().toLowerCase() == + 'track', + ) + .toList(growable: false); + } + + Future>> _searchDeezerTracksForSmartQueue( + String query, { + required int trackLimit, + }) async { + final result = await PlatformBridge.searchDeezerAll( + query, + trackLimit: trackLimit, + artistLimit: 0, + filter: 'track', + ); + final tracks = result['tracks'] as List? ?? const []; + return tracks + .whereType() + .map((entry) { + final map = Map.from(entry); + map.putIfAbsent('provider_id', () => 'deezer'); + map.putIfAbsent('source', () => 'deezer'); + return map; + }) + .toList(growable: false); + } + + 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, + }); +} + +final playbackProvider = NotifierProvider( + PlaybackController.new, +); diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index f293d005..18a6a9f0 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -3,12 +3,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:spotiflac_android/models/settings.dart'; +import 'package:spotiflac_android/constants/app_info.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/logger.dart'; const _settingsKey = 'app_settings'; const _migrationVersionKey = 'settings_migration_version'; -const _currentMigrationVersion = 2; +const _currentMigrationVersion = 4; const _spotifyClientSecretKey = 'spotify_client_secret'; final _log = AppLogger('SettingsProvider'); @@ -93,6 +94,18 @@ class SettingsNotifier extends Notifier { if (!state.isFirstLaunch && !state.hasCompletedTutorial) { state = state.copyWith(hasCompletedTutorial: true); } + // Migration 4: include Spotify Lyrics API in provider order for existing users + if (!state.lyricsProviders.contains('spotify_api')) { + final updatedProviders = List.from(state.lyricsProviders); + final lrclibIndex = updatedProviders.indexOf('lrclib'); + if (lrclibIndex >= 0) { + updatedProviders.insert(lrclibIndex + 1, 'spotify_api'); + } else { + updatedProviders.add('spotify_api'); + } + state = state.copyWith(lyricsProviders: updatedProviders); + } + state = state.copyWith(lastSeenVersion: AppInfo.version); await prefs.setInt(_migrationVersionKey, _currentMigrationVersion); await _saveSettings(); } @@ -266,11 +279,26 @@ class SettingsNotifier extends Notifier { _saveSettings(); } + void setAutoSkipUnavailableTracks(bool enabled) { + state = state.copyWith(autoSkipUnavailableTracks: enabled); + _saveSettings(); + } + + void setSmartQueueEnabled(bool enabled) { + state = state.copyWith(smartQueueEnabled: enabled); + _saveSettings(); + } + void setEmbedLyrics(bool enabled) { state = state.copyWith(embedLyrics: enabled); _saveSettings(); } + void setEmbedMetadata(bool enabled) { + state = state.copyWith(embedMetadata: enabled); + _saveSettings(); + } + void setLyricsMode(String mode) { if (mode == 'embed' || mode == 'external' || mode == 'both') { state = state.copyWith(lyricsMode: mode); diff --git a/lib/providers/track_provider.dart b/lib/providers/track_provider.dart index 3ecc340e..32022b81 100644 --- a/lib/providers/track_provider.dart +++ b/lib/providers/track_provider.dart @@ -551,6 +551,7 @@ class TrackNotifier extends Notifier { state = TrackState( isLoading: true, hasSearchText: state.hasSearchText, + isShowingRecentAccess: state.isShowingRecentAccess, selectedSearchFilter: currentFilter, ); @@ -713,6 +714,7 @@ class TrackNotifier extends Notifier { searchPlaylists: playlists, isLoading: false, hasSearchText: state.hasSearchText, + isShowingRecentAccess: state.isShowingRecentAccess, selectedSearchFilter: currentFilter, // Preserve filter in results ); } catch (e, stackTrace) { @@ -722,6 +724,7 @@ class TrackNotifier extends Notifier { isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText, + isShowingRecentAccess: state.isShowingRecentAccess, selectedSearchFilter: currentFilter, ); } @@ -737,6 +740,7 @@ class TrackNotifier extends Notifier { state = TrackState( isLoading: true, hasSearchText: state.hasSearchText, + isShowingRecentAccess: state.isShowingRecentAccess, selectedSearchFilter: state.selectedSearchFilter, // Preserve filter during loading ); @@ -776,6 +780,7 @@ class TrackNotifier extends Notifier { searchArtists: [], isLoading: false, hasSearchText: state.hasSearchText, + isShowingRecentAccess: state.isShowingRecentAccess, searchExtensionId: extensionId, // Store which extension was used selectedSearchFilter: state.selectedSearchFilter, // Preserve selected filter @@ -787,6 +792,7 @@ class TrackNotifier extends Notifier { isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText, + isShowingRecentAccess: state.isShowingRecentAccess, ); } } @@ -808,6 +814,8 @@ class TrackNotifier extends Notifier { artistName: track.artistName, albumName: track.albumName, albumArtist: track.albumArtist, + artistId: track.artistId, + albumId: track.albumId, coverUrl: track.coverUrl, isrc: track.isrc, duration: track.duration, @@ -876,19 +884,23 @@ class TrackNotifier extends Notifier { playlistName: playlistName, coverUrl: coverUrl, hasSearchText: state.hasSearchText, + isShowingRecentAccess: state.isShowingRecentAccess, ); } Track _parseTrack(Map data) { + final durationMs = _extractDurationMs(data); return Track( id: data['spotify_id'] as String? ?? '', name: data['name'] as String? ?? '', artistName: data['artists'] as String? ?? '', albumName: data['album_name'] as String? ?? '', albumArtist: data['album_artist'] as String?, + artistId: (data['artist_id'] ?? data['artistId'])?.toString(), + albumId: data['album_id']?.toString(), coverUrl: data['images'] as String?, isrc: data['isrc'] as String?, - duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(), + duration: (durationMs / 1000).round(), trackNumber: data['track_number'] as int?, discNumber: data['disc_number'] as int?, releaseDate: data['release_date'] as String?, @@ -896,13 +908,7 @@ class TrackNotifier extends Notifier { } Track _parseSearchTrack(Map data, {String? source}) { - int durationMs = 0; - final durationValue = data['duration_ms']; - if (durationValue is int) { - durationMs = durationValue; - } else if (durationValue is double) { - durationMs = durationValue.toInt(); - } + final durationMs = _extractDurationMs(data); final itemType = data['item_type']?.toString(); @@ -912,6 +918,8 @@ class TrackNotifier extends Notifier { 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(), @@ -927,6 +935,32 @@ class TrackNotifier extends Notifier { ); } + int _extractDurationMs(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; + } + ArtistAlbum _parseArtistAlbum(Map data) { return ArtistAlbum( id: data['id'] as String? ?? '', diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index 7e5d7d1c..a6516270 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -14,9 +14,7 @@ import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart'; -import 'package:spotiflac_android/screens/artist_screen.dart'; -import 'package:spotiflac_android/screens/home_tab.dart' - show ExtensionArtistScreen; +import 'package:spotiflac_android/utils/clickable_metadata.dart'; class _AlbumCache { static final Map _cache = {}; @@ -187,7 +185,8 @@ class _AlbumScreenState extends ConsumerState { .toList(); final albumInfo = metadata['album_info'] as Map?; - final artistId = albumInfo?['artist_id'] as String?; + final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId']) + ?.toString(); _AlbumCache.set(widget.albumId, tracks); @@ -215,6 +214,9 @@ class _AlbumScreenState extends ConsumerState { artistName: data['artists'] as String? ?? '', albumName: data['album_name'] as String? ?? '', albumArtist: data['album_artist'] as String?, + artistId: + (data['artist_id'] ?? data['artistId'])?.toString() ?? _artistId, + albumId: data['album_id']?.toString() ?? widget.albumId, coverUrl: data['images'] as String?, isrc: data['isrc'] as String?, duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(), @@ -368,19 +370,19 @@ class _AlbumScreenState extends ConsumerState { ), if (artistName != null && artistName.isNotEmpty) ...[ const SizedBox(height: 6), - GestureDetector( - onTap: () => _navigateToArtist(context, artistName), - child: Text( - artistName, - style: TextStyle( - color: colorScheme.primary, - fontSize: 16, - fontWeight: FontWeight.w600, - ), - textAlign: TextAlign.center, - maxLines: 1, - overflow: TextOverflow.ellipsis, + ClickableArtistName( + artistName: artistName, + artistId: _artistId, + coverUrl: widget.coverUrl, + extensionId: widget.extensionId, + style: TextStyle( + color: colorScheme.primary, + fontSize: 16, + fontWeight: FontWeight.w600, ), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, ), ], if (tracks.isNotEmpty) ...[ @@ -459,7 +461,7 @@ class _AlbumScreenState extends ConsumerState { const SizedBox(width: 12), FilledButton.icon( onPressed: () => _downloadAll(context), - icon: const Icon(Icons.download, size: 18), + icon: Icon(Icons.download, size: 18), label: Text( context.l10n.downloadAllCount(tracks.length), ), @@ -608,8 +610,9 @@ class _AlbumScreenState extends ConsumerState { ), ), child: IconButton( - onPressed: - tracks == null || tracks.isEmpty ? null : () => _loveAll(tracks), + onPressed: tracks == null || tracks.isEmpty + ? null + : () => _loveAll(tracks), icon: Icon( allLoved ? Icons.favorite : Icons.favorite_border, size: 22, @@ -634,10 +637,9 @@ class _AlbumScreenState extends ConsumerState { ), ), child: IconButton( - onPressed: - _tracks == null || _tracks!.isEmpty - ? null - : () => showAddTracksToPlaylistSheet(context, ref, _tracks!), + onPressed: _tracks == null || _tracks!.isEmpty + ? null + : () => showAddTracksToPlaylistSheet(context, ref, _tracks!), icon: const Icon(Icons.add, size: 22, color: Colors.white), tooltip: 'Add to Playlist', padding: EdgeInsets.zero, @@ -657,9 +659,7 @@ class _AlbumScreenState extends ConsumerState { } if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Removed ${tracks.length} tracks from Loved'), - ), + SnackBar(content: Text('Removed ${tracks.length} tracks from Loved')), ); } } else { @@ -672,55 +672,12 @@ class _AlbumScreenState extends ConsumerState { } if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Added $addedCount tracks to Loved'), - ), + SnackBar(content: Text('Added $addedCount tracks to Loved')), ); } } } - void _navigateToArtist(BuildContext context, String artistName) { - final artistId = - _artistId ?? - (widget.albumId.startsWith('deezer:') ? 'deezer:unknown' : 'unknown'); - - if (artistId == 'unknown' || - artistId == 'deezer:unknown' || - artistId.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Artist information not available')), - ); - return; - } - - if (widget.extensionId != null) { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => ExtensionArtistScreen( - extensionId: widget.extensionId!, - artistId: artistId, - artistName: artistName, - coverUrl: widget.coverUrl, - ), - ), - ); - return; - } - - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => ArtistScreen( - artistId: artistId, - artistName: artistName, - coverUrl: widget.coverUrl, - ), - ), - ); - } - Widget _buildErrorWidget(String error, ColorScheme colorScheme) { final isRateLimit = error.contains('429') || @@ -860,8 +817,10 @@ class _AlbumTrackItem extends ConsumerWidget { subtitle: Row( children: [ Flexible( - child: Text( - track.artistName, + child: ClickableArtistName( + artistName: track.artistName, + artistId: track.artistId, + coverUrl: track.coverUrl, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant), @@ -909,6 +868,11 @@ class _AlbumTrackItem extends ConsumerWidget { isInHistory: isInHistory, isInLocalLibrary: isInLocalLibrary, ), + onLongPress: () => TrackCollectionQuickActions.showTrackOptionsSheet( + context, + ref, + track, + ), ), ), ); diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index 76c9973d..420ee857 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -18,6 +18,7 @@ import 'package:spotiflac_android/screens/home_tab.dart' show ExtensionAlbumScreen; import 'package:spotiflac_android/widgets/download_service_picker.dart'; import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; +import 'package:spotiflac_android/utils/clickable_metadata.dart'; /// Simple in-memory cache for artist data class _ArtistCache { @@ -309,6 +310,10 @@ class _ArtistScreenState extends ConsumerState { 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() ?? + widget.artistId, + albumId: data['album_id']?.toString(), coverUrl: (data['cover_url'] ?? data['images'])?.toString(), isrc: data['isrc']?.toString(), duration: (durationMs / 1000).round(), @@ -675,6 +680,7 @@ class _ArtistScreenState extends ConsumerState { showModalBottomSheet( context: context, + useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(28)), @@ -772,7 +778,6 @@ class _ArtistScreenState extends ConsumerState { List albums, ) async { final settings = ref.read(settingsProvider); - if (settings.askQualityBeforeDownload) { DownloadServicePicker.show( context, @@ -990,6 +995,8 @@ class _ArtistScreenState extends ConsumerState { .toString(), albumName: album.name, albumArtist: widget.artistName, + artistId: widget.artistId, + albumId: album.id.isNotEmpty ? album.id : null, coverUrl: album.coverUrl, isrc: data['isrc']?.toString(), duration: (durationMs / 1000).round(), @@ -1110,17 +1117,18 @@ class _ArtistScreenState extends ConsumerState { children: [ Text( widget.artistName, - style: Theme.of(context).textTheme.headlineLarge?.copyWith( - fontWeight: FontWeight.bold, - color: Colors.white, - shadows: [ - Shadow( - offset: const Offset(0, 1), - blurRadius: 4, - color: Colors.black.withValues(alpha: 0.5), + style: Theme.of(context).textTheme.headlineLarge + ?.copyWith( + fontWeight: FontWeight.bold, + color: Colors.white, + shadows: [ + Shadow( + offset: const Offset(0, 1), + blurRadius: 4, + color: Colors.black.withValues(alpha: 0.5), + ), + ], ), - ], - ), maxLines: 2, overflow: TextOverflow.ellipsis, ), @@ -1128,16 +1136,19 @@ class _ArtistScreenState extends ConsumerState { const SizedBox(height: 4), Text( listenersText, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Colors.white.withValues(alpha: 0.8), - shadows: [ - Shadow( - offset: const Offset(0, 1), - blurRadius: 2, - color: Colors.black.withValues(alpha: 0.5), + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith( + color: Colors.white.withValues(alpha: 0.8), + shadows: [ + Shadow( + offset: const Offset(0, 1), + blurRadius: 2, + color: Colors.black.withValues( + alpha: 0.5, + ), + ), + ], ), - ], - ), ), ], ], @@ -1263,6 +1274,11 @@ class _ArtistScreenState extends ConsumerState { isInHistory: isInHistory, isInLocalLibrary: isInLocalLibrary, ), + onLongPress: () => TrackCollectionQuickActions.showTrackOptionsSheet( + context, + ref, + track, + ), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Row( @@ -1329,8 +1345,12 @@ class _ArtistScreenState extends ConsumerState { overflow: TextOverflow.ellipsis, ), if (track.albumName.isNotEmpty) - Text( - track.albumName, + ClickableAlbumName( + albumName: track.albumName, + albumId: track.albumId, + artistName: track.artistName, + coverUrl: track.coverUrl, + extensionId: widget.extensionId, style: Theme.of(context).textTheme.bodySmall ?.copyWith(color: colorScheme.onSurfaceVariant), maxLines: 1, @@ -1339,9 +1359,7 @@ class _ArtistScreenState extends ConsumerState { ], ), ), - TrackCollectionQuickActions( - track: track, - ), + TrackCollectionQuickActions(track: track), ], ), ), diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index 3187aed0..0db150eb 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -13,6 +13,7 @@ import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; +import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart'; import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart'; @@ -267,9 +268,17 @@ class _DownloadedAlbumScreenState extends ConsumerState { } } - Future _openFile(String filePath) async { + Future _openFile(DownloadHistoryItem track) async { try { - await openFile(filePath); + await ref + .read(playbackProvider.notifier) + .playLocalPath( + path: track.filePath, + title: track.trackName, + artist: track.artistName, + album: track.albumName, + coverUrl: track.coverUrl ?? '', + ); } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -849,7 +858,7 @@ class _DownloadedAlbumScreenState extends ConsumerState { trailing: _isSelectionMode ? null : IconButton( - onPressed: () => _openFile(track.filePath), + onPressed: () => _openFile(track), icon: Icon(Icons.play_arrow, color: colorScheme.primary), style: IconButton.styleFrom( backgroundColor: colorScheme.primaryContainer.withValues( @@ -915,6 +924,7 @@ class _DownloadedAlbumScreenState extends ConsumerState { showModalBottomSheet( context: context, + useRootNavigator: true, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 2066e08e..6462d952 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -26,6 +26,7 @@ import 'package:spotiflac_android/screens/playlist_screen.dart'; import 'package:spotiflac_android/screens/downloaded_album_screen.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; +import 'package:spotiflac_android/utils/clickable_metadata.dart'; class HomeTab extends ConsumerStatefulWidget { const HomeTab({super.key}); @@ -64,6 +65,121 @@ class _CsvImportOptions { }); } +class _SearchResultBuckets { + final List realTracks; + final List realTrackIndexes; + final List albumItems; + final List playlistItems; + final List artistItems; + + const _SearchResultBuckets({ + required this.realTracks, + required this.realTrackIndexes, + required this.albumItems, + required this.playlistItems, + required this.artistItems, + }); +} + +_RecentAccessView _buildRecentAccessViewData( + List items, + List historyItems, + Set hiddenIds, +) { + final albumGroups = {}; + for (final h in historyItems) { + final artistForKey = (h.albumArtist != null && h.albumArtist!.isNotEmpty) + ? h.albumArtist! + : h.artistName; + final albumKey = '${h.albumName}|$artistForKey'; + final existing = albumGroups[albumKey]; + if (existing == null) { + albumGroups[albumKey] = _RecentAlbumAggregate(count: 1, mostRecent: h); + } else { + existing.count++; + if (h.downloadedAt.isAfter(existing.mostRecent.downloadedAt)) { + existing.mostRecent = h; + } + } + } + + final downloadIds = []; + final visibleDownloads = []; + final downloadFilePathByRecentKey = {}; + for (final aggregate in albumGroups.values) { + final mostRecent = aggregate.mostRecent; + final artistForKey = + (mostRecent.albumArtist != null && mostRecent.albumArtist!.isNotEmpty) + ? mostRecent.albumArtist! + : mostRecent.artistName; + + final isSingleTrack = aggregate.count == 1; + final recentId = isSingleTrack + ? (mostRecent.spotifyId ?? mostRecent.id) + : '${mostRecent.albumName}|$artistForKey'; + final recent = RecentAccessItem( + id: recentId, + name: isSingleTrack ? mostRecent.trackName : mostRecent.albumName, + subtitle: isSingleTrack ? mostRecent.artistName : artistForKey, + imageUrl: mostRecent.coverUrl, + type: isSingleTrack ? RecentAccessType.track : RecentAccessType.album, + accessedAt: mostRecent.downloadedAt, + providerId: 'download', + ); + + downloadIds.add(recentId); + downloadFilePathByRecentKey['${recent.type.name}:${recent.id}'] = + mostRecent.filePath; + if (!hiddenIds.contains(recentId)) { + visibleDownloads.add(recent); + } + } + + visibleDownloads.sort((a, b) => b.accessedAt.compareTo(a.accessedAt)); + if (visibleDownloads.length > 10) { + visibleDownloads.removeRange(10, visibleDownloads.length); + } + + final allItems = [...items, ...visibleDownloads]; + allItems.sort((a, b) => b.accessedAt.compareTo(a.accessedAt)); + + final seen = {}; + final uniqueItems = []; + for (final item in allItems) { + final key = '${item.type.name}:${item.id}'; + if (seen.add(key)) { + uniqueItems.add(item); + if (uniqueItems.length >= 10) { + break; + } + } + } + + return _RecentAccessView( + uniqueItems: uniqueItems, + downloadIds: downloadIds, + downloadFilePathByRecentKey: downloadFilePathByRecentKey, + hasHiddenDownloads: hiddenIds.isNotEmpty, + ); +} + +final recentAccessViewProvider = Provider<_RecentAccessView>((ref) { + final historyItems = ref.watch( + downloadHistoryProvider.select((s) => s.items), + ); + final recentAccessItems = ref.watch( + recentAccessProvider.select((s) => s.items), + ); + final hiddenDownloadIds = ref.watch( + recentAccessProvider.select((s) => s.hiddenDownloadIds), + ); + return _buildRecentAccessViewData( + recentAccessItems, + historyItems, + hiddenDownloadIds, + ); +}); + class _HomeTabState extends ConsumerState with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin { final _urlController = TextEditingController(); @@ -79,13 +195,24 @@ class _HomeTabState extends ConsumerState static const int _minLiveSearchChars = 3; static const Duration _liveSearchDelay = Duration(milliseconds: 800); - List? _recentAccessHistoryCache; - List? _recentAccessItemsCache; - Set? _recentAccessHiddenIdsCache; - _RecentAccessView? _recentAccessViewCache; bool _embeddedCoverRefreshScheduled = false; List? _thumbnailSizesExtensionsCache; + bool _isCsvImporting = false; + + void _setCsvImporting(bool value) { + if (_isCsvImporting == value) return; + if (!mounted) { + _isCsvImporting = value; + return; + } + setState(() { + _isCsvImporting = value; + }); + } + Map? _thumbnailSizesCache; + List? _searchBucketsSourceTracks; + _SearchResultBuckets? _searchBucketsCache; double _responsiveScale({ required BuildContext context, @@ -140,6 +267,12 @@ class _HomeTabState extends ConsumerState _urlController.addListener(_onSearchChanged); _searchFocusNode.addListener(_onSearchFocusChanged); + // Run an initial fetch check in case extensions were already initialized + // before HomeTab was mounted (e.g. auto-installed during first setup). + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _fetchExploreIfNeeded(); + }); + _trackStateSub = ref.listenManual(trackProvider, ( previous, next, @@ -230,6 +363,74 @@ class _HomeTabState extends ConsumerState return map; } + List _resolveSearchFilters( + String? currentSearchProvider, + List extensions, + ) { + final isUsingExtensionSearch = + currentSearchProvider != null && + currentSearchProvider.isNotEmpty && + extensions.any((e) => e.id == currentSearchProvider && e.enabled); + + if (isUsingExtensionSearch) { + final currentSearchExtension = extensions + .where((e) => e.id == currentSearchProvider && e.enabled) + .firstOrNull; + final filters = currentSearchExtension?.searchBehavior?.filters; + if (filters != null && filters.isNotEmpty) { + return filters; + } + } + + return const [ + SearchFilter(id: 'track', label: 'Tracks', icon: 'music'), + SearchFilter(id: 'artist', label: 'Artists', icon: 'artist'), + SearchFilter(id: 'album', label: 'Albums', icon: 'album'), + SearchFilter(id: 'playlist', label: 'Playlists', icon: 'playlist'), + ]; + } + + _SearchResultBuckets _getSearchResultBuckets(List tracks) { + final cached = _searchBucketsCache; + if (cached != null && identical(tracks, _searchBucketsSourceTracks)) { + return cached; + } + + final realTracks = []; + final realTrackIndexes = []; + final albumItems = []; + final playlistItems = []; + final artistItems = []; + + for (int i = 0; i < tracks.length; i++) { + final track = tracks[i]; + if (!track.isCollection) { + realTracks.add(track); + realTrackIndexes.add(i); + } + if (track.isAlbumItem) { + albumItems.add(track); + } + if (track.isPlaylistItem) { + playlistItems.add(track); + } + if (track.isArtistItem) { + artistItems.add(track); + } + } + + final buckets = _SearchResultBuckets( + realTracks: realTracks, + realTrackIndexes: realTrackIndexes, + albumItems: albumItems, + playlistItems: playlistItems, + artistItems: artistItems, + ); + _searchBucketsSourceTracks = tracks; + _searchBucketsCache = buckets; + return buckets; + } + void _onSearchFocusChanged() { if (mounted) { setState(() {}); @@ -502,20 +703,28 @@ class _HomeTabState extends ConsumerState } Future _importCsv(BuildContext context, WidgetRef ref) async { + if (_isCsvImporting) return; + _setCsvImporting(true); + int currentProgress = 0; int totalTracks = 0; - bool dialogShown = false; + bool progressDialogInitialized = false; + bool progressDialogVisible = false; + BuildContext? progressDialogContext; StateSetter? setDialogState; void showProgressDialog() { - if (dialogShown || !mounted) return; - dialogShown = true; + if (progressDialogInitialized || !mounted) return; + progressDialogInitialized = true; + progressDialogVisible = true; showDialog( context: this.context, + useRootNavigator: false, barrierDismissible: false, builder: (dialogCtx) => StatefulBuilder( builder: (dialogCtx, setState) { + progressDialogContext = dialogCtx; setDialogState = setState; return AlertDialog( content: Column( @@ -536,169 +745,193 @@ class _HomeTabState extends ConsumerState ); }, ), - ); + ).then((_) { + progressDialogVisible = false; + progressDialogContext = null; + }); } - final tracks = await CsvImportService.pickAndParseCsv( - onProgress: (current, total) { - currentProgress = current; - totalTracks = total; - if (!dialogShown && total > 0) { - showProgressDialog(); + void closeProgressDialog() { + if (!progressDialogVisible) return; + setDialogState = null; + try { + if (progressDialogContext != null) { + Navigator.of(progressDialogContext!).pop(); + } else if (mounted) { + final navigator = Navigator.of(this.context); + if (navigator.canPop()) { + navigator.pop(); + } } - setDialogState?.call(() {}); - }, - ); - - if (dialogShown && mounted) { - Navigator.of(this.context).pop(); + } catch (_) {} + progressDialogVisible = false; + progressDialogContext = null; } - if (tracks.isNotEmpty) { - final settings = ref.read(settingsProvider); - - if (!mounted) return; - - // ignore: use_build_context_synchronously - final l10n = context.l10n; - - final options = await showDialog<_CsvImportOptions>( - context: this.context, - builder: (dialogCtx) { - var skipDownloaded = true; - return StatefulBuilder( - builder: (dialogCtx, setDialogState) => AlertDialog( - title: Text(l10n.dialogImportPlaylistTitle), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(l10n.dialogImportPlaylistMessage(tracks.length)), - const SizedBox(height: 12), - CheckboxListTile( - contentPadding: EdgeInsets.zero, - title: const Text('Skip already downloaded songs'), - value: skipDownloaded, - onChanged: (value) { - setDialogState(() { - skipDownloaded = value ?? true; - }); - }, - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop( - dialogCtx, - const _CsvImportOptions( - confirmed: false, - skipDownloaded: true, - ), - ), - child: Text(l10n.dialogCancel), - ), - FilledButton( - onPressed: () => Navigator.pop( - dialogCtx, - _CsvImportOptions( - confirmed: true, - skipDownloaded: skipDownloaded, - ), - ), - child: Text(l10n.dialogImport), - ), - ], - ), - ); + try { + final tracks = await CsvImportService.pickAndParseCsv( + onProgress: (current, total) { + currentProgress = current; + totalTracks = total; + if (!progressDialogInitialized && total > 0) { + showProgressDialog(); + } + setDialogState?.call(() {}); }, ); - if (options == null || !options.confirmed) return; + closeProgressDialog(); - var tracksToQueue = tracks; - var skippedDownloadedCount = 0; + if (tracks.isNotEmpty) { + final settings = ref.read(settingsProvider); - if (options.skipDownloaded) { - final historyState = ref.read(downloadHistoryProvider); - tracksToQueue = []; - for (final track in tracks) { - final isDownloaded = - historyState.isDownloaded(track.id) || - (track.isrc != null && - historyState.getByIsrc(track.isrc!) != null); - if (isDownloaded) { - skippedDownloadedCount++; - continue; - } - tracksToQueue.add(track); - } - } + if (!mounted) return; - if (tracksToQueue.isEmpty) { - if (mounted) { - ScaffoldMessenger.of(this.context).showSnackBar( - SnackBar( - content: Text( - l10n.discographySkippedDownloaded(0, skippedDownloadedCount), - ), - ), - ); - } - return; - } + // ignore: use_build_context_synchronously + final l10n = context.l10n; - final queueSnackbarMessage = skippedDownloadedCount > 0 - ? l10n.discographySkippedDownloaded( - tracksToQueue.length, - skippedDownloadedCount, - ) - : l10n.snackbarAddedTracksToQueue(tracksToQueue.length); - - if (!mounted) return; - - if (settings.askQualityBeforeDownload) { - DownloadServicePicker.show( - this.context, - trackName: l10n.csvImportTracks(tracksToQueue.length), - artistName: l10n.dialogImportPlaylistTitle, - onSelect: (quality, service) { - ref - .read(downloadQueueProvider.notifier) - .addMultipleToQueue( - tracksToQueue, - service, - qualityOverride: quality, - ); - if (mounted) { - ScaffoldMessenger.of(this.context).showSnackBar( - SnackBar( - content: Text(queueSnackbarMessage), - action: SnackBarAction( - label: l10n.snackbarViewQueue, - onPressed: () {}, - ), + final options = await showDialog<_CsvImportOptions>( + context: this.context, + useRootNavigator: false, + builder: (dialogCtx) { + var skipDownloaded = true; + return StatefulBuilder( + builder: (dialogCtx, setDialogState) => AlertDialog( + title: Text(l10n.dialogImportPlaylistTitle), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.dialogImportPlaylistMessage(tracks.length)), + const SizedBox(height: 12), + CheckboxListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Skip already downloaded songs'), + value: skipDownloaded, + onChanged: (value) { + setDialogState(() { + skipDownloaded = value ?? true; + }); + }, + ), + ], ), - ); - } + actions: [ + TextButton( + onPressed: () => Navigator.pop( + dialogCtx, + const _CsvImportOptions( + confirmed: false, + skipDownloaded: true, + ), + ), + child: Text(l10n.dialogCancel), + ), + FilledButton( + onPressed: () => Navigator.pop( + dialogCtx, + _CsvImportOptions( + confirmed: true, + skipDownloaded: skipDownloaded, + ), + ), + child: Text(l10n.dialogImport), + ), + ], + ), + ); }, ); - } else { - ref - .read(downloadQueueProvider.notifier) - .addMultipleToQueue(tracksToQueue, settings.defaultService); - if (mounted) { - ScaffoldMessenger.of(this.context).showSnackBar( - SnackBar( - content: Text(queueSnackbarMessage), - action: SnackBarAction( - label: l10n.snackbarViewQueue, - onPressed: () {}, + + if (options == null || !options.confirmed) return; + + var tracksToQueue = tracks; + var skippedDownloadedCount = 0; + + if (options.skipDownloaded) { + final historyState = ref.read(downloadHistoryProvider); + tracksToQueue = []; + for (final track in tracks) { + final isDownloaded = + historyState.isDownloaded(track.id) || + (track.isrc != null && + historyState.getByIsrc(track.isrc!) != null); + if (isDownloaded) { + skippedDownloadedCount++; + continue; + } + tracksToQueue.add(track); + } + } + + if (tracksToQueue.isEmpty) { + if (mounted) { + ScaffoldMessenger.of(this.context).showSnackBar( + SnackBar( + content: Text( + l10n.discographySkippedDownloaded(0, skippedDownloadedCount), + ), ), - ), + ); + } + return; + } + + final queueSnackbarMessage = skippedDownloadedCount > 0 + ? l10n.discographySkippedDownloaded( + tracksToQueue.length, + skippedDownloadedCount, + ) + : l10n.snackbarAddedTracksToQueue(tracksToQueue.length); + + if (!mounted) return; + + if (settings.askQualityBeforeDownload) { + DownloadServicePicker.show( + this.context, + trackName: l10n.csvImportTracks(tracksToQueue.length), + artistName: l10n.dialogImportPlaylistTitle, + onSelect: (quality, service) { + ref + .read(downloadQueueProvider.notifier) + .addMultipleToQueue( + tracksToQueue, + service, + qualityOverride: quality, + ); + if (mounted) { + ScaffoldMessenger.of(this.context).showSnackBar( + SnackBar( + content: Text(queueSnackbarMessage), + action: SnackBarAction( + label: l10n.snackbarViewQueue, + onPressed: () {}, + ), + ), + ); + } + }, ); + } else { + ref + .read(downloadQueueProvider.notifier) + .addMultipleToQueue(tracksToQueue, settings.defaultService); + if (mounted) { + ScaffoldMessenger.of(this.context).showSnackBar( + SnackBar( + content: Text(queueSnackbarMessage), + action: SnackBarAction( + label: l10n.snackbarViewQueue, + onPressed: () {}, + ), + ), + ); + } } } + } finally { + closeProgressDialog(); + _setCsvImporting(false); } } @@ -706,25 +939,22 @@ class _HomeTabState extends ConsumerState Widget build(BuildContext context) { super.build(context); - final tracks = ref.watch(trackProvider.select((s) => s.tracks)); - final searchArtists = ref.watch( - trackProvider.select((s) => s.searchArtists), - ); - final searchAlbums = ref.watch(trackProvider.select((s) => s.searchAlbums)); - final searchPlaylists = ref.watch( - trackProvider.select((s) => s.searchPlaylists), + final hasActualResults = ref.watch( + trackProvider.select( + (s) => + s.tracks.isNotEmpty || + (s.searchArtists != null && s.searchArtists!.isNotEmpty) || + (s.searchAlbums != null && s.searchAlbums!.isNotEmpty) || + (s.searchPlaylists != null && s.searchPlaylists!.isNotEmpty), + ), ); final isLoading = ref.watch(trackProvider.select((s) => s.isLoading)); - final error = ref.watch(trackProvider.select((s) => s.error)); final hasSearchedBefore = ref.watch( settingsProvider.select((s) => s.hasSearchedBefore), ); - final exploreSections = ref.watch( - exploreProvider.select((s) => s.sections), - ); - final exploreGreeting = ref.watch( - exploreProvider.select((s) => s.greeting), + final hasExploreContent = ref.watch( + exploreProvider.select((s) => s.sections.isNotEmpty), ); final exploreLoading = ref.watch( exploreProvider.select((s) => s.isLoading), @@ -736,11 +966,6 @@ class _HomeTabState extends ConsumerState ); final colorScheme = Theme.of(context).colorScheme; - final hasActualResults = - tracks.isNotEmpty || - (searchArtists != null && searchArtists.isNotEmpty) || - (searchAlbums != null && searchAlbums.isNotEmpty) || - (searchPlaylists != null && searchPlaylists.isNotEmpty); final searchText = _urlController.text.trim(); final hasSearchInput = searchText.isNotEmpty; final isSearchFocused = _searchFocusNode.hasFocus; @@ -755,12 +980,6 @@ class _HomeTabState extends ConsumerState final historyItems = ref.watch( downloadHistoryProvider.select((s) => s.items), ); - final recentAccessItems = ref.watch( - recentAccessProvider.select((s) => s.items), - ); - final hiddenDownloadIds = ref.watch( - recentAccessProvider.select((s) => s.hiddenDownloadIds), - ); final recentModeRequested = isShowingRecentAccess || isSearchFocused; final showRecentAccess = @@ -769,15 +988,6 @@ class _HomeTabState extends ConsumerState !isLoading; final hasResults = hasSearchInput || hasActualResults || isLoading || showRecentAccess; - final recentAccessView = showRecentAccess - ? _getRecentAccessView( - recentAccessItems, - historyItems, - hiddenDownloadIds, - ) - : null; - - final hasExploreContent = exploreSections.isNotEmpty; final showExplore = !hasActualResults && !isLoading && @@ -785,52 +995,6 @@ class _HomeTabState extends ConsumerState (hasHomeFeedExtension || hasExploreContent) && hasExploreContent; - // Get current search extension and its filters - final currentSearchProvider = ref.watch( - settingsProvider.select((s) => s.searchProvider), - ); - final extensions = ref.watch(extensionProvider.select((s) => s.extensions)); - final selectedSearchFilter = ref.watch( - trackProvider.select((s) => s.selectedSearchFilter), - ); - final searchExtensionId = ref.watch( - trackProvider.select((s) => s.searchExtensionId), - ); - final localLibrarySettings = ref.watch( - settingsProvider.select( - (s) => (s.localLibraryEnabled, s.localLibraryShowDuplicates), - ), - ); - final showLocalLibraryIndicator = - localLibrarySettings.$1 && localLibrarySettings.$2; - final thumbnailSizesByExtensionId = _getThumbnailSizesByExtensionId( - extensions, - ); - Extension? currentSearchExtension; - List searchFilters = []; - - final isUsingExtensionSearch = - currentSearchProvider != null && - currentSearchProvider.isNotEmpty && - extensions.any((e) => e.id == currentSearchProvider && e.enabled); - - if (isUsingExtensionSearch) { - currentSearchExtension = extensions - .where((e) => e.id == currentSearchProvider && e.enabled) - .firstOrNull; - if (currentSearchExtension?.searchBehavior?.filters.isNotEmpty == true) { - searchFilters = currentSearchExtension!.searchBehavior!.filters; - } - } else { - // Default Deezer filters - searchFilters = const [ - SearchFilter(id: 'track', label: 'Tracks', icon: 'music'), - SearchFilter(id: 'artist', label: 'Artists', icon: 'artist'), - SearchFilter(id: 'album', label: 'Albums', icon: 'album'), - SearchFilter(id: 'playlist', label: 'Playlists', icon: 'playlist'), - ]; - } - if (hasActualResults && isShowingRecentAccess && hasSearchInput && @@ -953,20 +1117,45 @@ class _HomeTabState extends ConsumerState ), // Search filter bar (only shown when has search results) - if (searchFilters.isNotEmpty && - hasActualResults && - !showRecentAccess) - SliverToBoxAdapter( - child: _buildSearchFilterBar( - searchFilters, - selectedSearchFilter, - colorScheme, - ), + if (hasActualResults && !showRecentAccess) + Consumer( + builder: (context, ref, _) { + final currentSearchProvider = ref.watch( + settingsProvider.select((s) => s.searchProvider), + ); + final extensions = ref.watch( + extensionProvider.select((s) => s.extensions), + ); + final selectedSearchFilter = ref.watch( + trackProvider.select((s) => s.selectedSearchFilter), + ); + final searchFilters = _resolveSearchFilters( + currentSearchProvider, + extensions, + ); + if (searchFilters.isEmpty) { + return const SliverToBoxAdapter(child: SizedBox.shrink()); + } + return SliverToBoxAdapter( + child: _buildSearchFilterBar( + searchFilters, + selectedSearchFilter, + colorScheme, + ), + ); + }, ), if (showRecentAccess) - SliverToBoxAdapter( - child: _buildRecentAccess(recentAccessView!, colorScheme), + Consumer( + builder: (context, ref, _) { + final recentAccessView = ref.watch( + recentAccessViewProvider, + ); + return SliverToBoxAdapter( + child: _buildRecentAccess(recentAccessView, colorScheme), + ); + }, ), SliverToBoxAdapter( @@ -1008,10 +1197,22 @@ class _HomeTabState extends ConsumerState ), if (showExplore) - ..._buildExploreSections( - exploreSections, - exploreGreeting, - colorScheme, + Consumer( + builder: (context, ref, _) { + final exploreSections = ref.watch( + exploreProvider.select((s) => s.sections), + ); + final exploreGreeting = ref.watch( + exploreProvider.select((s) => s.greeting), + ); + return SliverMainAxisGroup( + slivers: _buildExploreSections( + exploreSections, + exploreGreeting, + colorScheme, + ), + ); + }, ), if (hasHomeFeedExtension && @@ -1025,18 +1226,63 @@ class _HomeTabState extends ConsumerState ), ), - ..._buildSearchResults( - tracks: tracks, - searchArtists: searchArtists, - searchAlbums: searchAlbums, - searchPlaylists: searchPlaylists, - isLoading: isLoading, - error: error, - colorScheme: colorScheme, - hasResults: hasActualResults || isLoading, - searchExtensionId: searchExtensionId, - showLocalLibraryIndicator: showLocalLibraryIndicator, - thumbnailSizesByExtensionId: thumbnailSizesByExtensionId, + Consumer( + builder: (context, ref, _) { + final tracks = ref.watch( + trackProvider.select((s) => s.tracks), + ); + final searchArtists = ref.watch( + trackProvider.select((s) => s.searchArtists), + ); + final searchAlbums = ref.watch( + trackProvider.select((s) => s.searchAlbums), + ); + final searchPlaylists = ref.watch( + trackProvider.select((s) => s.searchPlaylists), + ); + final isLoading = ref.watch( + trackProvider.select((s) => s.isLoading), + ); + final error = ref.watch(trackProvider.select((s) => s.error)); + final searchExtensionId = ref.watch( + trackProvider.select((s) => s.searchExtensionId), + ); + final localLibrarySettings = ref.watch( + settingsProvider.select( + (s) => + (s.localLibraryEnabled, s.localLibraryShowDuplicates), + ), + ); + final extensions = ref.watch( + extensionProvider.select((s) => s.extensions), + ); + final showLocalLibraryIndicator = + localLibrarySettings.$1 && localLibrarySettings.$2; + final thumbnailSizesByExtensionId = + _getThumbnailSizesByExtensionId(extensions); + final hasResults = + tracks.isNotEmpty || + (searchArtists != null && searchArtists.isNotEmpty) || + (searchAlbums != null && searchAlbums.isNotEmpty) || + (searchPlaylists != null && searchPlaylists.isNotEmpty) || + isLoading; + + return SliverMainAxisGroup( + slivers: _buildSearchResults( + tracks: tracks, + searchArtists: searchArtists, + searchAlbums: searchAlbums, + searchPlaylists: searchPlaylists, + isLoading: isLoading, + error: error, + colorScheme: colorScheme, + hasResults: hasResults, + searchExtensionId: searchExtensionId, + showLocalLibraryIndicator: showLocalLibraryIndicator, + thumbnailSizesByExtensionId: thumbnailSizesByExtensionId, + ), + ); + }, ), ], ), @@ -1159,103 +1405,6 @@ class _HomeTabState extends ConsumerState ); } - _RecentAccessView _getRecentAccessView( - List items, - List historyItems, - Set hiddenIds, - ) { - final cached = _recentAccessViewCache; - if (cached != null && - identical(historyItems, _recentAccessHistoryCache) && - identical(items, _recentAccessItemsCache) && - identical(hiddenIds, _recentAccessHiddenIdsCache)) { - return cached; - } - - final albumGroups = {}; - for (final h in historyItems) { - final artistForKey = (h.albumArtist != null && h.albumArtist!.isNotEmpty) - ? h.albumArtist! - : h.artistName; - final albumKey = '${h.albumName}|$artistForKey'; - final existing = albumGroups[albumKey]; - if (existing == null) { - albumGroups[albumKey] = _RecentAlbumAggregate(count: 1, mostRecent: h); - } else { - existing.count++; - if (h.downloadedAt.isAfter(existing.mostRecent.downloadedAt)) { - existing.mostRecent = h; - } - } - } - - final downloadIds = []; - final visibleDownloads = []; - final downloadFilePathByRecentKey = {}; - for (final aggregate in albumGroups.values) { - final mostRecent = aggregate.mostRecent; - final artistForKey = - (mostRecent.albumArtist != null && mostRecent.albumArtist!.isNotEmpty) - ? mostRecent.albumArtist! - : mostRecent.artistName; - - final isSingleTrack = aggregate.count == 1; - final recentId = isSingleTrack - ? (mostRecent.spotifyId ?? mostRecent.id) - : '${mostRecent.albumName}|$artistForKey'; - final recent = RecentAccessItem( - id: recentId, - name: isSingleTrack ? mostRecent.trackName : mostRecent.albumName, - subtitle: isSingleTrack ? mostRecent.artistName : artistForKey, - imageUrl: mostRecent.coverUrl, - type: isSingleTrack ? RecentAccessType.track : RecentAccessType.album, - accessedAt: mostRecent.downloadedAt, - providerId: 'download', - ); - - downloadIds.add(recentId); - downloadFilePathByRecentKey['${recent.type.name}:${recent.id}'] = - mostRecent.filePath; - if (!hiddenIds.contains(recentId)) { - visibleDownloads.add(recent); - } - } - - visibleDownloads.sort((a, b) => b.accessedAt.compareTo(a.accessedAt)); - if (visibleDownloads.length > 10) { - visibleDownloads.removeRange(10, visibleDownloads.length); - } - - final allItems = [...items, ...visibleDownloads]; - allItems.sort((a, b) => b.accessedAt.compareTo(a.accessedAt)); - - final seen = {}; - final uniqueItems = []; - for (final item in allItems) { - final key = '${item.type.name}:${item.id}'; - if (seen.add(key)) { - uniqueItems.add(item); - if (uniqueItems.length >= 10) { - break; - } - } - } - - final view = _RecentAccessView( - uniqueItems: uniqueItems, - downloadIds: downloadIds, - downloadFilePathByRecentKey: downloadFilePathByRecentKey, - hasHiddenDownloads: hiddenIds.isNotEmpty, - ); - - _recentAccessHistoryCache = historyItems; - _recentAccessItemsCache = items; - _recentAccessHiddenIdsCache = hiddenIds; - _recentAccessViewCache = view; - - return view; - } - List _buildExploreSections( List sections, String? greeting, @@ -1407,8 +1556,10 @@ class _HomeTabState extends ConsumerState ), ), if (item.artists.isNotEmpty && !isArtist) - Text( - item.artists, + ClickableArtistName( + artistName: item.artists, + coverUrl: item.coverUrl, + extensionId: item.providerId, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall?.copyWith( @@ -1499,6 +1650,7 @@ class _HomeTabState extends ConsumerState showModalBottomSheet( context: context, + useRootNavigator: true, backgroundColor: colorScheme.surface, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), @@ -1554,8 +1706,10 @@ class _HomeTabState extends ConsumerState overflow: TextOverflow.ellipsis, ), const SizedBox(height: 4), - Text( - item.artists, + ClickableArtistName( + artistName: item.artists, + coverUrl: item.coverUrl, + extensionId: item.providerId, style: Theme.of(context).textTheme.bodyMedium ?.copyWith(color: colorScheme.onSurfaceVariant), maxLines: 1, @@ -1573,7 +1727,7 @@ class _HomeTabState extends ConsumerState title: Text(context.l10n.downloadTitle), onTap: () { Navigator.pop(context); - _downloadExploreTrack(item); + _handleExploreTrackPrimaryAction(item); }, ), ListTile( @@ -1591,7 +1745,7 @@ class _HomeTabState extends ConsumerState ); } - Future _downloadExploreTrack(ExploreItem item) async { + Future _handleExploreTrackPrimaryAction(ExploreItem item) async { final settings = ref.read(settingsProvider); final track = Track( @@ -1599,6 +1753,7 @@ class _HomeTabState extends ConsumerState name: item.name, artistName: item.artists, albumName: item.albumName ?? '', + albumId: item.albumId, duration: item.durationMs ~/ 1000, trackNumber: 1, discNumber: 1, @@ -1921,6 +2076,7 @@ class _HomeTabState extends ConsumerState ), ); } + return; case RecentAccessType.album: if (item.providerId == 'download') { Navigator.push( @@ -1960,6 +2116,7 @@ class _HomeTabState extends ConsumerState ), ); } + return; case RecentAccessType.track: final historyItem = ref .read(downloadHistoryProvider.notifier) @@ -1971,10 +2128,44 @@ class _HomeTabState extends ConsumerState context, ).showSnackBar(SnackBar(content: Text(item.name))); } + return; case RecentAccessType.playlist: - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.recentPlaylistInfo(item.name))), - ); + if (item.id.trim().isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.recentPlaylistInfo(item.name))), + ); + return; + } + + if (item.providerId != null && + item.providerId!.isNotEmpty && + item.providerId != 'deezer' && + item.providerId != 'spotify') { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ExtensionPlaylistScreen( + extensionId: item.providerId!, + playlistId: item.id, + playlistName: item.name, + coverUrl: item.imageUrl, + ), + ), + ); + } else { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PlaylistScreen( + playlistName: item.name, + coverUrl: item.imageUrl, + tracks: const [], + playlistId: item.id, + ), + ), + ); + } + return; } } @@ -2107,28 +2298,12 @@ class _HomeTabState extends ConsumerState return [const SliverToBoxAdapter(child: SizedBox.shrink())]; } - final realTracks = []; - final realTrackIndexes = []; - final albumItems = []; - final playlistItems = []; - final artistItems = []; - - for (int i = 0; i < tracks.length; i++) { - final track = tracks[i]; - if (!track.isCollection) { - realTracks.add(track); - realTrackIndexes.add(i); - } - if (track.isAlbumItem) { - albumItems.add(track); - } - if (track.isPlaylistItem) { - playlistItems.add(track); - } - if (track.isArtistItem) { - artistItems.add(track); - } - } + final buckets = _getSearchResultBuckets(tracks); + final realTracks = buckets.realTracks; + final realTrackIndexes = buckets.realTrackIndexes; + final albumItems = buckets.albumItems; + final playlistItems = buckets.playlistItems; + final artistItems = buckets.artistItems; final slivers = [ if (error != null) @@ -2676,7 +2851,9 @@ class _HomeTabState extends ConsumerState else ...[ IconButton( icon: const Icon(Icons.file_upload_outlined), - onPressed: () => _importCsv(context, ref), + onPressed: _isCsvImporting + ? null + : () => _importCsv(context, ref), tooltip: 'Import CSV', ), IconButton( @@ -2969,6 +3146,11 @@ class _TrackItemWithStatus extends ConsumerWidget { isInHistory: isInHistory, isInLocalLibrary: isInLocalLibrary, ), + onLongPress: () => TrackCollectionQuickActions.showTrackOptionsSheet( + context, + ref, + track, + ), splashColor: colorScheme.primary.withValues(alpha: 0.12), highlightColor: colorScheme.primary.withValues(alpha: 0.08), child: Padding( @@ -3014,8 +3196,11 @@ class _TrackItemWithStatus extends ConsumerWidget { Row( children: [ Flexible( - child: Text( - track.artistName, + child: ClickableArtistName( + artistName: track.artistName, + artistId: track.artistId, + coverUrl: track.coverUrl, + extensionId: extensionId, style: Theme.of(context).textTheme.bodySmall ?.copyWith( color: colorScheme.onSurfaceVariant, @@ -3061,9 +3246,7 @@ class _TrackItemWithStatus extends ConsumerWidget { ], ), ), - TrackCollectionQuickActions( - track: track, - ), + TrackCollectionQuickActions(track: track), ], ), ), @@ -3407,8 +3590,11 @@ class _SearchAlbumItemWidget extends StatelessWidget { overflow: TextOverflow.ellipsis, ), const SizedBox(height: 2), - Text( - album.artists.isNotEmpty ? album.artists : 'Album', + ClickableArtistName( + artistName: album.artists.isNotEmpty + ? album.artists + : 'Album', + coverUrl: album.imageUrl, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, ), @@ -3608,7 +3794,7 @@ class _ExtensionAlbumScreenState extends ConsumerState { .toList(); // Extract artist info from album response - final artistId = result['artist_id'] as String?; + final artistId = (result['artist_id'] ?? result['artistId'])?.toString(); final artistName = result['artists'] as String?; setState(() { @@ -3640,6 +3826,9 @@ class _ExtensionAlbumScreenState extends ConsumerState { name: (data['name'] ?? '').toString(), artistName: (data['artists'] ?? data['artist'] ?? '').toString(), albumName: (data['album_name'] ?? widget.albumName).toString(), + artistId: + (data['artist_id'] ?? data['artistId'])?.toString() ?? _artistId, + albumId: data['album_id']?.toString() ?? widget.albumId, coverUrl: _resolveCoverUrl( data['cover_url']?.toString(), widget.coverUrl, @@ -3792,6 +3981,8 @@ class _ExtensionPlaylistScreenState name: (data['name'] ?? '').toString(), artistName: (data['artists'] ?? data['artist'] ?? '').toString(), albumName: (data['album_name'] ?? '').toString(), + artistId: (data['artist_id'] ?? data['artistId'])?.toString(), + albumId: data['album_id']?.toString(), coverUrl: _resolveCoverUrl( data['cover_url']?.toString(), widget.coverUrl, @@ -3963,6 +4154,10 @@ class _ExtensionArtistScreenState extends ConsumerState { 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() ?? + widget.artistId, + albumId: data['album_id']?.toString(), coverUrl: (data['cover_url'] ?? data['images'])?.toString(), isrc: data['isrc']?.toString(), duration: (durationMs / 1000).round(), @@ -4177,8 +4372,10 @@ class _QuickPicksPageViewState extends State<_QuickPicksPageView> { ), ), if (item.artists.isNotEmpty) - Text( - item.artists, + ClickableArtistName( + artistName: item.artists, + coverUrl: item.coverUrl, + extensionId: item.providerId, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall?.copyWith( diff --git a/lib/screens/library_playlists_screen.dart b/lib/screens/library_playlists_screen.dart index 5a2eaf7f..2f07b435 100644 --- a/lib/screens/library_playlists_screen.dart +++ b/lib/screens/library_playlists_screen.dart @@ -149,6 +149,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget { showModalBottomSheet( context: context, + useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(28)), @@ -264,6 +265,9 @@ class LibraryPlaylistsScreen extends ConsumerWidget { final colorScheme = Theme.of(context).colorScheme; const double size = 48; final borderRadius = BorderRadius.circular(8); + final dpr = MediaQuery.devicePixelRatioOf(context); + final cacheWidth = (size * dpr).round().clamp(64, 512); + final placeholder = _playlistIconFallback(colorScheme, size); // Priority: custom cover > first track cover URL > icon fallback final customCoverPath = playlist.coverImagePath; @@ -275,7 +279,14 @@ class LibraryPlaylistsScreen extends ConsumerWidget { width: size, height: size, fit: BoxFit.cover, - errorBuilder: (_, _, _) => _playlistIconFallback(colorScheme, size), + cacheWidth: cacheWidth, + gaplessPlayback: true, + filterQuality: FilterQuality.low, + frameBuilder: (_, child, frame, wasSynchronouslyLoaded) { + if (wasSynchronouslyLoaded || frame != null) return child; + return placeholder; + }, + errorBuilder: (_, _, _) => placeholder, ), ); } @@ -302,7 +313,14 @@ class LibraryPlaylistsScreen extends ConsumerWidget { width: size, height: size, fit: BoxFit.cover, - errorBuilder: (_, _, _) => _playlistIconFallback(colorScheme, size), + cacheWidth: cacheWidth, + gaplessPlayback: true, + filterQuality: FilterQuality.low, + frameBuilder: (_, child, frame, wasSynchronouslyLoaded) { + if (wasSynchronouslyLoaded || frame != null) return child; + return placeholder; + }, + errorBuilder: (_, _, _) => placeholder, ), ); } @@ -314,15 +332,15 @@ class LibraryPlaylistsScreen extends ConsumerWidget { width: size, height: size, fit: BoxFit.cover, - memCacheWidth: (size * 2).toInt(), + memCacheWidth: cacheWidth, cacheManager: CoverCacheManager.instance, - placeholder: (_, _) => _playlistIconFallback(colorScheme, size), - errorWidget: (_, _, _) => _playlistIconFallback(colorScheme, size), + placeholder: (_, _) => placeholder, + errorWidget: (_, _, _) => placeholder, ), ); } - return _playlistIconFallback(colorScheme, size); + return placeholder; } Widget _playlistIconFallback(ColorScheme colorScheme, double size) { diff --git a/lib/screens/library_tracks_folder_screen.dart b/lib/screens/library_tracks_folder_screen.dart index cd9e68c4..4198be85 100644 --- a/lib/screens/library_tracks_folder_screen.dart +++ b/lib/screens/library_tracks_folder_screen.dart @@ -6,9 +6,11 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.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/services/library_database.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; @@ -75,11 +77,47 @@ class _LibraryTracksFolderScreenState }; } + String? _resolveEntryCoverUrl( + CollectionTrackEntry entry, + LocalLibraryState localState, + ) { + final rawCover = entry.track.coverUrl?.trim(); + if (rawCover != null && + rawCover.isNotEmpty && + !rawCover.startsWith('content://')) { + return rawCover; + } + + final isrc = entry.track.isrc?.trim(); + if (isrc != null && isrc.isNotEmpty) { + final byIsrc = localState.getByIsrc(isrc); + final localCover = byIsrc?.coverPath?.trim(); + if (localCover != null && localCover.isNotEmpty) { + return localCover; + } + } + + final byTrack = localState.findByTrackAndArtist( + entry.track.name, + entry.track.artistName, + ); + final localCover = byTrack?.coverPath?.trim(); + if (localCover != null && localCover.isNotEmpty) { + return localCover; + } + + return null; + } + /// Find the first available cover URL from entries. - String? _firstCoverUrl(List entries) { + String? _firstCoverUrl( + List entries, + LocalLibraryState localState, + ) { for (final entry in entries) { - if (entry.track.coverUrl != null && entry.track.coverUrl!.isNotEmpty) { - return entry.track.coverUrl; + final cover = _resolveEntryCoverUrl(entry, localState); + if (cover != null && cover.isNotEmpty) { + return cover; } } return null; @@ -173,11 +211,7 @@ class _LibraryTracksFolderScreenState if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.selectionSelected(count), - ), - ), + SnackBar(content: Text(context.l10n.selectionSelected(count))), ); } @@ -196,11 +230,7 @@ class _LibraryTracksFolderScreenState if (!mounted || count == 0) return; ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.selectionSelected(count), - ), - ), + SnackBar(content: Text(context.l10n.selectionSelected(count))), ); } @@ -217,6 +247,8 @@ class _LibraryTracksFolderScreenState @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + ref.watch(localLibraryProvider.select((s) => s.items)); + final localState = ref.read(localLibraryProvider); final UserPlaylistCollection? playlist; final List entries; @@ -280,6 +312,9 @@ class _LibraryTracksFolderScreenState LibraryTracksFolderMode.playlist => context.l10n.collectionPlaylistEmptySubtitle, }; + final folderTracks = entries + .map((entry) => entry.track) + .toList(growable: false); final bottomPadding = MediaQuery.of(context).padding.bottom; @@ -296,7 +331,14 @@ class _LibraryTracksFolderScreenState CustomScrollView( controller: _scrollController, slivers: [ - _buildAppBar(context, colorScheme, title, entries, playlist), + _buildAppBar( + context, + colorScheme, + title, + entries, + playlist, + localState, + ), if (entries.isEmpty) SliverFillRemaining( hasScrollBody: false, @@ -316,6 +358,8 @@ class _LibraryTracksFolderScreenState entry: entry, mode: widget.mode, playlistId: widget.playlistId, + localLibraryState: localState, + folderTracks: folderTracks, isSelectionMode: _isSelectionMode, isSelected: isSelected, onTap: _isSelectionMode @@ -494,8 +538,8 @@ class _LibraryTracksFolderScreenState selectedCount > 0 ? '${widget.mode == LibraryTracksFolderMode.playlist ? context.l10n.collectionRemoveFromPlaylist : context.l10n.collectionRemoveFromFolder} ($selectedCount)' : widget.mode == LibraryTracksFolderMode.playlist - ? context.l10n.collectionRemoveFromPlaylist - : context.l10n.collectionRemoveFromFolder, + ? context.l10n.collectionRemoveFromPlaylist + : context.l10n.collectionRemoveFromFolder, ), style: FilledButton.styleFrom( backgroundColor: selectedCount > 0 @@ -551,13 +595,14 @@ class _LibraryTracksFolderScreenState String title, List entries, UserPlaylistCollection? playlist, + LocalLibraryState localState, ) { final expandedHeight = _calculateExpandedHeight(context); final customCoverPath = playlist?.coverImagePath; final isLovedMode = widget.mode == LibraryTracksFolderMode.loved; final isPlaylistMode = widget.mode == LibraryTracksFolderMode.playlist; // Loved always shows the heart icon (like Spotify's Liked Songs) - final coverUrl = isLovedMode ? null : _firstCoverUrl(entries); + final coverUrl = isLovedMode ? null : _firstCoverUrl(entries, localState); final hasCustomCover = customCoverPath != null && customCoverPath.isNotEmpty; final hasCoverUrl = coverUrl != null; @@ -608,6 +653,18 @@ class _LibraryTracksFolderScreenState (constraints.maxHeight - kToolbarHeight) / (expandedHeight - kToolbarHeight); final showContent = collapseRatio > 0.3; + final dpr = MediaQuery.devicePixelRatioOf(context); + final cacheWidth = (MediaQuery.sizeOf(context).width * dpr) + .round() + .clamp(320, 2048); + final coverFallback = Container( + color: colorScheme.surfaceContainerHighest, + child: Icon( + _modeIcon(), + size: 80, + color: colorScheme.onSurfaceVariant, + ), + ); return FlexibleSpaceBar( collapseMode: CollapseMode.pin, @@ -619,26 +676,37 @@ class _LibraryTracksFolderScreenState Image.file( File(customCoverPath), fit: BoxFit.cover, - errorBuilder: (_, _, _) => Container( - color: colorScheme.surfaceContainerHighest, - child: Icon( - _modeIcon(), - size: 80, - color: colorScheme.onSurfaceVariant, - ), - ), + cacheWidth: cacheWidth, + filterQuality: FilterQuality.low, + gaplessPlayback: true, + frameBuilder: (_, child, frame, wasSynchronouslyLoaded) { + if (wasSynchronouslyLoaded || frame != null) return child; + return coverFallback; + }, + errorBuilder: (_, _, _) => coverFallback, ) else if (hasCoverUrl) _isCoverLocalPath(coverUrl) ? Image.file( File(coverUrl), fit: BoxFit.cover, + cacheWidth: cacheWidth, + filterQuality: FilterQuality.low, + gaplessPlayback: true, + frameBuilder: + (_, child, frame, wasSynchronouslyLoaded) { + if (wasSynchronouslyLoaded || frame != null) { + return child; + } + return Container(color: colorScheme.surface); + }, errorBuilder: (_, _, _) => Container(color: colorScheme.surface), ) : CachedNetworkImage( imageUrl: _highResCoverUrl(coverUrl) ?? coverUrl, fit: BoxFit.cover, + memCacheWidth: cacheWidth, cacheManager: CoverCacheManager.instance, placeholder: (_, _) => Container(color: colorScheme.surface), @@ -646,14 +714,7 @@ class _LibraryTracksFolderScreenState Container(color: colorScheme.surface), ) else - Container( - color: colorScheme.surfaceContainerHighest, - child: Icon( - _modeIcon(), - size: 80, - color: colorScheme.onSurfaceVariant, - ), - ), + coverFallback, // Bottom gradient for readability Positioned( left: 0, @@ -728,6 +789,18 @@ class _LibraryTracksFolderScreenState ], ), ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (widget.mode != + LibraryTracksFolderMode.wishlist) ...[ + _buildShuffleButton(entries), + const SizedBox(width: 12), + ], + _buildDownloadAllCenterButton(context, entries), + ], + ), ], ], ), @@ -758,11 +831,127 @@ class _LibraryTracksFolderScreenState ); } + // ── Shuffle / Download buttons ── + + 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 _buildDownloadAllCenterButton( + BuildContext context, + List entries, + ) { + final tracks = entries.map((e) => e.track).toList(growable: false); + return FilledButton.icon( + onPressed: tracks.isEmpty ? null : () => _downloadAll(context, 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, + minimumSize: const Size(0, 48), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), + ), + ); + } + + void _shufflePlay(List entries) { + final tracks = entries.map((e) => e.track).toList(growable: false); + 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')), + ); + }); + } + + void _downloadAll(BuildContext context, List tracks) { + if (tracks.isEmpty) return; + 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); + _executeDownloadAll(context, tracks); + }, + child: const Text('Download'), + ), + ], + ); + }, + ); + } + + void _executeDownloadAll(BuildContext context, List tracks) { + final settings = ref.read(settingsProvider); + if (settings.askQualityBeforeDownload) { + DownloadServicePicker.show( + context, + trackName: '${tracks.length} tracks', + artistName: '', + onSelect: (quality, service) { + ref + .read(downloadQueueProvider.notifier) + .addMultipleToQueue(tracks, service, qualityOverride: quality); + if (!context.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) { final colorScheme = Theme.of(context).colorScheme; showModalBottomSheet( context: context, + useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(28)), @@ -840,6 +1029,8 @@ class _CollectionTrackTile extends ConsumerWidget { final CollectionTrackEntry entry; final LibraryTracksFolderMode mode; final String? playlistId; + final LocalLibraryState localLibraryState; + final List folderTracks; final bool isSelectionMode; final bool isSelected; final VoidCallback? onTap; @@ -849,6 +1040,8 @@ class _CollectionTrackTile extends ConsumerWidget { required this.entry, required this.mode, required this.playlistId, + required this.localLibraryState, + required this.folderTracks, this.isSelectionMode = false, this.isSelected = false, this.onTap, @@ -859,6 +1052,7 @@ class _CollectionTrackTile extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final track = entry.track; final colorScheme = Theme.of(context).colorScheme; + final effectiveCoverUrl = _resolveCoverUrl(track); return Padding( padding: const EdgeInsets.symmetric(horizontal: 8), @@ -903,8 +1097,8 @@ class _CollectionTrackTile extends ConsumerWidget { ], ClipRRect( borderRadius: BorderRadius.circular(8), - child: track.coverUrl != null && track.coverUrl!.isNotEmpty - ? _buildTrackCover(context, track.coverUrl!, 52) + child: effectiveCoverUrl != null && effectiveCoverUrl.isNotEmpty + ? _buildTrackCover(context, effectiveCoverUrl, 52) : Container( width: 52, height: 52, @@ -917,8 +1111,7 @@ class _CollectionTrackTile extends ConsumerWidget { ), ], ), - title: - Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis), + title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis), subtitle: Text( track.artistName, maxLines: 1, @@ -936,15 +1129,45 @@ class _CollectionTrackTile extends ConsumerWidget { ), onTap: isSelectionMode ? onTap - : mode == LibraryTracksFolderMode.wishlist - ? () => _downloadTrack(context, ref) - : () => _navigateToMetadata(context, ref), + : () { + if (mode == LibraryTracksFolderMode.wishlist) { + _downloadTrack(context, ref); + return; + } + + _navigateToMetadata(context, ref); + }, onLongPress: isSelectionMode ? onTap : onLongPress, ), ), ); } + String? _resolveCoverUrl(Track track) { + final rawCover = track.coverUrl?.trim(); + if (rawCover != null && + rawCover.isNotEmpty && + !rawCover.startsWith('content://')) { + return rawCover; + } + + final isrc = track.isrc?.trim(); + if (isrc != null && isrc.isNotEmpty) { + final byIsrc = localLibraryState.getByIsrc(isrc); + final localCover = byIsrc?.coverPath?.trim(); + if (localCover != null && localCover.isNotEmpty) return localCover; + } + + final byTrack = localLibraryState.findByTrackAndArtist( + track.name, + track.artistName, + ); + final localCover = byTrack?.coverPath?.trim(); + if (localCover != null && localCover.isNotEmpty) return localCover; + + return null; + } + /// Builds a cover image widget that handles both network URLs and local file paths. Widget _buildTrackCover(BuildContext context, String coverUrl, double size) { final isLocal = @@ -984,9 +1207,11 @@ class _CollectionTrackTile extends ConsumerWidget { void _showTrackOptionsSheet(BuildContext context, WidgetRef ref) { final track = entry.track; + final effectiveCoverUrl = _resolveCoverUrl(track); final colorScheme = Theme.of(context).colorScheme; final historyState = ref.read(downloadHistoryProvider); - final isDownloaded = historyState.isDownloaded(track.id) || + final isDownloaded = + historyState.isDownloaded(track.id) || (track.isrc != null && track.isrc!.isNotEmpty && historyState.getByIsrc(track.isrc!) != null) || @@ -997,6 +1222,7 @@ class _CollectionTrackTile extends ConsumerWidget { showModalBottomSheet( context: context, + useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(28)), @@ -1024,8 +1250,9 @@ class _CollectionTrackTile extends ConsumerWidget { ClipRRect( borderRadius: BorderRadius.circular(8), child: - track.coverUrl != null && track.coverUrl!.isNotEmpty - ? _buildTrackCover(context, track.coverUrl!, 56) + effectiveCoverUrl != null && + effectiveCoverUrl.isNotEmpty + ? _buildTrackCover(context, effectiveCoverUrl, 56) : Container( width: 56, height: 56, @@ -1170,15 +1397,15 @@ class _CollectionTrackTile extends ConsumerWidget { var historyItem = historyState.getBySpotifyId(track.id); // 2. Download history by ISRC - if (historyItem == null && - track.isrc != null && - track.isrc!.isNotEmpty) { + if (historyItem == null && track.isrc != null && track.isrc!.isNotEmpty) { historyItem = historyState.getByIsrc(track.isrc!); } // 3. Download history by track name + artist (handles ID/ISRC mismatch) - historyItem ??= - historyState.findByTrackAndArtist(track.name, track.artistName); + historyItem ??= historyState.findByTrackAndArtist( + track.name, + track.artistName, + ); if (historyItem != null) { await Navigator.of(context).push( @@ -1287,9 +1514,7 @@ class _SelectionActionButton extends StatelessWidget { ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant, padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), ), ); } diff --git a/lib/screens/local_album_screen.dart b/lib/screens/local_album_screen.dart index e7642a45..83298d72 100644 --- a/lib/screens/local_album_screen.dart +++ b/lib/screens/local_album_screen.dart @@ -11,6 +11,7 @@ import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/ffmpeg_service.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; +import 'package:spotiflac_android/providers/playback_provider.dart'; /// Screen to display tracks from a local library album class LocalAlbumScreen extends ConsumerStatefulWidget { @@ -204,9 +205,17 @@ class _LocalAlbumScreenState extends ConsumerState { } } - Future _openFile(String filePath) async { + Future _openFile(LocalLibraryItem track) async { try { - await openFile(filePath); + await ref + .read(playbackProvider.notifier) + .playLocalPath( + path: track.filePath, + title: track.trackName, + artist: track.artistName, + album: track.albumName, + coverUrl: track.coverPath ?? '', + ); } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -639,7 +648,7 @@ class _LocalAlbumScreenState extends ConsumerState { ), onTap: _isSelectionMode ? () => _toggleSelection(track.id) - : () => _openFile(track.filePath), + : () => _openFile(track), onLongPress: _isSelectionMode ? null : () => _enterSelectionMode(track.id), @@ -724,7 +733,7 @@ class _LocalAlbumScreenState extends ConsumerState { trailing: _isSelectionMode ? null : IconButton( - onPressed: () => _openFile(track.filePath), + onPressed: () => _openFile(track), icon: Icon(Icons.play_arrow, color: colorScheme.primary), style: IconButton.styleFrom( backgroundColor: colorScheme.primaryContainer.withValues( @@ -989,6 +998,7 @@ class _LocalAlbumScreenState extends ConsumerState { showModalBottomSheet( context: context, + useRootNavigator: true, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index 8d7b96ca..42e20dd2 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -16,9 +16,11 @@ import 'package:spotiflac_android/screens/store_tab.dart'; import 'package:spotiflac_android/screens/queue_tab.dart'; import 'package:spotiflac_android/screens/settings/settings_tab.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; +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'); @@ -36,11 +38,21 @@ class _MainShellState extends ConsumerState { bool _hasCheckedUpdate = false; StreamSubscription? _shareSubscription; DateTime? _lastBackPress; + final GlobalKey _homeTabNavigatorKey = + ShellNavigationService.homeTabNavigatorKey; + final GlobalKey _libraryTabNavigatorKey = + ShellNavigationService.libraryTabNavigatorKey; + final GlobalKey _storeTabNavigatorKey = + ShellNavigationService.storeTabNavigatorKey; @override void initState() { super.initState(); _pageController = PageController(initialPage: _currentIndex); + ShellNavigationService.syncState( + currentTabIndex: _currentIndex, + showStoreTab: false, + ); WidgetsBinding.instance.addPostFrameCallback((_) { _checkForUpdates(); _setupShareListener(); @@ -86,6 +98,7 @@ class _MainShellState extends ConsumerState { if (!mounted) return; Navigator.of(context).popUntil((route) => route.isFirst); + _homeTabNavigatorKey.currentState?.popUntil((route) => route.isFirst); if (_currentIndex != 0) { _onNavTap(0); @@ -213,10 +226,34 @@ class _MainShellState extends ConsumerState { super.dispose(); } + void _resetHomeToMain() { + final showStore = ref.read( + settingsProvider.select((s) => s.showExtensionStore), + ); + final homeNavigator = _navigatorForTab(0, showStore); + homeNavigator?.popUntil((route) => route.isFirst); + // Unfocus BEFORE clear so _onTrackStateChanged can properly + // clear _urlController (it checks !_searchFocusNode.hasFocus) + FocusManager.instance.primaryFocus?.unfocus(); + ref.read(trackProvider.notifier).clear(); + } + void _onNavTap(int index) { + if (index == 0 && _currentIndex == 0) { + _resetHomeToMain(); + return; + } + if (_currentIndex != index) { HapticFeedback.selectionClick(); setState(() => _currentIndex = index); + final showStore = ref.read( + settingsProvider.select((s) => s.showExtensionStore), + ); + ShellNavigationService.syncState( + currentTabIndex: _currentIndex, + showStoreTab: showStore, + ); _pageController.animateToPage( index, duration: const Duration(milliseconds: 250), @@ -226,48 +263,121 @@ class _MainShellState extends ConsumerState { } void _onPageChanged(int index) { + final previousIndex = _currentIndex; if (_currentIndex != index) { setState(() => _currentIndex = index); + final showStore = ref.read( + settingsProvider.select((s) => s.showExtensionStore), + ); + ShellNavigationService.syncState( + currentTabIndex: _currentIndex, + showStoreTab: showStore, + ); FocusManager.instance.primaryFocus?.unfocus(); + if (index == 0 && previousIndex != 0) { + _resetHomeToMain(); + } } } void _handleBackPress() { + final rootNavigator = Navigator.of(context, rootNavigator: true); + if (rootNavigator.canPop()) { + _log.i('Back: step 1 - root navigator pop'); + rootNavigator.pop(); + _lastBackPress = null; + return; + } + + final showStore = ref.read( + settingsProvider.select((s) => s.showExtensionStore), + ); + final currentNavigator = _navigatorForTab(_currentIndex, showStore); + if (currentNavigator != null && currentNavigator.canPop()) { + _log.i('Back: step 2 - tab navigator pop (tab=$_currentIndex)'); + currentNavigator.pop(); + _lastBackPress = null; + return; + } + final trackState = ref.read(trackProvider); final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0; - if (isKeyboardVisible) { + + _log.d( + 'Back: state check - tab=$_currentIndex, ' + 'isShowingRecentAccess=${trackState.isShowingRecentAccess}, ' + 'hasSearchText=${trackState.hasSearchText}, ' + 'hasContent=${trackState.hasContent}, ' + 'isLoading=${trackState.isLoading}, ' + 'isKeyboardVisible=$isKeyboardVisible', + ); + + if (_currentIndex == 0 && + trackState.isShowingRecentAccess && + !trackState.isLoading && + (trackState.hasSearchText || trackState.hasContent)) { + // Has recent access AND search content — clear everything at once + _log.i( + 'Back: step 3a - dismiss recent access + clear search/content ' + '(hasSearchText=${trackState.hasSearchText}, hasContent=${trackState.hasContent})', + ); FocusManager.instance.primaryFocus?.unfocus(); + ref.read(trackProvider.notifier).clear(); + _lastBackPress = null; return; } if (_currentIndex == 0 && trackState.isShowingRecentAccess) { + // Recent access overlay only (no search content) — just dismiss it + _log.i('Back: step 3b - dismiss recent access only'); ref.read(trackProvider.notifier).setShowingRecentAccess(false); FocusManager.instance.primaryFocus?.unfocus(); + _lastBackPress = null; return; } if (_currentIndex == 0 && !trackState.isLoading && (trackState.hasSearchText || trackState.hasContent)) { + _log.i( + 'Back: step 4 - clear search/content ' + '(hasSearchText=${trackState.hasSearchText}, hasContent=${trackState.hasContent})', + ); + // Unfocus BEFORE clear so _onTrackStateChanged can properly + // clear _urlController (it checks !_searchFocusNode.hasFocus) + FocusManager.instance.primaryFocus?.unfocus(); ref.read(trackProvider.notifier).clear(); + _lastBackPress = null; + return; + } + + if (_currentIndex == 0 && isKeyboardVisible) { + _log.i('Back: step 5 - dismiss keyboard'); + FocusManager.instance.primaryFocus?.unfocus(); + _lastBackPress = null; return; } if (_currentIndex != 0) { + _log.i('Back: step 6 - switch to home tab from tab=$_currentIndex'); _onNavTap(0); + _lastBackPress = null; return; } if (trackState.isLoading) { + _log.i('Back: blocked - loading in progress'); return; } final now = DateTime.now(); if (_lastBackPress != null && now.difference(_lastBackPress!) < const Duration(seconds: 2)) { + _log.i('Back: step 8 - double-tap exit'); SystemNavigator.pop(); } else { + _log.i('Back: step 7 - first tap, showing exit snackbar'); _lastBackPress = now; ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -279,46 +389,46 @@ class _MainShellState extends ConsumerState { } } + NavigatorState? _navigatorForTab(int index, bool showStore) { + if (index == 0) return _homeTabNavigatorKey.currentState; + if (index == 1) return _libraryTabNavigatorKey.currentState; + if (showStore && index == 2) return _storeTabNavigatorKey.currentState; + return null; + } + @override Widget build(BuildContext context) { final queueState = ref.watch( downloadQueueProvider.select((s) => s.queuedCount), ); - final trackHasSearchText = ref.watch( - trackProvider.select((s) => s.hasSearchText), - ); - final trackHasContent = ref.watch( - trackProvider.select((s) => s.hasContent), - ); - final trackIsLoading = ref.watch(trackProvider.select((s) => s.isLoading)); - final trackIsShowingRecentAccess = ref.watch( - trackProvider.select((s) => s.isShowingRecentAccess), - ); final showStore = ref.watch( settingsProvider.select((s) => s.showExtensionStore), ); + ShellNavigationService.syncState( + currentTabIndex: _currentIndex, + showStoreTab: showStore, + ); final storeUpdatesCount = ref.watch( storeProvider.select((s) => s.updatesAvailableCount), ); - final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0; - - final canPop = - _currentIndex == 0 && - !trackHasSearchText && - !trackHasContent && - !trackIsLoading && - !trackIsShowingRecentAccess && - !isKeyboardVisible; - final tabs = [ - const HomeTab(), - QueueTab( - parentPageController: _pageController, - parentPageIndex: 1, - nextPageIndex: showStore ? 2 : 3, + _TabNavigator( + key: const ValueKey('tab-home'), + navigatorKey: _homeTabNavigatorKey, + child: const HomeTab(), ), - if (showStore) const StoreTab(), + _TabNavigator( + key: const ValueKey('tab-library'), + navigatorKey: _libraryTabNavigatorKey, + child: _LibraryTabRoot(parentPageController: _pageController), + ), + if (showStore) + _TabNavigator( + key: const ValueKey('tab-store'), + navigatorKey: _storeTabNavigatorKey, + child: const StoreTab(), + ), const SettingsTab(), ]; @@ -378,7 +488,7 @@ class _MainShellState extends ConsumerState { } return PopScope( - canPop: canPop, + canPop: false, onPopInvokedWithResult: (didPop, result) async { if (didPop) { return; @@ -387,13 +497,18 @@ class _MainShellState extends ConsumerState { _handleBackPress(); }, child: Scaffold( - body: PageView( - controller: _pageController, - onPageChanged: _onPageChanged, - physics: (_currentIndex == 0 && trackIsShowingRecentAccess) - ? const _NoSwipeRightPhysics() - : const ClampingScrollPhysics(), - children: tabs, + body: Column( + children: [ + Expanded( + child: PageView( + controller: _pageController, + onPageChanged: _onPageChanged, + physics: const NeverScrollableScrollPhysics(), + children: tabs, + ), + ), + const MiniPlayerBar(), + ], ), bottomNavigationBar: NavigationBar( selectedIndex: _currentIndex.clamp(0, maxIndex), @@ -415,23 +530,42 @@ class _MainShellState extends ConsumerState { } } -/// Custom physics that blocks swiping to the right (next page) while -/// still allowing vertical scrolling inside the page content. -class _NoSwipeRightPhysics extends ScrollPhysics { - const _NoSwipeRightPhysics({super.parent}); +class _TabNavigator extends StatelessWidget { + final GlobalKey navigatorKey; + final Widget child; + + const _TabNavigator({ + super.key, + required this.navigatorKey, + required this.child, + }); @override - _NoSwipeRightPhysics applyTo(ScrollPhysics? ancestor) { - return _NoSwipeRightPhysics(parent: buildParent(ancestor)); + Widget build(BuildContext context) { + return Navigator( + key: navigatorKey, + onGenerateInitialRoutes: (_, _) => [ + MaterialPageRoute(builder: (_) => child), + ], + ); } +} + +class _LibraryTabRoot extends ConsumerWidget { + final PageController parentPageController; + + const _LibraryTabRoot({required this.parentPageController}); @override - double applyPhysicsToUserOffset(ScrollMetrics position, double offset) { - // In a horizontal PageView, a negative offset means the user is - // dragging left (i.e. trying to go to the next page / right). - // Block that direction only. - if (offset < 0) return 0.0; - return super.applyPhysicsToUserOffset(position, offset); + Widget build(BuildContext context, WidgetRef ref) { + final showStore = ref.watch( + settingsProvider.select((s) => s.showExtensionStore), + ); + return QueueTab( + parentPageController: parentPageController, + parentPageIndex: 1, + nextPageIndex: showStore ? 2 : 3, + ); } } diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index f127d9d7..ac9a3f76 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -6,9 +6,11 @@ import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; +import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; +import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; @@ -108,6 +110,8 @@ class _PlaylistScreenState extends ConsumerState { 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(), @@ -297,22 +301,15 @@ class _PlaylistScreenState extends ConsumerState { ), ), const SizedBox(height: 16), - Center( - child: FilledButton.icon( - onPressed: () => _downloadAll(context), - icon: const Icon(Icons.download, size: 18), - label: Text( - context.l10n.downloadAllCount(_tracks.length), - ), - style: FilledButton.styleFrom( - backgroundColor: Colors.white, - foregroundColor: Colors.black87, - minimumSize: const Size(0, 48), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(24), - ), - ), - ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildLoveAllButton(), + const SizedBox(width: 12), + _buildDownloadAllCenterButton(context), + const SizedBox(width: 12), + _buildShufflePlayButton(), + ], ), ], ], @@ -410,6 +407,7 @@ class _PlaylistScreenState extends ConsumerState { void _downloadTrack(BuildContext context, Track track) { final settings = ref.read(settingsProvider); + if (settings.askQualityBeforeDownload) { DownloadServicePicker.show( context, @@ -437,22 +435,175 @@ class _PlaylistScreenState extends ConsumerState { } } - void _downloadAll(BuildContext context) { + // ── Shuffle / Love / Download buttons ── + + Widget _buildCircleButton({ + required IconData icon, + required String tooltip, + required VoidCallback? onPressed, + }) { + 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: onPressed, + icon: Icon(icon, size: 22, color: Colors.white), + tooltip: tooltip, + padding: EdgeInsets.zero, + ), + ); + } + + Widget _buildLoveAllButton() { + final collectionsState = ref.watch(libraryCollectionsProvider); + final allLoved = + _tracks.isNotEmpty && _tracks.every((t) => collectionsState.isLoved(t)); + + 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: _tracks.isEmpty ? null : () => _loveAll(_tracks), + icon: Icon( + allLoved ? Icons.favorite : Icons.favorite_border, + size: 22, + color: allLoved ? Colors.redAccent : Colors.white, + ), + tooltip: allLoved ? 'Remove from Loved' : 'Love All', + padding: EdgeInsets.zero, + ), + ); + } + + Widget _buildDownloadAllCenterButton(BuildContext context) { + return FilledButton.icon( + onPressed: _tracks.isEmpty ? null : () => _confirmDownloadAll(context), + icon: const Icon(Icons.download_rounded, size: 18), + label: Text(context.l10n.downloadAllCount(_tracks.length)), + style: FilledButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.black87, + minimumSize: const Size(0, 48), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), + ), + ); + } + + Widget _buildShufflePlayButton() { + return _buildCircleButton( + icon: Icons.shuffle_rounded, + tooltip: 'Shuffle Play', + onPressed: _tracks.isEmpty ? null : _shufflePlayLocal, + ); + } + + void _shufflePlayLocal() { 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')), + ); + }); + } + + void _confirmDownloadAll(BuildContext context) { + if (_tracks.isEmpty) return; + 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(context); + }, + child: const Text('Download'), + ), + ], + ); + }, + ); + } + + Future _loveAll(List tracks) async { + final notifier = ref.read(libraryCollectionsProvider.notifier); + final state = ref.read(libraryCollectionsProvider); + final allLoved = tracks.every((t) => state.isLoved(t)); + + if (allLoved) { + for (final track in tracks) { + final key = trackCollectionKey(track); + await notifier.removeFromLoved(key); + } + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Removed ${tracks.length} tracks from Loved')), + ); + } + } else { + int addedCount = 0; + for (final track in tracks) { + if (!state.isLoved(track)) { + await notifier.toggleLoved(track); + addedCount++; + } + } + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Added $addedCount tracks to Loved')), + ); + } + } + } + + void _downloadAll(BuildContext context) { + _downloadTracks(context, _tracks); + } + + void _downloadTracks(BuildContext context, List tracks) { + if (tracks.isEmpty) return; final settings = ref.read(settingsProvider); if (settings.askQualityBeforeDownload) { DownloadServicePicker.show( context, - trackName: '${_tracks.length} tracks', + trackName: '${tracks.length} tracks', artistName: widget.playlistName, onSelect: (quality, service) { ref .read(downloadQueueProvider.notifier) - .addMultipleToQueue(_tracks, service, qualityOverride: quality); + .addMultipleToQueue(tracks, service, qualityOverride: quality); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - context.l10n.snackbarAddedTracksToQueue(_tracks.length), + context.l10n.snackbarAddedTracksToQueue(tracks.length), ), ), ); @@ -461,12 +612,10 @@ class _PlaylistScreenState extends ConsumerState { } else { ref .read(downloadQueueProvider.notifier) - .addMultipleToQueue(_tracks, settings.defaultService); + .addMultipleToQueue(tracks, settings.defaultService); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text( - context.l10n.snackbarAddedTracksToQueue(_tracks.length), - ), + content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)), ), ); } @@ -602,9 +751,7 @@ class _PlaylistTrackItem extends ConsumerWidget { ], ], ), - trailing: TrackCollectionQuickActions( - track: track, - ), + trailing: TrackCollectionQuickActions(track: track), onTap: () => _handleTap( context, ref, @@ -612,6 +759,11 @@ class _PlaylistTrackItem extends ConsumerWidget { isInHistory: isInHistory, isInLocalLibrary: isInLocalLibrary, ), + onLongPress: () => TrackCollectionQuickActions.showTrackOptionsSheet( + context, + ref, + track, + ), ), ), ); diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 02f05e3f..ad462f37 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -20,6 +20,7 @@ import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; +import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/history_database.dart'; import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart'; @@ -27,6 +28,7 @@ import 'package:spotiflac_android/screens/track_metadata_screen.dart'; import 'package:spotiflac_android/screens/downloaded_album_screen.dart'; import 'package:spotiflac_android/screens/library_tracks_folder_screen.dart'; import 'package:spotiflac_android/screens/local_album_screen.dart'; +import 'package:spotiflac_android/utils/clickable_metadata.dart'; enum LibraryItemSource { downloaded, local } @@ -363,9 +365,15 @@ class _QueueTabState extends ConsumerState { bool _isSelectionMode = false; final Set _selectedIds = {}; + OverlayEntry? _selectionOverlayEntry; + List _selectionOverlayItems = const []; + double _selectionOverlayBottomPadding = 0; bool _isPlaylistSelectionMode = false; final Set _selectedPlaylistIds = {}; + OverlayEntry? _playlistSelectionOverlayEntry; + List _playlistSelectionOverlayItems = const []; + double _playlistSelectionOverlayBottomPadding = 0; PageController? _filterPageController; final List _filterModes = ['all', 'albums', 'singles']; @@ -405,6 +413,24 @@ class _QueueTabState extends ConsumerState { String _localFilterQueryCache = ''; List _filteredLocalItemsCache = const []; final Map _unifiedItemsCache = {}; + final Map _filterContentDataCache = {}; + List? _filterCacheAllHistoryItems; + _HistoryStats? _filterCacheHistoryStats; + List? _filterCacheLocalLibraryItems; + LibraryCollectionsState? _filterCacheCollectionState; + String _filterCacheSearchQuery = ''; + String? _filterCacheSource; + String? _filterCacheQuality; + String? _filterCacheFormat; + String _filterCacheSortMode = 'latest'; + _HistoryStats? _groupedAlbumFilterHistoryStatsCache; + String _groupedAlbumFilterSearchQuery = ''; + String? _groupedAlbumFilterSource; + String? _groupedAlbumFilterQuality; + String? _groupedAlbumFilterFormat; + String _groupedAlbumFilterSortMode = 'latest'; + List<_GroupedAlbum> _filteredGroupedAlbumsCache = const []; + List<_GroupedLocalAlbum> _filteredGroupedLocalAlbumsCache = const []; // Advanced filters String? _filterSource; // null = all, 'downloaded', 'local' String? _filterQuality; // null = all, 'hires', 'cd', 'lossy' @@ -440,6 +466,8 @@ class _QueueTabState extends ConsumerState { @override void dispose() { + _hideSelectionOverlay(); + _hidePlaylistSelectionOverlay(); for (final notifier in _fileExistsNotifiers.values) { notifier.dispose(); } @@ -469,6 +497,47 @@ class _QueueTabState extends ConsumerState { _requestFilterRefresh(); } + void _invalidateFilterContentCache() { + _filterContentDataCache.clear(); + _filterCacheAllHistoryItems = null; + _filterCacheHistoryStats = null; + _filterCacheLocalLibraryItems = null; + _filterCacheCollectionState = null; + } + + void _prepareFilterContentCache({ + required List allHistoryItems, + required _HistoryStats historyStats, + required List localLibraryItems, + required LibraryCollectionsState collectionState, + }) { + final isCacheValid = + identical(_filterCacheAllHistoryItems, allHistoryItems) && + identical(_filterCacheHistoryStats, historyStats) && + identical(_filterCacheLocalLibraryItems, localLibraryItems) && + identical(_filterCacheCollectionState, collectionState) && + _filterCacheSearchQuery == _searchQuery && + _filterCacheSource == _filterSource && + _filterCacheQuality == _filterQuality && + _filterCacheFormat == _filterFormat && + _filterCacheSortMode == _sortMode; + + if (isCacheValid) { + return; + } + + _filterContentDataCache.clear(); + _filterCacheAllHistoryItems = allHistoryItems; + _filterCacheHistoryStats = historyStats; + _filterCacheLocalLibraryItems = localLibraryItems; + _filterCacheCollectionState = collectionState; + _filterCacheSearchQuery = _searchQuery; + _filterCacheSource = _filterSource; + _filterCacheQuality = _filterQuality; + _filterCacheFormat = _filterFormat; + _filterCacheSortMode = _sortMode; + } + void _ensureHistoryCaches( List items, List localItems, @@ -491,6 +560,7 @@ class _QueueTabState extends ConsumerState { _filteredLocalItemsCache = const []; } _unifiedItemsCache.clear(); + _invalidateFilterContentCache(); if (historyChanged) { final validPaths = items @@ -736,9 +806,12 @@ class _QueueTabState extends ConsumerState { void _enterSelectionMode(String itemId) { HapticFeedback.mediumImpact(); setState(() { + _isPlaylistSelectionMode = false; + _selectedPlaylistIds.clear(); _isSelectionMode = true; _selectedIds.add(itemId); }); + _hidePlaylistSelectionOverlay(); } void _exitSelectionMode() { @@ -746,6 +819,7 @@ class _QueueTabState extends ConsumerState { _isSelectionMode = false; _selectedIds.clear(); }); + _hideSelectionOverlay(); } void _toggleSelection(String itemId) { @@ -767,14 +841,109 @@ class _QueueTabState extends ConsumerState { }); } + void _hideSelectionOverlay() { + _selectionOverlayEntry?.remove(); + _selectionOverlayEntry = null; + } + + void _syncSelectionOverlay({ + required List items, + required double bottomPadding, + }) { + if (!mounted) return; + if (!_isSelectionMode || _isPlaylistSelectionMode) { + _hideSelectionOverlay(); + return; + } + + _selectionOverlayItems = items; + _selectionOverlayBottomPadding = bottomPadding; + + if (_selectionOverlayEntry != null) { + _selectionOverlayEntry!.markNeedsBuild(); + return; + } + + final overlay = Overlay.of(context, rootOverlay: true); + _selectionOverlayEntry = OverlayEntry( + builder: (overlayContext) { + final colorScheme = Theme.of(context).colorScheme; + return Positioned( + left: 0, + right: 0, + bottom: 0, + child: Material( + color: Colors.transparent, + child: _buildSelectionBottomBar( + context, + colorScheme, + _selectionOverlayItems, + _selectionOverlayBottomPadding, + ), + ), + ); + }, + ); + overlay.insert(_selectionOverlayEntry!); + } + + void _hidePlaylistSelectionOverlay() { + _playlistSelectionOverlayEntry?.remove(); + _playlistSelectionOverlayEntry = null; + } + + void _syncPlaylistSelectionOverlay({ + required List playlists, + required double bottomPadding, + }) { + if (!mounted) return; + if (!_isPlaylistSelectionMode || _isSelectionMode) { + _hidePlaylistSelectionOverlay(); + return; + } + + _playlistSelectionOverlayItems = playlists; + _playlistSelectionOverlayBottomPadding = bottomPadding; + + if (_playlistSelectionOverlayEntry != null) { + _playlistSelectionOverlayEntry!.markNeedsBuild(); + return; + } + + final overlay = Overlay.of(context, rootOverlay: true); + _playlistSelectionOverlayEntry = OverlayEntry( + builder: (overlayContext) { + final colorScheme = Theme.of(context).colorScheme; + return Positioned( + left: 0, + right: 0, + bottom: 0, + child: Material( + color: Colors.transparent, + child: _buildPlaylistSelectionBottomBar( + context, + colorScheme, + _playlistSelectionOverlayItems, + _playlistSelectionOverlayBottomPadding, + ), + ), + ); + }, + ); + overlay.insert(_playlistSelectionOverlayEntry!); + } + // --- Playlist selection mode --- void _enterPlaylistSelectionMode(String playlistId) { HapticFeedback.mediumImpact(); setState(() { + _isSelectionMode = false; + _selectedIds.clear(); _isPlaylistSelectionMode = true; _selectedPlaylistIds.add(playlistId); }); + _hideSelectionOverlay(); } void _exitPlaylistSelectionMode() { @@ -782,6 +951,7 @@ class _QueueTabState extends ConsumerState { _isPlaylistSelectionMode = false; _selectedPlaylistIds.clear(); }); + _hidePlaylistSelectionOverlay(); } void _togglePlaylistSelection(String playlistId) { @@ -1172,6 +1342,7 @@ class _QueueTabState extends ConsumerState { _filterFormat = null; _sortMode = 'latest'; _unifiedItemsCache.clear(); + _invalidateFilterContentCache(); }); } @@ -1400,6 +1571,46 @@ class _QueueTabState extends ConsumerState { return result; } + ({List<_GroupedAlbum> albums, List<_GroupedLocalAlbum> localAlbums}) + _resolveFilteredGroupedAlbums(_HistoryStats historyStats) { + final cacheValid = + identical(_groupedAlbumFilterHistoryStatsCache, historyStats) && + _groupedAlbumFilterSearchQuery == _searchQuery && + _groupedAlbumFilterSource == _filterSource && + _groupedAlbumFilterQuality == _filterQuality && + _groupedAlbumFilterFormat == _filterFormat && + _groupedAlbumFilterSortMode == _sortMode; + + if (cacheValid) { + return ( + albums: _filteredGroupedAlbumsCache, + localAlbums: _filteredGroupedLocalAlbumsCache, + ); + } + + final filteredGroupedAlbums = _filterGroupedAlbums( + historyStats.groupedAlbums, + _searchQuery, + ); + final filteredGroupedLocalAlbums = _filterGroupedLocalAlbums( + historyStats.groupedLocalAlbums, + _searchQuery, + ); + + _groupedAlbumFilterHistoryStatsCache = historyStats; + _groupedAlbumFilterSearchQuery = _searchQuery; + _groupedAlbumFilterSource = _filterSource; + _groupedAlbumFilterQuality = _filterQuality; + _groupedAlbumFilterFormat = _filterFormat; + _groupedAlbumFilterSortMode = _sortMode; + _filteredGroupedAlbumsCache = filteredGroupedAlbums; + _filteredGroupedLocalAlbumsCache = filteredGroupedLocalAlbums; + return ( + albums: filteredGroupedAlbums, + localAlbums: filteredGroupedLocalAlbums, + ); + } + Set _getAvailableFormats(List items) { final formats = {}; for (final item in items) { @@ -1425,6 +1636,7 @@ class _QueueTabState extends ConsumerState { showModalBottomSheet( context: context, + useRootNavigator: true, isScrollControlled: true, backgroundColor: colorScheme.surfaceContainerLow, shape: const RoundedRectangleBorder( @@ -1433,201 +1645,224 @@ class _QueueTabState extends ConsumerState { builder: (context) => StatefulBuilder( builder: (context, setSheetState) { return SafeArea( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Center( - child: Container( - width: 32, - height: 4, - margin: const EdgeInsets.only(bottom: 16), - decoration: BoxDecoration( - color: colorScheme.outlineVariant, - borderRadius: BorderRadius.circular(2), + child: LayoutBuilder( + builder: (context, constraints) { + final maxSheetHeight = constraints.maxHeight * 0.9; + return ConstrainedBox( + constraints: BoxConstraints(maxHeight: maxSheetHeight), + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: SingleChildScrollView( + physics: const ClampingScrollPhysics(), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 32, + height: 4, + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: colorScheme.outlineVariant, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + + Row( + children: [ + Text( + context.l10n.libraryFilterTitle, + style: Theme.of(context).textTheme.titleLarge + ?.copyWith(fontWeight: FontWeight.bold), + ), + const Spacer(), + TextButton( + onPressed: () { + setSheetState(() { + tempSource = null; + tempQuality = null; + tempFormat = null; + tempSortMode = 'latest'; + }); + }, + child: Text(context.l10n.libraryFilterReset), + ), + ], + ), + const SizedBox(height: 16), + + Text( + context.l10n.libraryFilterSource, + style: Theme.of(context).textTheme.titleSmall + ?.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: [ + FilterChip( + label: Text(context.l10n.libraryFilterAll), + selected: tempSource == null, + onSelected: (_) => + setSheetState(() => tempSource = null), + ), + FilterChip( + label: Text( + context.l10n.libraryFilterDownloaded, + ), + selected: tempSource == 'downloaded', + onSelected: (_) => setSheetState( + () => tempSource = 'downloaded', + ), + ), + FilterChip( + label: Text(context.l10n.libraryFilterLocal), + selected: tempSource == 'local', + onSelected: (_) => + setSheetState(() => tempSource = 'local'), + ), + ], + ), + const SizedBox(height: 16), + + Text( + context.l10n.libraryFilterQuality, + style: Theme.of(context).textTheme.titleSmall + ?.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: [ + FilterChip( + label: Text(context.l10n.libraryFilterAll), + selected: tempQuality == null, + onSelected: (_) => + setSheetState(() => tempQuality = null), + ), + FilterChip( + label: Text( + context.l10n.libraryFilterQualityHiRes, + ), + selected: tempQuality == 'hires', + onSelected: (_) => + setSheetState(() => tempQuality = 'hires'), + ), + FilterChip( + label: Text( + context.l10n.libraryFilterQualityCD, + ), + selected: tempQuality == 'cd', + onSelected: (_) => + setSheetState(() => tempQuality = 'cd'), + ), + FilterChip( + label: Text( + context.l10n.libraryFilterQualityLossy, + ), + selected: tempQuality == 'lossy', + onSelected: (_) => + setSheetState(() => tempQuality = 'lossy'), + ), + ], + ), + const SizedBox(height: 16), + + Text( + context.l10n.libraryFilterFormat, + style: Theme.of(context).textTheme.titleSmall + ?.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: [ + FilterChip( + label: Text(context.l10n.libraryFilterAll), + selected: tempFormat == null, + onSelected: (_) => + setSheetState(() => tempFormat = null), + ), + for (final format + in availableFormats.toList()..sort()) + FilterChip( + label: Text(format.toUpperCase()), + selected: tempFormat == format, + onSelected: (_) => + setSheetState(() => tempFormat = format), + ), + ], + ), + const SizedBox(height: 16), + + Text( + context.l10n.libraryFilterSort, + style: Theme.of(context).textTheme.titleSmall + ?.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: [ + FilterChip( + label: Text( + context.l10n.libraryFilterSortLatest, + ), + selected: tempSortMode == 'latest', + onSelected: (_) => setSheetState( + () => tempSortMode = 'latest', + ), + ), + FilterChip( + label: Text( + context.l10n.libraryFilterSortOldest, + ), + selected: tempSortMode == 'oldest', + onSelected: (_) => setSheetState( + () => tempSortMode = 'oldest', + ), + ), + FilterChip( + label: const Text('A-Z'), + selected: tempSortMode == 'a-z', + onSelected: (_) => + setSheetState(() => tempSortMode = 'a-z'), + ), + FilterChip( + label: const Text('Z-A'), + selected: tempSortMode == 'z-a', + onSelected: (_) => + setSheetState(() => tempSortMode = 'z-a'), + ), + ], + ), + const SizedBox(height: 24), + + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: () { + setState(() { + _filterSource = tempSource; + _filterQuality = tempQuality; + _filterFormat = tempFormat; + _sortMode = tempSortMode; + _unifiedItemsCache.clear(); + _invalidateFilterContentCache(); + }); + Navigator.pop(context); + }, + child: Text(context.l10n.libraryFilterApply), + ), + ), + ], ), ), ), - - Row( - children: [ - Text( - context.l10n.libraryFilterTitle, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - const Spacer(), - TextButton( - onPressed: () { - setSheetState(() { - tempSource = null; - tempQuality = null; - tempFormat = null; - tempSortMode = 'latest'; - }); - }, - child: Text(context.l10n.libraryFilterReset), - ), - ], - ), - const SizedBox(height: 16), - - Text( - context.l10n.libraryFilterSource, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - children: [ - FilterChip( - label: Text(context.l10n.libraryFilterAll), - selected: tempSource == null, - onSelected: (_) => - setSheetState(() => tempSource = null), - ), - FilterChip( - label: Text(context.l10n.libraryFilterDownloaded), - selected: tempSource == 'downloaded', - onSelected: (_) => - setSheetState(() => tempSource = 'downloaded'), - ), - FilterChip( - label: Text(context.l10n.libraryFilterLocal), - selected: tempSource == 'local', - onSelected: (_) => - setSheetState(() => tempSource = 'local'), - ), - ], - ), - const SizedBox(height: 16), - - Text( - context.l10n.libraryFilterQuality, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - children: [ - FilterChip( - label: Text(context.l10n.libraryFilterAll), - selected: tempQuality == null, - onSelected: (_) => - setSheetState(() => tempQuality = null), - ), - FilterChip( - label: Text(context.l10n.libraryFilterQualityHiRes), - selected: tempQuality == 'hires', - onSelected: (_) => - setSheetState(() => tempQuality = 'hires'), - ), - FilterChip( - label: Text(context.l10n.libraryFilterQualityCD), - selected: tempQuality == 'cd', - onSelected: (_) => - setSheetState(() => tempQuality = 'cd'), - ), - FilterChip( - label: Text(context.l10n.libraryFilterQualityLossy), - selected: tempQuality == 'lossy', - onSelected: (_) => - setSheetState(() => tempQuality = 'lossy'), - ), - ], - ), - const SizedBox(height: 16), - - Text( - context.l10n.libraryFilterFormat, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - children: [ - FilterChip( - label: Text(context.l10n.libraryFilterAll), - selected: tempFormat == null, - onSelected: (_) => - setSheetState(() => tempFormat = null), - ), - for (final format in availableFormats.toList()..sort()) - FilterChip( - label: Text(format.toUpperCase()), - selected: tempFormat == format, - onSelected: (_) => - setSheetState(() => tempFormat = format), - ), - ], - ), - const SizedBox(height: 16), - - Text( - context.l10n.libraryFilterSort, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - children: [ - FilterChip( - label: Text(context.l10n.libraryFilterSortLatest), - selected: tempSortMode == 'latest', - onSelected: (_) => - setSheetState(() => tempSortMode = 'latest'), - ), - FilterChip( - label: Text(context.l10n.libraryFilterSortOldest), - selected: tempSortMode == 'oldest', - onSelected: (_) => - setSheetState(() => tempSortMode = 'oldest'), - ), - FilterChip( - label: const Text('A-Z'), - selected: tempSortMode == 'a-z', - onSelected: (_) => - setSheetState(() => tempSortMode = 'a-z'), - ), - FilterChip( - label: const Text('Z-A'), - selected: tempSortMode == 'z-a', - onSelected: (_) => - setSheetState(() => tempSortMode = 'z-a'), - ), - ], - ), - const SizedBox(height: 24), - - SizedBox( - width: double.infinity, - child: FilledButton( - onPressed: () { - setState(() { - _filterSource = tempSource; - _filterQuality = tempQuality; - _filterFormat = tempFormat; - _sortMode = tempSortMode; - _unifiedItemsCache.clear(); - }); - Navigator.pop(context); - }, - child: Text(context.l10n.libraryFilterApply), - ), - ), - ], - ), + ); + }, ), ); }, @@ -1635,10 +1870,25 @@ class _QueueTabState extends ConsumerState { ); } - Future _openFile(String filePath) async { + Future _openFile( + String filePath, { + String title = '', + String artist = '', + String album = '', + String coverUrl = '', + }) async { final cleanPath = _cleanFilePath(filePath); try { - await openFile(cleanPath); + final fallbackTitle = cleanPath.split('/').last.split('\\').last; + await ref + .read(playbackProvider.notifier) + .playLocalPath( + path: cleanPath, + title: title.isNotEmpty ? title : fallbackTitle, + artist: artist, + album: album, + coverUrl: coverUrl, + ); } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -2058,11 +2308,17 @@ class _QueueTabState extends ConsumerState { /// Pass a finite [size] (e.g. 56) for list view, or `null` for grid view /// where the widget should expand to fill its parent. Widget _buildPlaylistCover( + BuildContext context, UserPlaylistCollection playlist, ColorScheme colorScheme, [ double? size, ]) { final borderRadius = BorderRadius.circular(8); + final dpr = MediaQuery.devicePixelRatioOf(context); + final cacheExtent = size != null + ? (size * dpr).round().clamp(64, 1024) + : 420; + final placeholder = _playlistIconFallback(colorScheme, size); final customCoverPath = playlist.coverImagePath; if (customCoverPath != null && customCoverPath.isNotEmpty) { @@ -2073,7 +2329,14 @@ class _QueueTabState extends ConsumerState { width: size, height: size, fit: BoxFit.cover, - errorBuilder: (_, _, _) => _playlistIconFallback(colorScheme, size), + cacheWidth: cacheExtent, + gaplessPlayback: true, + filterQuality: FilterQuality.low, + frameBuilder: (_, child, frame, wasSynchronouslyLoaded) { + if (wasSynchronouslyLoaded || frame != null) return child; + return placeholder; + }, + errorBuilder: (_, _, _) => placeholder, ), ); } @@ -2096,7 +2359,14 @@ class _QueueTabState extends ConsumerState { width: size, height: size, fit: BoxFit.cover, - errorBuilder: (_, _, _) => _playlistIconFallback(colorScheme, size), + cacheWidth: cacheExtent, + gaplessPlayback: true, + filterQuality: FilterQuality.low, + frameBuilder: (_, child, frame, wasSynchronouslyLoaded) { + if (wasSynchronouslyLoaded || frame != null) return child; + return placeholder; + }, + errorBuilder: (_, _, _) => placeholder, ), ); } @@ -2107,13 +2377,15 @@ class _QueueTabState extends ConsumerState { width: size, height: size, fit: BoxFit.cover, - placeholder: (_, _) => _playlistIconFallback(colorScheme, size), - errorWidget: (_, _, _) => _playlistIconFallback(colorScheme, size), + memCacheWidth: cacheExtent, + cacheManager: CoverCacheManager.instance, + placeholder: (_, _) => placeholder, + errorWidget: (_, _, _) => placeholder, ), ); } - return _playlistIconFallback(colorScheme, size); + return placeholder; } /// Icon fallback for playlists with no cover. @@ -2267,22 +2539,20 @@ class _QueueTabState extends ConsumerState { final historyStats = _historyStatsCache ?? _buildHistoryStats(allHistoryItems, localLibraryItems); - final groupedAlbums = historyStats.groupedAlbums; - final groupedLocalAlbums = historyStats.groupedLocalAlbums; - final filteredGroupedAlbums = _filterGroupedAlbums( - groupedAlbums, - _searchQuery, - ); - final filteredGroupedLocalAlbums = _filterGroupedLocalAlbums( - groupedLocalAlbums, - _searchQuery, - ); + final filteredGrouped = _resolveFilteredGroupedAlbums(historyStats); + final filteredGroupedAlbums = filteredGrouped.albums; + final filteredGroupedLocalAlbums = filteredGrouped.localAlbums; final albumCount = historyStats.totalAlbumCount; final singleCount = historyStats.totalSingleTracks; - final filterDataCache = {}; + _prepareFilterContentCache( + allHistoryItems: allHistoryItems, + historyStats: historyStats, + localLibraryItems: localLibraryItems, + collectionState: collectionState, + ); _FilterContentData getFilterData(String filterMode) { - return filterDataCache.putIfAbsent( + return _filterContentDataCache.putIfAbsent( filterMode, () => _computeFilterContentData( filterMode: filterMode, @@ -2298,12 +2568,29 @@ class _QueueTabState extends ConsumerState { } final bottomPadding = MediaQuery.of(context).padding.bottom; + final selectionItems = getFilterData( + historyFilterMode, + ).filteredUnifiedItems; + WidgetsBinding.instance.addPostFrameCallback((_) { + _syncSelectionOverlay( + items: selectionItems, + bottomPadding: bottomPadding, + ); + _syncPlaylistSelectionOverlay( + playlists: collectionState.playlists, + bottomPadding: bottomPadding, + ); + }); return PopScope( - canPop: !_isSelectionMode, + canPop: !_isSelectionMode && !_isPlaylistSelectionMode, onPopInvokedWithResult: (didPop, result) { - if (!didPop && _isSelectionMode) { - _exitSelectionMode(); + if (!didPop) { + if (_isPlaylistSelectionMode) { + _exitPlaylistSelectionMode(); + } else if (_isSelectionMode) { + _exitSelectionMode(); + } } }, child: Stack( @@ -2485,172 +2772,33 @@ class _QueueTabState extends ConsumerState { ), ), ], - body: NotificationListener( - onNotification: (notification) { - final parentController = widget.parentPageController; - if (parentController == null || - !parentController.hasClients) { - return false; - } - - final page = _filterPageController!.page?.round() ?? 0; - - if (notification is OverscrollNotification) { - final overscroll = notification.overscroll; - - if (page == 0 && overscroll < 0) { - final currentOffset = parentController.offset; - final targetOffset = (currentOffset + overscroll).clamp( - 0.0, - parentController.position.maxScrollExtent, - ); - parentController.jumpTo(targetOffset); - return true; - } - - if (page == 2 && overscroll > 0) { - final currentOffset = parentController.offset; - final targetOffset = (currentOffset + overscroll).clamp( - 0.0, - parentController.position.maxScrollExtent, - ); - parentController.jumpTo(targetOffset); - return true; - } - } - - if (notification is ScrollEndNotification) { - if (page == 0 || page == 2) { - final currentPage = - parentController.page ?? - widget.parentPageIndex.toDouble(); - final historyPage = widget.parentPageIndex.toDouble(); - final offset = currentPage - historyPage; - - if (offset.abs() > 0.01) { - if (offset < -0.3) { - parentController.animateToPage( - widget.parentPageIndex - 1, - duration: const Duration(milliseconds: 250), - curve: Curves.easeOutCubic, - ); - } else if (offset > 0.3) { - parentController.animateToPage( - widget.nextPageIndex ?? - (widget.parentPageIndex + 1), - duration: const Duration(milliseconds: 250), - curve: Curves.easeOutCubic, - ); - } else { - parentController.jumpToPage(widget.parentPageIndex); - } - } - } - } - - return false; + body: PageView.builder( + controller: _filterPageController!, + physics: const ClampingScrollPhysics(), + onPageChanged: _onFilterPageChanged, + itemCount: _filterModes.length, + itemBuilder: (context, index) { + final filterMode = _filterModes[index]; + final filterData = getFilterData(filterMode); + return _buildFilterContent( + context: context, + colorScheme: colorScheme, + filterMode: filterMode, + historyViewMode: historyViewMode, + hasQueueItems: hasQueueItems, + filterData: filterData, + localLibraryItems: localLibraryItems, + collectionState: collectionState, + ); }, - child: PageView.builder( - controller: _filterPageController!, - physics: const ClampingScrollPhysics(), - onPageChanged: _onFilterPageChanged, - itemCount: _filterModes.length, - itemBuilder: (context, index) { - final filterMode = _filterModes[index]; - final filterData = getFilterData(filterMode); - return _buildFilterContent( - context: context, - colorScheme: colorScheme, - filterMode: filterMode, - historyViewMode: historyViewMode, - hasQueueItems: hasQueueItems, - filterData: filterData, - localLibraryItems: localLibraryItems, - collectionState: collectionState, - ); - }, - ), ), ), ), // ScrollConfiguration - - AnimatedPositioned( - duration: const Duration(milliseconds: 250), - curve: Curves.easeOutCubic, - left: 0, - right: 0, - bottom: _isSelectionMode ? 0 : -(200 + bottomPadding), - child: _isSelectionMode - ? _buildSelectionBottomBar( - context, - colorScheme, - _buildUnifiedItemsForSelection( - filterMode: historyFilterMode, - allHistoryItems: allHistoryItems, - albumCounts: historyStats.albumCounts, - localLibraryItems: localLibraryItems, - localAlbumCounts: historyStats.localAlbumCounts, - collectionState: collectionState, - ), - bottomPadding, - ) - : const SizedBox.shrink(), - ), - - // Playlist selection bottom bar - AnimatedPositioned( - duration: const Duration(milliseconds: 250), - curve: Curves.easeOutCubic, - left: 0, - right: 0, - bottom: _isPlaylistSelectionMode ? 0 : -(200 + bottomPadding), - child: _isPlaylistSelectionMode - ? _buildPlaylistSelectionBottomBar( - context, - colorScheme, - collectionState.playlists, - bottomPadding, - ) - : const SizedBox.shrink(), - ), ], ), ); } - /// Build unified items list for selection mode - List _buildUnifiedItemsForSelection({ - required String filterMode, - required List allHistoryItems, - required Map albumCounts, - required List localLibraryItems, - required Map localAlbumCounts, - required LibraryCollectionsState collectionState, - }) { - final historyItems = _resolveHistoryItems( - filterMode: filterMode, - allHistoryItems: allHistoryItems, - albumCounts: albumCounts, - ); - - final unifiedItems = _getUnifiedItems( - filterMode: filterMode, - historyItems: historyItems, - localLibraryItems: localLibraryItems, - localAlbumCounts: localAlbumCounts, - ); - - // Apply advanced filters to match what's displayed - final filtered = _applyAdvancedFilters(unifiedItems); - - if (!collectionState.hasPlaylistTracks) return filtered; - return filtered - .where( - (item) => !collectionState.isTrackInAnyPlaylist(item.collectionKey), - ) - .toList(growable: false); - } - List _getUnifiedItems({ required String filterMode, required List historyItems, @@ -3016,7 +3164,11 @@ class _QueueTabState extends ConsumerState { _buildCollectionGridItem( context: context, colorScheme: colorScheme, - coverWidget: _buildPlaylistCover(playlist, colorScheme), + coverWidget: _buildPlaylistCover( + context, + playlist, + colorScheme, + ), title: playlist.name, count: playlist.tracks.length, onTap: _isPlaylistSelectionMode @@ -3031,14 +3183,16 @@ class _QueueTabState extends ConsumerState { left: 0, top: 0, right: 0, - child: AspectRatio( - aspectRatio: 1, - child: Container( - decoration: BoxDecoration( - color: isSelected - ? colorScheme.primary.withValues(alpha: 0.3) - : Colors.transparent, - borderRadius: BorderRadius.circular(8), + child: IgnorePointer( + child: AspectRatio( + aspectRatio: 1, + child: Container( + decoration: BoxDecoration( + color: isSelected + ? colorScheme.primary.withValues(alpha: 0.3) + : Colors.transparent, + borderRadius: BorderRadius.circular(8), + ), ), ), ), @@ -3047,26 +3201,28 @@ class _QueueTabState extends ConsumerState { Positioned( top: 4, right: 4, - child: Container( - decoration: BoxDecoration( - color: isSelected - ? colorScheme.primary - : colorScheme.surface.withValues(alpha: 0.85), - shape: BoxShape.circle, - border: Border.all( + child: IgnorePointer( + child: Container( + decoration: BoxDecoration( color: isSelected ? colorScheme.primary - : colorScheme.outline, - width: 2, + : colorScheme.surface.withValues(alpha: 0.85), + shape: BoxShape.circle, + border: Border.all( + color: isSelected + ? colorScheme.primary + : colorScheme.outline, + width: 2, + ), ), + child: isSelected + ? Icon( + Icons.check, + size: 16, + color: colorScheme.onPrimary, + ) + : const SizedBox(width: 16, height: 16), ), - child: isSelected - ? Icon( - Icons.check, - size: 16, - color: colorScheme.onPrimary, - ) - : const SizedBox(width: 16, height: 16), ), ), ], @@ -3138,35 +3294,44 @@ class _QueueTabState extends ConsumerState { child: Row( children: [ if (_isPlaylistSelectionMode) - Padding( - padding: const EdgeInsets.only(left: 8), - child: Container( - decoration: BoxDecoration( - color: isSelected - ? colorScheme.primary - : Colors.transparent, - shape: BoxShape.circle, - border: Border.all( + GestureDetector( + onTap: () => _togglePlaylistSelection(playlist.id), + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.only(left: 8), + child: Container( + decoration: BoxDecoration( color: isSelected ? colorScheme.primary - : colorScheme.outline, - width: 2, + : Colors.transparent, + shape: BoxShape.circle, + border: Border.all( + color: isSelected + ? colorScheme.primary + : colorScheme.outline, + width: 2, + ), ), + child: isSelected + ? Icon( + Icons.check, + size: 18, + color: colorScheme.onPrimary, + ) + : const SizedBox(width: 18, height: 18), ), - child: isSelected - ? Icon( - Icons.check, - size: 18, - color: colorScheme.onPrimary, - ) - : const SizedBox(width: 18, height: 18), ), ), Expanded( child: _buildCollectionListItem( context: context, colorScheme: colorScheme, - coverWidget: _buildPlaylistCover(playlist, colorScheme, 56), + coverWidget: _buildPlaylistCover( + context, + playlist, + colorScheme, + 56, + ), title: playlist.name, subtitle: '${playlist.tracks.length} ${playlist.tracks.length == 1 ? 'track' : 'tracks'}', @@ -3844,8 +4009,9 @@ class _QueueTabState extends ConsumerState { context, ).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), ), - Text( - album.artistName, + ClickableArtistName( + artistName: album.artistName, + coverUrl: album.coverUrl, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall?.copyWith( @@ -3950,8 +4116,8 @@ class _QueueTabState extends ConsumerState { context, ).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), ), - Text( - album.artistName, + ClickableArtistName( + artistName: album.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall?.copyWith( @@ -4264,15 +4430,20 @@ class _QueueTabState extends ConsumerState { } /// Show batch convert bottom sheet for selected tracks - void _showBatchConvertSheet( + Future _showBatchConvertSheet( BuildContext context, List allItems, - ) { + ) async { String selectedFormat = 'MP3'; String selectedBitrate = '320k'; + var didStartConversion = false; - showModalBottomSheet( + _hideSelectionOverlay(); + _hidePlaylistSelectionOverlay(); + + await showModalBottomSheet( context: context, + useRootNavigator: true, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), @@ -4367,6 +4538,7 @@ class _QueueTabState extends ConsumerState { width: double.infinity, child: FilledButton( onPressed: () { + didStartConversion = true; Navigator.pop(context); _performBatchConversion( allItems: allItems, @@ -4395,6 +4567,19 @@ class _QueueTabState extends ConsumerState { ); }, ); + + if (!mounted || didStartConversion) return; + if (_isSelectionMode) { + _syncSelectionOverlay( + items: allItems, + bottomPadding: MediaQuery.of(this.context).padding.bottom, + ); + } else if (_isPlaylistSelectionMode) { + _syncPlaylistSelectionOverlay( + playlists: ref.read(libraryCollectionsProvider).playlists, + bottomPadding: MediaQuery.of(this.context).padding.bottom, + ); + } } /// Perform batch conversion on selected tracks @@ -4964,8 +5149,10 @@ class _QueueTabState extends ConsumerState { ), ), const SizedBox(height: 2), - Text( - item.track.artistName, + ClickableArtistName( + artistName: item.track.artistName, + artistId: item.track.artistId, + coverUrl: item.track.coverUrl, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall?.copyWith( @@ -5111,7 +5298,13 @@ class _QueueTabState extends ConsumerState { children: [ if (fileExists) IconButton( - onPressed: () => _openFile(item.filePath!), + onPressed: () => _openFile( + item.filePath!, + title: item.track.name, + artist: item.track.artistName, + album: item.track.albumName, + coverUrl: item.track.coverUrl ?? '', + ), icon: Icon(Icons.play_arrow, color: colorScheme.primary), tooltip: 'Play', style: IconButton.styleFrom( @@ -5417,7 +5610,13 @@ class _QueueTabState extends ConsumerState { ? () => _navigateToHistoryMetadataScreen(item.historyItem!) : item.localItem != null ? () => _navigateToLocalMetadataScreen(item.localItem!) - : () => _openFile(item.filePath), + : () => _openFile( + item.filePath, + title: item.trackName, + artist: item.artistName, + album: item.albumName, + coverUrl: item.coverUrl ?? item.localCoverPath ?? '', + ), onLongPress: _isSelectionMode ? null : () => _enterSelectionMode(item.id), @@ -5469,8 +5668,9 @@ class _QueueTabState extends ConsumerState { ), ), const SizedBox(height: 2), - Text( - item.artistName, + ClickableArtistName( + artistName: item.artistName, + coverUrl: item.coverUrl, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall?.copyWith( @@ -5556,7 +5756,14 @@ class _QueueTabState extends ConsumerState { children: [ if (fileExists) IconButton( - onPressed: () => _openFile(item.filePath), + onPressed: () => _openFile( + item.filePath, + title: item.trackName, + artist: item.artistName, + album: item.albumName, + coverUrl: + item.coverUrl ?? item.localCoverPath ?? '', + ), icon: Icon( Icons.play_arrow, color: colorScheme.primary, @@ -5601,7 +5808,13 @@ class _QueueTabState extends ConsumerState { ? () => _navigateToHistoryMetadataScreen(item.historyItem!) : item.localItem != null ? () => _navigateToLocalMetadataScreen(item.localItem!) - : () => _openFile(item.filePath), + : () => _openFile( + item.filePath, + title: item.trackName, + artist: item.artistName, + album: item.albumName, + coverUrl: item.coverUrl ?? item.localCoverPath ?? '', + ), onLongPress: _isSelectionMode ? null : () => _enterSelectionMode(item.id), child: Stack( children: [ @@ -5676,7 +5889,16 @@ class _QueueTabState extends ConsumerState { builder: (context, fileExists, child) { return fileExists ? GestureDetector( - onTap: () => _openFile(item.filePath), + onTap: () => _openFile( + item.filePath, + title: item.trackName, + artist: item.artistName, + album: item.albumName, + coverUrl: + item.coverUrl ?? + item.localCoverPath ?? + '', + ), child: Container( padding: const EdgeInsets.all(6), decoration: BoxDecoration( @@ -5727,8 +5949,9 @@ class _QueueTabState extends ConsumerState { context, ).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500), ), - Text( - item.artistName, + ClickableArtistName( + artistName: item.artistName, + coverUrl: item.coverUrl, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.labelSmall?.copyWith( diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index 142035af..a348ecac 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -6,6 +6,8 @@ import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/providers/track_provider.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; +import 'package:spotiflac_android/utils/clickable_metadata.dart'; class SearchScreen extends ConsumerStatefulWidget { final String query; @@ -61,9 +63,10 @@ class _SearchScreenState extends ConsumerState { @override Widget build(BuildContext context) { - final trackState = ref.watch(trackProvider); + final tracks = ref.watch(trackProvider.select((s) => s.tracks)); + final isLoading = ref.watch(trackProvider.select((s) => s.isLoading)); + final error = ref.watch(trackProvider.select((s) => s.error)); final colorScheme = Theme.of(context).colorScheme; - final tracks = trackState.tracks; return Scaffold( appBar: AppBar( @@ -86,15 +89,11 @@ class _SearchScreenState extends ConsumerState { ), body: Column( children: [ - if (trackState.isLoading) - LinearProgressIndicator(color: colorScheme.primary), - if (trackState.error != null) + if (isLoading) LinearProgressIndicator(color: colorScheme.primary), + if (error != null) Padding( padding: const EdgeInsets.all(16.0), - child: Text( - trackState.error!, - style: TextStyle(color: colorScheme.error), - ), + child: Text(error, style: TextStyle(color: colorScheme.error)), ), Expanded( child: tracks.isEmpty @@ -159,14 +158,19 @@ class _SearchScreenState extends ConsumerState { subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - track.artistName, + ClickableArtistName( + artistName: track.artistName, + artistId: track.artistId, + coverUrl: track.coverUrl, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant), ), - Text( - track.albumName, + ClickableAlbumName( + albumName: track.albumName, + albumId: track.albumId, + artistName: track.artistName, + coverUrl: track.coverUrl, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall?.copyWith( @@ -175,7 +179,21 @@ class _SearchScreenState extends ConsumerState { ), ], ), - trailing: null, + onLongPress: () => TrackCollectionQuickActions.showTrackOptionsSheet( + context, + ref, + track, + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.download_rounded), + tooltip: 'Download', + onPressed: () => _downloadTrack(track), + ), + ], + ), onTap: () => _downloadTrack(track), ); } diff --git a/lib/screens/settings/appearance_settings_page.dart b/lib/screens/settings/appearance_settings_page.dart index 17a174a6..4778e95f 100644 --- a/lib/screens/settings/appearance_settings_page.dart +++ b/lib/screens/settings/appearance_settings_page.dart @@ -763,6 +763,7 @@ class _LanguageSelector extends StatelessWidget { final colorScheme = Theme.of(context).colorScheme; showModalBottomSheet( context: context, + useRootNavigator: true, backgroundColor: colorScheme.surface, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), diff --git a/lib/screens/settings/donate_page.dart b/lib/screens/settings/donate_page.dart index 44944d49..a1933808 100644 --- a/lib/screens/settings/donate_page.dart +++ b/lib/screens/settings/donate_page.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:spotiflac_android/constants/app_info.dart'; import 'package:spotiflac_android/utils/app_bar_layout.dart'; @@ -166,6 +167,9 @@ class _RecentDonorsCard extends StatelessWidget { Widget build(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; const donorNames = [ + 'NinoBrown', + '@nino_sandzak', + 'IMJ', 'J', 'Julian', 'matt_3050', @@ -282,6 +286,19 @@ class _DonateLinksCard extends StatelessWidget { url: AppInfo.githubSponsorsUrl, colorScheme: colorScheme, ), + Divider( + height: 1, + thickness: 1, + indent: 74, + endIndent: 16, + color: colorScheme.outlineVariant.withValues(alpha: 0.3), + ), + _CryptoWalletItem( + title: 'USDT (TRC20)', + walletAddress: 'TL7iAqjq9M8BwVMi9AtHvuAGHtdwEvsDta', + color: const Color(0xFF26A17B), + colorScheme: colorScheme, + ), ], ), ); @@ -357,13 +374,97 @@ class _DonateCardItem extends StatelessWidget { } } +class _CryptoWalletItem extends StatelessWidget { + final String title; + final String walletAddress; + final Color color; + final ColorScheme colorScheme; + + const _CryptoWalletItem({ + required this.title, + required this.walletAddress, + required this.color, + required this.colorScheme, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: () { + Clipboard.setData(ClipboardData(text: walletAddress)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('$title address copied to clipboard'), + behavior: SnackBarBehavior.floating, + duration: const Duration(seconds: 2), + ), + ); + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(12), + ), + child: const Center( + child: Text( + '\$', + style: TextStyle( + color: Colors.white, + fontSize: 22, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + ), + ), + const SizedBox(height: 2), + Text( + walletAddress, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontSize: 11, + ), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + Icon( + Icons.copy_rounded, + size: 18, + color: colorScheme.onSurfaceVariant, + ), + ], + ), + ), + ); + } +} + int _cr(String v) { int r = 0x1F; for (final c in v.codeUnits) { r = (r * 31 + c) & 0x7FFFFFFF; } return r; } -// Highlighted supporters (hashes of names): Julian, J. -const _cv = {1825257268, 1035}; +// Highlighted supporters (hashes of names): Julian, J, NinoBrown, @nino_sandzak, IMJ. +const _cv = {1825257268, 1035, 1497948283, 398058782, 996135}; class _SupporterChip extends StatelessWidget { final String name; diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index 389629f1..37918235 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -501,14 +501,17 @@ class _DownloadSettingsPageState extends ConsumerState { SettingsSwitchItem( icon: Icons.subtitles_outlined, title: context.l10n.optionsEmbedLyrics, - subtitle: context.l10n.optionsEmbedLyricsSubtitle, + subtitle: settings.embedMetadata + ? context.l10n.optionsEmbedLyricsSubtitle + : 'Disabled while Embed Metadata is turned off', value: settings.embedLyrics, + enabled: settings.embedMetadata, onChanged: (value) => ref .read(settingsProvider.notifier) .setEmbedLyrics(value), - showDivider: settings.embedLyrics, + showDivider: settings.embedMetadata && settings.embedLyrics, ), - if (settings.embedLyrics) ...[ + if (settings.embedMetadata && settings.embedLyrics) ...[ SettingsItem( icon: Icons.lyrics_outlined, title: context.l10n.lyricsMode, @@ -858,6 +861,7 @@ class _DownloadSettingsPageState extends ConsumerState { ) { showModalBottomSheet( context: context, + useRootNavigator: true, builder: (context) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, @@ -992,6 +996,7 @@ class _DownloadSettingsPageState extends ConsumerState { showModalBottomSheet( context: context, + useRootNavigator: true, isScrollControlled: true, backgroundColor: colorScheme.surface, shape: const RoundedRectangleBorder( @@ -1209,6 +1214,7 @@ class _DownloadSettingsPageState extends ConsumerState { settings.storageMode == 'saf' && settings.downloadTreeUri.isNotEmpty; showModalBottomSheet( context: context, + useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(28)), @@ -1288,6 +1294,7 @@ class _DownloadSettingsPageState extends ConsumerState { final colorScheme = Theme.of(context).colorScheme; showModalBottomSheet( context: context, + useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(28)), @@ -1451,6 +1458,7 @@ class _DownloadSettingsPageState extends ConsumerState { final colorScheme = Theme.of(context).colorScheme; showModalBottomSheet( context: context, + useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(28)), @@ -1516,6 +1524,7 @@ class _DownloadSettingsPageState extends ConsumerState { } static const _providerDisplayNames = { + 'spotify_api': 'Spotify Lyrics API', 'lrclib': 'LRCLIB', 'netease': 'Netease', 'musixmatch': 'Musixmatch', @@ -1544,6 +1553,7 @@ class _DownloadSettingsPageState extends ConsumerState { showModalBottomSheet( context: context, + useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(28)), @@ -1604,6 +1614,7 @@ class _DownloadSettingsPageState extends ConsumerState { showModalBottomSheet( context: context, + useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(28)), @@ -1702,6 +1713,7 @@ class _DownloadSettingsPageState extends ConsumerState { final colorScheme = Theme.of(context).colorScheme; showModalBottomSheet( context: context, + useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(28)), @@ -1786,6 +1798,7 @@ class _DownloadSettingsPageState extends ConsumerState { final colorScheme = Theme.of(context).colorScheme; showModalBottomSheet( context: context, + useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(28)), @@ -1857,6 +1870,7 @@ class _DownloadSettingsPageState extends ConsumerState { final normalizedCurrent = current.trim().toUpperCase(); showModalBottomSheet( context: context, + useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, isScrollControlled: true, shape: const RoundedRectangleBorder( @@ -1924,6 +1938,7 @@ class _DownloadSettingsPageState extends ConsumerState { final colorScheme = Theme.of(context).colorScheme; showModalBottomSheet( context: context, + useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, isScrollControlled: true, shape: const RoundedRectangleBorder( @@ -2024,16 +2039,13 @@ class _ServiceSelector extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final extState = ref.watch(extensionProvider); + final builtInServiceIds = ['tidal', 'qobuz', 'amazon', 'youtube']; final extensionProviders = extState.extensions .where((e) => e.enabled && e.hasDownloadProvider) .toList(); - final isExtensionService = ![ - 'tidal', - 'qobuz', - 'amazon', - ].contains(currentService); + final isExtensionService = !builtInServiceIds.contains(currentService); final isCurrentExtensionEnabled = isExtensionService ? extensionProviders.any((e) => e.id == currentService) : true; @@ -2046,47 +2058,56 @@ class _ServiceSelector extends ConsumerWidget { children: [ Row( children: [ - _ServiceChip( - icon: Icons.music_note, - label: 'Tidal', - isSelected: effectiveService == 'tidal', - onTap: () => onChanged('tidal'), + Expanded( + child: _ServiceChip( + icon: Icons.music_note, + label: 'Tidal', + isSelected: effectiveService == 'tidal', + onTap: () => onChanged('tidal'), + ), ), const SizedBox(width: 8), - _ServiceChip( - icon: Icons.album, - label: 'Qobuz', - isSelected: effectiveService == 'qobuz', - onTap: () => onChanged('qobuz'), + Expanded( + child: _ServiceChip( + icon: Icons.album, + label: 'Qobuz', + isSelected: effectiveService == 'qobuz', + onTap: () => onChanged('qobuz'), + ), ), const SizedBox(width: 8), - _ServiceChip( - icon: Icons.shopping_bag_outlined, - label: 'Amazon', - isSelected: effectiveService == 'amazon', - onTap: () => onChanged('amazon'), + Expanded( + child: _ServiceChip( + icon: Icons.shopping_bag_outlined, + label: 'Amazon', + isSelected: effectiveService == 'amazon', + onTap: () => onChanged('amazon'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: _ServiceChip( + icon: Icons.smart_display, + label: 'YouTube', + isSelected: effectiveService == 'youtube', + onTap: () => onChanged('youtube'), + ), ), ], ), if (extensionProviders.isNotEmpty) ...[ const SizedBox(height: 8), - Row( + Wrap( + spacing: 8, + runSpacing: 8, children: [ - for (int i = 0; i < extensionProviders.length; i++) ...[ - if (i > 0) const SizedBox(width: 8), - Expanded( - child: _ServiceChip( - icon: Icons.extension, - label: extensionProviders[i].displayName, - isSelected: effectiveService == extensionProviders[i].id, - onTap: () => onChanged(extensionProviders[i].id), - ), + for (final extension in extensionProviders) + _ServiceChip( + icon: Icons.extension, + label: extension.displayName, + isSelected: effectiveService == extension.id, + onTap: () => onChanged(extension.id), ), - ], - for (int i = extensionProviders.length; i < 3; i++) ...[ - const SizedBox(width: 8), - const Expanded(child: SizedBox()), - ], ], ), ], @@ -2120,38 +2141,35 @@ class _ServiceChip extends StatelessWidget { ) : colorScheme.surfaceContainerHigh; - return Expanded( - child: Material( - color: isSelected ? colorScheme.primaryContainer : unselectedColor, + return Material( + color: isSelected ? colorScheme.primaryContainer : unselectedColor, + borderRadius: BorderRadius.circular(12), + child: InkWell( + onTap: onTap, borderRadius: BorderRadius.circular(12), - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(12), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 14), - child: Column( - children: [ - Icon( - icon, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 12), + child: Column( + children: [ + Icon( + icon, + color: isSelected + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 6), + Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant, ), - const SizedBox(height: 6), - Text( - label, - style: TextStyle( - fontSize: 12, - fontWeight: isSelected - ? FontWeight.w600 - : FontWeight.normal, - color: isSelected - ? colorScheme.onPrimaryContainer - : colorScheme.onSurfaceVariant, - ), - ), - ], - ), + overflow: TextOverflow.ellipsis, + ), + ], ), ), ), diff --git a/lib/screens/settings/extensions_page.dart b/lib/screens/settings/extensions_page.dart index 5c13458b..d339e24d 100644 --- a/lib/screens/settings/extensions_page.dart +++ b/lib/screens/settings/extensions_page.dart @@ -20,12 +20,15 @@ class ExtensionsPage extends ConsumerStatefulWidget { } class _ExtensionsPageState extends ConsumerState { - static final RegExp _platformExceptionPattern = - RegExp(r'PlatformException\([^,]+,\s*([^,]+(?:,[^,]+)?),'); - static final RegExp _platformExceptionSimplePattern = - RegExp(r'PlatformException\([^,]+,\s*(.+?),\s*null'); - static final RegExp _trailingNullsPattern = - RegExp(r',\s*null\s*,\s*null\)?$'); + static final RegExp _platformExceptionPattern = RegExp( + r'PlatformException\([^,]+,\s*([^,]+(?:,[^,]+)?),', + ); + static final RegExp _platformExceptionSimplePattern = RegExp( + r'PlatformException\([^,]+,\s*(.+?),\s*null', + ); + static final RegExp _trailingNullsPattern = RegExp( + r',\s*null\s*,\s*null\)?$', + ); static final RegExp _leadingCommaPattern = RegExp(r'^\s*,\s*'); @override @@ -40,11 +43,13 @@ class _ExtensionsPageState extends ConsumerState { final appDir = await getApplicationDocumentsDirectory(); final extensionsDir = '${appDir.path}/extensions'; final dataDir = '${appDir.path}/extension_data'; - + await Directory(extensionsDir).create(recursive: true); await Directory(dataDir).create(recursive: true); - - await ref.read(extensionProvider.notifier).initialize(extensionsDir, dataDir); + + await ref + .read(extensionProvider.notifier) + .initialize(extensionsDir, dataDir); } } @@ -59,67 +64,205 @@ class _ExtensionsPageState extends ConsumerState { child: Scaffold( body: CustomScrollView( slivers: [ - SliverAppBar( - expandedHeight: 120 + topPadding, - collapsedHeight: kToolbarHeight, - floating: false, - pinned: true, - backgroundColor: colorScheme.surface, - surfaceTintColor: Colors.transparent, - leading: IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () => Navigator.pop(context), - ), - flexibleSpace: LayoutBuilder( - builder: (context, constraints) { - final maxHeight = 120 + topPadding; - final minHeight = kToolbarHeight + topPadding; - final expandRatio = ((constraints.maxHeight - minHeight) / - (maxHeight - minHeight)) - .clamp(0.0, 1.0); - final leftPadding = 56 - (32 * expandRatio); - return FlexibleSpaceBar( - expandedTitleScale: 1.0, - titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16), - title: Text( - context.l10n.extensionsTitle, - style: TextStyle( - fontSize: 20 + (8 * expandRatio), - fontWeight: FontWeight.bold, - color: colorScheme.onSurface, + SliverAppBar( + expandedHeight: 120 + topPadding, + collapsedHeight: kToolbarHeight, + floating: false, + pinned: true, + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.pop(context), + ), + flexibleSpace: LayoutBuilder( + builder: (context, constraints) { + final maxHeight = 120 + topPadding; + final minHeight = kToolbarHeight + topPadding; + final expandRatio = + ((constraints.maxHeight - minHeight) / + (maxHeight - minHeight)) + .clamp(0.0, 1.0); + final leftPadding = 56 - (32 * expandRatio); + return FlexibleSpaceBar( + expandedTitleScale: 1.0, + titlePadding: EdgeInsets.only( + left: leftPadding, + bottom: 16, ), - ), - ); - }, - ), - ), - - if (extState.isLoading) - const SliverToBoxAdapter( - child: Padding( - padding: EdgeInsets.all(32), - child: Center(child: CircularProgressIndicator()), + title: Text( + context.l10n.extensionsTitle, + style: TextStyle( + fontSize: 20 + (8 * expandRatio), + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + ); + }, ), ), - if (extState.error != null) + if (extState.isLoading) + const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.all(32), + child: Center(child: CircularProgressIndicator()), + ), + ), + + if (extState.error != null) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(16), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.errorContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon(Icons.error_outline, color: colorScheme.error), + const SizedBox(width: 12), + Expanded( + child: Text( + extState.error!, + style: TextStyle( + color: colorScheme.onErrorContainer, + ), + ), + ), + ], + ), + ), + ), + ), + + SliverToBoxAdapter( + child: SettingsSectionHeader( + title: context.l10n.extensionsProviderPrioritySection, + ), + ), + SliverToBoxAdapter( + child: SettingsGroup( + children: [ + _DownloadPriorityItem(), + _MetadataPriorityItem(), + _SearchProviderSelector(), + ], + ), + ), + + SliverToBoxAdapter( + child: SettingsSectionHeader( + title: context.l10n.extensionsInstalledSection, + ), + ), + + if (extState.extensions.isEmpty && !extState.isLoading) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest.withValues( + alpha: 0.3, + ), + borderRadius: BorderRadius.circular(16), + ), + child: Column( + children: [ + Icon( + Icons.extension_outlined, + size: 48, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 12), + Text( + context.l10n.extensionsNoExtensions, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(color: colorScheme.onSurfaceVariant), + ), + const SizedBox(height: 4), + Text( + context.l10n.extensionsNoExtensionsSubtitle, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: colorScheme.onSurfaceVariant), + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ), + + if (extState.extensions.isNotEmpty) + SliverToBoxAdapter( + child: SettingsGroup( + children: extState.extensions.asMap().entries.map((entry) { + final index = entry.key; + final ext = entry.value; + return _ExtensionItem( + extension: ext, + showDivider: index < extState.extensions.length - 1, + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => + ExtensionDetailPage(extensionId: ext.id), + ), + ), + onToggle: (enabled) => ref + .read(extensionProvider.notifier) + .setExtensionEnabled(ext.id, enabled), + ); + }).toList(), + ), + ), + SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.all(16), + child: FilledButton.icon( + onPressed: _installExtension, + icon: const Icon(Icons.add), + label: Text(context.l10n.extensionsInstallButton), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + ), + ), + ), + + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 32), child: Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: colorScheme.errorContainer, + color: colorScheme.tertiaryContainer.withValues(alpha: 0.3), borderRadius: BorderRadius.circular(12), ), child: Row( children: [ - Icon(Icons.error_outline, color: colorScheme.error), + Icon( + Icons.info_outline, + size: 20, + color: colorScheme.tertiary, + ), const SizedBox(width: 12), Expanded( child: Text( - extState.error!, - style: TextStyle(color: colorScheme.onErrorContainer), + context.l10n.extensionsInfoTip, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: colorScheme.onTertiaryContainer, + ), ), ), ], @@ -127,131 +270,9 @@ class _ExtensionsPageState extends ConsumerState { ), ), ), - - SliverToBoxAdapter( - child: SettingsSectionHeader(title: context.l10n.extensionsProviderPrioritySection), - ), - SliverToBoxAdapter( - child: SettingsGroup( - children: [ - _DownloadPriorityItem(), - _MetadataPriorityItem(), - _SearchProviderSelector(), - ], - ), - ), - - SliverToBoxAdapter( - child: SettingsSectionHeader(title: context.l10n.extensionsInstalledSection), - ), - - if (extState.extensions.isEmpty && !extState.isLoading) - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Container( - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(16), - ), - child: Column( - children: [ - Icon( - Icons.extension_outlined, - size: 48, - color: colorScheme.onSurfaceVariant, - ), - const SizedBox(height: 12), - Text( - context.l10n.extensionsNoExtensions, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 4), - Text( - context.l10n.extensionsNoExtensionsSubtitle, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - textAlign: TextAlign.center, - ), - ], - ), - ), - ), - ), - - if (extState.extensions.isNotEmpty) - SliverToBoxAdapter( - child: SettingsGroup( - children: extState.extensions.asMap().entries.map((entry) { - final index = entry.key; - final ext = entry.value; - return _ExtensionItem( - extension: ext, - showDivider: index < extState.extensions.length - 1, - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (_) => ExtensionDetailPage(extensionId: ext.id), - ), - ), - onToggle: (enabled) => ref - .read(extensionProvider.notifier) - .setExtensionEnabled(ext.id, enabled), - ); - }).toList(), - ), - ), - - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(16), - child: FilledButton.icon( - onPressed: _installExtension, - icon: const Icon(Icons.add), - label: Text(context.l10n.extensionsInstallButton), - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - ), - ), - ), - ), - - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 32), - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: colorScheme.tertiaryContainer.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(12), - ), - child: Row( - children: [ - Icon(Icons.info_outline, size: 20, color: colorScheme.tertiary), - const SizedBox(width: 12), - Expanded( - child: Text( - context.l10n.extensionsInfoTip, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onTertiaryContainer, - ), - ), - ), - ], - ), - ), - ), - ), - ], + ], + ), ), - ), ); } @@ -267,9 +288,7 @@ class _ExtensionsPageState extends ConsumerState { if (!file.path!.endsWith('.spotiflac-ext')) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.snackbarSelectExtFile), - ), + SnackBar(content: Text(context.l10n.snackbarSelectExtFile)), ); } return; @@ -287,12 +306,12 @@ class _ExtensionsPageState extends ConsumerState { } else { message = _getFriendlyErrorMessage(extState.error); } - + ref.read(extensionProvider.notifier).clearError(); - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(message)), - ); + + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); } } } @@ -301,9 +320,9 @@ class _ExtensionsPageState extends ConsumerState { /// Parse error message to be more user-friendly String _getFriendlyErrorMessage(String? error) { if (error == null) return 'Failed to install extension'; - + String message = error; - + if (message.contains('PlatformException')) { final match = _platformExceptionPattern.firstMatch(message); if (match != null) { @@ -315,10 +334,10 @@ class _ExtensionsPageState extends ConsumerState { } } } - + message = message.replaceAll(_trailingNullsPattern, ''); message = message.replaceAll(_leadingCommaPattern, ''); - + return message; } } @@ -359,7 +378,9 @@ class _ExtensionItem extends StatelessWidget { : colorScheme.primaryContainer, borderRadius: BorderRadius.circular(12), ), - child: extension.iconPath != null && extension.iconPath!.isNotEmpty + child: + extension.iconPath != null && + extension.iconPath!.isNotEmpty ? ClipRRect( borderRadius: BorderRadius.circular(12), child: Image.file( @@ -396,7 +417,8 @@ class _ExtensionItem extends StatelessWidget { const SizedBox(height: 2), Text( hasError - ? extension.errorMessage ?? context.l10n.extensionsErrorLoading + ? extension.errorMessage ?? + context.l10n.extensionsErrorLoading : 'v${extension.version} ${context.l10n.extensionsAuthor(extension.author)}', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: hasError @@ -435,17 +457,16 @@ class _DownloadPriorityItem extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final extState = ref.watch(extensionProvider); final colorScheme = Theme.of(context).colorScheme; - - final hasDownloadExtensions = extState.extensions - .any((e) => e.enabled && e.hasDownloadProvider); - + + final hasDownloadExtensions = extState.extensions.any( + (e) => e.enabled && e.hasDownloadProvider, + ); + return InkWell( - onTap: hasDownloadExtensions + onTap: hasDownloadExtensions ? () => Navigator.push( context, - MaterialPageRoute( - builder: (_) => const ProviderPriorityPage(), - ), + MaterialPageRoute(builder: (_) => const ProviderPriorityPage()), ) : null, child: Padding( @@ -454,8 +475,8 @@ class _DownloadPriorityItem extends ConsumerWidget { children: [ Icon( Icons.download, - color: hasDownloadExtensions - ? colorScheme.onSurfaceVariant + color: hasDownloadExtensions + ? colorScheme.onSurfaceVariant : colorScheme.outline, ), const SizedBox(width: 16), @@ -466,14 +487,12 @@ class _DownloadPriorityItem extends ConsumerWidget { Text( context.l10n.extensionsDownloadPriority, style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: hasDownloadExtensions - ? null - : colorScheme.outline, + color: hasDownloadExtensions ? null : colorScheme.outline, ), ), const SizedBox(height: 2), Text( - hasDownloadExtensions + hasDownloadExtensions ? context.l10n.extensionsDownloadPrioritySubtitle : context.l10n.extensionsNoDownloadProvider, style: Theme.of(context).textTheme.bodySmall?.copyWith( @@ -485,8 +504,8 @@ class _DownloadPriorityItem extends ConsumerWidget { ), Icon( Icons.chevron_right, - color: hasDownloadExtensions - ? colorScheme.onSurfaceVariant + color: hasDownloadExtensions + ? colorScheme.onSurfaceVariant : colorScheme.outline, ), ], @@ -503,12 +522,13 @@ class _MetadataPriorityItem extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final extState = ref.watch(extensionProvider); final colorScheme = Theme.of(context).colorScheme; - - final hasMetadataExtensions = extState.extensions - .any((e) => e.enabled && e.hasMetadataProvider); - + + final hasMetadataExtensions = extState.extensions.any( + (e) => e.enabled && e.hasMetadataProvider, + ); + return InkWell( - onTap: hasMetadataExtensions + onTap: hasMetadataExtensions ? () => Navigator.push( context, MaterialPageRoute( @@ -522,8 +542,8 @@ class _MetadataPriorityItem extends ConsumerWidget { children: [ Icon( Icons.search, - color: hasMetadataExtensions - ? colorScheme.onSurfaceVariant + color: hasMetadataExtensions + ? colorScheme.onSurfaceVariant : colorScheme.outline, ), const SizedBox(width: 16), @@ -534,14 +554,12 @@ class _MetadataPriorityItem extends ConsumerWidget { Text( context.l10n.extensionsMetadataPriority, style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: hasMetadataExtensions - ? null - : colorScheme.outline, + color: hasMetadataExtensions ? null : colorScheme.outline, ), ), const SizedBox(height: 2), Text( - hasMetadataExtensions + hasMetadataExtensions ? context.l10n.extensionsMetadataPrioritySubtitle : context.l10n.extensionsNoMetadataProvider, style: Theme.of(context).textTheme.bodySmall?.copyWith( @@ -553,8 +571,8 @@ class _MetadataPriorityItem extends ConsumerWidget { ), Icon( Icons.chevron_right, - color: hasMetadataExtensions - ? colorScheme.onSurfaceVariant + color: hasMetadataExtensions + ? colorScheme.onSurfaceVariant : colorScheme.outline, ), ], @@ -572,32 +590,40 @@ class _SearchProviderSelector extends ConsumerWidget { final settings = ref.watch(settingsProvider); final extState = ref.watch(extensionProvider); final colorScheme = Theme.of(context).colorScheme; - + final searchProviders = extState.extensions .where((e) => e.enabled && e.hasCustomSearch) .toList(); - + String currentProviderName = context.l10n.extensionDefaultProvider; - if (settings.searchProvider != null && settings.searchProvider!.isNotEmpty) { - final ext = searchProviders.where((e) => e.id == settings.searchProvider).firstOrNull; + if (settings.searchProvider != null && + settings.searchProvider!.isNotEmpty) { + final ext = searchProviders + .where((e) => e.id == settings.searchProvider) + .firstOrNull; currentProviderName = ext?.displayName ?? settings.searchProvider!; } - + return Column( mainAxisSize: MainAxisSize.min, children: [ InkWell( - onTap: searchProviders.isEmpty - ? null - : () => _showSearchProviderPicker(context, ref, settings, searchProviders), + onTap: searchProviders.isEmpty + ? null + : () => _showSearchProviderPicker( + context, + ref, + settings, + searchProviders, + ), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( children: [ Icon( Icons.manage_search, - color: searchProviders.isEmpty - ? colorScheme.outline + color: searchProviders.isEmpty + ? colorScheme.outline : colorScheme.onSurfaceVariant, ), const SizedBox(width: 16), @@ -608,14 +634,14 @@ class _SearchProviderSelector extends ConsumerWidget { Text( context.l10n.extensionsSearchProvider, style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: searchProviders.isEmpty - ? colorScheme.outline + color: searchProviders.isEmpty + ? colorScheme.outline : null, ), ), const SizedBox(height: 2), Text( - searchProviders.isEmpty + searchProviders.isEmpty ? context.l10n.extensionsNoCustomSearch : currentProviderName, style: Theme.of(context).textTheme.bodySmall?.copyWith( @@ -627,8 +653,8 @@ class _SearchProviderSelector extends ConsumerWidget { ), Icon( Icons.chevron_right, - color: searchProviders.isEmpty - ? colorScheme.outline + color: searchProviders.isEmpty + ? colorScheme.outline : colorScheme.onSurfaceVariant, ), ], @@ -646,9 +672,10 @@ class _SearchProviderSelector extends ConsumerWidget { List searchProviders, ) { final colorScheme = Theme.of(context).colorScheme; - + showModalBottomSheet( context: context, + useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(28)), @@ -662,9 +689,9 @@ class _SearchProviderSelector extends ConsumerWidget { padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), child: Text( ctx.l10n.extensionsSearchProvider, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - ), + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), ), ), Padding( @@ -680,7 +707,9 @@ class _SearchProviderSelector extends ConsumerWidget { leading: Icon(Icons.music_note, color: colorScheme.primary), title: Text(ctx.l10n.extensionDefaultProvider), subtitle: Text(ctx.l10n.extensionDefaultProviderSubtitle), - trailing: (settings.searchProvider == null || settings.searchProvider!.isEmpty) + trailing: + (settings.searchProvider == null || + settings.searchProvider!.isEmpty) ? Icon(Icons.check_circle, color: colorScheme.primary) : Icon(Icons.circle_outlined, color: colorScheme.outline), onTap: () { @@ -688,18 +717,23 @@ class _SearchProviderSelector extends ConsumerWidget { Navigator.pop(ctx); }, ), - ...searchProviders.map((ext) => ListTile( - leading: Icon(Icons.extension, color: colorScheme.secondary), - title: Text(ext.displayName), - subtitle: Text(ext.searchBehavior?.placeholder ?? ctx.l10n.extensionsCustomSearch), - trailing: settings.searchProvider == ext.id - ? Icon(Icons.check_circle, color: colorScheme.primary) - : Icon(Icons.circle_outlined, color: colorScheme.outline), - onTap: () { - ref.read(settingsProvider.notifier).setSearchProvider(ext.id); - Navigator.pop(ctx); - }, - )), + ...searchProviders.map( + (ext) => ListTile( + leading: Icon(Icons.extension, color: colorScheme.secondary), + title: Text(ext.displayName), + subtitle: Text( + ext.searchBehavior?.placeholder ?? + ctx.l10n.extensionsCustomSearch, + ), + trailing: settings.searchProvider == ext.id + ? Icon(Icons.check_circle, color: colorScheme.primary) + : Icon(Icons.circle_outlined, color: colorScheme.outline), + onTap: () { + ref.read(settingsProvider.notifier).setSearchProvider(ext.id); + Navigator.pop(ctx); + }, + ), + ), const SizedBox(height: 16), ], ), diff --git a/lib/screens/settings/lyrics_provider_priority_page.dart b/lib/screens/settings/lyrics_provider_priority_page.dart index b203717f..a33e8a50 100644 --- a/lib/screens/settings/lyrics_provider_priority_page.dart +++ b/lib/screens/settings/lyrics_provider_priority_page.dart @@ -16,6 +16,7 @@ class _LyricsProviderPriorityPageState extends ConsumerState { static const _allProviderIds = [ 'lrclib', + 'spotify_api', 'netease', 'musixmatch', 'apple_music', @@ -183,6 +184,12 @@ class _LyricsProviderPriorityPageState static _LyricsProviderInfo _getLyricsProviderInfo(String id) { switch (id) { + case 'spotify_api': + return _LyricsProviderInfo( + name: 'Spotify Lyrics API', + description: 'Spotify-sourced synced lyrics via community API', + icon: Icons.music_note_outlined, + ); case 'lrclib': return _LyricsProviderInfo( name: 'LRCLIB', diff --git a/lib/screens/settings/options_settings_page.dart b/lib/screens/settings/options_settings_page.dart index 013bc6e0..dbd4669c 100644 --- a/lib/screens/settings/options_settings_page.dart +++ b/lib/screens/settings/options_settings_page.dart @@ -152,6 +152,30 @@ 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), + ), + 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, @@ -164,11 +188,24 @@ class OptionsSettingsPage extends ConsumerWidget { .read(settingsProvider.notifier) .setUseExtensionProviders(v), ), + SettingsSwitchItem( + icon: Icons.sell_outlined, + title: 'Embed Metadata', + subtitle: settings.embedMetadata + ? 'Write metadata, cover art, and embedded lyrics to files' + : 'Disabled (advanced): skip all metadata embedding', + value: settings.embedMetadata, + onChanged: (v) => + ref.read(settingsProvider.notifier).setEmbedMetadata(v), + ), SettingsSwitchItem( icon: Icons.image, title: context.l10n.optionsMaxQualityCover, - subtitle: context.l10n.optionsMaxQualityCoverSubtitle, + subtitle: settings.embedMetadata + ? context.l10n.optionsMaxQualityCoverSubtitle + : 'Disabled when metadata embedding is off', value: settings.maxQualityCover, + enabled: settings.embedMetadata, onChanged: (v) => ref .read(settingsProvider.notifier) .setMaxQualityCover(v), @@ -375,6 +412,7 @@ class OptionsSettingsPage extends ConsumerWidget { showModalBottomSheet( context: context, + useRootNavigator: true, isScrollControlled: true, backgroundColor: colorScheme.surface, shape: const RoundedRectangleBorder( @@ -972,9 +1010,9 @@ class _MetadataSourceSelector extends ConsumerWidget { Expanded( child: Text( context.l10n.optionsSpotifyDeprecationWarning, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.error, - ), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: colorScheme.error), ), ), ], diff --git a/lib/screens/settings/provider_priority_page.dart b/lib/screens/settings/provider_priority_page.dart index b20ef45d..ed44ca46 100644 --- a/lib/screens/settings/provider_priority_page.dart +++ b/lib/screens/settings/provider_priority_page.dart @@ -8,7 +8,8 @@ class ProviderPriorityPage extends ConsumerStatefulWidget { const ProviderPriorityPage({super.key}); @override - ConsumerState createState() => _ProviderPriorityPageState(); + ConsumerState createState() => + _ProviderPriorityPageState(); } class _ProviderPriorityPageState extends ConsumerState { @@ -23,8 +24,10 @@ class _ProviderPriorityPageState extends ConsumerState { void _loadProviders() { final extState = ref.read(extensionProvider); - final allProviders = ref.read(extensionProvider.notifier).getAllDownloadProviders(); - + final allProviders = ref + .read(extensionProvider.notifier) + .getAllDownloadProviders(); + if (extState.providerPriority.isNotEmpty) { _providers = List.from(extState.providerPriority); for (final provider in allProviders) { @@ -86,13 +89,17 @@ class _ProviderPriorityPageState extends ConsumerState { builder: (context, constraints) { final maxHeight = 120 + topPadding; final minHeight = kToolbarHeight + topPadding; - final expandRatio = ((constraints.maxHeight - minHeight) / - (maxHeight - minHeight)) - .clamp(0.0, 1.0); + final expandRatio = + ((constraints.maxHeight - minHeight) / + (maxHeight - minHeight)) + .clamp(0.0, 1.0); final leftPadding = 56 - (32 * expandRatio); return FlexibleSpaceBar( expandedTitleScale: 1.0, - titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16), + titlePadding: EdgeInsets.only( + left: leftPadding, + bottom: 16, + ), title: Text( context.l10n.providerPriorityTitle, style: TextStyle( @@ -156,14 +163,19 @@ class _ProviderPriorityPageState extends ConsumerState { ), child: Row( children: [ - Icon(Icons.info_outline, size: 20, color: colorScheme.tertiary), + Icon( + Icons.info_outline, + size: 20, + color: colorScheme.tertiary, + ), const SizedBox(width: 12), Expanded( child: Text( context.l10n.providerPriorityInfo, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onTertiaryContainer, - ), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: colorScheme.onTertiaryContainer, + ), ), ), ], @@ -292,7 +304,9 @@ class _ProviderItem extends StatelessWidget { ), ), Text( - info.isBuiltIn ? context.l10n.providerBuiltIn : context.l10n.providerExtension, + info.isBuiltIn + ? context.l10n.providerBuiltIn + : context.l10n.providerExtension, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, ), @@ -300,10 +314,7 @@ class _ProviderItem extends StatelessWidget { ], ), ), - Icon( - Icons.drag_handle, - color: colorScheme.onSurfaceVariant, - ), + Icon(Icons.drag_handle, color: colorScheme.onSurfaceVariant), ], ), ), @@ -321,17 +332,19 @@ class _ProviderItem extends StatelessWidget { isBuiltIn: true, ); case 'qobuz': - return _ProviderInfo( - name: 'Qobuz', - icon: Icons.album, - isBuiltIn: true, - ); + return _ProviderInfo(name: 'Qobuz', icon: Icons.album, isBuiltIn: true); case 'amazon': return _ProviderInfo( name: 'Amazon Music', icon: Icons.shopping_bag, isBuiltIn: true, ); + case 'youtube': + return _ProviderInfo( + name: 'YouTube', + icon: Icons.play_circle_outline, + isBuiltIn: true, + ); default: return _ProviderInfo( name: provider, diff --git a/lib/screens/setup_screen.dart b/lib/screens/setup_screen.dart index e32204d0..8ec82457 100644 --- a/lib/screens/setup_screen.dart +++ b/lib/screens/setup_screen.dart @@ -6,6 +6,7 @@ import 'package:file_picker/file_picker.dart'; import 'package:path_provider/path_provider.dart'; import 'package:go_router/go_router.dart'; import 'package:device_info_plus/device_info_plus.dart'; +import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; @@ -30,11 +31,8 @@ class _SetupScreenState extends ConsumerState { bool _isLoading = false; int _androidSdkVersion = 0; - // Spotify form - final _clientIdController = TextEditingController(); - final _clientSecretController = TextEditingController(); - bool _useSpotifyApi = false; - bool _showClientSecret = false; + // Mode selection + String _selectedMode = 'downloader'; // We add 1 for the Welcome step int get _totalSteps => (_androidSdkVersion >= 33 ? 4 : 3) + 1; @@ -48,8 +46,6 @@ class _SetupScreenState extends ConsumerState { @override void dispose() { _pageController.dispose(); - _clientIdController.dispose(); - _clientSecretController.dispose(); super.dispose(); } @@ -291,6 +287,7 @@ class _SetupScreenState extends ConsumerState { final colorScheme = Theme.of(context).colorScheme; await showModalBottomSheet( context: context, + useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(28)), @@ -339,8 +336,13 @@ class _SetupScreenState extends ConsumerState { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(validation.errorReason ?? 'Invalid folder selected'), - backgroundColor: Theme.of(context).colorScheme.error, + content: Text( + validation.errorReason ?? + 'Invalid folder selected', + ), + backgroundColor: Theme.of( + context, + ).colorScheme.error, duration: const Duration(seconds: 4), ), ); @@ -402,20 +404,10 @@ class _SetupScreenState extends ConsumerState { ); } - if (_useSpotifyApi && - _clientIdController.text.trim().isNotEmpty && - _clientSecretController.text.trim().isNotEmpty) { - ref - .read(settingsProvider.notifier) - .setSpotifyCredentials( - _clientIdController.text.trim(), - _clientSecretController.text.trim(), - ); - ref.read(settingsProvider.notifier).setMetadataSource('spotify'); - } else { - ref.read(settingsProvider.notifier).setMetadataSource('deezer'); - } - + ref.read(settingsProvider.notifier).setMetadataSource('deezer'); + await ref + .read(extensionProvider.notifier) + .ensureSpotifyWebExtensionReady(); ref.read(settingsProvider.notifier).setFirstLaunchComplete(); if (mounted) context.go('/tutorial'); @@ -475,7 +467,7 @@ class _SetupScreenState extends ConsumerState { case 2: return _selectedDirectory != null; case 3: - return false; // Spotify is last/submit + return true; // Mode selection always has a default } } else { switch (logicStep) { @@ -484,7 +476,7 @@ class _SetupScreenState extends ConsumerState { case 1: return _selectedDirectory != null; case 2: - return false; // Spotify + return true; // Mode selection always has a default } } return false; @@ -561,7 +553,7 @@ class _SetupScreenState extends ConsumerState { if (_androidSdkVersion >= 33) _buildNotificationStep(colorScheme), _buildDirectoryStep(colorScheme), - _buildSpotifyStep(colorScheme), + _buildModeSelectionStep(colorScheme), ], ), ), @@ -581,12 +573,7 @@ class _SetupScreenState extends ConsumerState { icon: const SizedBox.shrink(), // Custom layout ) : FloatingActionButton.extended( - onPressed: - (!_useSpotifyApi || - (_clientIdController.text.isNotEmpty && - _clientSecretController.text.isNotEmpty)) - ? _completeSetup - : null, + onPressed: _isLoading ? null : _completeSetup, label: _isLoading ? SizedBox( width: 20, @@ -761,106 +748,32 @@ class _SetupScreenState extends ConsumerState { ); } - Widget _buildSpotifyStep(ColorScheme colorScheme) { - return SingleChildScrollView( - padding: const EdgeInsets.all(24), + Widget _buildModeSelectionStep(ColorScheme colorScheme) { + return _StepLayout( + title: context.l10n.setupModeSelectionTitle, + description: context.l10n.setupModeSelectionDescription, + icon: Icons.tune, child: Column( children: [ - Icon(Icons.api, size: 48, color: colorScheme.primary), - const SizedBox(height: 24), - Text( - context.l10n.setupSpotifyApiOptional, - style: Theme.of( - context, - ).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold), - textAlign: TextAlign.center, + _ModeCard( + icon: Icons.download, + title: context.l10n.setupModeDownloaderTitle, + features: [ + context.l10n.setupModeDownloaderFeature1, + context.l10n.setupModeDownloaderFeature2, + context.l10n.setupModeDownloaderFeature3, + ], + isSelected: _selectedMode == 'downloader', + onTap: () => setState(() => _selectedMode = 'downloader'), + colorScheme: colorScheme, ), - const SizedBox(height: 8), + const SizedBox(height: 16), Text( - context.l10n.setupSpotifyApiDescription, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( + context.l10n.setupModeChangeableLater, + style: Theme.of(context).textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, ), - ), - const SizedBox(height: 32), - - Card( - elevation: 0, - color: colorScheme.surfaceContainerHighest, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - child: Column( - children: [ - SwitchListTile( - value: _useSpotifyApi, - onChanged: (v) => setState(() => _useSpotifyApi = v), - title: Text(context.l10n.setupUseSpotifyApi), - subtitle: Text( - _useSpotifyApi - ? context.l10n.setupEnterCredentialsBelow - : "Using bundled metadata", - ), - ), - if (_useSpotifyApi) ...[ - const Divider(), - Padding( - padding: const EdgeInsets.all(16), - child: Column( - children: [ - TextField( - controller: _clientIdController, - decoration: InputDecoration( - labelText: context.l10n.credentialsClientId, - prefixIcon: const Icon(Icons.key), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: colorScheme.outline, - width: 0.5, - ), - ), - ), - ), - const SizedBox(height: 16), - TextField( - controller: _clientSecretController, - obscureText: !_showClientSecret, - decoration: InputDecoration( - labelText: context.l10n.credentialsClientSecret, - prefixIcon: const Icon(Icons.lock), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: colorScheme.outline, - width: 0.5, - ), - ), - suffixIcon: IconButton( - icon: Icon( - _showClientSecret - ? Icons.visibility_off - : Icons.visibility, - ), - onPressed: () => setState( - () => _showClientSecret = !_showClientSecret, - ), - ), - ), - ), - ], - ), - ), - ], - ], - ), + textAlign: TextAlign.center, ), ], ), @@ -975,3 +888,126 @@ class _SuccessCard extends StatelessWidget { ); } } + +class _ModeCard extends StatelessWidget { + final IconData icon; + final String title; + final List features; + final bool isSelected; + final VoidCallback onTap; + final ColorScheme colorScheme; + + const _ModeCard({ + required this.icon, + required this.title, + required this.features, + required this.isSelected, + required this.onTap, + required this.colorScheme, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: isSelected + ? colorScheme.primaryContainer + : colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: isSelected + ? colorScheme.primary + : colorScheme.outlineVariant, + width: isSelected ? 2 : 1, + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 2), + child: Icon( + isSelected + ? Icons.radio_button_checked + : Icons.radio_button_unchecked, + size: 22, + color: isSelected + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + icon, + size: 22, + color: isSelected + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + title, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith( + fontWeight: FontWeight.bold, + color: isSelected + ? colorScheme.onPrimaryContainer + : colorScheme.onSurface, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + ...features.map( + (feature) => Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '\u2022 ', + style: TextStyle( + color: isSelected + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, + ), + ), + Expanded( + child: Text( + feature, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: isSelected + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, + height: 1.4, + ), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/store_tab.dart b/lib/screens/store_tab.dart index d6689886..d8d83e7b 100644 --- a/lib/screens/store_tab.dart +++ b/lib/screens/store_tab.dart @@ -43,7 +43,30 @@ class _StoreTabState extends ConsumerState { @override Widget build(BuildContext context) { - final state = ref.watch(storeProvider); + final storeFilterState = ref.watch( + storeProvider.select( + (s) => (s.extensions, s.selectedCategory, s.searchQuery), + ), + ); + final extensions = storeFilterState.$1; + final selectedCategory = storeFilterState.$2; + final searchQuery = storeFilterState.$3; + final isLoading = ref.watch(storeProvider.select((s) => s.isLoading)); + final error = ref.watch(storeProvider.select((s) => s.error)); + final downloadingId = ref.watch( + storeProvider.select((s) => s.downloadingId), + ); + final filteredExtensions = StoreState( + extensions: extensions, + selectedCategory: selectedCategory, + searchQuery: searchQuery, + ).filteredExtensions; + if (_searchController.text != searchQuery) { + _searchController.value = TextEditingValue( + text: searchQuery, + selection: TextSelection.collapsed(offset: searchQuery.length), + ); + } final colorScheme = Theme.of(context).colorScheme; final topPadding = normalizedHeaderTopPadding(context); @@ -89,41 +112,46 @@ class _StoreTabState extends ConsumerState { SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), - child: TextField( - controller: _searchController, - decoration: InputDecoration( - hintText: context.l10n.storeSearch, - prefixIcon: const Icon(Icons.search), - suffixIcon: _searchController.text.isNotEmpty - ? IconButton( - icon: const Icon(Icons.clear), - onPressed: () { - _searchController.clear(); - ref - .read(storeProvider.notifier) - .setSearchQuery(''); - }, - ) - : null, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(28), - borderSide: BorderSide.none, - ), - filled: true, - fillColor: Theme.of(context).brightness == Brightness.dark - ? Color.alphaBlend( - Colors.white.withValues(alpha: 0.08), - colorScheme.surface, - ) - : colorScheme.surfaceContainerHighest, - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - ), - onChanged: (value) { - ref.read(storeProvider.notifier).setSearchQuery(value); - setState(() {}); + child: ValueListenableBuilder( + valueListenable: _searchController, + builder: (context, value, _) { + return TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: context.l10n.storeSearch, + prefixIcon: const Icon(Icons.search), + suffixIcon: value.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + ref + .read(storeProvider.notifier) + .setSearchQuery(''); + }, + ) + : null, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(28), + borderSide: BorderSide.none, + ), + filled: true, + fillColor: + Theme.of(context).brightness == Brightness.dark + ? Color.alphaBlend( + Colors.white.withValues(alpha: 0.08), + colorScheme.surface, + ) + : colorScheme.surfaceContainerHighest, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + ), + onChanged: (value) { + ref.read(storeProvider.notifier).setSearchQuery(value); + }, + ); }, ), ), @@ -141,7 +169,7 @@ class _StoreTabState extends ConsumerState { _CategoryChip( label: context.l10n.storeFilterAll, icon: Icons.apps, - isSelected: state.selectedCategory == null, + isSelected: selectedCategory == null, onTap: () => ref.read(storeProvider.notifier).setCategory(null), ), @@ -149,8 +177,7 @@ class _StoreTabState extends ConsumerState { _CategoryChip( label: context.l10n.storeFilterMetadata, icon: Icons.label_outline, - isSelected: - state.selectedCategory == StoreCategory.metadata, + isSelected: selectedCategory == StoreCategory.metadata, onTap: () => ref .read(storeProvider.notifier) .setCategory(StoreCategory.metadata), @@ -159,8 +186,7 @@ class _StoreTabState extends ConsumerState { _CategoryChip( label: context.l10n.storeFilterDownload, icon: Icons.download_outlined, - isSelected: - state.selectedCategory == StoreCategory.download, + isSelected: selectedCategory == StoreCategory.download, onTap: () => ref .read(storeProvider.notifier) .setCategory(StoreCategory.download), @@ -169,8 +195,7 @@ class _StoreTabState extends ConsumerState { _CategoryChip( label: context.l10n.storeFilterUtility, icon: Icons.build_outlined, - isSelected: - state.selectedCategory == StoreCategory.utility, + isSelected: selectedCategory == StoreCategory.utility, onTap: () => ref .read(storeProvider.notifier) .setCategory(StoreCategory.utility), @@ -179,8 +204,7 @@ class _StoreTabState extends ConsumerState { _CategoryChip( label: context.l10n.storeFilterLyrics, icon: Icons.lyrics_outlined, - isSelected: - state.selectedCategory == StoreCategory.lyrics, + isSelected: selectedCategory == StoreCategory.lyrics, onTap: () => ref .read(storeProvider.notifier) .setCategory(StoreCategory.lyrics), @@ -189,8 +213,7 @@ class _StoreTabState extends ConsumerState { _CategoryChip( label: context.l10n.storeFilterIntegration, icon: Icons.link, - isSelected: - state.selectedCategory == StoreCategory.integration, + isSelected: selectedCategory == StoreCategory.integration, onTap: () => ref .read(storeProvider.notifier) .setCategory(StoreCategory.integration), @@ -200,22 +223,26 @@ class _StoreTabState extends ConsumerState { ), ), - if (state.isLoading && state.extensions.isEmpty) + if (isLoading && extensions.isEmpty) const SliverFillRemaining( child: Center(child: CircularProgressIndicator()), ) - else if (state.error != null && state.extensions.isEmpty) + else if (error != null && extensions.isEmpty) + SliverFillRemaining(child: _buildErrorState(error, colorScheme)) + else if (filteredExtensions.isEmpty) SliverFillRemaining( - child: _buildErrorState(state.error!, colorScheme), + child: _buildEmptyState( + hasFilters: + searchQuery.isNotEmpty || selectedCategory != null, + colorScheme: colorScheme, + ), ) - else if (state.filteredExtensions.isEmpty) - SliverFillRemaining(child: _buildEmptyState(state, colorScheme)) else ...[ SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), child: Text( - '${state.filteredExtensions.length} ${state.filteredExtensions.length == 1 ? 'extension' : 'extensions'}', + '${filteredExtensions.length} ${filteredExtensions.length == 1 ? 'extension' : 'extensions'}', style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: colorScheme.onSurfaceVariant, ), @@ -227,16 +254,13 @@ class _StoreTabState extends ConsumerState { child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: SettingsGroup( - children: state.filteredExtensions.asMap().entries.map(( - entry, - ) { + children: filteredExtensions.asMap().entries.map((entry) { final index = entry.key; final ext = entry.value; return _ExtensionItem( extension: ext, - showDivider: - index < state.filteredExtensions.length - 1, - isDownloading: state.downloadingId == ext.id, + showDivider: index < filteredExtensions.length - 1, + isDownloading: downloadingId == ext.id, onInstall: () => _installExtension(ext), onUpdate: () => _updateExtension(ext), onTap: () => _showExtensionDetails(ext), @@ -288,10 +312,10 @@ class _StoreTabState extends ConsumerState { ); } - Widget _buildEmptyState(StoreState state, ColorScheme colorScheme) { - final hasFilters = - state.searchQuery.isNotEmpty || state.selectedCategory != null; - + Widget _buildEmptyState({ + required bool hasFilters, + required ColorScheme colorScheme, + }) { return Center( child: Column( mainAxisSize: MainAxisSize.min, @@ -541,7 +565,10 @@ class _ExtensionItem extends StatelessWidget { if (extension.requiresNewerApp) ...[ const SizedBox(height: 4), Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), decoration: BoxDecoration( color: colorScheme.errorContainer, borderRadius: BorderRadius.circular(4), @@ -549,14 +576,19 @@ class _ExtensionItem extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.warning_amber_rounded, size: 12, color: colorScheme.onErrorContainer), + Icon( + Icons.warning_amber_rounded, + size: 12, + color: colorScheme.onErrorContainer, + ), const SizedBox(width: 4), Text( 'Requires v${extension.minAppVersion}+', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: colorScheme.onErrorContainer, - fontWeight: FontWeight.w500, - ), + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: colorScheme.onErrorContainer, + fontWeight: FontWeight.w500, + ), ), ], ), @@ -565,9 +597,8 @@ class _ExtensionItem extends StatelessWidget { const SizedBox(height: 4), Text( extension.description, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: colorScheme.onSurfaceVariant), maxLines: 2, overflow: TextOverflow.ellipsis, ), diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index b8024977..c34e7400 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -13,6 +13,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:share_plus/share_plus.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; +import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/ffmpeg_service.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; @@ -2336,6 +2337,7 @@ class _TrackMetadataScreenState extends ConsumerState { ) { showModalBottomSheet( context: context, + useRootNavigator: true, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), @@ -2566,6 +2568,7 @@ class _TrackMetadataScreenState extends ConsumerState { showModalBottomSheet( context: context, + useRootNavigator: true, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), @@ -3003,6 +3006,7 @@ class _TrackMetadataScreenState extends ConsumerState { final saved = await showModalBottomSheet( context: context, + useRootNavigator: true, isScrollControlled: true, backgroundColor: colorScheme.surface, shape: const RoundedRectangleBorder( @@ -3039,6 +3043,7 @@ class _TrackMetadataScreenState extends ConsumerState { ) { showDialog( context: context, + useRootNavigator: false, builder: (context) => AlertDialog( title: Text(context.l10n.trackDeleteConfirmTitle), content: Text(context.l10n.trackDeleteConfirmMessage), @@ -3088,7 +3093,15 @@ class _TrackMetadataScreenState extends ConsumerState { Future _openFile(BuildContext context, String filePath) async { try { - await openFile(filePath); + await ref + .read(playbackProvider.notifier) + .playLocalPath( + path: filePath, + title: trackName, + artist: artistName, + album: albumName, + coverUrl: _coverUrl ?? '', + ); } catch (e) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( diff --git a/lib/services/csv_import_service.dart b/lib/services/csv_import_service.dart index 5d77fa4e..6cb77e0d 100644 --- a/lib/services/csv_import_service.dart +++ b/lib/services/csv_import_service.dart @@ -106,6 +106,8 @@ class CsvImportService { artistName: trackData['artists'] as String? ?? track.artistName, albumName: trackData['album_name'] as String? ?? track.albumName, albumArtist: trackData['album_artist'] as String?, + artistId: trackData['artist_id']?.toString(), + albumId: trackData['album_id']?.toString(), coverUrl: coverUrl ?? track.coverUrl, isrc: trackData['isrc'] as String? ?? track.isrc, duration: durationMs > 0 ? durationMs ~/ 1000 : track.duration, diff --git a/lib/services/download_request_payload.dart b/lib/services/download_request_payload.dart index afdbfdcf..604e558e 100644 --- a/lib/services/download_request_payload.dart +++ b/lib/services/download_request_payload.dart @@ -10,6 +10,7 @@ class DownloadRequestPayload { final String outputDir; final String filenameFormat; final String quality; + final bool embedMetadata; final bool embedLyrics; final bool embedMaxQualityCover; final int trackNumber; @@ -47,6 +48,7 @@ class DownloadRequestPayload { required this.outputDir, required this.filenameFormat, this.quality = 'LOSSLESS', + this.embedMetadata = true, this.embedLyrics = true, this.embedMaxQualityCover = true, this.trackNumber = 1, @@ -86,6 +88,7 @@ class DownloadRequestPayload { 'output_dir': outputDir, 'filename_format': filenameFormat, 'quality': quality, + 'embed_metadata': embedMetadata, 'embed_lyrics': embedLyrics, 'embed_max_quality_cover': embedMaxQualityCover, 'track_number': trackNumber, @@ -129,6 +132,7 @@ class DownloadRequestPayload { outputDir: outputDir, filenameFormat: filenameFormat, quality: quality, + embedMetadata: embedMetadata, embedLyrics: embedLyrics, embedMaxQualityCover: embedMaxQualityCover, trackNumber: trackNumber, diff --git a/lib/services/downloaded_embedded_cover_resolver.dart b/lib/services/downloaded_embedded_cover_resolver.dart index 9b613510..b7a20b44 100644 --- a/lib/services/downloaded_embedded_cover_resolver.dart +++ b/lib/services/downloaded_embedded_cover_resolver.dart @@ -25,6 +25,7 @@ class DownloadedEmbeddedCoverResolver { LinkedHashMap(); static final Set _pendingExtract = {}; static final Set _pendingRefresh = {}; + static final Set _pendingPreviewValidation = {}; static final Set _failedExtract = {}; static String cleanFilePath(String? filePath) { @@ -66,12 +67,9 @@ class DownloadedEmbeddedCoverResolver { final cached = _cache[cleanPath]; if (cached != null) { - if (File(cached.previewPath).existsSync()) { - _touch(cleanPath, cached); - return cached.previewPath; - } - _cache.remove(cleanPath); - _cleanupTempCoverPathSync(cached.previewPath); + _touch(cleanPath, cached); + _validateCachedPreviewAsync(cleanPath, cached, onChanged: onChanged); + return cached.previewPath; } return null; @@ -106,6 +104,7 @@ class DownloadedEmbeddedCoverResolver { final cached = _cache.remove(cleanPath); _pendingExtract.remove(cleanPath); _pendingRefresh.remove(cleanPath); + _pendingPreviewValidation.remove(cleanPath); _failedExtract.remove(cleanPath); if (cached != null) { _cleanupTempCoverPathSync(cached.previewPath); @@ -144,10 +143,36 @@ class DownloadedEmbeddedCoverResolver { } _pendingExtract.remove(oldestKey); _pendingRefresh.remove(oldestKey); + _pendingPreviewValidation.remove(oldestKey); _failedExtract.remove(oldestKey); } } + static void _validateCachedPreviewAsync( + String cleanPath, + _EmbeddedCoverCacheEntry entry, { + VoidCallback? onChanged, + }) { + if (_pendingPreviewValidation.contains(cleanPath)) return; + _pendingPreviewValidation.add(cleanPath); + Future.microtask(() async { + try { + final exists = await fileExists(entry.previewPath); + if (!exists) { + final latest = _cache[cleanPath]; + if (latest != null && latest.previewPath == entry.previewPath) { + _cache.remove(cleanPath); + _failedExtract.remove(cleanPath); + onChanged?.call(); + } + _cleanupTempCoverPathSync(entry.previewPath); + } + } finally { + _pendingPreviewValidation.remove(cleanPath); + } + }); + } + static void _ensureCover( String cleanPath, { bool forceRefresh = false, diff --git a/lib/services/ffmpeg_service.dart b/lib/services/ffmpeg_service.dart index 81b511d2..b352162f 100644 --- a/lib/services/ffmpeg_service.dart +++ b/lib/services/ffmpeg_service.dart @@ -1,9 +1,11 @@ import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; -import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit.dart'; -import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit_config.dart'; -import 'package:ffmpeg_kit_flutter_new_audio/return_code.dart'; +import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_kit.dart'; +import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_kit_config.dart'; +import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_session.dart'; +import 'package:ffmpeg_kit_flutter_new_full/return_code.dart'; +import 'package:ffmpeg_kit_flutter_new_full/session_state.dart'; import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/utils/logger.dart'; @@ -11,7 +13,20 @@ final _log = AppLogger('FFmpeg'); class FFmpegService { static const int _commandLogPreviewLength = 300; + static const Duration _liveTunnelStartupTimeout = Duration(seconds: 8); + static const Duration _liveTunnelStartupPollInterval = Duration( + milliseconds: 200, + ); + static const Duration _liveTunnelStabilizationDelay = Duration( + milliseconds: 900, + ); static int _tempEmbedCounter = 0; + static FFmpegSession? _activeLiveDecryptSession; + static String? _activeLiveDecryptUrl; + static String? _activeLiveTempInputPath; + static String? _activeNativeDashManifestPath; + static String? _activeNativeDashManifestUrl; + static final Set _preparedNativeDashManifestPaths = {}; static String _buildOutputPath(String inputPath, String extension) { final normalizedExt = extension.startsWith('.') ? extension : '.$extension'; @@ -305,6 +320,433 @@ class FFmpegService { return null; } + static bool isActiveLiveDecryptedUrl(String url) { + final active = _activeLiveDecryptUrl; + if (active == null || active.isEmpty) return false; + return active == url.trim(); + } + + static bool isActiveNativeDashManifestUrl(String url) { + final activeUrl = _activeNativeDashManifestUrl; + if (activeUrl == null || activeUrl.isEmpty) return false; + + final normalized = url.trim(); + if (activeUrl == normalized) return true; + + try { + final activePath = Uri.parse(activeUrl).toFilePath(); + final incomingPath = Uri.parse(normalized).toFilePath(); + return activePath == incomingPath; + } catch (_) { + return false; + } + } + + static Future prepareTidalDashManifestForNativePlayback({ + required String manifestPayload, + bool registerAsActive = true, + }) async { + final rawPayload = manifestPayload.trim(); + if (rawPayload.isEmpty) return null; + + final payload = rawPayload.startsWith('MANIFEST:') + ? rawPayload.substring('MANIFEST:'.length) + : rawPayload; + + final manifestPath = await _writeTempManifestFile(payload); + if (manifestPath == null) { + _log.e('Failed to prepare Tidal DASH manifest for native playback'); + return null; + } + + final manifestUrl = Uri.file(manifestPath).toString(); + _preparedNativeDashManifestPaths.add(manifestPath); + if (registerAsActive) { + await activatePreparedNativeDashManifest(manifestUrl); + } + return manifestUrl; + } + + static Future activatePreparedNativeDashManifest(String url) async { + final normalized = url.trim(); + if (normalized.isEmpty) return; + + final manifestPath = _nativeDashManifestPathFromUrl(normalized); + if (manifestPath == null || + !_preparedNativeDashManifestPaths.contains(manifestPath)) { + return; + } + + final previousPath = _activeNativeDashManifestPath; + _activeNativeDashManifestPath = manifestPath; + _activeNativeDashManifestUrl = Uri.file(manifestPath).toString(); + + if (previousPath != null && + previousPath.isNotEmpty && + previousPath != manifestPath) { + _preparedNativeDashManifestPaths.remove(previousPath); + await _deleteNativeDashManifestFile(previousPath); + } + } + + static Future stopNativeDashManifestPlayback() async { + final manifestPath = _activeNativeDashManifestPath; + _activeNativeDashManifestPath = null; + _activeNativeDashManifestUrl = null; + + if (manifestPath == null || manifestPath.isEmpty) return; + _preparedNativeDashManifestPaths.remove(manifestPath); + await _deleteNativeDashManifestFile(manifestPath); + } + + static Future cleanupInactivePreparedNativeDashManifests() async { + final activePath = _activeNativeDashManifestPath; + final stalePaths = _preparedNativeDashManifestPaths + .where((path) => path != activePath) + .toList(growable: false); + + for (final path in stalePaths) { + _preparedNativeDashManifestPaths.remove(path); + await _deleteNativeDashManifestFile(path); + } + } + + static String? _nativeDashManifestPathFromUrl(String url) { + try { + final uri = Uri.parse(url); + if (uri.scheme.toLowerCase() != 'file') { + return null; + } + final path = uri.toFilePath(); + return path.trim().isEmpty ? null : path; + } catch (_) { + return null; + } + } + + static Future _deleteNativeDashManifestFile(String path) async { + try { + final file = File(path); + if (await file.exists()) { + await file.delete(); + } + } catch (_) {} + } + + static Future stopLiveDecryptedStream() async { + final session = _activeLiveDecryptSession; + final tempInputPath = _activeLiveTempInputPath; + _activeLiveDecryptSession = null; + _activeLiveDecryptUrl = null; + _activeLiveTempInputPath = null; + + if (session != null) { + try { + await session.cancel(); + } catch (e) { + final sessionId = session.getSessionId(); + if (sessionId != null) { + try { + await FFmpegKit.cancel(sessionId); + } catch (_) {} + } + _log.w('Failed to stop live decrypt session cleanly: $e'); + } + } + + if (tempInputPath != null && tempInputPath.isNotEmpty) { + try { + final file = File(tempInputPath); + if (await file.exists()) { + await file.delete(); + } + } catch (_) {} + } + } + + static Future startTidalDashLiveStream({ + required String manifestPayload, + String preferredFormat = 'm4a', + }) async { + final rawPayload = manifestPayload.trim(); + if (rawPayload.isEmpty) return null; + + final payload = rawPayload.startsWith('MANIFEST:') + ? rawPayload.substring('MANIFEST:'.length) + : rawPayload; + + final manifestPath = await _writeTempManifestFile(payload); + if (manifestPath == null) { + _log.e('Failed to prepare Tidal DASH manifest for live stream'); + return null; + } + + await stopLiveDecryptedStream(); + await stopNativeDashManifestPlayback(); + + final attempts = _buildLiveDashFormatAttempts(preferredFormat); + for (final format in attempts) { + final stream = await _tryStartLiveDashAttempt( + manifestPath: manifestPath, + format: format, + ); + if (stream != null) { + _activeLiveDecryptSession = stream.session; + _activeLiveDecryptUrl = stream.localUrl; + _activeLiveTempInputPath = manifestPath; + return stream; + } + } + + try { + final file = File(manifestPath); + if (await file.exists()) { + await file.delete(); + } + } catch (_) {} + return null; + } + + static Future _writeTempManifestFile(String payload) async { + if (payload.trim().isEmpty) return null; + + Uint8List bytes; + try { + bytes = base64Decode(payload); + } catch (_) { + bytes = Uint8List.fromList(utf8.encode(payload)); + } + + final manifestText = utf8.decode(bytes, allowMalformed: true).trim(); + if (manifestText.isEmpty) return null; + + final tempDir = await getTemporaryDirectory(); + final manifestPath = + '${tempDir.path}${Platform.pathSeparator}tidal_dash_${DateTime.now().microsecondsSinceEpoch}.mpd'; + await File(manifestPath).writeAsString(manifestText, flush: true); + return manifestPath; + } + + static List<_LiveDecryptFormat> _buildLiveDashFormatAttempts( + String preferredFormat, + ) { + final normalized = preferredFormat.trim().toLowerCase(); + if (normalized == 'flac') { + return const [_LiveDecryptFormat.flac, _LiveDecryptFormat.m4a]; + } + return const [_LiveDecryptFormat.m4a, _LiveDecryptFormat.flac]; + } + + static Future _awaitLiveTunnelReady(FFmpegSession session) async { + final deadline = DateTime.now().add(_liveTunnelStartupTimeout); + var seenRunning = false; + + while (DateTime.now().isBefore(deadline)) { + final state = await session.getState(); + if (state == SessionState.running) { + seenRunning = true; + break; + } + if (state != SessionState.created) { + return false; + } + await Future.delayed(_liveTunnelStartupPollInterval); + } + + if (!seenRunning) { + return false; + } + + await Future.delayed(_liveTunnelStabilizationDelay); + return (await session.getState()) == SessionState.running; + } + + static Future _tryStartLiveDashAttempt({ + required String manifestPath, + required _LiveDecryptFormat format, + }) async { + final port = await _allocateLoopbackPort(); + final ext = format == _LiveDecryptFormat.flac ? 'flac' : 'm4a'; + final mimeType = format == _LiveDecryptFormat.flac + ? 'audio/flac' + : 'audio/mp4'; + final localUrl = 'http://localhost:$port/stream.$ext'; + + final commandArguments = [ + '-nostdin', + '-hide_banner', + '-loglevel', + 'error', + '-protocol_whitelist', + 'file,http,https,tcp,tls,crypto,data', + '-i', + manifestPath, + '-map', + '0:a:0', + '-c:a', + 'copy', + if (format == _LiveDecryptFormat.flac) ...['-f', 'flac'], + if (format == _LiveDecryptFormat.m4a) ...[ + '-movflags', + '+frag_keyframe+empty_moov+default_base_moof', + '-f', + 'mp4', + ], + '-content_type', + mimeType, + '-listen', + '1', + localUrl, + ]; + + _log.d( + 'Starting Tidal DASH tunnel: ${_previewCommandForLog(commandArguments.join(' '))}', + ); + + final session = await FFmpegKit.executeWithArgumentsAsync(commandArguments); + final isReady = await _awaitLiveTunnelReady(session); + if (isReady) { + return LiveDecryptedStreamResult( + localUrl: localUrl, + format: ext, + session: session, + ); + } + + final state = await session.getState(); + final output = (await session.getOutput() ?? '').trim(); + if (output.isNotEmpty) { + _log.w('Tidal DASH tunnel failed ($ext): $output'); + } else { + _log.w('Tidal DASH tunnel failed ($ext) with session state: $state'); + } + + try { + await session.cancel(); + } catch (_) {} + return null; + } + + static Future startAmazonLiveDecryptedStream({ + required String encryptedStreamUrl, + required String decryptionKey, + String preferredFormat = 'flac', + }) async { + final inputUrl = encryptedStreamUrl.trim(); + if (inputUrl.isEmpty) return null; + + final keyCandidates = _buildDecryptionKeyCandidates(decryptionKey); + if (keyCandidates.isEmpty) { + _log.e('No usable decryption key candidates for live stream'); + return null; + } + + await stopLiveDecryptedStream(); + + final attempts = _buildLiveDecryptFormatAttempts(preferredFormat); + for (final format in attempts) { + for (final keyCandidate in keyCandidates) { + final stream = await _tryStartLiveDecryptAttempt( + inputUrl: inputUrl, + decryptionKey: keyCandidate, + format: format, + ); + if (stream != null) { + _activeLiveDecryptSession = stream.session; + _activeLiveDecryptUrl = stream.localUrl; + _activeLiveTempInputPath = null; + return stream; + } + } + } + + return null; + } + + static List<_LiveDecryptFormat> _buildLiveDecryptFormatAttempts( + String preferredFormat, + ) { + final normalized = preferredFormat.trim().toLowerCase(); + if (normalized == 'm4a' || normalized == 'mp4' || normalized == 'aac') { + return const [_LiveDecryptFormat.m4a, _LiveDecryptFormat.flac]; + } + return const [_LiveDecryptFormat.flac, _LiveDecryptFormat.m4a]; + } + + static Future _tryStartLiveDecryptAttempt({ + required String inputUrl, + required String decryptionKey, + required _LiveDecryptFormat format, + }) async { + final port = await _allocateLoopbackPort(); + final ext = format == _LiveDecryptFormat.flac ? 'flac' : 'm4a'; + final mimeType = format == _LiveDecryptFormat.flac + ? 'audio/flac' + : 'audio/mp4'; + final localUrl = 'http://localhost:$port/stream.$ext'; + + final commandArguments = [ + '-nostdin', + '-hide_banner', + '-loglevel', + 'error', + '-decryption_key', + decryptionKey, + '-i', + inputUrl, + '-map', + '0:a:0', + '-c:a', + 'copy', + if (format == _LiveDecryptFormat.flac) ...['-f', 'flac'], + if (format == _LiveDecryptFormat.m4a) ...[ + '-movflags', + '+frag_keyframe+empty_moov+default_base_moof', + '-f', + 'mp4', + ], + '-content_type', + mimeType, + '-listen', + '1', + localUrl, + ]; + + _log.d( + 'Starting live decrypt tunnel: ${_previewCommandForLog(commandArguments.join(' '))}', + ); + + final session = await FFmpegKit.executeWithArgumentsAsync(commandArguments); + final isReady = await _awaitLiveTunnelReady(session); + if (isReady) { + return LiveDecryptedStreamResult( + localUrl: localUrl, + format: ext, + session: session, + ); + } + + final state = await session.getState(); + final output = (await session.getOutput() ?? '').trim(); + if (output.isNotEmpty) { + _log.w('Live decrypt attempt failed ($ext): $output'); + } else { + _log.w('Live decrypt attempt failed ($ext) with session state: $state'); + } + + try { + await session.cancel(); + } catch (_) {} + return null; + } + + static Future _allocateLoopbackPort() async { + final socket = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + final port = socket.port; + await socket.close(); + return port; + } + static Future convertFlacToOpus( String inputPath, { String bitrate = '128k', @@ -861,9 +1303,10 @@ class FFmpegService { for (final entry in vorbisMetadata.entries) { final key = entry.key.toUpperCase(); + final normalizedKey = key.replaceAll(RegExp(r'[^A-Z0-9]'), ''); final value = entry.value; - switch (key) { + switch (normalizedKey) { case 'TITLE': id3Map['title'] = value; break; @@ -878,10 +1321,12 @@ class FFmpegService { break; case 'TRACKNUMBER': case 'TRACK': + case 'TRCK': id3Map['track'] = value; break; case 'DISCNUMBER': case 'DISC': + case 'TPOS': id3Map['disc'] = value; break; case 'DATE': @@ -921,3 +1366,17 @@ class FFmpegResult { required this.output, }); } + +enum _LiveDecryptFormat { flac, m4a } + +class LiveDecryptedStreamResult { + final String localUrl; + final String format; + final FFmpegSession session; + + LiveDecryptedStreamResult({ + required this.localUrl, + required this.format, + required this.session, + }); +} diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 6e121f4c..6710df4b 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -7,6 +7,12 @@ final _log = AppLogger('PlatformBridge'); class PlatformBridge { static const _channel = MethodChannel('com.zarz.spotiflac/backend'); + static const _downloadProgressEvents = EventChannel( + 'com.zarz.spotiflac/download_progress_stream', + ); + static const _libraryScanProgressEvents = EventChannel( + 'com.zarz.spotiflac/library_scan_progress_stream', + ); static Future> parseSpotifyUrl(String url) async { _log.d('parseSpotifyUrl: $url'); @@ -48,6 +54,17 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } + static Future> getSpotifyRelatedArtists( + String artistId, { + int limit = 12, + }) async { + final result = await _channel.invokeMethod('getSpotifyRelatedArtists', { + 'artist_id': artistId, + 'limit': limit, + }); + return jsonDecode(result as String) as Map; + } + static Future> checkAvailability( String spotifyId, String isrc, @@ -113,6 +130,18 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } + static Stream> downloadProgressStream() { + return _downloadProgressEvents.receiveBroadcastStream().map((event) { + if (event is String) { + return jsonDecode(event) as Map; + } + if (event is Map) { + return Map.from(event); + } + return const {}; + }); + } + static Future initItemProgress(String itemId) async { await _channel.invokeMethod('initItemProgress', {'item_id': itemId}); } @@ -532,6 +561,17 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } + static Future> getDeezerRelatedArtists( + String artistId, { + int limit = 12, + }) async { + final result = await _channel.invokeMethod('getDeezerRelatedArtists', { + 'artist_id': artistId, + 'limit': limit, + }); + return jsonDecode(result as String) as Map; + } + static Future> getDeezerMetadata( String resourceType, String resourceId, @@ -1098,6 +1138,18 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } + static Stream> libraryScanProgressStream() { + return _libraryScanProgressEvents.receiveBroadcastStream().map((event) { + if (event is String) { + return jsonDecode(event) as Map; + } + if (event is Map) { + return Map.from(event); + } + return const {}; + }); + } + /// Cancel ongoing library scan static Future cancelLibraryScan() async { await _channel.invokeMethod('cancelLibraryScan'); diff --git a/lib/services/shell_navigation_service.dart b/lib/services/shell_navigation_service.dart new file mode 100644 index 00000000..614628c4 --- /dev/null +++ b/lib/services/shell_navigation_service.dart @@ -0,0 +1,30 @@ +import 'package:flutter/widgets.dart'; + +class ShellNavigationService { + static final GlobalKey homeTabNavigatorKey = + GlobalKey(); + static final GlobalKey libraryTabNavigatorKey = + GlobalKey(); + static final GlobalKey storeTabNavigatorKey = + GlobalKey(); + + static int _currentTabIndex = 0; + static bool _showStoreTab = false; + + static void syncState({ + required int currentTabIndex, + required bool showStoreTab, + }) { + _currentTabIndex = currentTabIndex; + _showStoreTab = showStoreTab; + } + + static NavigatorState? activeTabNavigator() { + if (_currentTabIndex == 0) return homeTabNavigatorKey.currentState; + if (_currentTabIndex == 1) return libraryTabNavigatorKey.currentState; + if (_showStoreTab && _currentTabIndex == 2) { + return storeTabNavigatorKey.currentState; + } + return null; + } +} diff --git a/lib/services/update_checker.dart b/lib/services/update_checker.dart index 1f791496..3a2fb50f 100644 --- a/lib/services/update_checker.dart +++ b/lib/services/update_checker.dart @@ -1,5 +1,4 @@ import 'dart:convert'; -import 'dart:io'; import 'package:http/http.dart' as http; import 'package:spotiflac_android/constants/app_info.dart'; import 'package:spotiflac_android/utils/logger.dart'; @@ -28,36 +27,6 @@ class UpdateChecker { static const String _latestApiUrl = 'https://api.github.com/repos/${AppInfo.githubRepo}/releases/latest'; static const String _allReleasesApiUrl = 'https://api.github.com/repos/${AppInfo.githubRepo}/releases'; - static Future _getDeviceArch() async { - if (!Platform.isAndroid) return 'unknown'; - - try { - final cpuInfo = await File('/proc/cpuinfo').readAsString(); - - if (cpuInfo.contains('AArch64') || cpuInfo.contains('aarch64')) { - return 'arm64'; - } - - final result = await Process.run('uname', ['-m']); - final arch = result.stdout.toString().trim().toLowerCase(); - - if (arch.contains('aarch64') || arch.contains('arm64')) { - return 'arm64'; - } else if (arch.contains('armv7') || arch.contains('arm')) { - return 'arm32'; - } else if (arch.contains('x86_64')) { - return 'x86_64'; - } else if (arch.contains('x86') || arch.contains('i686')) { - return 'x86'; - } - - return 'arm64'; - } catch (e) { - _log.e('Error detecting arch: $e'); - return 'arm64'; - } - } - /// Check for updates based on channel preference /// [channel] can be 'stable' or 'preview' static Future checkForUpdate({String channel = 'stable'}) async { @@ -109,11 +78,7 @@ class UpdateChecker { final htmlUrl = releaseData['html_url'] as String? ?? '${AppInfo.githubUrl}/releases'; final publishedAt = DateTime.tryParse(releaseData['published_at'] as String? ?? '') ?? DateTime.now(); - final deviceArch = await _getDeviceArch(); - _log.d('Device architecture: $deviceArch'); - String? arm64Url; - String? arm32Url; String? universalUrl; final assets = releaseData['assets'] as List? ?? []; @@ -128,22 +93,14 @@ class UpdateChecker { } if (name.contains('arm64') || name.contains('v8a')) { arm64Url = downloadUrl; - } else if (name.contains('arm32') || name.contains('v7a') || name.contains('armeabi')) { - arm32Url = downloadUrl; } else if (name.contains('universal')) { universalUrl = downloadUrl; } } } - String? apkUrl; - if (deviceArch == 'arm64') { - apkUrl = arm64Url ?? universalUrl ?? arm32Url; - } else if (deviceArch == 'arm32') { - apkUrl = arm32Url ?? universalUrl; - } else { - apkUrl = universalUrl ?? arm64Url ?? arm32Url; - } + // Only arm64 is supported; fall back to universal if available + final apkUrl = arm64Url ?? universalUrl; _log.i('Update available: $latestVersion (prerelease: $isPrerelease), APK URL: $apkUrl'); diff --git a/lib/utils/artist_utils.dart b/lib/utils/artist_utils.dart new file mode 100644 index 00000000..697a9a89 --- /dev/null +++ b/lib/utils/artist_utils.dart @@ -0,0 +1,15 @@ +final RegExp _artistNameSplitPattern = RegExp( + r'\s*(?:,|&|\bx\b)\s*|\s+\b(?:feat(?:uring)?|ft|with)\.?(?=\s|$)\s*', + caseSensitive: false, +); + +List splitArtistNames(String rawArtists) { + final raw = rawArtists.trim(); + if (raw.isEmpty) return const []; + + return raw + .split(_artistNameSplitPattern) + .map((part) => part.trim()) + .where((part) => part.isNotEmpty) + .toList(growable: false); +} diff --git a/lib/utils/clickable_metadata.dart b/lib/utils/clickable_metadata.dart new file mode 100644 index 00000000..e7f6d8d7 --- /dev/null +++ b/lib/utils/clickable_metadata.dart @@ -0,0 +1,577 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/screens/artist_screen.dart'; +import 'package:spotiflac_android/screens/album_screen.dart'; +import 'package:spotiflac_android/screens/home_tab.dart' + show ExtensionArtistScreen, ExtensionAlbumScreen; +import 'package:spotiflac_android/services/shell_navigation_service.dart'; +import 'package:spotiflac_android/utils/artist_utils.dart'; +import 'package:spotiflac_android/utils/logger.dart'; + +final _log = AppLogger('ClickableMetadata'); + +/// Navigate to an artist screen by searching Deezer for the artist ID. +/// +/// If [artistId] is provided and valid, navigates directly. +/// Otherwise, searches Deezer by [artistName] to resolve the ID first. +/// For extension-based content, pass [extensionId] to use ExtensionArtistScreen. +Future navigateToArtist( + BuildContext context, { + required String artistName, + String? artistId, + String? coverUrl, + String? extensionId, +}) async { + if (artistName.isEmpty) return; + + final normalizedArtistId = _normalizeArtistId(artistId); + + // If we have a valid artist ID already, navigate directly + if (normalizedArtistId != null && + _canNavigateArtistDirectly( + artistId: normalizedArtistId, + extensionId: extensionId, + )) { + _pushArtistScreen( + context, + artistId: normalizedArtistId, + artistName: artistName, + coverUrl: coverUrl, + extensionId: extensionId, + ); + return; + } + + // Search Deezer to resolve the artist ID + _showLoadingSnackBar(context, 'Looking up artist...'); + try { + final results = await PlatformBridge.searchDeezerAll( + artistName, + trackLimit: 0, + artistLimit: 3, + ); + if (!context.mounted) return; + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + + final artistList = results['artists'] as List? ?? []; + if (artistList.isEmpty) { + _showUnavailable(context, 'Artist'); + return; + } + + // Find best match - prefer exact name match (case-insensitive) + Map? bestMatch; + final lowerName = artistName.toLowerCase().trim(); + for (final a in artistList) { + if (a is Map) { + final name = (a['name'] as String? ?? '').toLowerCase().trim(); + if (name == lowerName) { + bestMatch = a; + break; + } + } + } + bestMatch ??= artistList.first as Map; + + final resolvedId = bestMatch['id'] as String? ?? ''; + final resolvedName = bestMatch['name'] as String? ?? artistName; + final resolvedImage = bestMatch['images'] as String?; + + if (resolvedId.isEmpty) { + _showUnavailable(context, 'Artist'); + return; + } + + if (!context.mounted) return; + _pushArtistScreen( + context, + artistId: resolvedId, + artistName: resolvedName, + coverUrl: resolvedImage ?? coverUrl, + ); + } catch (e) { + _log.e('Failed to look up artist "$artistName": $e', e); + if (!context.mounted) return; + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + _showUnavailable(context, 'Artist'); + } +} + +/// Navigate to an album screen by searching Deezer for the album ID. +/// +/// If [albumId] is provided and valid, navigates directly. +/// Otherwise, searches Deezer by [albumName] (optionally with [artistName]) to resolve the ID. +/// For extension-based content, pass [extensionId] to use ExtensionAlbumScreen. +Future navigateToAlbum( + BuildContext context, { + required String albumName, + String? albumId, + String? artistName, + String? coverUrl, + String? extensionId, +}) async { + if (albumName.isEmpty) return; + + // If we have a valid album ID already, navigate directly + if (albumId != null && + albumId.isNotEmpty && + albumId != 'unknown' && + albumId != 'deezer:unknown') { + _pushAlbumScreen( + context, + albumId: albumId, + albumName: albumName, + coverUrl: coverUrl, + extensionId: extensionId, + ); + return; + } + + // If it's extension-based content without an ID, can't search Deezer for it + if (extensionId != null) { + _showUnavailable(context, 'Album'); + return; + } + + // Search Deezer to resolve the album ID + _showLoadingSnackBar(context, 'Looking up album...'); + try { + // Build search query: "albumName artistName" for better accuracy + final query = artistName != null && artistName.isNotEmpty + ? '$albumName $artistName' + : albumName; + + final results = await PlatformBridge.searchDeezerAll( + query, + trackLimit: 0, + artistLimit: 0, + ); + if (!context.mounted) return; + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + + final albumList = results['albums'] as List? ?? []; + if (albumList.isEmpty) { + _showUnavailable(context, 'Album'); + return; + } + + // Find best match - prefer exact name match (case-insensitive) + Map? bestMatch; + final lowerName = albumName.toLowerCase().trim(); + for (final a in albumList) { + if (a is Map) { + final name = (a['name'] as String? ?? '').toLowerCase().trim(); + if (name == lowerName) { + bestMatch = a; + break; + } + } + } + bestMatch ??= albumList.first as Map; + + final resolvedId = bestMatch['id'] as String? ?? ''; + final resolvedName = bestMatch['name'] as String? ?? albumName; + final resolvedImage = bestMatch['images'] as String?; + + if (resolvedId.isEmpty) { + _showUnavailable(context, 'Album'); + return; + } + + if (!context.mounted) return; + _pushAlbumScreen( + context, + albumId: resolvedId, + albumName: resolvedName, + coverUrl: resolvedImage ?? coverUrl, + ); + } catch (e) { + _log.e('Failed to look up album "$albumName": $e', e); + if (!context.mounted) return; + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + _showUnavailable(context, 'Album'); + } +} + +void _pushArtistScreen( + BuildContext context, { + required String artistId, + required String artistName, + String? coverUrl, + String? extensionId, +}) { + _pushViaPreferredNavigator( + context, + (context) => extensionId != null + ? ExtensionArtistScreen( + extensionId: extensionId, + artistId: artistId, + artistName: artistName, + coverUrl: coverUrl, + ) + : ArtistScreen( + artistId: artistId, + artistName: artistName, + coverUrl: coverUrl, + ), + ); +} + +void _pushAlbumScreen( + BuildContext context, { + required String albumId, + required String albumName, + String? coverUrl, + String? extensionId, +}) { + _pushViaPreferredNavigator( + context, + (context) => extensionId != null + ? ExtensionAlbumScreen( + extensionId: extensionId, + albumId: albumId, + albumName: albumName, + coverUrl: coverUrl, + ) + : AlbumScreen( + albumId: albumId, + albumName: albumName, + coverUrl: coverUrl, + tracks: const [], + ), + ); +} + +void _pushViaPreferredNavigator(BuildContext context, WidgetBuilder builder) { + final currentNavigator = Navigator.of(context); + final rootNavigator = Navigator.of(context, rootNavigator: true); + final activeTabNavigator = ShellNavigationService.activeTabNavigator(); + + final shouldRouteToTabNavigator = + identical(currentNavigator, rootNavigator) && activeTabNavigator != null; + + if (!shouldRouteToTabNavigator) { + currentNavigator.push(MaterialPageRoute(builder: builder)); + return; + } + + final currentRoute = ModalRoute.of(context); + final shouldPopCurrentRoute = + currentRoute != null && currentRoute.isFirst == false; + + if (shouldPopCurrentRoute && currentNavigator.canPop()) { + currentNavigator.pop(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!activeTabNavigator.mounted) return; + activeTabNavigator.push(MaterialPageRoute(builder: builder)); + }); + return; + } + + activeTabNavigator.push(MaterialPageRoute(builder: builder)); +} + +void _showLoadingSnackBar(BuildContext context, String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: 12), + Text(message), + ], + ), + duration: const Duration(seconds: 10), + ), + ); +} + +void _showUnavailable(BuildContext context, String type) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('$type information not available'))); +} + +/// A reusable widget that makes text tappable to navigate to an artist screen. +/// +/// Wraps the text in a GestureDetector that, when tapped, looks up the artist +/// via Deezer search and navigates to the ArtistScreen. +class ClickableArtistName extends StatefulWidget { + final String artistName; + final String? artistId; + final String? coverUrl; + final String? extensionId; + final TextStyle? style; + final int? maxLines; + final TextOverflow? overflow; + final TextAlign? textAlign; + + const ClickableArtistName({ + super.key, + required this.artistName, + this.artistId, + this.coverUrl, + this.extensionId, + this.style, + this.maxLines, + this.overflow, + this.textAlign, + }); + + @override + State createState() => _ClickableArtistNameState(); +} + +class _ClickableArtistNameState extends State { + List<_ArtistTapTarget> _artistTargets = const []; + final List _recognizers = []; + + @override + void initState() { + super.initState(); + _rebuildArtistTargets(); + } + + @override + void didUpdateWidget(covariant ClickableArtistName oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.artistName != widget.artistName || + oldWidget.artistId != widget.artistId || + oldWidget.coverUrl != widget.coverUrl || + oldWidget.extensionId != widget.extensionId) { + _rebuildArtistTargets(); + } + } + + @override + void dispose() { + _disposeRecognizers(); + super.dispose(); + } + + void _disposeRecognizers() { + for (final recognizer in _recognizers) { + recognizer.dispose(); + } + _recognizers.clear(); + } + + void _rebuildArtistTargets() { + _disposeRecognizers(); + _artistTargets = _buildArtistTapTargets(widget.artistName, widget.artistId); + if (_artistTargets.length <= 1) return; + + for (final target in _artistTargets) { + final recognizer = TapGestureRecognizer() + ..onTap = () => navigateToArtist( + context, + artistName: target.name, + artistId: target.artistId, + coverUrl: widget.coverUrl, + extensionId: _extensionIdForTarget(target), + ); + _recognizers.add(recognizer); + } + } + + String? _extensionIdForTarget(_ArtistTapTarget target) { + if (widget.extensionId == null) return null; + if (_artistTargets.length == 1) return widget.extensionId; + return target.artistId != null ? widget.extensionId : null; + } + + List _buildMultiArtistSpans() { + final spans = []; + for (var i = 0; i < _artistTargets.length; i++) { + final target = _artistTargets[i]; + spans.add( + TextSpan( + text: target.name, + style: widget.style, + recognizer: _recognizers[i], + ), + ); + if (i < _artistTargets.length - 1) { + spans.add(TextSpan(text: ', ', style: widget.style)); + } + } + return spans; + } + + @override + Widget build(BuildContext context) { + if (_artistTargets.isEmpty) { + return Text( + widget.artistName, + style: widget.style, + maxLines: widget.maxLines, + overflow: widget.overflow, + textAlign: widget.textAlign, + ); + } + + if (_artistTargets.length == 1) { + final target = _artistTargets.first; + return GestureDetector( + onTap: () => navigateToArtist( + context, + artistName: target.name, + artistId: target.artistId, + coverUrl: widget.coverUrl, + extensionId: _extensionIdForTarget(target), + ), + child: Text( + target.name, + style: widget.style, + maxLines: widget.maxLines, + overflow: widget.overflow, + textAlign: widget.textAlign, + ), + ); + } + + return Text.rich( + TextSpan(style: widget.style, children: _buildMultiArtistSpans()), + maxLines: widget.maxLines, + overflow: widget.overflow ?? TextOverflow.clip, + textAlign: widget.textAlign ?? TextAlign.start, + ); + } +} + +class _ArtistTapTarget { + final String name; + final String? artistId; + + const _ArtistTapTarget({required this.name, this.artistId}); +} + +List<_ArtistTapTarget> _buildArtistTapTargets( + String rawArtistNames, + String? rawArtistIds, +) { + final parsedNames = splitArtistNames(rawArtistNames); + if (parsedNames.isEmpty) return const []; + + final uniqueNames = []; + final seen = {}; + for (final parsed in parsedNames) { + final key = parsed.toLowerCase().replaceAll(RegExp(r'\s+'), ' ').trim(); + if (key.isEmpty || !seen.add(key)) continue; + uniqueNames.add(parsed); + } + if (uniqueNames.isEmpty) return const []; + + if (uniqueNames.length == 1) { + return [ + _ArtistTapTarget( + name: uniqueNames.first, + artistId: _normalizeArtistId(rawArtistIds), + ), + ]; + } + + final parsedIds = _parseArtistIds(rawArtistIds); + if (parsedIds.length == uniqueNames.length) { + return List<_ArtistTapTarget>.generate( + uniqueNames.length, + (index) => _ArtistTapTarget( + name: uniqueNames[index], + artistId: parsedIds[index], + ), + growable: false, + ); + } + + return uniqueNames + .map((name) => _ArtistTapTarget(name: name)) + .toList(growable: false); +} + +List _parseArtistIds(String? rawArtistIds) { + final raw = rawArtistIds?.trim(); + if (raw == null || raw.isEmpty) return const []; + + final parsed = []; + for (final part in raw.split(RegExp(r'\s*,\s*'))) { + final normalized = _normalizeArtistId(part); + if (normalized != null) { + parsed.add(normalized); + } + } + return parsed; +} + +String? _normalizeArtistId(String? artistId) { + final id = artistId?.trim(); + if (id == null || id.isEmpty || id == 'unknown' || id == 'deezer:unknown') { + return null; + } + return id; +} + +bool _canNavigateArtistDirectly({ + required String artistId, + required String? extensionId, +}) { + if (extensionId != null) return true; + if (artistId.startsWith('deezer:')) return true; + return _spotifyArtistIdPattern.hasMatch(artistId); +} + +final RegExp _spotifyArtistIdPattern = RegExp(r'^[A-Za-z0-9]{22}$'); + +/// A reusable widget that makes text tappable to navigate to an album screen. +/// +/// Wraps the text in a GestureDetector that, when tapped, looks up the album +/// via Deezer search and navigates to the AlbumScreen. +class ClickableAlbumName extends StatelessWidget { + final String albumName; + final String? albumId; + final String? artistName; + final String? coverUrl; + final String? extensionId; + final TextStyle? style; + final int? maxLines; + final TextOverflow? overflow; + final TextAlign? textAlign; + + const ClickableAlbumName({ + super.key, + required this.albumName, + this.albumId, + this.artistName, + this.coverUrl, + this.extensionId, + this.style, + this.maxLines, + this.overflow, + this.textAlign, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () => navigateToAlbum( + context, + albumName: albumName, + albumId: albumId, + artistName: artistName, + coverUrl: coverUrl, + extensionId: extensionId, + ), + child: Text( + albumName, + style: style, + maxLines: maxLines, + overflow: overflow, + textAlign: textAlign, + ), + ); + } +} diff --git a/lib/widgets/download_service_picker.dart b/lib/widgets/download_service_picker.dart index 0396cb25..21b05b5e 100644 --- a/lib/widgets/download_service_picker.dart +++ b/lib/widgets/download_service_picker.dart @@ -22,7 +22,7 @@ class BuiltInService { }); } -/// Default quality options for built-in services (Tidal, Qobuz, Amazon, YouTube) +/// Default quality options for built-in services /// Note: Tidal lossy (HIGH) removed - use YouTube for lossy downloads const _builtInServices = [ BuiltInService( @@ -129,6 +129,7 @@ class DownloadServicePicker extends ConsumerStatefulWidget { showModalBottomSheet( context: context, + useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(28)), diff --git a/lib/widgets/mini_player_bar.dart b/lib/widgets/mini_player_bar.dart new file mode 100644 index 00000000..3a31bd2d --- /dev/null +++ b/lib/widgets/mini_player_bar.dart @@ -0,0 +1,2040 @@ +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/playlist_picker_sheet.dart b/lib/widgets/playlist_picker_sheet.dart index 525d3b28..2b6fd162 100644 --- a/lib/widgets/playlist_picker_sheet.dart +++ b/lib/widgets/playlist_picker_sheet.dart @@ -13,134 +13,9 @@ Future showAddTrackToPlaylistSheet( WidgetRef ref, Track track, ) async { - final notifier = ref.read(libraryCollectionsProvider.notifier); - final state = ref.read(libraryCollectionsProvider); - - if (!context.mounted) return; - - await showModalBottomSheet( - context: context, - showDragHandle: true, - builder: (sheetContext) { - final playlists = ref.watch( - libraryCollectionsProvider.select((state) => state.playlists), - ); - return SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: const Icon(Icons.playlist_add), - title: Text(sheetContext.l10n.collectionAddToPlaylist), - subtitle: Text('${track.name} • ${track.artistName}'), - ), - const Divider(height: 1), - ListTile( - leading: const Icon(Icons.add_circle_outline), - title: Text(sheetContext.l10n.collectionCreatePlaylist), - onTap: () async { - Navigator.of(sheetContext).pop(); - final name = await _promptPlaylistName(context); - if (name == null || name.trim().isEmpty || !context.mounted) { - return; - } - final playlistId = await notifier.createPlaylist(name.trim()); - final added = await notifier.addTrackToPlaylist( - playlistId, - track, - ); - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - added - ? context.l10n.collectionAddedToPlaylist(name.trim()) - : context.l10n.collectionAlreadyInPlaylist( - name.trim(), - ), - ), - ), - ); - }, - ), - if (playlists.isEmpty) - Padding( - padding: const EdgeInsets.fromLTRB(20, 8, 20, 24), - child: Text( - sheetContext.l10n.collectionNoPlaylistsYet, - style: Theme.of(sheetContext).textTheme.bodyMedium?.copyWith( - color: Theme.of(sheetContext).colorScheme.onSurfaceVariant, - ), - ), - ) - else - ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 320), - child: ListView.builder( - shrinkWrap: true, - itemCount: playlists.length, - itemBuilder: (context, index) { - final playlist = playlists[index]; - final alreadyInPlaylist = playlist.containsTrack(track); - return ListTile( - leading: _PlaylistPickerThumbnail( - playlist: playlist, - isSelected: alreadyInPlaylist, - ), - title: Text(playlist.name), - subtitle: Text( - context.l10n.collectionPlaylistTracks( - playlist.tracks.length, - ), - ), - enabled: !alreadyInPlaylist, - onTap: !alreadyInPlaylist - ? () async { - final added = await notifier.addTrackToPlaylist( - playlist.id, - track, - ); - if (!context.mounted) return; - Navigator.of(sheetContext).pop(); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - added - ? context.l10n - .collectionAddedToPlaylist( - playlist.name, - ) - : context.l10n - .collectionAlreadyInPlaylist( - playlist.name, - ), - ), - ), - ); - } - : null, - ); - }, - ), - ), - const SizedBox(height: 8), - ], - ), - ); - }, - ); - - if (!context.mounted) return; - - final afterState = ref.read(libraryCollectionsProvider); - if (afterState.playlists.length != state.playlists.length) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.collectionPlaylistCreated)), - ); - } + return showAddTracksToPlaylistSheet(context, ref, [track]); } -/// Batch version: add multiple tracks to a chosen playlist. Future showAddTracksToPlaylistSheet( BuildContext context, WidgetRef ref, @@ -148,79 +23,157 @@ Future showAddTracksToPlaylistSheet( ) async { if (tracks.isEmpty) return; - final notifier = ref.read(libraryCollectionsProvider.notifier); - if (!context.mounted) return; await showModalBottomSheet( context: context, + useRootNavigator: true, showDragHandle: true, + isScrollControlled: true, builder: (sheetContext) { - final playlists = ref.watch( - libraryCollectionsProvider.select((state) => state.playlists), + return _PlaylistPickerSheetContent(tracks: tracks); + }, + ); +} + +class _PlaylistPickerSheetContent extends ConsumerStatefulWidget { + final List tracks; + + const _PlaylistPickerSheetContent({required this.tracks}); + + @override + ConsumerState<_PlaylistPickerSheetContent> createState() => + _PlaylistPickerSheetContentState(); +} + +class _PlaylistPickerSheetContentState + extends ConsumerState<_PlaylistPickerSheetContent> { + final Set _selectedPlaylistIds = {}; + final Set _initialDisabledIds = {}; + bool _initialized = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (!_initialized) { + final playlists = ref.read(libraryCollectionsProvider).playlists; + for (final playlist in playlists) { + final alreadyInPlaylist = + widget.tracks.every((t) => playlist.containsTrack(t)); + if (alreadyInPlaylist) { + _initialDisabledIds.add(playlist.id); + _selectedPlaylistIds.add(playlist.id); + } + } + _initialized = true; + } + } + + void _handleDone() async { + final notifier = ref.read(libraryCollectionsProvider.notifier); + final idsToAdd = _selectedPlaylistIds.difference(_initialDisabledIds); + final addedNames = []; + + for (final playlistId in idsToAdd) { + final playlist = + ref.read(libraryCollectionsProvider).playlistById(playlistId); + if (playlist != null) { + addedNames.add(playlist.name); + } + await notifier.addTracksToPlaylist(playlistId, widget.tracks); + } + + if (!mounted) return; + Navigator.of(context).pop(); + + if (addedNames.isNotEmpty) { + final name = + addedNames.length == 1 ? addedNames.first : addedNames.join(', '); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.collectionAddedToPlaylist(name)), + ), ); - return SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: const Icon(Icons.playlist_add), - title: Text(sheetContext.l10n.collectionAddToPlaylist), - subtitle: Text( - '${tracks.length} ${tracks.length == 1 ? 'track' : 'tracks'}', - ), - ), - const Divider(height: 1), - ListTile( - leading: const Icon(Icons.add_circle_outline), - title: Text(sheetContext.l10n.collectionCreatePlaylist), - onTap: () async { - Navigator.of(sheetContext).pop(); - final name = await _promptPlaylistName(context); - if (name == null || name.trim().isEmpty || !context.mounted) { - return; - } - final playlistId = await notifier.createPlaylist(name.trim()); - final result = await notifier.addTracksToPlaylist( - playlistId, - tracks, - ); - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - result.addedCount > 0 - ? context.l10n.collectionAddedToPlaylist(name.trim()) - : context.l10n.collectionAlreadyInPlaylist( - name.trim(), - ), - ), - ), - ); - }, - ), - if (playlists.isEmpty) - Padding( - padding: const EdgeInsets.fromLTRB(20, 8, 20, 24), - child: Text( - sheetContext.l10n.collectionNoPlaylistsYet, - style: Theme.of(sheetContext).textTheme.bodyMedium?.copyWith( - color: Theme.of(sheetContext).colorScheme.onSurfaceVariant, - ), + } + } + + @override + Widget build(BuildContext context) { + final playlists = ref.watch( + libraryCollectionsProvider.select((state) => state.playlists), + ); + final notifier = ref.read(libraryCollectionsProvider.notifier); + + final String subtitle; + if (widget.tracks.length == 1) { + final track = widget.tracks.first; + subtitle = '${track.name} • ${track.artistName}'; + } else { + subtitle = + '${widget.tracks.length} ${widget.tracks.length == 1 ? 'track' : 'tracks'}'; + } + + final idsToAdd = _selectedPlaylistIds.difference(_initialDisabledIds); + final hasNewSelections = idsToAdd.isNotEmpty; + + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.playlist_add), + title: Text(context.l10n.collectionAddToPlaylist), + subtitle: Text(subtitle), + ), + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.add_circle_outline), + title: Text(context.l10n.collectionCreatePlaylist), + onTap: () async { + final name = await _promptPlaylistName(context); + if (name == null || name.trim().isEmpty || !context.mounted) { + return; + } + final playlistId = await notifier.createPlaylist(name.trim()); + await notifier.addTracksToPlaylist(playlistId, widget.tracks); + setState(() { + _initialDisabledIds.add(playlistId); + _selectedPlaylistIds.add(playlistId); + }); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.collectionAddedToPlaylist(name.trim())), ), - ) - else - ConstrainedBox( + ); + }, + ), + if (playlists.isEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 24), + child: Text( + context.l10n.collectionNoPlaylistsYet, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ) + else + Flexible( + child: ConstrainedBox( constraints: const BoxConstraints(maxHeight: 320), child: ListView.builder( shrinkWrap: true, itemCount: playlists.length, itemBuilder: (context, index) { final playlist = playlists[index]; + final isAlreadyIn = _initialDisabledIds.contains(playlist.id); + final isSelected = _selectedPlaylistIds.contains(playlist.id); + return ListTile( leading: _PlaylistPickerThumbnail( playlist: playlist, - isSelected: false, + isSelected: isSelected, ), title: Text(playlist.name), subtitle: Text( @@ -228,37 +181,44 @@ Future showAddTracksToPlaylistSheet( playlist.tracks.length, ), ), - onTap: () async { - final result = await notifier.addTracksToPlaylist( - playlist.id, - tracks, - ); - if (!context.mounted) return; - Navigator.of(sheetContext).pop(); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - result.addedCount > 0 - ? context.l10n.collectionAddedToPlaylist( - playlist.name, - ) - : context.l10n.collectionAlreadyInPlaylist( - playlist.name, - ), - ), - ), - ); - }, + enabled: !isAlreadyIn, + onTap: !isAlreadyIn + ? () { + setState(() { + if (isSelected) { + _selectedPlaylistIds.remove(playlist.id); + } else { + _selectedPlaylistIds.add(playlist.id); + } + }); + } + : null, ); }, ), ), - const SizedBox(height: 8), - ], - ), - ); - }, - ); + ), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: () { + if (hasNewSelections) { + _handleDone(); + } else { + Navigator.of(context).pop(); + } + }, + child: Text(context.l10n.dialogDone), + ), + ), + ), + ], + ), + ); + } } Future _promptPlaylistName(BuildContext context) async { @@ -352,10 +312,7 @@ class _PlaylistPickerThumbnail extends StatelessWidget { decoration: BoxDecoration( color: colorScheme.primary, shape: BoxShape.circle, - border: Border.all( - color: colorScheme.primary, - width: 1.5, - ), + border: Border.all(color: colorScheme.primary, width: 1.5), ), child: Icon( Icons.check, diff --git a/lib/widgets/track_collection_quick_actions.dart b/lib/widgets/track_collection_quick_actions.dart index 1f9e345d..601fac9c 100644 --- a/lib/widgets/track_collection_quick_actions.dart +++ b/lib/widgets/track_collection_quick_actions.dart @@ -3,17 +3,36 @@ 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/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/widgets/playlist_picker_sheet.dart'; +import 'package:spotiflac_android/utils/clickable_metadata.dart'; class TrackCollectionQuickActions extends ConsumerWidget { final Track track; - const TrackCollectionQuickActions({ - super.key, - required this.track, - }); + const TrackCollectionQuickActions({super.key, required this.track}); + + static void showTrackOptionsSheet( + BuildContext context, + WidgetRef ref, + Track track, + ) { + final colorScheme = Theme.of(context).colorScheme; + showModalBottomSheet( + context: context, + useRootNavigator: true, + isScrollControlled: true, + backgroundColor: colorScheme.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + builder: (sheetContext) => _TrackOptionsSheet(track: track), + ); + } @override Widget build(BuildContext context, WidgetRef ref) { @@ -25,24 +44,11 @@ class TrackCollectionQuickActions extends ConsumerWidget { color: colorScheme.onSurfaceVariant, size: 20, ), - onPressed: () => _showTrackOptionsSheet(context, ref), + onPressed: () => showTrackOptionsSheet(context, ref, track), padding: const EdgeInsets.only(left: 12), constraints: const BoxConstraints(minWidth: 36, minHeight: 36), ); } - - void _showTrackOptionsSheet(BuildContext context, WidgetRef ref) { - final colorScheme = Theme.of(context).colorScheme; - - showModalBottomSheet( - context: context, - backgroundColor: colorScheme.surfaceContainerHigh, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(28)), - ), - builder: (sheetContext) => _TrackOptionsSheet(track: track), - ); - } } class _TrackOptionsSheet extends ConsumerWidget { @@ -53,6 +59,9 @@ 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)), @@ -62,155 +71,214 @@ class _TrackOptionsSheet extends ConsumerWidget { ); return SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Header with drag handle + track info (matches _TrackInfoHeader) - Column( + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.82, + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, children: [ - const SizedBox(height: 8), - Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), - borderRadius: BorderRadius.circular(2), - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), - child: Row( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: track.coverUrl != null && track.coverUrl!.isNotEmpty - ? CachedNetworkImage( - imageUrl: track.coverUrl!, - width: 56, - height: 56, - fit: BoxFit.cover, - memCacheWidth: 112, - cacheManager: CoverCacheManager.instance, - errorWidget: (context, url, error) => Container( - width: 56, - height: 56, - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.music_note, - color: colorScheme.onSurfaceVariant, - ), - ), - ) - : Container( - width: 56, - height: 56, - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.music_note, - color: colorScheme.onSurfaceVariant, - ), - ), + // Header with drag handle + track info (matches _TrackInfoHeader) + Column( + children: [ + const SizedBox(height: 8), + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: colorScheme.onSurfaceVariant.withValues( + alpha: 0.4, + ), + borderRadius: BorderRadius.circular(2), ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - track.name, - style: Theme.of(context) - .textTheme - .titleMedium - ?.copyWith(fontWeight: FontWeight.w600), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 2), - Text( - track.artistName, - style: Theme.of(context) - .textTheme - .bodyMedium - ?.copyWith( - color: colorScheme.onSurfaceVariant, + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: + track.coverUrl != null && + track.coverUrl!.isNotEmpty + ? CachedNetworkImage( + imageUrl: track.coverUrl!, + width: 56, + height: 56, + fit: BoxFit.cover, + memCacheWidth: 112, + cacheManager: CoverCacheManager.instance, + errorWidget: (context, url, error) => + Container( + width: 56, + height: 56, + color: + colorScheme.surfaceContainerHighest, + child: Icon( + Icons.music_note, + color: colorScheme.onSurfaceVariant, + ), + ), + ) + : Container( + width: 56, + height: 56, + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.music_note, + color: colorScheme.onSurfaceVariant, + ), ), - maxLines: 1, - overflow: TextOverflow.ellipsis, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + track.name, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.w600), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + ClickableArtistName( + artistName: track.artistName, + artistId: track.artistId, + coverUrl: track.coverUrl, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], ), - ], + ), + ], + ), + ), + ], + ), + Divider( + height: 1, + color: colorScheme.outlineVariant.withValues(alpha: 0.5), + ), + + // Action items (matches _QualityOption style) + _OptionTile( + icon: Icons.download_rounded, + title: context.l10n.downloadTitle, + onTap: () async { + Navigator.pop(context); + if (settings.askQualityBeforeDownload) { + DownloadServicePicker.show( + rootContext, + trackName: track.name, + artistName: track.artistName, + coverUrl: track.coverUrl, + onSelect: (quality, service) { + container + .read(downloadQueueProvider.notifier) + .addToQueue( + track, + service, + qualityOverride: quality, + ); + ScaffoldMessenger.of(rootContext).showSnackBar( + SnackBar( + content: Text( + rootContext.l10n.snackbarAddedToQueue(track.name), + ), + ), + ); + }, + ); + } else { + container + .read(downloadQueueProvider.notifier) + .addToQueue(track, settings.defaultService); + if (!rootContext.mounted) { + return; + } + ScaffoldMessenger.of(rootContext).showSnackBar( + SnackBar( + content: Text( + rootContext.l10n.snackbarAddedToQueue(track.name), + ), + ), + ); + } + }, + ), + _OptionTile( + icon: isLoved ? Icons.favorite : Icons.favorite_border, + iconColor: isLoved ? colorScheme.error : null, + title: isLoved + ? context.l10n.trackOptionRemoveFromLoved + : context.l10n.trackOptionAddToLoved, + onTap: () async { + Navigator.pop(context); + final added = await ref + .read(libraryCollectionsProvider.notifier) + .toggleLoved(track); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + added + ? context.l10n.collectionAddedToLoved(track.name) + : context.l10n.collectionRemovedFromLoved( + track.name, + ), ), ), - ], - ), + ); + }, ), + _OptionTile( + icon: isInWishlist + ? Icons.playlist_add_check_circle + : Icons.add_circle_outline, + iconColor: isInWishlist ? colorScheme.primary : null, + title: isInWishlist + ? context.l10n.trackOptionRemoveFromWishlist + : context.l10n.trackOptionAddToWishlist, + onTap: () async { + Navigator.pop(context); + final added = await ref + .read(libraryCollectionsProvider.notifier) + .toggleWishlist(track); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + added + ? context.l10n.collectionAddedToWishlist(track.name) + : context.l10n.collectionRemovedFromWishlist( + track.name, + ), + ), + ), + ); + }, + ), + _OptionTile( + icon: Icons.playlist_add, + title: context.l10n.collectionAddToPlaylist, + onTap: () { + Navigator.pop(context); + showAddTrackToPlaylistSheet(context, ref, track); + }, + ), + + const SizedBox(height: 16), ], ), - Divider( - height: 1, - color: colorScheme.outlineVariant.withValues(alpha: 0.5), - ), - - // Action items (matches _QualityOption style) - _OptionTile( - icon: isLoved ? Icons.favorite : Icons.favorite_border, - iconColor: isLoved ? colorScheme.error : null, - title: isLoved - ? context.l10n.trackOptionRemoveFromLoved - : context.l10n.trackOptionAddToLoved, - onTap: () async { - Navigator.pop(context); - final added = await ref - .read(libraryCollectionsProvider.notifier) - .toggleLoved(track); - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - added - ? context.l10n.collectionAddedToLoved(track.name) - : context.l10n.collectionRemovedFromLoved(track.name), - ), - ), - ); - }, - ), - _OptionTile( - icon: isInWishlist - ? Icons.playlist_add_check_circle - : Icons.add_circle_outline, - iconColor: isInWishlist ? colorScheme.primary : null, - title: isInWishlist - ? context.l10n.trackOptionRemoveFromWishlist - : context.l10n.trackOptionAddToWishlist, - onTap: () async { - Navigator.pop(context); - final added = await ref - .read(libraryCollectionsProvider.notifier) - .toggleWishlist(track); - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - added - ? context.l10n.collectionAddedToWishlist(track.name) - : context.l10n.collectionRemovedFromWishlist( - track.name), - ), - ), - ); - }, - ), - _OptionTile( - icon: Icons.playlist_add, - title: context.l10n.collectionAddToPlaylist, - onTap: () { - Navigator.pop(context); - showAddTrackToPlaylistSheet(context, ref, track); - }, - ), - - const SizedBox(height: 16), - ], + ), ), ); } diff --git a/pubspec.lock b/pubspec.lock index a250b404..3492ac5c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -49,6 +49,38 @@ 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: @@ -233,14 +265,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" - cupertino_icons: - dependency: "direct main" - description: - name: cupertino_icons - sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 - url: "https://pub.dev" - source: hosted - version: "1.0.8" dart_style: dependency: transitive description: @@ -273,22 +297,6 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.3" - dio: - dependency: "direct main" - description: - name: dio - sha256: d90ee57923d1828ac14e492ca49440f65477f4bb1263575900be731a3dac66a9 - url: "https://pub.dev" - source: hosted - version: "5.9.0" - dio_web_adapter: - dependency: transitive - description: - name: dio_web_adapter - sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" - url: "https://pub.dev" - source: hosted - version: "2.1.1" dynamic_color: dependency: "direct main" description: @@ -313,11 +321,11 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" - ffmpeg_kit_flutter_new_audio: + ffmpeg_kit_flutter_new_full: dependency: "direct main" description: - name: ffmpeg_kit_flutter_new_audio - sha256: "0a698b46cd163c8e9917af75325c84d27871a2a8b2c37de3b40486cd0ab662ae" + name: ffmpeg_kit_flutter_new_full + sha256: "48938db8d1bfb5ab4409d4291aedf99563e033dd7430ce41b5a677945a821679" url: "https://pub.dev" source: hosted version: "2.0.0" @@ -483,14 +491,6 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.0" - flutter_svg: - dependency: "direct main" - description: - name: flutter_svg - sha256: "87fbd7c534435b6c5d9d98b01e1fd527812b82e68ddd8bd35fc45ed0fa8f0a95" - url: "https://pub.dev" - source: hosted - version: "2.2.3" flutter_test: dependency: "direct dev" description: flutter @@ -613,6 +613,30 @@ 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: @@ -670,7 +694,7 @@ packages: source: hosted version: "0.12.17" material_color_utilities: - dependency: "direct main" + dependency: transitive description: name: material_color_utilities sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec @@ -749,14 +773,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" - path_parsing: - dependency: transitive - description: - name: path_parsing - sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" - url: "https://pub.dev" - source: hosted - version: "1.1.0" path_provider: dependency: "direct main" description: @@ -934,7 +950,7 @@ packages: source: hosted version: "1.0.0-dev.8" riverpod_annotation: - dependency: "direct main" + dependency: transitive description: name: riverpod_annotation sha256: cc1474bc2df55ec3c1da1989d139dcef22cd5e2bd78da382e867a69a8eca2e46 @@ -1314,30 +1330,6 @@ packages: url: "https://pub.dev" source: hosted version: "4.5.2" - vector_graphics: - dependency: transitive - description: - name: vector_graphics - sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6 - url: "https://pub.dev" - source: hosted - version: "1.1.19" - vector_graphics_codec: - dependency: transitive - description: - name: vector_graphics_codec - sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" - url: "https://pub.dev" - source: hosted - version: "1.1.13" - vector_graphics_compiler: - dependency: transitive - description: - name: vector_graphics_compiler - sha256: d354a7ec6931e6047785f4db12a1f61ec3d43b207fc0790f863818543f8ff0dc - url: "https://pub.dev" - source: hosted - version: "1.1.19" vector_math: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 61840f87..f6cefdfe 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music publish_to: "none" -version: 3.7.0+83 +version: 4.0.1+102 environment: sdk: ^3.10.0 @@ -17,7 +17,6 @@ dependencies: # State Management flutter_riverpod: ^3.1.0 - riverpod_annotation: ^4.0.0 # Navigation go_router: ^17.0.1 @@ -31,18 +30,14 @@ dependencies: # HTTP & Network http: ^1.6.0 - dio: ^5.8.0 connectivity_plus: 7.0.0 # UI Components - cupertino_icons: ^1.0.8 cached_network_image: ^3.4.1 flutter_cache_manager: ^3.4.1 - flutter_svg: ^2.1.0 # Material Expressive 3 / Dynamic Color dynamic_color: ^1.7.0 - material_color_utilities: ">=0.11.1 <0.14.0" # Permissions permission_handler: ^12.0.1 @@ -61,11 +56,14 @@ dependencies: logger: ^2.5.0 # FFmpeg for audio conversion - ffmpeg_kit_flutter_new_audio: ^2.0.0 + ffmpeg_kit_flutter_new_full: ^2.0.0 open_filex: ^4.7.0 # 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: