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