diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..d080aebf --- /dev/null +++ b/.gitattributes @@ -0,0 +1,20 @@ +* text=auto eol=lf + +# Windows scripts +*.bat text eol=crlf +*.cmd text eol=crlf +*.ps1 text eol=crlf + +# Binary files +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.webp binary +*.ico binary +*.pdf binary +*.zip binary +*.jar binary +*.aar binary +*.keystore binary +*.jks binary diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index c2fd467a..6048d59c 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -22,13 +22,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Pages uses: actions/configure-pages@v5 - name: Upload artifact - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@v4 with: path: site 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 78eccb93..25d41bf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,162 @@ # Changelog +## [3.7.1] - 2026-03-06 + +### Added + +- **Deezer Download Service**: Deezer is now available as a built-in download service (FLAC CD Quality). +- **Smarter YouTube Downloads**: If the YouTube Music extension is installed, the app now uses it first to find the correct song — more accurate than SongLink, especially for new releases. +- **Songs-Only Search Filter**: YouTube Music extension search now filters results server-side, so you only get actual songs — no music videos or covers mixed in. +- **Qobuz Squid.wtf Fallback**: Added Squid.wtf as an additional Qobuz download provider. +- **Qobuz Search Fallback**: If Qobuz API search returns nothing, the app now tries the Qobuz web store as a backup to find the track. +- **Better ISRC Lookup**: Tracks can now be resolved via ISRC even without a Spotify ID, using Deezer as an intermediary. + +### Fixed + +- **Download Queue Stability**: Fixed duplicate queue item IDs, cancel not working reliably, and "Clear All" not properly stopping active downloads. +- **Queue Restore on Restart**: Duplicate or broken queue item IDs are now auto-fixed when the app restarts. + +### Changed + +- **Update Checker**: The app can now detect updates across all versions, not just within the same major version. +- **Localization Cleanup**: Cleaned up and consolidated translation files across all 13 supported languages. + +--- + +## [3.7.0] - 2026-03-04 + +Hey everyone, thank you so much for sticking with SpotiFLAC Mobile. + +Starting from this release, we're rolling the version back from **v4.x to v3.x**. + +### Removed + +- **Internal Audio Player** — Removed `just_audio`, `audio_service`, and `audio_session` dependencies entirely. The internal playback engine (smart queue, media notification, shuffle/repeat, lyrics sync, prefetch, playback state persistence) has been completely removed. Playback now delegates to the system's external player. +- **PlaybackItem Model** — No longer needed without internal playback. +- **MiniPlayerBar Widget** — Removed the in-app mini player UI. +- **Media Notification Controls** — Removed notification drawables (`ic_stat_favorite`, `ic_stat_favorite_border`) and the `keep.xml` resource file. +- **Player Mode Setting** — The `playerMode` setting has been removed since external player is now the only mode. +- **Online Playback Feature** — Online streaming mode, DASH pipeline, and related components introduced in v4.0.0 are gone from the main branch. + +### Changed + +- **MainActivity** now extends `FlutterFragmentActivity` directly (previously `AudioServiceFragmentActivity`). +- **PlaybackController** simplified from ~1200 lines to ~87 lines — now only resolves local file paths and opens them via external player. +- **ProGuard rules** cleaned up — removed audio_service/just_audio/audio_session rules. +- **Qobuz** migrated to MusicDL API (Thanks @Ruubiiiii for Hosting the API). + +### Note +There are three main reasons behind this decision: + + 1. **Respecting the API providers** — After giving it some thought, we realized that the streaming feature was indirectly hurting the API providers who have been generous enough to make their services available. They already offer streaming directly on their own websites, and it only feels right to direct streaming usage back to their platforms. + + 2. **Long-term sustainability** — We want SpotiFLAC to be around for as long as possible. Keeping certain features in the app could attract unwanted attention and put the project's continued existence at risk. Removing them is a proactive step to keep things running smoothly for everyone. + +**Still want online playback? Check out these services:** +- [DabMusic](https://dabmusic.xyz) +- [SquidWTF](https://tidal.squid.wtf) + +Thank you for your understanding and continued support. This decision was made to ensure the long-term sustainability of the app and to respect the ecosystem that has been supporting SpotiFLAC all along. You guys are the best, and we truly appreciate each and every one of you! + +--- + +## [3.6.0] - 2026-02-19 + +### Added + +- **Library Tab Redesign**: Wishlist, Loved, and individual Playlist collections now appear as unified list/grid items in the "All" tab alongside tracks, replacing the old "My Folders" horizontal card section +- **Drag-and-Drop Track Categorization**: Long-press-drag tracks onto playlist items to add them to that playlist; when multiple tracks are selected and one is dragged, all selected tracks are added to the target playlist + - Drag feedback widget displays multi-select count badge +- **Playlist Multi-Select Deletion**: Long-press playlists to enter selection mode, select multiple playlists, and batch-delete all selected at once via a dedicated selection bottom bar +- **Track Categorization System**: Tracks added to any playlist are automatically hidden from the main tracks list; removing a track from a playlist or deleting the playlist makes the track reappear — no actual file deletion ever occurs +- **Create Playlist Button**: New "+" `TextButton.icon` in Library tab header with dynamic theme colors, replacing the old "Select" button +- **Track Options Bottom Sheet**: Rewrote `TrackCollectionQuickActions` from inline action buttons to a single styled bottom sheet with track header (cover, title, artist), divider, and option tiles matching `DownloadServicePicker` visual style +- **Library Tracks Folder SliverAppBar**: Wishlist, Loved, and Playlist detail screens now feature a collapsible SliverAppBar with cover art (45% viewport height, parallax, gradient overlay), mode-specific icons (bookmark/heart/queue_music), title, and track count badge +- **Custom Playlist Cover Images**: Users can set custom cover images for playlists via long-press menu or camera icon in SliverAppBar + - Covers stored locally in app support directory with priority: custom cover > first track URL > icon fallback + - Cover options bottom sheet with change/remove actions + - Playlist list screen shows cover thumbnails +- **Long-Press Context Menus**: Track tiles in library folders and playlist list items now use long-press for styled bottom sheet context menus instead of trailing icon buttons, matching platform conventions +- **Wishlist Quick Download**: Tapping a track in Wishlist opens quality picker (respects "Ask quality before download" setting) and starts download +- **Playlist Track Playback**: Tapping a downloaded track in a Playlist opens it in the device's external music player via `openFile()` with file existence check +- **Collapsible AppBar on Playlist List Screen**: Playlist list screen now uses a collapsible SliverAppBar matching Settings sub-page style (animated title size 20→28px, animated left padding 56→24px) for visual consistency +- **`UnifiedLibraryItem.collectionKey` Getter**: Efficient playlist membership checking without constructing a full `Track` object +- **Multi-select Share**: Share multiple downloaded/local tracks at once from the selection bottom bar + - Supports SAF content URIs via native `ACTION_SEND_MULTIPLE` intent + - Supports regular file paths via SharePlus + - Available in Downloaded Album, Local Album, and Queue tab screens +- **Multi-select Batch Convert**: Convert multiple selected tracks to MP3 or Opus in one operation + - Bottom sheet UI with format (MP3 / Opus) and bitrate (128k / 192k / 256k / 320k) selection + - Full SAF support: copies to temp, converts, writes back, deletes original, updates history + - Progress and result snackbar feedback during conversion + - Available in Downloaded Album, Local Album, and Queue tab screens +- **Native `shareMultipleContentUris`**: New Android `ACTION_SEND_MULTIPLE` handler in `MainActivity` for sharing multiple SAF content URIs +- **Localization**: Added selection share/convert strings to all 13 supported locales (`selectionShareCount`, `selectionShareNoFiles`, `selectionConvertCount`, `selectionConvertNoConvertible`, `selectionBatchConvertConfirmTitle`, `selectionBatchConvertConfirmMessage`, `selectionBatchConvertProgress`, `selectionBatchConvertSuccess`) +- **Localization**: Added library collection l10n keys (`trackOptionAddToLoved`, `trackOptionRemoveFromLoved`, `trackOptionAddToWishlist`, `trackOptionRemoveFromWishlist`, `libraryTracksUnit`, `collectionPlaylistChangeCover`, `collectionPlaylistRemoveCover`) +- **Global Network Compatibility Mode**: New Download settings toggle to help restricted/ISP-filtered networks + - Applies to backend API requests (not SongLink-only) + - Enables HTTP scheme fallback and optional insecure TLS behavior in one switch + - Synced end-to-end across Flutter settings, platform channel (Android/iOS), and Go backend + +### Changed + +- **Removed "My Folders" Section**: Horizontal card section removed from Library tab header; collections are now inline items in the unified main list/grid +- **Playlist Subtitle Simplified**: Playlist items now show "N tracks" instead of "Playlist • N tracks" +- **Pinned App Bar on All Detail Screens**: `SliverAppBar` changed from `pinned: false` to `pinned: true` in 6 detail screens (album, downloaded album, local album, playlist, track metadata, library tracks folder) so the app bar stays visible when scrolling +- **Local Album Multi-select Action Updated**: Replaced batch `Share` action with batch `Re-enrich` + - Local album selection bar now uses `Re-enrich` + `Convert` actions + - Added batch re-enrich processing for local tracks (FLAC native path and MP3/Opus FFmpeg path, including SAF write-back flow) + - After batch re-enrich completes, local library is refreshed via incremental scan so updated metadata appears in UI immediately +- **Queue Multi-select Local Action Updated**: Queue selection bar now switches the first action to `Re-enrich` when selected items are local-only + - If selection contains downloaded or mixed items, action remains `Share` + - Local-only selection now supports batch re-enrich with the same native/FFmpeg + SAF flow and auto-refreshes local library metadata after completion +- **SongLink Network Option Scope Expanded**: The previous SongLink compatibility path now routes through global network compatibility controls so all supported backend API clients can benefit under problematic networks +- **Removed Per-Track Action Buttons**: Album, playlist, home, artist, and search screens no longer show individual download/add buttons on each track tile; all actions accessed via `TrackCollectionQuickActions` bottom sheet +- **Loved SliverAppBar Always Shows Heart Icon**: Loved tracks folder always displays the heart icon as cover, never uses first track's cover art (like Spotify's Liked Songs) +- **Wishlist Long-Press Menu Conditional Actions**: "Add to Playlist" option only appears when the track is already downloaded +- **Loved Track Tap Disabled**: Tapping a track in the Loved folder performs no action (long-press for options only) +- **Removed Duplicate Create Playlist Button**: Removed `+` IconButton from playlist list screen AppBar since the FAB already serves the same purpose +- **`coverImagePath` Field on `UserPlaylistCollection`**: Model now supports nullable custom cover path with `copyWith` using `String? Function()?` pattern for explicit null assignment + +### Fixed + +- **Local Cover Path Handling**: All cover image renderers (Library tab, playlist detail screen hero cover, per-track tiles, options bottom sheet) now detect whether `coverUrl` is a URL or local file path and use `Image.file` for local paths instead of `CachedNetworkImage` +- **Empty Playlists Now Clickable**: Empty playlist items in Library tab can now be tapped to navigate to their detail screen +- **RenderFlex Overflow**: Fixed overflow in unified library item Row layout when track metadata text was too long +- **SAF FD Permission Denied on Tidal Downloads**: Fixed `failed to create file: open /proc/self/fd/*: permission denied` on some devices/providers + - Android SAF bridge now hands off detached raw FD (`output_fd`) to Go instead of forcing procfs path reopen + - Go output writer includes safer procfs fallback behavior for providers that reject truncate semantics +- **Batch Convert Lyrics Embedding Gap**: Batch convert in Downloaded Album, Local Album, and Queue now preserves/adds lyrics consistently like single convert + - Reuses embedded lyrics when available + - Falls back to sidecar `.lrc` when present + - Falls back to online lyrics fetch and injects into conversion metadata when embedding is enabled + +--- + +## [3.6.9] - 2026-02-17 + +### Added + +- **YouTube Bitrate Presets**: YouTube bitrate selection now uses supported presets only + - Opus: 128 / 256 kbps + - MP3: 128 / 256 / 320 kbps +- **Go Test Coverage for YouTube Quality Parsing**: Added tests for supported-bitrate normalization behavior +- **Localization for YouTube Bitrate UI**: Added localized strings (EN/ID) for YouTube bitrate titles and labels + +### Fixed + +- **Cover Image Cache Clear Not Working**: Clearing "Cover image cache" now performs a full on-disk wipe, clears in-memory image cache, and reinitializes cache manager state + - Prevents stale/orphaned cache files from keeping the same storage usage after clear +- **YouTube Queue Fallback Quality Mismatch**: Queue fallback now normalizes YouTube quality IDs so conversion paths use valid bitrate format IDs + +### Changed + +- **Default Lyrics Behavior**: `Apple/QQ Multi-Person Word-by-Word` is now OFF by default for new installs +- **Removed Dynamic YouTube Bitrate Mode**: Arbitrary values are now normalized to nearest supported Spotube preset across settings, picker, queue fallback, and Go backend parser +- **Lyrics Embedding Control**: Users can now disable the embedded-lyrics process from settings (`Embed Lyrics` off) + +--- + ## [3.6.8] - 2026-02-14 ### Added diff --git a/README.md b/README.md index a44bbb96..83669b33 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ -[![GitHub All Releases](https://img.shields.io/github/downloads/zarzet/SpotiFLAC-Mobile/total?style=for-the-badge)](https://github.com/zarzet/SpotiFLAC-Mobile/releases) -[![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/40f8f1914287dea317122a837f98b0ddf7af3205adc2f84a350d767e0a6a345c) +[![GitHub All Releases](https://img.shields.io/github/downloads/zarzet/SpotiFLAC-Mobile/total?style=for-the-badge&refresh=1)](https://github.com/zarzet/SpotiFLAC-Mobile/releases) +[![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/0a2bd2a033551983fc9fcd83f82fd912c83914fd1094cd8d1c7c6a68eb23233f) [![Crowdin](https://img.shields.io/badge/HELP%20TRANSLATE%20ON-CROWDIN-%2321252b?style=for-the-badge&logo=crowdin)](https://crowdin.com/project/spotiflac-mobile)
@@ -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/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 16e7ffbb..c30a1775 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -4,21 +4,25 @@ import android.app.Activity import android.content.Intent import android.net.Uri import android.os.Build -import android.os.ParcelFileDescriptor +import androidx.activity.OnBackPressedCallback import androidx.activity.result.contract.ActivityResultContracts import androidx.documentfile.provider.DocumentFile +import io.flutter.embedding.android.FlutterFragmentActivity 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 @@ -30,11 +34,23 @@ import java.util.Locale class MainActivity: FlutterFragmentActivity() { 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 + private var flutterBackCallback: OnBackPressedCallback? = null @Volatile private var safScanCancel = false @Volatile private var safScanActive = false private val safTreeLauncher = registerForActivityResult( @@ -381,6 +397,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()) { @@ -666,21 +754,13 @@ class MainActivity: FlutterFragmentActivity() { val pfd = contentResolver.openFileDescriptor(document.uri, "rw") ?: return errorJson("Failed to open SAF file") - var fdHandedOffToGo = false + var detachedFd: Int? = null try { - // Keep the original PFD open so the document provider receives close signaling. - // Pass a duplicated FD to Go and detach only the duplicate. - val writerPfd = ParcelFileDescriptor.dup(pfd.fileDescriptor) - val detachedFd = writerPfd.detachFd() - try { - writerPfd.close() - } catch (_: Exception) {} - - // After detach, ownership is intended for Go. Kotlin must never close this FD, - // otherwise Android fdsan may abort on double-close during cancellation races. - fdHandedOffToGo = true - req.put("output_path", "/proc/self/fd/$detachedFd") + // Prefer handing off a detached FD directly to Go. + // Some devices/providers reject re-opening /proc/self/fd/* with permission denied. + detachedFd = pfd.detachFd() + req.put("output_path", "") req.put("output_fd", detachedFd) req.put("output_ext", outputExt) val response = downloader(req.toString()) @@ -696,12 +776,13 @@ class MainActivity: FlutterFragmentActivity() { document.delete() return errorJson("SAF download failed: ${e.message}") } finally { - if (!fdHandedOffToGo) { - android.util.Log.w("SpotiFLAC", "SAF writer FD was not handed off to Go") + // If detachFd() failed before handoff, close original ParcelFileDescriptor. + // Otherwise Go owns the detached raw FD and is responsible for closing it. + if (detachedFd == null) { + try { + pfd.close() + } catch (_: Exception) {} } - try { - pfd.close() - } catch (_: Exception) {} } } @@ -1260,19 +1341,92 @@ 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. + flutterBackCallback = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + flutterEngine.navigationChannel.popRoute() + } + } + onBackPressedDispatcher.addCallback(this, flutterBackCallback!!) + + 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) { + "exitApp" -> { + flutterBackCallback?.isEnabled = false + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + finishAndRemoveTask() + } else { + finish() + } + result.success(null) + } "parseSpotifyUrl" -> { val url = call.argument("url") ?: "" val response = withContext(Dispatchers.IO) { @@ -1304,6 +1458,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") ?: "" @@ -1368,6 +1530,14 @@ class MainActivity: FlutterFragmentActivity() { } result.success(null) } + "setNetworkCompatibilityOptions", "setSongLinkNetworkOptions" -> { + val allowHttp = call.argument("allow_http") ?: false + val insecureTls = call.argument("insecure_tls") ?: false + withContext(Dispatchers.IO) { + Gobackend.setNetworkCompatibilityOptions(allowHttp, insecureTls) + } + result.success(null) + } "checkDuplicate" -> { val outputDir = call.argument("output_dir") ?: "" val isrc = call.argument("isrc") ?: "" @@ -1546,6 +1716,28 @@ class MainActivity: FlutterFragmentActivity() { result.error("share_failed", e.message, null) } } + "shareMultipleContentUris" -> { + val uriStrings = call.argument>("uris") ?: emptyList() + val title = call.argument("title") ?: "" + try { + val uris = ArrayList(uriStrings.size) + for (s in uriStrings) { + uris.add(Uri.parse(s)) + } + val shareIntent = Intent(Intent.ACTION_SEND_MULTIPLE).apply { + putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris) + setType("audio/*") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + if (title.isNotBlank()) { + putExtra(Intent.EXTRA_SUBJECT, title) + } + } + startActivity(Intent.createChooser(shareIntent, title.ifBlank { "Share" })) + result.success(true) + } catch (e: Exception) { + result.error("share_failed", e.message, null) + } + } "fetchLyrics" -> { val spotifyId = call.argument("spotify_id") ?: "" val trackName = call.argument("track_name") ?: "" @@ -1951,6 +2143,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-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png index 5e7edf11..fd5e939d 100644 Binary files a/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png index 48070c29..23686cb5 100644 Binary files a/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png index 6bea80f2..093d5c26 100644 Binary files a/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png index 83c9cc3a..590c5cda 100644 Binary files a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png index f4b452d0..1f540975 100644 Binary files a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index 4c27a15d..ee6d88cc 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index acac2238..a21d95ae 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index 8d1697a5..382e5cbb 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index c727fe1d..de7571e9 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index b7cbde6e..6f0aa29e 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml index 19d9c82e..beab31f7 100644 --- a/android/app/src/main/res/values/colors.xml +++ b/android/app/src/main/res/values/colors.xml @@ -1,4 +1,4 @@ - #1a1a2e + #000000 \ No newline at end of file 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/audio_metadata.go b/go_backend/audio_metadata.go index 86112467..4ccfc627 100644 --- a/go_backend/audio_metadata.go +++ b/go_backend/audio_metadata.go @@ -5,6 +5,7 @@ import ( "encoding/binary" "fmt" "io" + "math" "os" "path/filepath" "strconv" @@ -1127,17 +1128,33 @@ func GetOggQuality(filePath string) (*OggQuality, error) { // Opus always uses 48kHz granule position internally totalSamples := granule - int64(preSkip) if totalSamples > 0 { - quality.Duration = int(totalSamples / 48000) + durationSec := float64(totalSamples) / 48000.0 + if durationSec > 0 { + quality.Duration = int(math.Round(durationSec)) + quality.Bitrate = int(float64(fileSize*8) / durationSec) + } } } else if quality.SampleRate > 0 { - quality.Duration = int(granule / int64(quality.SampleRate)) + durationSec := float64(granule) / float64(quality.SampleRate) + if durationSec > 0 { + quality.Duration = int(math.Round(durationSec)) + quality.Bitrate = int(float64(fileSize*8) / durationSec) + } } } - // Calculate average bitrate from file size and actual duration - if quality.Duration > 0 { + // Fallback bitrate estimate if duration exists but bitrate couldn't be derived. + if quality.Bitrate <= 0 && quality.Duration > 0 { quality.Bitrate = int(fileSize * 8 / int64(quality.Duration)) } + // Guard against obviously invalid values from corrupted/unreliable granule reads. + if quality.Duration > 24*60*60 { + quality.Duration = 0 + quality.Bitrate = 0 + } + if quality.Bitrate > 0 && quality.Bitrate < 8000 { + quality.Bitrate = 0 + } return quality, nil } @@ -1162,21 +1179,35 @@ func readLastOggGranulePosition(file *os.File, fileSize int64) int64 { } buf = buf[:n] - // Scan backwards for "OggS" magic - lastPageOffset := -1 for i := n - 4; i >= 0; i-- { - if buf[i] == 'O' && buf[i+1] == 'g' && buf[i+2] == 'g' && buf[i+3] == 'S' { - lastPageOffset = i - break + if buf[i] != 'O' || buf[i+1] != 'g' || buf[i+2] != 'g' || buf[i+3] != 'S' { + continue } + if i+27 > n { + continue + } + // Validate minimal header fields to avoid false positives inside payload bytes. + version := buf[i+4] + headerType := buf[i+5] + if version != 0 || headerType > 0x07 { + continue + } + segmentCount := int(buf[i+26]) + headerLen := 27 + segmentCount + if i+headerLen > n { + continue + } + payloadLen := 0 + for s := 0; s < segmentCount; s++ { + payloadLen += int(buf[i+27+s]) + } + if i+headerLen+payloadLen > n { + continue + } + // Granule position is at bytes 6-13 of the Ogg page header (little-endian int64). + return int64(binary.LittleEndian.Uint64(buf[i+6 : i+14])) } - - if lastPageOffset < 0 || lastPageOffset+14 > n { - return 0 - } - - // Granule position is at bytes 6-13 of the Ogg page header (little-endian int64) - return int64(binary.LittleEndian.Uint64(buf[lastPageOffset+6 : lastPageOffset+14])) + return 0 } // ============================================================================= 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..0c64b658 --- /dev/null +++ b/go_backend/deezer_download.go @@ -0,0 +1,561 @@ +package gobackend + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" +) + +const deezerYoinkifyURL = "https://yoinkify.lol/api/download" +const deezerMusicDLURL = "https://www.musicdl.me/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 resolveDeezerTrackURL(req DownloadRequest) (string, error) { + deezerID := strings.TrimSpace(req.DeezerID) + if deezerID == "" { + if prefixed, found := strings.CutPrefix(strings.TrimSpace(req.SpotifyID), "deezer:"); found { + deezerID = strings.TrimSpace(prefixed) + } + } + if deezerID != "" { + return fmt.Sprintf("https://www.deezer.com/track/%s", deezerID), nil + } + + // Try resolving Deezer ID from Spotify ID via SongLink + spotifyID := strings.TrimSpace(req.SpotifyID) + if spotifyID != "" && isLikelySpotifyTrackID(spotifyID) { + songlink := NewSongLinkClient() + availability, err := songlink.CheckTrackAvailability(spotifyID, "") + if err == nil && availability.Deezer && availability.DeezerURL != "" { + return availability.DeezerURL, nil + } + } + + // Try resolving from ISRC + isrc := strings.TrimSpace(req.ISRC) + if isrc != "" { + ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout) + defer cancel() + track, err := GetDeezerClient().SearchByISRC(ctx, isrc) + if err == nil && track != nil { + deezerID = songLinkExtractDeezerTrackID(track) + if deezerID != "" { + return fmt.Sprintf("https://www.deezer.com/track/%s", deezerID), nil + } + } + } + + return "", fmt.Errorf("could not resolve Deezer track URL") +} + +type deezerMusicDLRequest struct { + Platform string `json:"platform"` + URL string `json:"url"` +} + +func (c *DeezerClient) GetMusicDLDownloadURL(deezerTrackURL string) (string, error) { + payload := deezerMusicDLRequest{ + Platform: "deezer", + URL: deezerTrackURL, + } + jsonData, err := json.Marshal(payload) + if err != nil { + return "", fmt.Errorf("failed to encode MusicDL request: %w", err) + } + + req, err := http.NewRequest(http.MethodPost, deezerMusicDLURL, bytes.NewReader(jsonData)) + if err != nil { + return "", fmt.Errorf("failed to create MusicDL request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Debug-Key", getQobuzDebugKey()) + req.Header.Set("User-Agent", getRandomUserAgent()) + + resp, err := c.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("MusicDL request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) + if err != nil { + return "", fmt.Errorf("failed to read MusicDL response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("MusicDL returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + + var raw map[string]any + if err := json.Unmarshal(body, &raw); err != nil { + return "", fmt.Errorf("invalid MusicDL JSON: %w", err) + } + + if errMsg, ok := raw["error"].(string); ok && strings.TrimSpace(errMsg) != "" { + return "", fmt.Errorf("MusicDL error: %s", errMsg) + } + + // Try various response fields for download URL + for _, key := range []string{"download_url", "url", "link"} { + if urlVal, ok := raw[key].(string); ok && strings.TrimSpace(urlVal) != "" { + return strings.TrimSpace(urlVal), nil + } + } + if data, ok := raw["data"].(map[string]any); ok { + for _, key := range []string{"download_url", "url", "link"} { + if urlVal, ok := data[key].(string); ok && strings.TrimSpace(urlVal) != "" { + return strings.TrimSpace(urlVal), nil + } + } + } + + return "", fmt.Errorf("no download URL found in MusicDL response") +} + +func (c *DeezerClient) DownloadFromMusicDL(deezerTrackURL, outputPath string, outputFD int, itemID string) error { + GoLog("[Deezer] Resolving download URL via MusicDL for: %s\n", deezerTrackURL) + + downloadURL, err := c.GetMusicDLDownloadURL(deezerTrackURL) + if err != nil { + return err + } + GoLog("[Deezer] MusicDL returned download URL, starting download...\n") + + 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, "GET", downloadURL, nil) + if err != nil { + return fmt.Errorf("failed to create download request: %w", err) + } + req.Header.Set("User-Agent", getRandomUserAgent()) + + resp, err := c.httpClient.Do(req) + if err != nil { + if isDownloadCancelled(itemID) { + return ErrDownloadCancelled + } + return fmt.Errorf("download request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("download returned HTTP %d", resp.StatusCode) + } + + 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 MusicDL: %.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), + ) + }() + + // Try MusicDL first (better quality), fallback to Yoinkify + var downloadErr error + deezerTrackURL, deezerURLErr := resolveDeezerTrackURL(req) + if deezerURLErr == nil { + GoLog("[Deezer] Trying MusicDL for: %s\n", deezerTrackURL) + downloadErr = deezerClient.DownloadFromMusicDL(deezerTrackURL, outputPath, req.OutputFD, req.ItemID) + if downloadErr != nil { + if errors.Is(downloadErr, ErrDownloadCancelled) { + return DeezerDownloadResult{}, ErrDownloadCancelled + } + GoLog("[Deezer] MusicDL failed: %v, falling back to Yoinkify\n", downloadErr) + } + } else { + GoLog("[Deezer] Could not resolve Deezer URL: %v, using Yoinkify directly\n", deezerURLErr) + } + + if downloadErr != nil || deezerURLErr != nil { + downloadErr = deezerClient.DownloadFromYoinkify(spotifyURL, outputPath, req.OutputFD, req.ItemID) + if downloadErr != nil { + if errors.Is(downloadErr, ErrDownloadCancelled) { + return DeezerDownloadResult{}, ErrDownloadCancelled + } + return DeezerDownloadResult{}, fmt.Errorf("deezer download failed (MusicDL + Yoinkify): %w", downloadErr) + } + } + + <-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 3adfcb9f..3c05a0fd 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -1,5 +1,3 @@ -// Package gobackend provides exported functions for gomobile binding -// These functions are the bridge between Flutter and Go backend package gobackend import ( @@ -125,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) @@ -140,6 +167,12 @@ func CheckAvailability(spotifyID, isrc string) (string, error) { return string(jsonBytes), nil } +// SetSongLinkNetworkOptions is kept for backward compatibility. +// It now applies global network compatibility options for all backend API requests. +func SetSongLinkNetworkOptions(allowHTTP, insecureTLS bool) { + SetNetworkCompatibilityOptions(allowHTTP, insecureTLS) +} + type DownloadRequest struct { ISRC string `json:"isrc"` Service string `json:"service"` @@ -155,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"` @@ -173,6 +207,7 @@ type DownloadRequest struct { LyricsMode string `json:"lyrics_mode,omitempty"` UseExtensions bool `json:"use_extensions,omitempty"` UseFallback bool `json:"use_fallback,omitempty"` + SongLinkRegion string `json:"songlink_region,omitempty"` } type DownloadResponse struct { @@ -374,11 +409,20 @@ func enrichRequestExtendedMetadata(req *DownloadRequest) { } } +func applySongLinkRegionFromRequest(req *DownloadRequest) { + if req == nil { + return + } + SetSongLinkRegion(req.SongLinkRegion) +} + func DownloadTrack(requestJSON string) (string, error) { var req DownloadRequest if err := json.Unmarshal([]byte(requestJSON), &req); err != nil { return errorResponse("Invalid request: " + err.Error()) } + applySongLinkRegionFromRequest(&req) + defer closeOwnedOutputFD(req.OutputFD) req.TrackName = strings.TrimSpace(req.TrackName) req.ArtistName = strings.TrimSpace(req.ArtistName) @@ -453,13 +497,31 @@ 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 { result = DownloadResult{ FilePath: youtubeResult.FilePath, - BitDepth: 0, // Lossy format, no bit depth - SampleRate: 0, // Lossy format + BitDepth: 0, + SampleRate: 0, Title: youtubeResult.Title, Artist: youtubeResult.Artist, Album: youtubeResult.Album, @@ -537,6 +599,11 @@ func DownloadByStrategy(requestJSON string) (string, error) { } if req.UseExtensions { + // Respect strict mode when auto fallback is disabled: + // for built-in providers, route directly to selected service only. + if !req.UseFallback && isBuiltInProvider(serviceNormalized) { + return DownloadTrack(normalizedJSON) + } resp, err := DownloadWithExtensionsJSON(normalizedJSON) if err != nil { return errorResponse(err.Error()) @@ -556,6 +623,8 @@ func DownloadWithFallback(requestJSON string) (string, error) { if err := json.Unmarshal([]byte(requestJSON), &req); err != nil { return errorResponse("Invalid request: " + err.Error()) } + applySongLinkRegionFromRequest(&req) + defer closeOwnedOutputFD(req.OutputFD) req.TrackName = strings.TrimSpace(req.TrackName) req.ArtistName = strings.TrimSpace(req.ArtistName) @@ -571,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" @@ -659,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) { @@ -910,7 +999,6 @@ func SetDownloadDirectory(path string) error { return setDownloadDir(path) } -// AllowDownloadDir adds a directory to the extension file sandbox allowlist. func AllowDownloadDir(path string) { if strings.TrimSpace(path) == "" { return @@ -1142,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() @@ -1518,16 +1626,13 @@ func errorResponse(msg string) (string, error) { return string(jsonBytes), nil } -// ==================== YOUTUBE PROVIDER (LOSSY ONLY) ==================== - -// DownloadFromYouTube downloads a track from YouTube via Cobalt API -// This is a lossy-only provider (Opus 256kbps or MP3 320kbps) -// It does NOT participate in the lossless fallback chain func DownloadFromYouTube(requestJSON string) (string, error) { var req DownloadRequest if err := json.Unmarshal([]byte(requestJSON), &req); err != nil { return errorResponse("Invalid request: " + err.Error()) } + applySongLinkRegionFromRequest(&req) + defer closeOwnedOutputFD(req.OutputFD) req.TrackName = strings.TrimSpace(req.TrackName) req.ArtistName = strings.TrimSpace(req.ArtistName) @@ -1569,20 +1674,14 @@ func DownloadFromYouTube(requestJSON string) (string, error) { return string(jsonBytes), nil } -// IsYouTubeURLExport checks if a URL is a YouTube URL (exported for Flutter) func IsYouTubeURLExport(urlStr string) bool { return IsYouTubeURL(urlStr) } -// ExtractYouTubeVideoIDExport extracts video ID from YouTube URL (exported for Flutter) func ExtractYouTubeVideoIDExport(urlStr string) (string, error) { return ExtractYouTubeVideoID(urlStr) } -// ==================== COVER & LYRICS SAVE ==================== - -// DownloadCoverToFile downloads cover art from URL and saves to outputPath. -// If maxQuality is true, upgrades to highest available resolution. func DownloadCoverToFile(coverURL string, outputPath string, maxQuality bool) error { if coverURL == "" { return fmt.Errorf("no cover URL provided") @@ -1601,7 +1700,6 @@ func DownloadCoverToFile(coverURL string, outputPath string, maxQuality bool) er return nil } -// ExtractCoverToFile extracts embedded cover art from audio file and saves to outputPath. func ExtractCoverToFile(audioPath string, outputPath string) error { lower := strings.ToLower(audioPath) @@ -1630,7 +1728,6 @@ func ExtractCoverToFile(audioPath string, outputPath string) error { return nil } -// FetchAndSaveLyrics fetches lyrics from lrclib and saves as .lrc file. func FetchAndSaveLyrics(trackName, artistName, spotifyID string, durationMs int64, outputPath string) error { client := NewLyricsClient() durationSec := float64(durationMs) / 1000.0 @@ -1657,9 +1754,6 @@ func FetchAndSaveLyrics(trackName, artistName, spotifyID string, durationMs int6 return nil } -// ==================== LYRICS PROVIDER SETTINGS ==================== - -// SetLyricsProvidersJSON sets the lyrics provider order from a JSON array of provider IDs. func SetLyricsProvidersJSON(providersJSON string) error { var providers []string if err := json.Unmarshal([]byte(providersJSON), &providers); err != nil { @@ -1670,7 +1764,6 @@ func SetLyricsProvidersJSON(providersJSON string) error { return nil } -// GetLyricsProvidersJSON returns the current lyrics provider order as JSON. func GetLyricsProvidersJSON() (string, error) { providers := GetLyricsProviderOrder() jsonBytes, err := json.Marshal(providers) @@ -1680,7 +1773,6 @@ func GetLyricsProvidersJSON() (string, error) { return string(jsonBytes), nil } -// GetAvailableLyricsProvidersJSON returns metadata about all available lyrics providers. func GetAvailableLyricsProvidersJSON() (string, error) { providers := GetAvailableLyricsProviders() jsonBytes, err := json.Marshal(providers) @@ -1690,7 +1782,6 @@ func GetAvailableLyricsProvidersJSON() (string, error) { return string(jsonBytes), nil } -// SetLyricsFetchOptionsJSON sets lyrics provider fetch options. func SetLyricsFetchOptionsJSON(optionsJSON string) error { opts := GetLyricsFetchOptions() if strings.TrimSpace(optionsJSON) != "" { @@ -1703,7 +1794,6 @@ func SetLyricsFetchOptionsJSON(optionsJSON string) error { return nil } -// GetLyricsFetchOptionsJSON returns current lyrics provider fetch options. func GetLyricsFetchOptionsJSON() (string, error) { opts := GetLyricsFetchOptions() jsonBytes, err := json.Marshal(opts) @@ -2260,6 +2350,8 @@ func DownloadWithExtensionsJSON(requestJSON string) (string, error) { if err := json.Unmarshal([]byte(requestJSON), &req); err != nil { return "", fmt.Errorf("invalid request: %w", err) } + applySongLinkRegionFromRequest(&req) + defer closeOwnedOutputFD(req.OutputFD) req.TrackName = strings.TrimSpace(req.TrackName) req.ArtistName = strings.TrimSpace(req.ArtistName) @@ -3141,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() { @@ -3152,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 daf09111..ceac1af4 100644 --- a/go_backend/extension_providers.go +++ b/go_backend/extension_providers.go @@ -1,4 +1,3 @@ -// Package gobackend provides extension provider interfaces package gobackend import ( @@ -15,9 +14,6 @@ import ( "github.com/dop251/goja" ) -// ==================== Metadata Types ==================== - -// ExtTrackMetadata represents track metadata from an extension type ExtTrackMetadata struct { ID string `json:"id"` Name string `json:"name"` @@ -635,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)) @@ -675,8 +671,20 @@ func isBuiltInProvider(providerID string) bool { func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, error) { priority := GetProviderPriority() extManager := GetExtensionManager() + strictMode := !req.UseFallback + selectedProvider := strings.TrimSpace(req.Service) - if req.Service != "" && isBuiltInProvider(req.Service) { + if strictMode { + if selectedProvider == "" { + selectedProvider = strings.TrimSpace(req.Source) + } + if selectedProvider != "" { + priority = []string{selectedProvider} + GoLog("[DownloadWithExtensionFallback] Strict mode enabled, provider locked to: %s\n", selectedProvider) + } + } + + if !strictMode && req.Service != "" && isBuiltInProvider(strings.ToLower(req.Service)) { GoLog("[DownloadWithExtensionFallback] User selected service: %s, prioritizing it first\n", req.Service) newPriority := []string{req.Service} for _, p := range priority { @@ -691,7 +699,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro var lastErr error var skipBuiltIn bool - if req.Source != "" && !isBuiltInProvider(req.Source) { + if req.Source != "" && !isBuiltInProvider(strings.ToLower(req.Source)) { ext, err := extManager.GetExtension(req.Source) if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsMetadataProvider() { GoLog("[DownloadWithExtensionFallback] Enriching track from extension '%s'...\n", req.Source) @@ -754,7 +762,9 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro } } - if req.Source != "" && !isBuiltInProvider(req.Source) { + if req.Source != "" && + !isBuiltInProvider(strings.ToLower(req.Source)) && + (!strictMode || selectedProvider == "" || strings.EqualFold(selectedProvider, req.Source)) { GoLog("[DownloadWithExtensionFallback] Track source is extension '%s', trying it first\n", req.Source) ext, err := extManager.GetExtension(req.Source) @@ -768,12 +778,29 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro GoLog("[DownloadWithExtensionFallback] Downloading from source extension with trackID: %s (skipBuiltInFallback: %v)\n", trackID, skipBuiltIn) outputPath := buildOutputPath(req) + if req.ItemID != "" { + StartItemProgress(req.ItemID) + } result, err := provider.Download(trackID, req.Quality, outputPath, func(percent int) { if req.ItemID != "" { - SetItemProgress(req.ItemID, float64(percent), 0, 0) + normalized := float64(percent) / 100.0 + if normalized < 0 { + normalized = 0 + } + if normalized > 1 { + normalized = 1 + } + SetItemProgress(req.ItemID, normalized, 0, 0) } }) + if req.ItemID != "" { + if err == nil && result != nil && result.Success { + CompleteItemProgress(req.ItemID) + } else { + RemoveItemProgress(req.ItemID) + } + } if err == nil && result.Success { resp := &DownloadResponse{ @@ -788,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 { @@ -860,18 +887,23 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro } for _, providerID := range priority { + providerID = strings.TrimSpace(providerID) + if providerID == "" { + continue + } + providerIDNormalized := strings.ToLower(providerID) if providerID == req.Source { continue } - if skipBuiltIn && isBuiltInProvider(providerID) { + if skipBuiltIn && isBuiltInProvider(providerIDNormalized) { GoLog("[DownloadWithExtensionFallback] Skipping built-in provider %s (skipBuiltInFallback)\n", providerID) continue } GoLog("[DownloadWithExtensionFallback] Trying provider: %s\n", providerID) - if isBuiltInProvider(providerID) { + if isBuiltInProvider(providerIDNormalized) { if (req.Genre == "" || req.Label == "") && req.ISRC != "" { GoLog("[DownloadWithExtensionFallback] Enriching extended metadata from Deezer for ISRC: %s\n", req.ISRC) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) @@ -892,9 +924,9 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro } } - result, err := tryBuiltInProvider(providerID, req) + result, err := tryBuiltInProvider(providerIDNormalized, req) if err == nil && result.Success { - result.Service = providerID + result.Service = providerIDNormalized if req.Label != "" { result.Label = req.Label } @@ -915,11 +947,11 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro Success: false, Error: "Download cancelled", ErrorType: "cancelled", - Service: providerID, + Service: providerIDNormalized, }, nil } lastErr = err - GoLog("[DownloadWithExtensionFallback] %s failed: %v\n", providerID, err) + GoLog("[DownloadWithExtensionFallback] %s failed: %v\n", providerIDNormalized, err) } } else { ext, err := extManager.GetExtension(providerID) @@ -944,12 +976,29 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro } outputPath := buildOutputPath(req) + if req.ItemID != "" { + StartItemProgress(req.ItemID) + } result, err := provider.Download(availability.TrackID, req.Quality, outputPath, func(percent int) { if req.ItemID != "" { - SetItemProgress(req.ItemID, float64(percent), 0, 0) + normalized := float64(percent) / 100.0 + if normalized < 0 { + normalized = 0 + } + if normalized > 1 { + normalized = 1 + } + SetItemProgress(req.ItemID, normalized, 0, 0) } }) + if req.ItemID != "" { + if err == nil && result != nil && result.Success { + CompleteItemProgress(req.ItemID) + } else { + RemoveItemProgress(req.ItemID) + } + } if err == nil && result.Success { resp := &DownloadResponse{ @@ -964,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 { @@ -1098,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 5ee1455e..c45b52c1 100644 --- a/go_backend/extension_runtime.go +++ b/go_backend/extension_runtime.go @@ -88,47 +88,82 @@ 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 + // allow_http scheme downgrade here, because some extension APIs (e.g. + // spotify-web) will redirect http -> https and can end up in 301 loops. + // We still reuse sharedTransport so insecure TLS compatibility mode remains effective. client := &http.Client{ - Timeout: 30 * time.Second, - Jar: jar, - CheckRedirect: func(req *http.Request, via []*http.Request) error { - if req.URL.Scheme != "https" { - GoLog("[Extension:%s] Redirect blocked: non-https scheme '%s'\n", ext.ID, req.URL.Scheme) - return fmt.Errorf("redirect blocked: only https is allowed") - } + Transport: sharedTransport, + Timeout: 30 * time.Second, + Jar: jar, + } + client.CheckRedirect = func(req *http.Request, via []*http.Request) error { + if req.URL.Scheme != "https" { + GoLog("[Extension:%s] Redirect blocked: non-https scheme '%s'\n", ext.ID, req.URL.Scheme) + return fmt.Errorf("redirect blocked: only https is allowed") + } - domain := req.URL.Hostname() - if domain == "" { - GoLog("[Extension:%s] Redirect blocked: missing hostname\n", ext.ID) - return fmt.Errorf("redirect blocked: hostname is required") - } - if !ext.Manifest.IsDomainAllowed(domain) { - GoLog("[Extension:%s] Redirect blocked: domain '%s' not in allowed list\n", ext.ID, domain) - return &RedirectBlockedError{Domain: domain} - } - if isPrivateIP(domain) { - GoLog("[Extension:%s] Redirect blocked: private IP '%s'\n", ext.ID, domain) - return &RedirectBlockedError{Domain: domain, IsPrivate: true} - } - if len(via) >= 10 { - return http.ErrUseLastResponse - } - return nil - }, + domain := req.URL.Hostname() + if domain == "" { + GoLog("[Extension:%s] Redirect blocked: missing hostname\n", ext.ID) + return fmt.Errorf("redirect blocked: hostname is required") + } + if !ext.Manifest.IsDomainAllowed(domain) { + GoLog("[Extension:%s] Redirect blocked: domain '%s' not in allowed list\n", ext.ID, domain) + return &RedirectBlockedError{Domain: domain} + } + if isPrivateIP(domain) { + GoLog("[Extension:%s] Redirect blocked: private IP '%s'\n", ext.ID, domain) + return &RedirectBlockedError{Domain: domain, IsPrivate: true} + } + if len(via) >= 10 { + return http.ErrUseLastResponse + } + return nil } runtime.httpClient = client @@ -147,7 +182,6 @@ func (e *RedirectBlockedError) Error() string { return "redirect blocked: domain '" + e.Domain + "' not in allowed list" } -// isPrivateIP checks if a hostname resolves to a private/local IP address func isPrivateIP(host string) bool { hostLower := strings.ToLower(strings.TrimSpace(host)) if hostLower == "" { @@ -162,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/extension_store.go b/go_backend/extension_store.go index 27c14dec..2fa17e84 100644 --- a/go_backend/extension_store.go +++ b/go_backend/extension_store.go @@ -77,7 +77,6 @@ type StoreRegistry struct { Extensions []StoreExtension `json:"extensions"` } -// StoreExtensionResponse is the normalized response sent to Flutter type StoreExtensionResponse struct { ID string `json:"id"` Name string `json:"name"` @@ -218,7 +217,7 @@ func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error LogInfo("ExtensionStore", "Fetching registry from %s", s.registryURL) - client := &http.Client{Timeout: 30 * time.Second} + client := NewHTTPClientWithTimeout(30 * time.Second) resp, err := client.Get(s.registryURL) if err != nil { if s.cache != nil { @@ -310,7 +309,7 @@ func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string) LogInfo("ExtensionStore", "Downloading %s from %s", ext.getDisplayName(), ext.getDownloadURL()) - client := &http.Client{Timeout: 5 * time.Minute} + client := NewHTTPClientWithTimeout(5 * time.Minute) resp, err := client.Get(ext.getDownloadURL()) if err != nil { return fmt.Errorf("failed to download: %w", err) @@ -421,7 +420,6 @@ func (s *ExtensionStore) ClearCache() { LogInfo("ExtensionStore", "Cache cleared") } -// Helper: case-insensitive contains func containsIgnoreCase(s, substr string) bool { return containsStr(toLower(s), substr) } diff --git a/go_backend/go.mod b/go_backend/go.mod index d903afd6..fe67fe7e 100644 --- a/go_backend/go.mod +++ b/go_backend/go.mod @@ -2,15 +2,15 @@ module github.com/zarz/spotiflac_android/go_backend go 1.25.0 -toolchain go1.26.0 +toolchain go1.25.7 require ( - github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 + github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5 github.com/go-flac/flacpicture/v2 v2.0.2 github.com/go-flac/flacvorbis/v2 v2.0.2 github.com/go-flac/go-flac/v2 v2.0.4 github.com/refraction-networking/utls v1.8.2 - golang.org/x/mobile v0.0.0-20260209203831-923679eb55af + golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864 golang.org/x/net v0.50.0 ) diff --git a/go_backend/go.sum b/go_backend/go.sum index e1f185bb..50e29433 100644 --- a/go_backend/go.sum +++ b/go_backend/go.sum @@ -8,6 +8,8 @@ github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yA github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM= github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4= +github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5 h1:QckvTXtu55YMopmVeDrPQ/r+T6xjw8KMCmE3UgUldkw= +github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4= github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE= github.com/go-flac/flacpicture/v2 v2.0.2/go.mod h1:DMZBPWPAmdLqNhqFSy5ZBs9wyBzOekXutGfP7/TFCuo= github.com/go-flac/flacvorbis/v2 v2.0.2 h1:xCL3OhxrxWkHrbWUBvGNe+6FQ03yLmBbz0v5z4V2PoQ= @@ -36,6 +38,8 @@ golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3 h1:NiJtT7g4ncNFVjVZMAYNBr golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3/go.mod h1:wReH3Q1agKmmLapipWFnd4NSs8KPz3fK6mSEZjXLkrg= golang.org/x/mobile v0.0.0-20260209203831-923679eb55af h1:VqXrZNyqFISxo0rNDFZQlRDRIp7RXSJDeh/LbrK+W1k= golang.org/x/mobile v0.0.0-20260209203831-923679eb55af/go.mod h1:tbwefIr7RlQD1OpZ0KEZ9nux/uiihAOGdafgZfJkmII= +golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864 h1:cTVynMSsMYgbUrtia2HB1jrhdUwQNtQti91vUCyjMp4= +golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864/go.mod h1:4OGHIUSBiIqyFAQDaX1tpY0BVnO20DvNDeATBu8aeFQ= golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= diff --git a/go_backend/httputil.go b/go_backend/httputil.go index d6033243..08137fa8 100644 --- a/go_backend/httputil.go +++ b/go_backend/httputil.go @@ -11,6 +11,7 @@ import ( "net/url" "strconv" "strings" + "sync" "syscall" "time" ) @@ -37,6 +38,16 @@ const ( Second = time.Second ) +type NetworkCompatibilityOptions struct { + AllowHTTP bool + InsecureTLS bool +} + +var ( + networkCompatibilityMu sync.RWMutex + networkCompatibilityOptions NetworkCompatibilityOptions +) + var sharedTransport = &http.Transport{ DialContext: (&net.Dialer{ Timeout: 30 * time.Second, @@ -77,18 +88,18 @@ var metadataTransport = &http.Transport{ } var sharedClient = &http.Client{ - Transport: sharedTransport, + Transport: newCompatibilityTransport(sharedTransport), Timeout: DefaultTimeout, } var downloadClient = &http.Client{ - Transport: sharedTransport, + Transport: newCompatibilityTransport(sharedTransport), Timeout: DownloadTimeout, } func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client { return &http.Client{ - Transport: sharedTransport, + Transport: newCompatibilityTransport(sharedTransport), Timeout: timeout, } } @@ -97,7 +108,7 @@ func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client { // Use this for API calls that should not be affected by download traffic. func NewMetadataHTTPClient(timeout time.Duration) *http.Client { return &http.Client{ - Transport: metadataTransport, + Transport: newCompatibilityTransport(metadataTransport), Timeout: timeout, } } @@ -115,6 +126,109 @@ func CloseIdleConnections() { metadataTransport.CloseIdleConnections() } +func SetNetworkCompatibilityOptions(allowHTTP, insecureTLS bool) { + networkCompatibilityMu.Lock() + networkCompatibilityOptions = NetworkCompatibilityOptions{ + AllowHTTP: allowHTTP, + InsecureTLS: insecureTLS, + } + networkCompatibilityMu.Unlock() + + applyTLSCompatibility(sharedTransport, insecureTLS) + applyTLSCompatibility(metadataTransport, insecureTLS) + CloseIdleConnections() + + GoLog("[HTTP] Network compatibility options updated: allow_http=%v insecure_tls=%v\n", allowHTTP, insecureTLS) +} + +func GetNetworkCompatibilityOptions() NetworkCompatibilityOptions { + networkCompatibilityMu.RLock() + defer networkCompatibilityMu.RUnlock() + return networkCompatibilityOptions +} + +func applyTLSCompatibility(transport *http.Transport, insecureTLS bool) { + if insecureTLS { + cfg := &tls.Config{InsecureSkipVerify: true} + if transport.TLSClientConfig != nil { + cfg = transport.TLSClientConfig.Clone() + cfg.InsecureSkipVerify = true + } + transport.TLSClientConfig = cfg + return + } + + transport.TLSClientConfig = nil +} + +type compatibilityTransport struct { + base http.RoundTripper +} + +func newCompatibilityTransport(base http.RoundTripper) http.RoundTripper { + return &compatibilityTransport{base: base} +} + +func (t *compatibilityTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if req == nil || req.URL == nil { + return t.base.RoundTrip(req) + } + + opts := GetNetworkCompatibilityOptions() + if !opts.AllowHTTP || req.URL.Scheme != "https" { + return t.base.RoundTrip(req) + } + + // Compatibility mode should prefer HTTPS and only fallback to HTTP on + // transport-level failures. Forcing HTTP unconditionally can trigger + // redirect loops (http -> https) on providers that enforce HTTPS. + resp, err := t.base.RoundTrip(req) + if err == nil { + return resp, nil + } + + if !canFallbackToHTTP(req) { + return nil, err + } + + fallbackReq, cloneErr := cloneRequestWithHTTPScheme(req, "http") + if cloneErr != nil { + return nil, err + } + + GoLog("[HTTP] HTTPS request failed for %s, retrying over HTTP: %v\n", req.URL.Host, err) + return t.base.RoundTrip(fallbackReq) +} + +func canFallbackToHTTP(req *http.Request) bool { + if req == nil { + return false + } + + switch strings.ToUpper(req.Method) { + case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodDelete: + return true + default: + return req.GetBody != nil + } +} + +func cloneRequestWithHTTPScheme(req *http.Request, scheme string) (*http.Request, error) { + reqCopy := req.Clone(req.Context()) + if req.Body != nil && req.GetBody != nil { + bodyCopy, err := req.GetBody() + if err != nil { + return nil, err + } + reqCopy.Body = bodyCopy + } + + urlCopy := *req.URL + urlCopy.Scheme = scheme + reqCopy.URL = &urlCopy + return reqCopy, nil +} + // Also checks for ISP blocking on errors func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Response, error) { req.Header.Set("User-Agent", getRandomUserAgent()) @@ -145,7 +259,6 @@ func DefaultRetryConfig() RetryConfig { func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConfig) (*http.Response, error) { var lastErr error delay := config.InitialDelay - requestURL := req.URL.String() for attempt := 0; attempt <= config.MaxRetries; attempt++ { reqCopy := req.Clone(req.Context()) @@ -155,8 +268,8 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf if err != nil { lastErr = err - if CheckAndLogISPBlocking(err, requestURL, "HTTP") { - return nil, WrapErrorWithISPCheck(err, requestURL, "HTTP") + if CheckAndLogISPBlocking(err, reqCopy.URL.String(), "HTTP") { + return nil, WrapErrorWithISPCheck(err, reqCopy.URL.String(), "HTTP") } if attempt < config.MaxRetries { diff --git a/go_backend/library_scan.go b/go_backend/library_scan.go index e0a1eafe..52e91fb8 100644 --- a/go_backend/library_scan.go +++ b/go_backend/library_scan.go @@ -67,6 +67,48 @@ var supportedAudioFormats = map[string]bool{ ".ogg": true, } +type libraryAudioFileInfo struct { + path string + modTime int64 +} + +func collectLibraryAudioFiles(folderPath string, cancelCh <-chan struct{}) ([]libraryAudioFileInfo, error) { + var files []libraryAudioFileInfo + + err := filepath.Walk(folderPath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + + select { + case <-cancelCh: + return fmt.Errorf("scan cancelled") + default: + } + + if info.IsDir() { + return nil + } + + ext := strings.ToLower(filepath.Ext(path)) + if !supportedAudioFormats[ext] { + return nil + } + + files = append(files, libraryAudioFileInfo{ + path: path, + modTime: info.ModTime().UnixMilli(), + }) + return nil + }) + + if err != nil { + return nil, err + } + + return files, nil +} + func SetLibraryCoverCacheDir(cacheDir string) { libraryCoverCacheMu.Lock() libraryCoverCacheDir = cacheDir @@ -98,31 +140,16 @@ func ScanLibraryFolder(folderPath string) (string, error) { cancelCh := libraryScanCancel libraryScanCancelMu.Unlock() - var audioFiles []string - err = filepath.Walk(folderPath, func(path string, info os.FileInfo, err error) error { - if err != nil { - return nil - } - - select { - case <-cancelCh: - return fmt.Errorf("scan cancelled") - default: - } - - if !info.IsDir() { - ext := strings.ToLower(filepath.Ext(path)) - if supportedAudioFormats[ext] { - audioFiles = append(audioFiles, path) - } - } - return nil - }) - + audioFileInfos, err := collectLibraryAudioFiles(folderPath, cancelCh) if err != nil { return "[]", err } + audioFiles := make([]string, 0, len(audioFileInfos)) + for _, fileInfo := range audioFileInfos { + audioFiles = append(audioFiles, fileInfo.path) + } + totalFiles := len(audioFiles) libraryScanProgressMu.Lock() libraryScanProgress.TotalFiles = totalFiles @@ -218,6 +245,18 @@ func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) { } } +func applyDefaultLibraryMetadata(filePath string, result *LibraryScanResult) { + if result.TrackName == "" { + result.TrackName = strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath)) + } + if result.ArtistName == "" { + result.ArtistName = "Unknown Artist" + } + if result.AlbumName == "" { + result.AlbumName = "Unknown Album" + } +} + func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) { metadata, err := ReadMetadata(filePath) if err != nil { @@ -243,15 +282,7 @@ func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResul } } - if result.TrackName == "" { - result.TrackName = strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath)) - } - if result.ArtistName == "" { - result.ArtistName = "Unknown Artist" - } - if result.AlbumName == "" { - result.AlbumName = "Unknown Album" - } + applyDefaultLibraryMetadata(filePath, result) return result, nil } @@ -297,15 +328,7 @@ func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult } } - if result.TrackName == "" { - result.TrackName = strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath)) - } - if result.ArtistName == "" { - result.ArtistName = "Unknown Artist" - } - if result.AlbumName == "" { - result.AlbumName = "Unknown Album" - } + applyDefaultLibraryMetadata(filePath, result) return result, nil } @@ -337,15 +360,7 @@ func scanOggFile(filePath string, result *LibraryScanResult) (*LibraryScanResult } } - if result.TrackName == "" { - result.TrackName = strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath)) - } - if result.ArtistName == "" { - result.ArtistName = "Unknown Artist" - } - if result.AlbumName == "" { - result.AlbumName = "Unknown Album" - } + applyDefaultLibraryMetadata(filePath, result) return result, nil } @@ -476,40 +491,14 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, libraryScanCancelMu.Unlock() // Collect all audio files with their mod times - type fileInfo struct { - path string - modTime int64 - } - var currentFiles []fileInfo - currentPathSet := make(map[string]bool) - - err = filepath.Walk(folderPath, func(path string, info os.FileInfo, err error) error { - if err != nil { - return nil - } - - select { - case <-cancelCh: - return fmt.Errorf("scan cancelled") - default: - } - - if !info.IsDir() { - ext := strings.ToLower(filepath.Ext(path)) - if supportedAudioFormats[ext] { - currentFiles = append(currentFiles, fileInfo{ - path: path, - modTime: info.ModTime().UnixMilli(), - }) - currentPathSet[path] = true - } - } - return nil - }) - + currentFiles, err := collectLibraryAudioFiles(folderPath, cancelCh) if err != nil { return "{}", err } + currentPathSet := make(map[string]bool, len(currentFiles)) + for _, fileInfo := range currentFiles { + currentPathSet[fileInfo.path] = true + } totalFiles := len(currentFiles) libraryScanProgressMu.Lock() @@ -517,7 +506,7 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, libraryScanProgressMu.Unlock() // Find files to scan (new or modified) - var filesToScan []fileInfo + var filesToScan []libraryAudioFileInfo skippedCount := 0 for _, f := range currentFiles { diff --git a/go_backend/lyrics.go b/go_backend/lyrics.go index 576772f7..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) @@ -651,6 +841,22 @@ func parseSyncedLyrics(syncedLyrics string) []LyricsLine { return lines } +func plainTextLyricsLines(rawLyrics string) []LyricsLine { + var lines []LyricsLine + for _, line := range strings.Split(rawLyrics, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + lines = append(lines, LyricsLine{ + StartTimeMs: 0, + Words: trimmed, + EndTimeMs: 0, + }) + } + return lines +} + func lyricsHasUsableText(lyrics *LyricsResponse) bool { if lyrics == nil { return false @@ -790,8 +996,18 @@ func simplifyTrackName(name string) string { re := regexp.MustCompile("(?i)" + pattern) result = re.ReplaceAllString(result, "") } + result = strings.TrimSpace(result) + if result == "" { + return result + } - return strings.TrimSpace(result) + // Add a loose fallback form for provider queries where punctuation + // and separators differ (e.g. "/" vs "_" vs spaces). + if loose := normalizeLooseTitle(result); loose != "" { + return loose + } + + return result } func normalizeArtistName(name string) string { diff --git a/go_backend/lyrics_apple.go b/go_backend/lyrics_apple.go index 41d650f8..957db6fc 100644 --- a/go_backend/lyrics_apple.go +++ b/go_backend/lyrics_apple.go @@ -355,18 +355,7 @@ func (c *AppleMusicClient) FetchLyrics( } // Fall back to plain text if no timestamps found - plainLines := strings.Split(lrcText, "\n") - var resultLines []LyricsLine - for _, line := range plainLines { - trimmed := strings.TrimSpace(line) - if trimmed != "" { - resultLines = append(resultLines, LyricsLine{ - StartTimeMs: 0, - Words: trimmed, - EndTimeMs: 0, - }) - } - } + resultLines := plainTextLyricsLines(lrcText) if len(resultLines) > 0 { return &LyricsResponse{ diff --git a/go_backend/lyrics_musixmatch.go b/go_backend/lyrics_musixmatch.go index c3582b66..71d4544e 100644 --- a/go_backend/lyrics_musixmatch.go +++ b/go_backend/lyrics_musixmatch.go @@ -131,17 +131,7 @@ func (c *MusixmatchClient) FetchLyricsInLanguage(songID int64, language string) // Fall back to unsynced lyrics for selected language if result.UnsyncedLyrics != nil && strings.TrimSpace(result.UnsyncedLyrics.Lyrics) != "" { - var lines []LyricsLine - for _, line := range strings.Split(result.UnsyncedLyrics.Lyrics, "\n") { - trimmed := strings.TrimSpace(line) - if trimmed != "" { - lines = append(lines, LyricsLine{ - StartTimeMs: 0, - Words: trimmed, - EndTimeMs: 0, - }) - } - } + lines := plainTextLyricsLines(result.UnsyncedLyrics.Lyrics) if len(lines) > 0 { return &LyricsResponse{ @@ -187,17 +177,7 @@ func (c *MusixmatchClient) FetchLyrics(trackName, artistName string, durationSec // Fall back to unsynced lyrics if result.UnsyncedLyrics != nil && strings.TrimSpace(result.UnsyncedLyrics.Lyrics) != "" { - var lines []LyricsLine - for _, line := range strings.Split(result.UnsyncedLyrics.Lyrics, "\n") { - trimmed := strings.TrimSpace(line) - if trimmed != "" { - lines = append(lines, LyricsLine{ - StartTimeMs: 0, - Words: trimmed, - EndTimeMs: 0, - }) - } - } + lines := plainTextLyricsLines(result.UnsyncedLyrics.Lyrics) if len(lines) > 0 { return &LyricsResponse{ diff --git a/go_backend/lyrics_qqmusic.go b/go_backend/lyrics_qqmusic.go index 6971ba24..0b76a49f 100644 --- a/go_backend/lyrics_qqmusic.go +++ b/go_backend/lyrics_qqmusic.go @@ -185,18 +185,7 @@ func (c *QQMusicClient) FetchLyrics( } // Fall back to plain text - plainLines := strings.Split(lrcText, "\n") - var resultLines []LyricsLine - for _, line := range plainLines { - trimmed := strings.TrimSpace(line) - if trimmed != "" { - resultLines = append(resultLines, LyricsLine{ - StartTimeMs: 0, - Words: trimmed, - EndTimeMs: 0, - }) - } - } + resultLines := plainTextLyricsLines(lrcText) if len(resultLines) > 0 { return &LyricsResponse{ diff --git a/go_backend/metadata.go b/go_backend/metadata.go index e0034dbd..29ab2d02 100644 --- a/go_backend/metadata.go +++ b/go_backend/metadata.go @@ -545,38 +545,60 @@ func ExtractLyrics(filePath string) (string, error) { lower := strings.ToLower(filePath) if strings.HasSuffix(lower, ".flac") { - return extractLyricsFromFlac(filePath) + lyrics, err := extractLyricsFromFlac(filePath) + if err == nil && strings.TrimSpace(lyrics) != "" { + return lyrics, nil + } + return extractLyricsFromSidecarLRC(filePath) } if strings.HasSuffix(lower, ".mp3") { meta, err := ReadID3Tags(filePath) - if err != nil || meta == nil { - return "", fmt.Errorf("no lyrics found in file") + if err == nil && meta != nil { + if strings.TrimSpace(meta.Lyrics) != "" { + return meta.Lyrics, nil + } + if looksLikeEmbeddedLyrics(meta.Comment) { + return meta.Comment, nil + } } - if strings.TrimSpace(meta.Lyrics) != "" { - return meta.Lyrics, nil - } - if looksLikeEmbeddedLyrics(meta.Comment) { - return meta.Comment, nil - } - return "", fmt.Errorf("no lyrics found in file") + return extractLyricsFromSidecarLRC(filePath) } if strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg") { meta, err := ReadOggVorbisComments(filePath) - if err != nil || meta == nil { - return "", fmt.Errorf("no lyrics found in file") - } - if strings.TrimSpace(meta.Lyrics) != "" { - return meta.Lyrics, nil - } - if looksLikeEmbeddedLyrics(meta.Comment) { - return meta.Comment, nil + if err == nil && meta != nil { + if strings.TrimSpace(meta.Lyrics) != "" { + return meta.Lyrics, nil + } + if looksLikeEmbeddedLyrics(meta.Comment) { + return meta.Comment, nil + } } + return extractLyricsFromSidecarLRC(filePath) + } + + return extractLyricsFromSidecarLRC(filePath) +} + +func extractLyricsFromSidecarLRC(filePath string) (string, error) { + ext := filepath.Ext(filePath) + base := strings.TrimSuffix(filePath, ext) + if strings.TrimSpace(base) == "" { return "", fmt.Errorf("no lyrics found in file") } - return "", fmt.Errorf("unsupported file format for lyrics extraction") + lrcPath := base + ".lrc" + data, err := os.ReadFile(lrcPath) + if err != nil { + return "", fmt.Errorf("no lyrics found in file") + } + + lyrics := strings.TrimSpace(string(data)) + if lyrics == "" { + return "", fmt.Errorf("no lyrics found in file") + } + return lyrics, nil } func extractLyricsFromFlac(filePath string) (string, error) { diff --git a/go_backend/output_fd.go b/go_backend/output_fd.go index 53a2bd3f..d5519dce 100644 --- a/go_backend/output_fd.go +++ b/go_backend/output_fd.go @@ -12,11 +12,68 @@ func isFDOutput(outputFD int) bool { func openOutputForWrite(outputPath string, outputFD int) (*os.File, error) { if isFDOutput(outputFD) { - return os.NewFile(uintptr(outputFD), fmt.Sprintf("saf_fd_%d", outputFD)), nil + // Never hand the original detached FD directly to a provider attempt. + // Fallback chains may retry with another provider after a failure. + // If the first attempt closes the original FD, its numeric ID can be + // reused by unrelated resources and a later close may trigger fdsan abort. + dupFD, err := dupOutputFD(outputFD) + if err != nil { + return nil, fmt.Errorf("failed to duplicate output fd %d: %w", outputFD, err) + } + if err := prepareDupFDForWrite(dupFD, outputFD); err != nil { + _ = closeFD(dupFD) + return nil, err + } + return os.NewFile(uintptr(dupFD), fmt.Sprintf("saf_fd_%d_dup_%d", outputFD, dupFD)), nil } + + path := strings.TrimSpace(outputPath) + if strings.HasPrefix(path, "/proc/self/fd/") { + // Re-open procfs fd path instead of taking ownership of raw detached fd. + // Some SAF providers reject O_TRUNC on these descriptors with EACCES/EPERM. + file, err := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0) + if err == nil { + return file, nil + } + if strings.Contains(strings.ToLower(err.Error()), "permission denied") { + return os.OpenFile(path, os.O_WRONLY, 0) + } + return nil, err + } + return os.Create(outputPath) } +func prepareDupFDForWrite(dupFD, originalFD int) error { + // Best-effort reset so retries start writing from byte 0. + if err := truncateFD(dupFD); err != nil { + if isBestEffortTruncateError(err) { + GoLog("[OutputFD] truncate not supported on fd %d (dup of %d): %v\n", dupFD, originalFD, err) + } else { + return fmt.Errorf("failed to truncate output fd %d (dup of %d): %w", dupFD, originalFD, err) + } + } + if err := seekFDStart(dupFD); err != nil { + GoLog("[OutputFD] seek reset failed on fd %d (dup of %d): %v\n", dupFD, originalFD, err) + } + return nil +} + +func closeOwnedOutputFD(outputFD int) { + if !isFDOutput(outputFD) { + return + } + + if err := closeFD(outputFD); err != nil { + if !isBadFD(err) { + GoLog("[OutputFD] failed to close detached fd %d: %v\n", outputFD, err) + } + return + } + + GoLog("[OutputFD] closed detached fd %d\n", outputFD) +} + func cleanupOutputOnError(outputPath string, outputFD int) { if isFDOutput(outputFD) { return diff --git a/go_backend/output_fd_unix.go b/go_backend/output_fd_unix.go new file mode 100644 index 00000000..a9eb76aa --- /dev/null +++ b/go_backend/output_fd_unix.go @@ -0,0 +1,35 @@ +//go:build !windows + +package gobackend + +import "syscall" + +func dupOutputFD(fd int) (int, error) { + return syscall.Dup(fd) +} + +func truncateFD(fd int) error { + return syscall.Ftruncate(fd, 0) +} + +func seekFDStart(fd int) error { + _, err := syscall.Seek(fd, 0, 0) + return err +} + +func closeFD(fd int) error { + return syscall.Close(fd) +} + +func isBestEffortTruncateError(err error) bool { + switch err { + case syscall.EPERM, syscall.EACCES, syscall.EINVAL, syscall.ESPIPE, syscall.ENOSYS: + return true + default: + return false + } +} + +func isBadFD(err error) bool { + return err == syscall.EBADF +} diff --git a/go_backend/output_fd_windows.go b/go_backend/output_fd_windows.go new file mode 100644 index 00000000..a0cedd95 --- /dev/null +++ b/go_backend/output_fd_windows.go @@ -0,0 +1,29 @@ +//go:build windows + +package gobackend + +func dupOutputFD(fd int) (int, error) { + // Windows build is primarily for local tooling/tests. + // Android runtime uses the !windows implementation. + return fd, nil +} + +func truncateFD(fd int) error { + return nil +} + +func seekFDStart(fd int) error { + return nil +} + +func closeFD(fd int) error { + return nil +} + +func isBestEffortTruncateError(err error) bool { + return true +} + +func isBadFD(err error) bool { + return false +} diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index 23914f5b..015dca8d 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -2,8 +2,8 @@ package gobackend import ( "bufio" + "bytes" "context" - "encoding/base64" "encoding/json" "errors" "fmt" @@ -12,6 +12,8 @@ import ( "net/url" "os" "path/filepath" + "regexp" + "strconv" "strings" "sync" "time" @@ -28,6 +30,28 @@ 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=" + qobuzStoreSearchBaseURL = "https://www.qobuz.com/us-en/search/tracks/" + qobuzTrackPlayBaseURL = "https://play.qobuz.com/track/" + qobuzDownloadAPIURL = "https://www.musicdl.me/api/qobuz/download" + qobuzDabMusicAPIURL = "https://dabmusic.xyz/api/stream?trackId=" + qobuzDeebAPIURL = "https://dab.yeet.su/api/stream?trackId=" + qobuzSquidAPIURL = "https://qobuz.squid.wtf/api/download-music?country=US&track_id=" + qobuzDebugKeyXORMask = byte(0x5A) +) + +var qobuzStoreTrackIDRegex = regexp.MustCompile(`/v4/ajax/popin-add-cart/track/([0-9]+)`) + +var qobuzDebugKeyObfuscated = []byte{ + 0x69, 0x3b, 0x38, 0x3e, 0x36, 0x37, 0x35, 0x2f, 0x36, 0x3b, + 0x33, 0x29, 0x2e, 0x32, 0x3f, 0x3d, 0x35, 0x3b, 0x2e, 0x3b, + 0x34, 0x3e, 0x34, 0x35, 0x35, 0x34, 0x3f, 0x39, 0x35, 0x37, + 0x3f, 0x29, 0x3f, 0x2c, 0x3f, 0x34, 0x39, 0x36, 0x35, 0x29, + 0x3f, +} + type QobuzTrack struct { ID int64 `json:"id"` Title string `json:"title"` @@ -174,6 +198,32 @@ func qobuzTitlesMatch(expectedTitle, foundTitle string) bool { return true } + looseExpected := normalizeLooseTitle(normExpected) + looseFound := normalizeLooseTitle(normFound) + if looseExpected != "" && looseFound != "" { + if looseExpected == looseFound { + return true + } + if strings.Contains(looseExpected, looseFound) || strings.Contains(looseFound, looseExpected) { + return true + } + } + + // 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) != "" { + 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) foundLatin := qobuzIsLatinScript(foundTitle) if expectedLatin != foundLatin { @@ -311,8 +361,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 { @@ -338,177 +387,198 @@ func (q *QobuzDownloader) GetTrackByID(trackID int64) (*QobuzTrack, error) { } func (q *QobuzDownloader) GetAvailableAPIs() []string { - encodedAPIs := []string{ - "ZGFiLnllZXQuc3UvYXBpL3N0cmVhbT90cmFja0lkPQ==", - "ZGFibXVzaWMueHl6L2FwaS9zdHJlYW0/dHJhY2tJZD0=", - "cW9idXouc3F1aWQud3RmL2FwaS9kb3dubG9hZC1tdXNpYz90cmFja19pZD0=", - } - - 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 + return []string{ + qobuzDownloadAPIURL, } } -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) +type qobuzAPIProvider struct { + Name string + URL string + Kind string } -func extractQobuzDownloadURLFromBody(body []byte) (string, error) { +const ( + qobuzAPIKindMusicDL = "musicdl" + qobuzAPIKindStandard = "standard" +) + +func (q *QobuzDownloader) GetAvailableProviders() []qobuzAPIProvider { + return []qobuzAPIProvider{ + {Name: "musicdl", URL: qobuzDownloadAPIURL, Kind: qobuzAPIKindMusicDL}, + {Name: "dabmusic", URL: qobuzDabMusicAPIURL, Kind: qobuzAPIKindStandard}, + // "deeb" is mapped from the legacy reference fallback endpoint. + {Name: "deeb", URL: qobuzDeebAPIURL, Kind: qobuzAPIKindStandard}, + {Name: "squid", URL: qobuzSquidAPIURL, Kind: qobuzAPIKindStandard}, + } +} + +type qobuzDownloadInfo struct { + DownloadURL string + BitDepth int + SampleRate int +} + +func extractQobuzDownloadInfoFromBody(body []byte) (qobuzDownloadInfo, error) { var raw map[string]any if err := json.Unmarshal(body, &raw); err != nil { - return "", fmt.Errorf("invalid JSON: %v", err) + return qobuzDownloadInfo{}, fmt.Errorf("invalid JSON: %v", err) } if errMsg, ok := raw["error"].(string); ok && strings.TrimSpace(errMsg) != "" { - return "", fmt.Errorf("%s", errMsg) + return qobuzDownloadInfo{}, fmt.Errorf("%s", errMsg) + } + if detail, ok := raw["detail"].(string); ok && strings.TrimSpace(detail) != "" { + return qobuzDownloadInfo{}, fmt.Errorf("%s", detail) } if success, ok := raw["success"].(bool); ok && !success { if msg, ok := raw["message"].(string); ok && strings.TrimSpace(msg) != "" { - return "", fmt.Errorf("%s", msg) + return qobuzDownloadInfo{}, fmt.Errorf("%s", msg) } - return "", fmt.Errorf("api returned success=false") + return qobuzDownloadInfo{}, fmt.Errorf("api returned success=false") } + info := qobuzDownloadInfo{ + BitDepth: qobuzParseBitDepth(raw["bit_depth"]), + SampleRate: qobuzParseSampleRate(raw["sampling_rate"]), + } + if urlVal, ok := raw["download_url"].(string); ok && strings.TrimSpace(urlVal) != "" { + info.DownloadURL = strings.TrimSpace(urlVal) + return info, nil + } if urlVal, ok := raw["url"].(string); ok && strings.TrimSpace(urlVal) != "" { - return strings.TrimSpace(urlVal), nil + info.DownloadURL = strings.TrimSpace(urlVal) + return info, nil } if linkVal, ok := raw["link"].(string); ok && strings.TrimSpace(linkVal) != "" { - return strings.TrimSpace(linkVal), nil + info.DownloadURL = strings.TrimSpace(linkVal) + return info, nil } if data, ok := raw["data"].(map[string]any); ok { + if info.BitDepth == 0 { + info.BitDepth = qobuzParseBitDepth(data["bit_depth"]) + } + if info.SampleRate == 0 { + info.SampleRate = qobuzParseSampleRate(data["sampling_rate"]) + } + if urlVal, ok := data["download_url"].(string); ok && strings.TrimSpace(urlVal) != "" { + info.DownloadURL = strings.TrimSpace(urlVal) + return info, nil + } if urlVal, ok := data["url"].(string); ok && strings.TrimSpace(urlVal) != "" { - return strings.TrimSpace(urlVal), nil + info.DownloadURL = strings.TrimSpace(urlVal) + return info, nil } if linkVal, ok := data["link"].(string); ok && strings.TrimSpace(linkVal) != "" { - return strings.TrimSpace(linkVal), nil + info.DownloadURL = strings.TrimSpace(linkVal) + return info, nil } } - return "", fmt.Errorf("no download URL in response") + return qobuzDownloadInfo{}, fmt.Errorf("no download URL in response") } -func (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) +func extractQobuzDownloadURLFromBody(body []byte) (string, error) { + info, err := extractQobuzDownloadInfoFromBody(body) if err != nil { return "", err } - req.Header.Set("User-Agent", getRandomUserAgent()) - req.Header.Set("Referer", "https://jumo-dl.pages.dev/") + return info.DownloadURL, nil +} - resp, err := client.Do(req) - if err != nil { - return "", err +func qobuzParseBitDepth(value any) int { + switch v := value.(type) { + case float64: + return int(v) + case int: + return v + case int64: + return int(v) + case json.Number: + n, _ := v.Int64() + return int(n) + default: + return 0 } - 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) +func qobuzParseSampleRate(value any) int { + switch v := value.(type) { + case float64: + if v > 0 && v < 1000 { + return int(v * 1000) } - } - - 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 + return int(v) + case int: + if v > 0 && v < 1000 { + return v * 1000 } + return v + case int64: + if v > 0 && v < 1000 { + return int(v * 1000) + } + return int(v) + case json.Number: + if n, err := v.Float64(); err == nil { + if n > 0 && n < 1000 { + return int(n * 1000) + } + return int(n) + } + return 0 + default: + return 0 } +} - if linkVal, ok := result["link"].(string); ok && linkVal != "" { - GoLog("[Qobuz] Jumo API returned URL successfully (from link)\n") - return linkVal, nil +func normalizeQobuzQualityCode(quality string) string { + switch strings.ToLower(strings.TrimSpace(quality)) { + case "", "5", "6", "cd", "lossless": + return "6" + case "7", "hi-res": + return "7" + case "27", "hi-res-max": + return "27" + default: + return "6" } +} - return "", fmt.Errorf("URL not found in Jumo response") +func mapQobuzQualityCodeToAPI(qualityCode string) string { + switch normalizeQobuzQualityCode(qualityCode) { + case "27": + return "hi-res-max" + case "7": + return "hi-res" + default: + return "cd" + } +} + +func getQobuzDebugKey() string { + decoded := make([]byte, len(qobuzDebugKeyObfuscated)) + for i, b := range qobuzDebugKeyObfuscated { + decoded[i] = b ^ qobuzDebugKeyXORMask + } + return string(decoded) } func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) { - apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9") - searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(isrc), q.appID) - - req, err := http.NewRequest("GET", searchURL, nil) + candidates, err := q.searchQobuzTracksWithFallback(isrc, 50) if err != nil { return nil, err } - resp, err := DoRequestWithUserAgent(q.client, req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return nil, fmt.Errorf("search failed: HTTP %d", resp.StatusCode) - } - - var result struct { - Tracks struct { - Items []QobuzTrack `json:"items"` - } `json:"tracks"` - } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, err - } - - for i := range result.Tracks.Items { - if result.Tracks.Items[i].ISRC == isrc { - return &result.Tracks.Items[i], nil + for i := range candidates { + if candidates[i].ISRC == isrc { + return &candidates[i], nil } } - if len(result.Tracks.Items) == 0 { + if len(candidates) == 0 { return nil, fmt.Errorf("no tracks found for ISRC: %s", isrc) } @@ -518,39 +588,17 @@ 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) - - req, err := http.NewRequest("GET", searchURL, nil) + candidates, err := q.searchQobuzTracksWithFallback(isrc, 50) if err != nil { return nil, err } - resp, err := DoRequestWithUserAgent(q.client, req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return nil, fmt.Errorf("search failed: HTTP %d", resp.StatusCode) - } - - var result struct { - Tracks struct { - Items []QobuzTrack `json:"items"` - } `json:"tracks"` - } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, err - } - - GoLog("[Qobuz] ISRC search returned %d results\n", len(result.Tracks.Items)) + GoLog("[Qobuz] ISRC search returned %d results\n", len(candidates)) var isrcMatches []*QobuzTrack - for i := range result.Tracks.Items { - if result.Tracks.Items[i].ISRC == isrc { - isrcMatches = append(isrcMatches, &result.Tracks.Items[i]) + for i := range candidates { + if candidates[i].ISRC == isrc { + isrcMatches = append(isrcMatches, &candidates[i]) } } @@ -585,7 +633,7 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur return isrcMatches[0], nil } - if len(result.Tracks.Items) == 0 { + if len(candidates) == 0 { return nil, fmt.Errorf("no tracks found for ISRC: %s", isrc) } @@ -601,8 +649,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 != "" { @@ -644,6 +690,7 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam var allTracks []QobuzTrack searchedQueries := make(map[string]bool) + seenTrackIDs := make(map[int64]struct{}) for _, query := range queries { cleanQuery := strings.TrimSpace(query) @@ -654,38 +701,26 @@ 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) - - req, err := http.NewRequest("GET", searchURL, nil) - if err != nil { - continue - } - - resp, err := DoRequestWithUserAgent(q.client, req) + result, err := q.searchQobuzTracksWithFallback(cleanQuery, 50) if err != nil { GoLog("[Qobuz] Search error for '%s': %v\n", cleanQuery, err) continue } - if resp.StatusCode != 200 { - resp.Body.Close() - continue - } - - var result struct { - Tracks struct { - Items []QobuzTrack `json:"items"` - } `json:"tracks"` - } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - resp.Body.Close() - continue - } - resp.Body.Close() - - if len(result.Tracks.Items) > 0 { - GoLog("[Qobuz] Found %d results for '%s'\n", len(result.Tracks.Items), cleanQuery) - allTracks = append(allTracks, result.Tracks.Items...) + if len(result) > 0 { + GoLog("[Qobuz] Found %d results for '%s'\n", len(result), cleanQuery) + for i := range result { + trackID := result[i].ID + if trackID <= 0 { + allTracks = append(allTracks, result[i]) + continue + } + if _, ok := seenTrackIDs[trackID]; ok { + continue + } + seenTrackIDs[trackID] = struct{}{} + allTracks = append(allTracks, result[i]) + } } } @@ -756,11 +791,136 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam return nil, fmt.Errorf("no matching track found for: %s - %s", artistName, trackName) } +func (q *QobuzDownloader) searchQobuzTracksViaAPI(query string, limit int) ([]QobuzTrack, error) { + searchURL := fmt.Sprintf("%s%s&limit=%d&app_id=%s", qobuzTrackSearchBaseURL, url.QueryEscape(query), limit, q.appID) + req, err := http.NewRequest("GET", searchURL, nil) + if err != nil { + return nil, err + } + + resp, err := DoRequestWithUserAgent(q.client, req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("search failed: HTTP %d", resp.StatusCode) + } + + var result struct { + Tracks struct { + Items []QobuzTrack `json:"items"` + } `json:"tracks"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + return result.Tracks.Items, nil +} + +func extractQobuzTrackIDsFromStoreSearchHTML(body []byte) []int64 { + matches := qobuzStoreTrackIDRegex.FindAllSubmatch(body, -1) + if len(matches) == 0 { + return nil + } + + trackIDs := make([]int64, 0, len(matches)) + seen := make(map[int64]struct{}, len(matches)) + for _, match := range matches { + if len(match) < 2 { + continue + } + id, err := strconv.ParseInt(string(match[1]), 10, 64) + if err != nil || id <= 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + trackIDs = append(trackIDs, id) + } + return trackIDs +} + +func (q *QobuzDownloader) searchQobuzTracksViaStore(query string, limit int) ([]QobuzTrack, error) { + searchURL := qobuzStoreSearchBaseURL + url.PathEscape(query) + req, err := http.NewRequest("GET", searchURL, nil) + if err != nil { + return nil, err + } + + resp, err := DoRequestWithUserAgent(q.client, req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("store search failed: HTTP %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + trackIDs := extractQobuzTrackIDsFromStoreSearchHTML(body) + if len(trackIDs) == 0 { + return nil, fmt.Errorf("store search did not contain track IDs") + } + + if limit > 0 && len(trackIDs) > limit { + trackIDs = trackIDs[:limit] + } + + tracks := make([]QobuzTrack, 0, len(trackIDs)) + for _, id := range trackIDs { + track, trackErr := q.GetTrackByID(id) + if trackErr != nil || track == nil { + continue + } + tracks = append(tracks, *track) + } + + if len(tracks) == 0 { + return nil, fmt.Errorf("store fallback returned IDs but no track metadata could be loaded") + } + return tracks, nil +} + +func (q *QobuzDownloader) searchQobuzTracksWithFallback(query string, limit int) ([]QobuzTrack, error) { + apiTracks, apiErr := q.searchQobuzTracksViaAPI(query, limit) + if apiErr == nil { + if len(apiTracks) > 0 { + return apiTracks, nil + } + GoLog("[Qobuz] API search returned 0 results for '%s', trying store fallback\n", query) + } else { + GoLog("[Qobuz] API search failed for '%s': %v. Trying store fallback.\n", query, apiErr) + } + + storeTracks, storeErr := q.searchQobuzTracksViaStore(query, limit) + if storeErr == nil && len(storeTracks) > 0 { + GoLog("[Qobuz] Store fallback returned %d candidate tracks for '%s'\n", len(storeTracks), query) + return storeTracks, nil + } + + if apiErr != nil && storeErr != nil { + return nil, fmt.Errorf("api search failed (%v); store fallback failed (%v)", apiErr, storeErr) + } + if storeErr != nil { + return nil, storeErr + } + return nil, fmt.Errorf("no tracks found for query: %s", query) +} + type qobuzAPIResult struct { - apiURL string - downloadURL string - err error - duration time.Duration + provider qobuzAPIProvider + info qobuzDownloadInfo + err error + duration time.Duration } // Qobuz API timeout configuration @@ -779,54 +939,73 @@ 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, "") +func fetchQobuzURLWithRetry(provider qobuzAPIProvider, trackID int64, quality string, timeout time.Duration) (qobuzDownloadInfo, error) { + return fetchQobuzURLSingleAttempt(provider, trackID, quality, timeout, "") } // fetchQobuzURLSingleAttempt fetches download URL with retry logic for a single API+country combination -func fetchQobuzURLSingleAttempt(api string, trackID int64, quality string, timeout time.Duration, country string) (string, error) { +func fetchQobuzURLSingleAttempt(provider qobuzAPIProvider, trackID int64, quality string, timeout time.Duration, country string) (qobuzDownloadInfo, error) { var lastErr error retryDelay := qobuzRetryDelay + var payloadBytes []byte + if provider.Kind == qobuzAPIKindMusicDL { + requestQuality := mapQobuzQualityCodeToAPI(quality) + payload := map[string]any{ + "quality": requestQuality, + "upload_to_r2": false, + "url": fmt.Sprintf("%s%d", qobuzTrackPlayBaseURL, trackID), + } + var err error + payloadBytes, err = json.Marshal(payload) + if err != nil { + return qobuzDownloadInfo{}, fmt.Errorf("failed to encode qobuz request: %w", err) + } + } for attempt := 0; attempt <= qobuzMaxRetries; attempt++ { if attempt > 0 { - GoLog("[Qobuz] Retry %d/%d for %s after %v\n", attempt, qobuzMaxRetries, api, retryDelay) + GoLog("[Qobuz] Retry %d/%d for %s after %v\n", attempt, qobuzMaxRetries, provider.Name, retryDelay) time.Sleep(retryDelay) retryDelay *= 2 // Exponential backoff } client := NewHTTPClientWithTimeout(timeout) - reqURL := fmt.Sprintf("%s%d&quality=%s", api, trackID, quality) + reqURL := provider.URL if country != "" { - reqURL += "&country=" + country + reqURL += "?country=" + url.QueryEscape(country) } - req, err := http.NewRequest("GET", reqURL, nil) + var ( + req *http.Request + err error + ) + if provider.Kind == qobuzAPIKindStandard { + separator := "&" + if !strings.Contains(reqURL, "?") { + separator = "?" + } + reqURL = fmt.Sprintf( + "%s%d%squality=%s", + reqURL, + trackID, + separator, + url.QueryEscape(normalizeQobuzQualityCode(quality)), + ) + req, err = http.NewRequest("GET", reqURL, nil) + } else { + req, err = http.NewRequest("POST", reqURL, bytes.NewReader(payloadBytes)) + } if err != nil { lastErr = err continue } + if provider.Kind == qobuzAPIKindMusicDL { + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Debug-Key", getQobuzDebugKey()) + } - resp, err := client.Do(req) + resp, err := DoRequestWithUserAgent(client, req) if err != nil { lastErr = err // Check for retryable errors (timeout, connection reset) @@ -859,7 +1038,7 @@ func fetchQobuzURLSingleAttempt(api string, trackID int64, quality string, timeo if resp.StatusCode != 200 { io.Copy(io.Discard, resp.Body) resp.Body.Close() - return "", fmt.Errorf("HTTP %d", resp.StatusCode) + return qobuzDownloadInfo{}, fmt.Errorf("HTTP %d", resp.StatusCode) } body, err := io.ReadAll(resp.Body) @@ -870,108 +1049,115 @@ func fetchQobuzURLSingleAttempt(api string, trackID int64, quality string, timeo } if len(body) > 0 && body[0] == '<' { - return "", fmt.Errorf("received HTML instead of JSON") + return qobuzDownloadInfo{}, fmt.Errorf("received HTML instead of JSON") } - urlVal, parseErr := extractQobuzDownloadURLFromBody(body) + info, parseErr := extractQobuzDownloadInfoFromBody(body) if parseErr == nil { - return urlVal, nil + return info, nil } lastErr = parseErr continue } if lastErr != nil { - return "", lastErr + return qobuzDownloadInfo{}, lastErr } - return "", fmt.Errorf("all retries failed") + return qobuzDownloadInfo{}, fmt.Errorf("all retries failed") } -func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (string, string, error) { - if len(apis) == 0 { - return "", "", fmt.Errorf("no APIs available") +func getQobuzDownloadURLParallel(providers []qobuzAPIProvider, trackID int64, quality string) (qobuzAPIProvider, qobuzDownloadInfo, error) { + if len(providers) == 0 { + return qobuzAPIProvider{}, qobuzDownloadInfo{}, fmt.Errorf("no APIs available") } - GoLog("[Qobuz] Requesting download URL from %d APIs in parallel (with retry)...\n", len(apis)) + GoLog("[Qobuz] Requesting download URL from %d APIs in parallel (with retry)...\n", len(providers)) - resultChan := make(chan qobuzAPIResult, len(apis)) + resultChan := make(chan qobuzAPIResult, len(providers)) startTime := time.Now() timeout := getQobuzAPITimeout() - for _, apiURL := range apis { - go func(api string) { + for _, provider := range providers { + go func(provider qobuzAPIProvider) { reqStart := time.Now() - downloadURL, err := fetchQobuzURLWithRetry(api, trackID, quality, timeout) + info, err := fetchQobuzURLWithRetry(provider, trackID, quality, timeout) resultChan <- qobuzAPIResult{ - apiURL: api, - downloadURL: downloadURL, - err: err, - duration: time.Since(reqStart), + provider: provider, + info: info, + err: err, + duration: time.Since(reqStart), } - }(apiURL) + }(provider) } var errors []string - for i := 0; i < len(apis); i++ { + for i := 0; i < len(providers); i++ { result := <-resultChan if result.err == nil { - GoLog("[Qobuz] [Parallel] Got response from %s in %v\n", result.apiURL, result.duration) + GoLog("[Qobuz] [Parallel] Got response from %s in %v\n", result.provider.Name, result.duration) go func(remaining int) { for j := 0; j < remaining; j++ { <-resultChan } - }(len(apis) - i - 1) + }(len(providers) - i - 1) GoLog("[Qobuz] [Parallel] Total time: %v (first success)\n", time.Since(startTime)) - return result.apiURL, result.downloadURL, nil + return result.provider, result.info, nil } errMsg := result.err.Error() if len(errMsg) > 50 { errMsg = errMsg[:50] + "..." } - errors = append(errors, fmt.Sprintf("%s: %s", result.apiURL, errMsg)) + errors = append(errors, fmt.Sprintf("%s: %s", result.provider.Name, errMsg)) } - GoLog("[Qobuz] [Parallel] All %d APIs failed in %v\n", len(apis), time.Since(startTime)) - return "", "", fmt.Errorf("all %d Qobuz APIs failed. Errors: %v", len(apis), errors) + GoLog("[Qobuz] [Parallel] All %d APIs failed in %v\n", len(providers), time.Since(startTime)) + return qobuzAPIProvider{}, qobuzDownloadInfo{}, fmt.Errorf("all %d Qobuz APIs failed. Errors: %v", len(providers), errors) } -func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string, error) { - apis := q.GetAvailableAPIs() - if len(apis) == 0 { - return "", fmt.Errorf("no Qobuz API available") +func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (qobuzDownloadInfo, error) { + providers := q.GetAvailableProviders() + if len(providers) == 0 { + return qobuzDownloadInfo{}, fmt.Errorf("no Qobuz API available") } - _, downloadURL, err := getQobuzDownloadURLParallel(apis, trackID, quality) + qualityCode := normalizeQobuzQualityCode(quality) + + downloadFunc := func(qual string) (qobuzDownloadInfo, error) { + provider, info, err := getQobuzDownloadURLParallel(providers, trackID, qual) + if err != nil { + return qobuzDownloadInfo{}, err + } + GoLog("[Qobuz] Download URL resolved via %s\n", provider.Name) + return info, nil + } + + downloadInfo, err := downloadFunc(qualityCode) if err == nil { - return downloadURL, nil + return downloadInfo, 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 + downloadInfo, err = downloadFunc("7") + if err == nil { + return downloadInfo, 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 + downloadInfo, err = downloadFunc("6") + if err == nil { + return downloadInfo, nil } } - return "", fmt.Errorf("all Qobuz APIs and Jumo fallback failed: %w", err) + return qobuzDownloadInfo{}, fmt.Errorf("all Qobuz APIs failed: %w", err) } func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error { @@ -1067,14 +1253,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 @@ -1084,15 +1268,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) } } } @@ -1100,10 +1284,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 } } @@ -1111,19 +1295,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) @@ -1135,16 +1319,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 } } @@ -1152,11 +1336,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 } } @@ -1166,14 +1350,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, @@ -1212,27 +1414,42 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { actualSampleRate := int(track.MaximumSamplingRate * 1000) GoLog("[Qobuz] Actual quality: %d-bit/%.1fkHz\n", actualBitDepth, track.MaximumSamplingRate) - downloadURL, err := downloader.GetDownloadURL(track.ID, qobuzQuality) + downloadInfo, err := downloader.GetDownloadURL(track.ID, qobuzQuality) if err != nil { return QobuzDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err) } + if downloadInfo.BitDepth > 0 { + actualBitDepth = downloadInfo.BitDepth + } + if downloadInfo.SampleRate > 0 { + actualSampleRate = downloadInfo.SampleRate + } + if actualBitDepth > 0 || actualSampleRate > 0 { + GoLog("[Qobuz] API returned quality: %d-bit/%dHz\n", actualBitDepth, actualSampleRate) + } var parallelResult *ParallelDownloadResult parallelDone := make(chan struct{}) 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), ) }() - if err := downloader.DownloadFile(downloadURL, outputPath, req.OutputFD, req.ItemID); err != nil { + if err := downloader.DownloadFile(downloadInfo.DownloadURL, outputPath, req.OutputFD, req.ItemID); err != nil { if errors.Is(err, ErrDownloadCancelled) { return QobuzDownloadResult{}, ErrDownloadCancelled } @@ -1277,8 +1494,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) @@ -1317,7 +1538,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/qobuz_test.go b/go_backend/qobuz_test.go index 124cd61e..0382b62f 100644 --- a/go_backend/qobuz_test.go +++ b/go_backend/qobuz_test.go @@ -3,6 +3,24 @@ package gobackend import "testing" func TestExtractQobuzDownloadURLFromBody(t *testing.T) { + t.Run("reads top-level download_url and quality metadata", func(t *testing.T) { + body := []byte(`{"success":true,"download_url":"https://example.test/new.flac","bit_depth":24,"sampling_rate":96}`) + + info, err := extractQobuzDownloadInfoFromBody(body) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if info.DownloadURL != "https://example.test/new.flac" { + t.Fatalf("unexpected URL: %q", info.DownloadURL) + } + if info.BitDepth != 24 { + t.Fatalf("unexpected bit depth: %d", info.BitDepth) + } + if info.SampleRate != 96000 { + t.Fatalf("unexpected sample rate: %d", info.SampleRate) + } + }) + t.Run("reads nested data.url", func(t *testing.T) { body := []byte(`{"success":true,"data":{"url":"https://example.test/audio.flac"}}`) @@ -44,4 +62,74 @@ func TestExtractQobuzDownloadURLFromBody(t *testing.T) { t.Fatalf("expected blocked error, got %v", err) } }) + + t.Run("returns detail error", func(t *testing.T) { + body := []byte(`{"detail":"Invalid quality 'lossless'. Choose from: ['mp3', 'cd', 'hi-res', 'hi-res-max']"}`) + + _, err := extractQobuzDownloadURLFromBody(body) + if err == nil || err.Error() != "Invalid quality 'lossless'. Choose from: ['mp3', 'cd', 'hi-res', 'hi-res-max']" { + t.Fatalf("expected detail error, got %v", err) + } + }) +} + +func TestNormalizeQobuzQualityCode(t *testing.T) { + tests := map[string]string{ + "": "6", + "5": "6", + "6": "6", + "cd": "6", + "lossless": "6", + "7": "7", + "hi-res": "7", + "27": "27", + "hi-res-max": "27", + "unexpected": "6", + } + + for input, want := range tests { + if got := normalizeQobuzQualityCode(input); got != want { + t.Fatalf("normalizeQobuzQualityCode(%q) = %q, want %q", input, got, want) + } + } +} + +func TestGetQobuzDebugKey(t *testing.T) { + got := getQobuzDebugKey() + if len(got) != len(qobuzDebugKeyObfuscated) { + t.Fatalf("unexpected debug key length: %d", len(got)) + } + for i := range got { + if got[i]^qobuzDebugKeyXORMask != qobuzDebugKeyObfuscated[i] { + t.Fatalf("unexpected debug key reconstruction at index %d", i) + } + } +} + +func TestQobuzAvailableProviders(t *testing.T) { + providers := NewQobuzDownloader().GetAvailableProviders() + if len(providers) != 3 { + t.Fatalf("expected 3 Qobuz providers, got %d", len(providers)) + } + + want := map[string]string{ + "musicdl": qobuzAPIKindMusicDL, + "dabmusic": qobuzAPIKindStandard, + "deeb": qobuzAPIKindStandard, + } + + for _, provider := range providers { + wantKind, ok := want[provider.Name] + if !ok { + t.Fatalf("unexpected provider %q", provider.Name) + } + if provider.Kind != wantKind { + t.Fatalf("provider %q has kind %q, want %q", provider.Name, provider.Kind, wantKind) + } + delete(want, provider.Name) + } + + if len(want) != 0 { + t.Fatalf("missing providers: %v", want) + } } diff --git a/go_backend/songlink.go b/go_backend/songlink.go index ed9346bd..b5dfd58d 100644 --- a/go_backend/songlink.go +++ b/go_backend/songlink.go @@ -1,7 +1,7 @@ package gobackend import ( - "encoding/base64" + "context" "encoding/json" "fmt" "net/http" @@ -35,6 +35,14 @@ type TrackAvailability struct { var ( globalSongLinkClient *SongLinkClient songLinkClientOnce sync.Once + songLinkRegion = "US" + songLinkRegionMu sync.RWMutex + songLinkSearchByISRC = func(ctx context.Context, isrc string) (*TrackMetadata, error) { + return GetDeezerClient().SearchByISRC(ctx, isrc) + } + songLinkCheckAvailabilityFromDeezer = func(s *SongLinkClient, deezerTrackID string) (*TrackAvailability, error) { + return s.CheckAvailabilityFromDeezer(deezerTrackID) + } ) func NewSongLinkClient() *SongLinkClient { @@ -46,14 +54,86 @@ func NewSongLinkClient() *SongLinkClient { return globalSongLinkClient } +func normalizeSongLinkRegion(region string) string { + normalized := strings.ToUpper(strings.TrimSpace(region)) + if len(normalized) != 2 { + return "US" + } + for _, ch := range normalized { + if ch < 'A' || ch > 'Z' { + return "US" + } + } + return normalized +} + +func SetSongLinkRegion(region string) { + normalized := normalizeSongLinkRegion(region) + songLinkRegionMu.Lock() + songLinkRegion = normalized + songLinkRegionMu.Unlock() +} + +func GetSongLinkRegion() string { + songLinkRegionMu.RLock() + region := songLinkRegion + songLinkRegionMu.RUnlock() + return region +} + +func songLinkBaseURL() string { + opts := GetNetworkCompatibilityOptions() + if opts.AllowHTTP { + return "http://api.song.link/v1-alpha.1/links" + } + return "https://api.song.link/v1-alpha.1/links" +} + +func buildSongLinkURLFromTarget(targetURL string, userCountry string) string { + if userCountry == "" { + userCountry = GetSongLinkRegion() + } + apiURL := fmt.Sprintf("%s?url=%s", songLinkBaseURL(), url.QueryEscape(targetURL)) + if userCountry != "" { + apiURL = fmt.Sprintf("%s&userCountry=%s", apiURL, url.QueryEscape(userCountry)) + } + return apiURL +} + +func buildSongLinkURLByPlatform(platform, entityType, entityID, userCountry string) string { + if userCountry == "" { + userCountry = GetSongLinkRegion() + } + apiURL := fmt.Sprintf("%s?platform=%s&type=%s&id=%s", + songLinkBaseURL(), + url.QueryEscape(platform), + url.QueryEscape(entityType), + url.QueryEscape(entityID)) + if userCountry != "" { + apiURL = fmt.Sprintf("%s&userCountry=%s", apiURL, url.QueryEscape(userCountry)) + } + return apiURL +} + func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) { + spotifyTrackID = strings.TrimSpace(spotifyTrackID) + isrc = strings.ToUpper(strings.TrimSpace(isrc)) + + switch { + case spotifyTrackID != "": + return s.checkTrackAvailabilityFromSpotify(spotifyTrackID) + case isrc != "": + return s.checkTrackAvailabilityFromISRC(isrc) + default: + return nil, fmt.Errorf("spotify track ID and ISRC are empty") + } +} + +func (s *SongLinkClient) checkTrackAvailabilityFromSpotify(spotifyTrackID string) (*TrackAvailability, error) { songLinkRateLimiter.WaitForSlot() - spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==") - spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID) - - apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==") - apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL)) + spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID) + apiURL := buildSongLinkURLFromTarget(spotifyURL, "") req, err := http.NewRequest("GET", apiURL, nil) if err != nil { @@ -141,6 +221,47 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri return availability, nil } +func (s *SongLinkClient) checkTrackAvailabilityFromISRC(isrc string) (*TrackAvailability, error) { + ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout) + defer cancel() + + track, err := songLinkSearchByISRC(ctx, isrc) + if err != nil { + return nil, fmt.Errorf("failed to resolve Deezer track from ISRC %s: %w", isrc, err) + } + + deezerTrackID := songLinkExtractDeezerTrackID(track) + if deezerTrackID == "" { + return nil, fmt.Errorf("failed to resolve Deezer track ID from ISRC %s", isrc) + } + + availability, err := songLinkCheckAvailabilityFromDeezer(s, deezerTrackID) + if err != nil { + return nil, fmt.Errorf("failed to resolve SongLink availability from ISRC %s via Deezer %s: %w", isrc, deezerTrackID, err) + } + + return availability, nil +} + +func songLinkExtractDeezerTrackID(track *TrackMetadata) string { + if track == nil { + return "" + } + + if deezerID, ok := strings.CutPrefix(strings.TrimSpace(track.SpotifyID), "deezer:"); ok { + deezerID = strings.TrimSpace(deezerID) + if deezerID != "" { + return deezerID + } + } + + if deezerID := extractDeezerIDFromURL(strings.TrimSpace(track.ExternalURL)); deezerID != "" { + return deezerID + } + + return "" +} + func (s *SongLinkClient) GetStreamingURLs(spotifyTrackID string) (map[string]string, error) { availability, err := s.CheckTrackAvailability(spotifyTrackID, "") if err != nil { @@ -158,7 +279,6 @@ func (s *SongLinkClient) GetStreamingURLs(spotifyTrackID string) (map[string]str return urls, nil } -// extractDeezerIDFromURL extracts Deezer track/album/artist ID from URL func extractDeezerIDFromURL(deezerURL string) string { parts := strings.Split(deezerURL, "/") if len(parts) > 0 { @@ -236,10 +356,6 @@ func extractQobuzIDFromURL(qobuzURL string) string { return "" } -// extractTidalIDFromURL extracts Tidal track ID from URL -// URL formats: -// - https://tidal.com/browse/track/12345678 -// - https://listen.tidal.com/track/12345678 func extractTidalIDFromURL(tidalURL string) string { if tidalURL == "" { return "" @@ -265,11 +381,6 @@ func extractTidalIDFromURL(tidalURL string) string { return "" } -// extractYouTubeIDFromURL extracts YouTube video ID from URL -// URL formats: -// - https://www.youtube.com/watch?v=VIDEO_ID -// - https://youtu.be/VIDEO_ID -// - https://music.youtube.com/watch?v=VIDEO_ID func extractYouTubeIDFromURL(youtubeURL string) string { if youtubeURL == "" { return "" @@ -326,7 +437,6 @@ func (s *SongLinkClient) GetDeezerIDFromSpotify(spotifyTrackID string) (string, return availability.DeezerID, nil } -// GetYouTubeURLFromSpotify converts a Spotify track ID to YouTube URL using SongLink func (s *SongLinkClient) GetYouTubeURLFromSpotify(spotifyTrackID string) (string, error) { availability, err := s.CheckTrackAvailability(spotifyTrackID, "") if err != nil { @@ -340,7 +450,6 @@ func (s *SongLinkClient) GetYouTubeURLFromSpotify(spotifyTrackID string) (string return availability.YouTubeURL, nil } -// AlbumAvailability represents album availability on different platforms type AlbumAvailability struct { SpotifyID string `json:"spotify_id"` Deezer bool `json:"deezer"` @@ -351,11 +460,8 @@ type AlbumAvailability struct { func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAvailability, error) { songLinkRateLimiter.WaitForSlot() - spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL2FsYnVtLw==") - spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyAlbumID) - - apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==") - apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL)) + spotifyURL := fmt.Sprintf("https://open.spotify.com/album/%s", spotifyAlbumID) + apiURL := buildSongLinkURLFromTarget(spotifyURL, "") req, err := http.NewRequest("GET", apiURL, nil) if err != nil { @@ -401,7 +507,6 @@ func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAv return availability, nil } -// GetDeezerAlbumIDFromSpotify converts a Spotify album ID to Deezer album ID using SongLink func (s *SongLinkClient) GetDeezerAlbumIDFromSpotify(spotifyAlbumID string) (string, error) { availability, err := s.CheckAlbumAvailability(spotifyAlbumID) if err != nil { @@ -440,9 +545,7 @@ func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID strin songLinkRateLimiter.WaitForSlot() deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID) - - apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==") - apiURL := fmt.Sprintf("%s%s&userCountry=US", string(apiBase), url.QueryEscape(deezerURL)) + apiURL := buildSongLinkURLFromTarget(deezerURL, "") req, err := http.NewRequest("GET", apiURL, nil) if err != nil { @@ -520,16 +623,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) } } @@ -546,10 +650,7 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit songLinkRateLimiter.WaitForSlot() - apiURL := fmt.Sprintf("https://api.song.link/v1-alpha.1/links?platform=%s&type=%s&id=%s&userCountry=US", - url.QueryEscape(platform), - url.QueryEscape(entityType), - url.QueryEscape(entityID)) + apiURL := buildSongLinkURLByPlatform(platform, entityType, entityID, "") req, err := http.NewRequest("GET", apiURL, nil) if err != nil { @@ -620,23 +721,23 @@ 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) } } return availability, nil } -// extractSpotifyIDFromURL extracts Spotify track ID from URL func extractSpotifyIDFromURL(spotifyURL string) string { parts := strings.Split(spotifyURL, "/track/") if len(parts) > 1 { @@ -662,7 +763,6 @@ func (s *SongLinkClient) GetSpotifyIDFromDeezer(deezerTrackID string) (string, e return availability.SpotifyID, nil } -// GetTidalURLFromDeezer converts a Deezer track ID to Tidal URL using SongLink func (s *SongLinkClient) GetTidalURLFromDeezer(deezerTrackID string) (string, error) { availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID) if err != nil { @@ -689,7 +789,6 @@ func (s *SongLinkClient) GetAmazonURLFromDeezer(deezerTrackID string) (string, e return availability.AmazonURL, nil } -// GetYouTubeURLFromDeezer converts a Deezer track ID to YouTube URL using SongLink func (s *SongLinkClient) GetYouTubeURLFromDeezer(deezerTrackID string) (string, error) { availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID) if err != nil { @@ -706,8 +805,7 @@ func (s *SongLinkClient) GetYouTubeURLFromDeezer(deezerTrackID string) (string, func (s *SongLinkClient) CheckAvailabilityFromURL(inputURL string) (*TrackAvailability, error) { songLinkRateLimiter.WaitForSlot() - apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==") - apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(inputURL)) + apiURL := buildSongLinkURLFromTarget(inputURL, "") req, err := http.NewRequest("GET", apiURL, nil) if err != nil { @@ -771,16 +869,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 8e52aa8b..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 @@ -1289,6 +925,32 @@ func titlesMatch(expectedTitle, foundTitle string) bool { return true } + looseExpected := normalizeLooseTitle(normExpected) + looseFound := normalizeLooseTitle(normFound) + if looseExpected != "" && looseFound != "" { + if looseExpected == looseFound { + return true + } + if strings.Contains(looseExpected, looseFound) || strings.Contains(looseFound, looseExpected) { + return true + } + } + + // 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) != "" { + 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) foundLatin := isLatinScript(foundTitle) if expectedLatin != foundLatin { @@ -1406,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 @@ -1592,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 @@ -1674,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), ) }() @@ -1764,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" @@ -1791,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" @@ -1829,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 new file mode 100644 index 00000000..039ff434 --- /dev/null +++ b/go_backend/title_match_utils.go @@ -0,0 +1,70 @@ +package gobackend + +import ( + "strings" + "unicode" +) + +// normalizeLooseTitle collapses separators/punctuation so titles like +// "Doctor / Cops" and "Doctor _ Cops" can still match. +func normalizeLooseTitle(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): + b.WriteRune(r) + case unicode.IsSpace(r): + b.WriteByte(' ') + // Treat common separators as spaces. + case r == '/', r == '\\', r == '_', r == '-', r == '|', r == '.', r == '&', r == '+': + b.WriteByte(' ') + default: + // Drop other punctuation/symbols (including emoji) for loose matching. + } + } + + return strings.Join(strings.Fields(b.String()), " ") +} + +func hasAlphaNumericRunes(value string) bool { + for _, r := range value { + if unicode.IsLetter(r) || unicode.IsNumber(r) { + return true + } + } + 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 new file mode 100644 index 00000000..edc63058 --- /dev/null +++ b/go_backend/title_match_utils_test.go @@ -0,0 +1,52 @@ +package gobackend + +import "testing" + +func TestNormalizeLooseTitle_Separators(t *testing.T) { + got := normalizeLooseTitle("Doctor / Cops") + if got != "doctor cops" { + t.Fatalf("expected doctor cops, got %q", got) + } + + got = normalizeLooseTitle("Doctor _ Cops") + if got != "doctor cops" { + t.Fatalf("expected doctor cops, got %q", got) + } +} + +func TestNormalizeLooseTitle_EmojiAndSymbols(t *testing.T) { + got := normalizeLooseTitle("Music Of The Spheres 🌎✨") + if got != "music of the spheres" { + t.Fatalf("expected music of the spheres, got %q", got) + } +} + +func TestTitlesMatch_SeparatorVariants(t *testing.T) { + if !titlesMatch("Doctor / Cops", "Doctor _ Cops") { + t.Fatal("expected tidal titlesMatch to accept / vs _ variant") + } +} + +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 0309321b..bfbedcbb 100644 --- a/go_backend/youtube.go +++ b/go_backend/youtube.go @@ -9,6 +9,7 @@ import ( "io" "net/http" "net/url" + "strconv" "strings" "sync" "time" @@ -20,6 +21,8 @@ type YouTubeDownloader struct { mu sync.Mutex } +const spotubeBaseURL = "https://spotubedl.com" + var ( globalYouTubeDownloader *YouTubeDownloader youtubeDownloaderOnce sync.Once @@ -29,9 +32,17 @@ type YouTubeQuality string const ( YouTubeQualityOpus256 YouTubeQuality = "opus_256" + YouTubeQualityOpus128 YouTubeQuality = "opus_128" + YouTubeQualityMP3128 YouTubeQuality = "mp3_128" + YouTubeQualityMP3256 YouTubeQuality = "mp3_256" YouTubeQualityMP3320 YouTubeQuality = "mp3_320" ) +var ( + youtubeOpusSupportedBitrates = []int{128, 256} + youtubeMp3SupportedBitrates = []int{128, 256, 320} +) + type CobaltRequest struct { URL string `json:"url"` AudioBitrate string `json:"audioBitrate,omitempty"` @@ -79,6 +90,77 @@ func NewYouTubeDownloader() *YouTubeDownloader { return globalYouTubeDownloader } +func extractBitrateFromQuality(raw string, defaultBitrate int) int { + parts := strings.FieldsFunc(raw, func(r rune) bool { + return (r < '0' || r > '9') + }) + for i := len(parts) - 1; i >= 0; i-- { + part := parts[i] + if part == "" { + continue + } + if parsed, err := strconv.Atoi(part); err == nil { + return parsed + } + } + return defaultBitrate +} + +func nearestSupportedBitrate(value int, supported []int) int { + nearest := supported[0] + nearestDistance := absInt(value - nearest) + + for _, option := range supported[1:] { + distance := absInt(value - option) + // On tie prefer higher quality. + if distance < nearestDistance || (distance == nearestDistance && option > nearest) { + nearest = option + nearestDistance = distance + } + } + + return nearest +} + +func absInt(value int) int { + if value < 0 { + return -value + } + return value +} + +func parseYouTubeQualityInput(raw string) (format string, bitrate int, normalized YouTubeQuality) { + normalizedRaw := strings.ToLower(strings.TrimSpace(raw)) + + if strings.HasPrefix(normalizedRaw, "opus") { + parsed := extractBitrateFromQuality(normalizedRaw, 256) + finalBitrate := nearestSupportedBitrate(parsed, youtubeOpusSupportedBitrates) + return "opus", finalBitrate, YouTubeQuality(fmt.Sprintf("opus_%d", finalBitrate)) + } + + if strings.HasPrefix(normalizedRaw, "mp3") { + parsed := extractBitrateFromQuality(normalizedRaw, 320) + finalBitrate := nearestSupportedBitrate(parsed, youtubeMp3SupportedBitrates) + return "mp3", finalBitrate, YouTubeQuality(fmt.Sprintf("mp3_%d", finalBitrate)) + } + + // Backward compatibility for legacy symbolic values. + switch normalizedRaw { + case "opus_256", "opus256", "opus": + return "opus", 256, YouTubeQualityOpus256 + case "opus_128", "opus128": + return "opus", 128, YouTubeQualityOpus128 + case "mp3_320", "mp3320", "mp3", "": + return "mp3", 320, YouTubeQualityMP3320 + case "mp3_256", "mp3256": + return "mp3", 256, YouTubeQualityMP3256 + case "mp3_128", "mp3128": + return "mp3", 128, YouTubeQualityMP3128 + default: + return "mp3", 320, YouTubeQualityMP3320 + } +} + // SearchYouTube returns a YouTube Music search URL for the given track func (y *YouTubeDownloader) SearchYouTube(trackName, artistName string) (string, error) { query := fmt.Sprintf("%s %s", artistName, trackName) @@ -95,22 +177,11 @@ func (y *YouTubeDownloader) GetDownloadURL(youtubeURL string, quality YouTubeQua y.mu.Lock() defer y.mu.Unlock() - var audioFormat string - var audioBitrate string - - switch quality { - case YouTubeQualityOpus256: - audioFormat = "opus" - audioBitrate = "256" - case YouTubeQualityMP3320: - audioFormat = "mp3" - audioBitrate = "320" - default: - audioFormat = "mp3" - audioBitrate = "320" - } + audioFormat, bitrate, _ := parseYouTubeQualityInput(string(quality)) + audioBitrate := strconv.Itoa(bitrate) // Try SpotubeDL first (primary) + var spotubeErr error videoID, extractErr := ExtractYouTubeVideoID(youtubeURL) if extractErr == nil { GoLog("[YouTube] Requesting from SpotubeDL: videoID=%s (format: %s, bitrate: %s)\n", @@ -120,6 +191,7 @@ func (y *YouTubeDownloader) GetDownloadURL(youtubeURL string, quality YouTubeQua if err == nil { return resp, nil } + spotubeErr = err GoLog("[YouTube] SpotubeDL failed: %v, trying Cobalt fallback...\n", err) } else { GoLog("[YouTube] Could not extract video ID: %v, skipping SpotubeDL\n", extractErr) @@ -132,6 +204,9 @@ func (y *YouTubeDownloader) GetDownloadURL(youtubeURL string, quality YouTubeQua resp, err := y.requestCobaltDirect(cobaltURL, audioFormat, audioBitrate) if err != nil { + if spotubeErr != nil { + return nil, fmt.Errorf("all download methods failed: spotubedl: %v, cobalt: %v", spotubeErr, err) + } return nil, fmt.Errorf("all download methods failed: spotubedl: extractErr=%v, cobalt: %v", extractErr, err) } @@ -201,11 +276,34 @@ func (y *YouTubeDownloader) requestCobaltDirect(videoURL, audioFormat, audioBitr } // requestSpotubeDL uses SpotubeDL as a Cobalt proxy (they handle auth to yt-dl.click instances). +// 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) { - apiURL := fmt.Sprintf("https://spotubedl.com/api/download/%s?engine=v1&format=%s&quality=%s", - videoID, audioFormat, audioBitrate) + engines := []string{"v1"} + if strings.EqualFold(audioFormat, "mp3") { + engines = append(engines, "v3", "v2") + } + var lastErr error - GoLog("[YouTube] Requesting from SpotubeDL: %s\n", apiURL) + for _, engine := range engines { + resp, err := y.requestSpotubeDLEngine(videoID, audioFormat, audioBitrate, engine) + if err == nil { + return resp, nil + } + lastErr = err + GoLog("[YouTube] SpotubeDL (%s) failed: %v\n", engine, err) + } + + if lastErr == nil { + lastErr = fmt.Errorf("no SpotubeDL engine available") + } + return nil, lastErr +} + +func (y *YouTubeDownloader) requestSpotubeDLEngine(videoID, audioFormat, audioBitrate, engine string) (*CobaltResponse, error) { + apiURL := fmt.Sprintf("%s/api/download/%s?engine=%s&format=%s&quality=%s", + spotubeBaseURL, videoID, url.QueryEscape(engine), url.QueryEscape(audioFormat), url.QueryEscape(audioBitrate)) + + GoLog("[YouTube] Requesting from SpotubeDL (%s): %s\n", engine, apiURL) req, err := http.NewRequest("GET", apiURL, nil) if err != nil { @@ -225,27 +323,60 @@ func (y *YouTubeDownloader) requestSpotubeDL(videoID, audioFormat, audioBitrate return nil, fmt.Errorf("failed to read response: %w", err) } - GoLog("[YouTube] SpotubeDL response status: %d\n", resp.StatusCode) + GoLog("[YouTube] SpotubeDL (%s) response status: %d\n", engine, resp.StatusCode) if resp.StatusCode != 200 { - return nil, fmt.Errorf("spotubedl returned status %d: %s", resp.StatusCode, string(body)) + return nil, fmt.Errorf("spotubedl(%s) returned status %d: %s", engine, resp.StatusCode, string(body)) } var result struct { - URL string `json:"url"` + URL string `json:"url"` + Status string `json:"status"` + Error string `json:"error"` + Message string `json:"message"` + Filename string `json:"filename"` } if err := json.Unmarshal(body, &result); err != nil { return nil, fmt.Errorf("failed to parse spotubedl response: %w", err) } - if result.URL == "" { - return nil, fmt.Errorf("no download URL from spotubedl") + downloadURL := strings.TrimSpace(result.URL) + if downloadURL == "" { + if result.Error != "" { + return nil, fmt.Errorf("spotubedl(%s) error: %s", engine, result.Error) + } + if result.Message != "" { + return nil, fmt.Errorf("spotubedl(%s) message: %s", engine, result.Message) + } + return nil, fmt.Errorf("no download URL from spotubedl(%s)", engine) } - GoLog("[YouTube] Got download URL from SpotubeDL\n") + if strings.HasPrefix(downloadURL, "/") { + downloadURL = spotubeBaseURL + downloadURL + } + + if !strings.HasPrefix(downloadURL, "http://") && !strings.HasPrefix(downloadURL, "https://") { + return nil, fmt.Errorf("invalid download URL from spotubedl(%s): %s", engine, downloadURL) + } + + filename := strings.TrimSpace(result.Filename) + if filename == "" { + if parsedURL, parseErr := url.Parse(downloadURL); parseErr == nil { + if queryFilename := strings.TrimSpace(parsedURL.Query().Get("filename")); queryFilename != "" { + if decodedFilename, decodeErr := url.QueryUnescape(queryFilename); decodeErr == nil { + filename = decodedFilename + } else { + filename = queryFilename + } + } + } + } + + GoLog("[YouTube] Got download URL from SpotubeDL (%s)\n", engine) return &CobaltResponse{ - Status: "tunnel", - URL: result.URL, + Status: "tunnel", + URL: downloadURL, + Filename: filename, }, nil } @@ -408,20 +539,65 @@ func ExtractYouTubeVideoID(urlStr string) (string, error) { return "", fmt.Errorf("could not extract video ID from URL") } +// searchYouTubeMusicViaExtension uses the YT Music extension's customSearch +// to find a track by artist + title. It filters for tracks only (not videos, +// albums, or playlists) and returns the YouTube Music watch URL for the first +// matching track, or "" if nothing was found. +func searchYouTubeMusicViaExtension(artistName, trackName string) string { + extManager := GetExtensionManager() + searchProviders := extManager.GetSearchProviders() + + // Find the ytmusic-spotiflac extension + var ytProvider *ExtensionProviderWrapper + for _, p := range searchProviders { + if p.extension.ID == "ytmusic-spotiflac" { + ytProvider = p + break + } + } + if ytProvider == nil { + GoLog("[YouTube] YT Music extension not found or not enabled, skipping fallback\n") + return "" + } + + query := strings.TrimSpace(artistName + " " + trackName) + if query == "" { + return "" + } + + GoLog("[YouTube] Searching YT Music extension for: %s\n", query) + results, err := ytProvider.CustomSearch(query, map[string]interface{}{ + "filter": "tracks", + }) + if err != nil { + GoLog("[YouTube] YT Music extension search failed: %v\n", err) + return "" + } + + // Find the first track result (item_type == "track" with a valid video ID) + for _, track := range results { + if track.ItemType != "" && track.ItemType != "track" { + continue + } + videoID := strings.TrimSpace(track.ID) + if videoID == "" { + continue + } + if isYouTubeVideoID(videoID) { + return BuildYouTubeWatchURL(videoID) + } + } + + GoLog("[YouTube] YT Music extension returned no matching tracks for: %s\n", query) + return "" +} + func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) { downloader := NewYouTubeDownloader() - var quality YouTubeQuality - switch strings.ToLower(req.Quality) { - case "opus_256", "opus256", "opus": - quality = YouTubeQualityOpus256 - case "mp3_320", "mp3320", "mp3": - quality = YouTubeQualityMP3320 - default: - quality = YouTubeQualityMP3320 // Default to MP3 320kbps - } + format, bitrate, quality := parseYouTubeQualityInput(req.Quality) - // URL lookup priority: YouTube video ID > Spotify ID > Deezer ID > ISRC + // URL lookup priority: YouTube video ID > YT Music extension > SongLink (Spotify/Deezer/ISRC) var youtubeURL string var lookupErr error @@ -431,7 +607,15 @@ func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) { GoLog("[YouTube] SpotifyID appears to be YouTube video ID, using directly: %s\n", youtubeURL) } - // Try Spotify ID via SongLink + // Try YT Music extension search first (if installed) - more accurate, tracks only + if youtubeURL == "" && (req.TrackName != "" || req.ArtistName != "") { + youtubeURL = searchYouTubeMusicViaExtension(req.ArtistName, req.TrackName) + if youtubeURL != "" { + GoLog("[YouTube] Found YouTube URL via YT Music extension: %s\n", youtubeURL) + } + } + + // Fallback: Try Spotify ID via SongLink if youtubeURL == "" && req.SpotifyID != "" && !isYouTubeVideoID(req.SpotifyID) { GoLog("[YouTube] Looking up YouTube URL via SongLink for Spotify ID: %s\n", req.SpotifyID) songlink := NewSongLinkClient() @@ -443,7 +627,7 @@ func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) { } } - // Try Deezer ID via SongLink + // Fallback: Try Deezer ID via SongLink if youtubeURL == "" && req.DeezerID != "" { GoLog("[YouTube] Looking up YouTube URL via SongLink for Deezer ID: %s\n", req.DeezerID) songlink := NewSongLinkClient() @@ -455,7 +639,7 @@ func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) { } } - // Try ISRC via SongLink + // Fallback: Try ISRC via SongLink if youtubeURL == "" && req.ISRC != "" { GoLog("[YouTube] Looking up YouTube URL via SongLink for ISRC: %s\n", req.ISRC) songlink := NewSongLinkClient() @@ -480,18 +664,23 @@ func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) { return YouTubeDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err) } - var ext string - var format string - var bitrate int - switch quality { - case YouTubeQualityOpus256: + ext := ".mp3" + if format == "opus" { ext = ".opus" - format = "opus" - bitrate = 256 - case YouTubeQualityMP3320: - ext = ".mp3" - format = "mp3" - bitrate = 320 + } + + // Some SpotubeDL engines may return a different output container than requested. + // Respect the provider-reported filename to avoid saving MP3 bytes with .opus extension. + if cobaltResp != nil && cobaltResp.Filename != "" { + lowerName := strings.ToLower(strings.TrimSpace(cobaltResp.Filename)) + switch { + case strings.HasSuffix(lowerName, ".mp3"): + ext = ".mp3" + format = "mp3" + case strings.HasSuffix(lowerName, ".opus"), strings.HasSuffix(lowerName, ".ogg"): + ext = ".opus" + format = "opus" + } } filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]any{ diff --git a/go_backend/youtube_quality_test.go b/go_backend/youtube_quality_test.go new file mode 100644 index 00000000..cb77e5bb --- /dev/null +++ b/go_backend/youtube_quality_test.go @@ -0,0 +1,41 @@ +package gobackend + +import "testing" + +func TestParseYouTubeQualityInput_OpusNormalizesToSupportedBitrates(t *testing.T) { + format, bitrate, normalized := parseYouTubeQualityInput("opus_160") + if format != "opus" { + t.Fatalf("expected opus format, got %s", format) + } + if bitrate != 128 { + t.Fatalf("expected 128 bitrate, got %d", bitrate) + } + if normalized != YouTubeQualityOpus128 { + t.Fatalf("expected %s normalized, got %s", YouTubeQualityOpus128, normalized) + } +} + +func TestParseYouTubeQualityInput_Mp3NormalizesToSupportedBitrates(t *testing.T) { + format, bitrate, normalized := parseYouTubeQualityInput("mp3_192") + if format != "mp3" { + t.Fatalf("expected mp3 format, got %s", format) + } + if bitrate != 256 { + t.Fatalf("expected 256 bitrate, got %d", bitrate) + } + if normalized != YouTubeQualityMP3256 { + t.Fatalf("expected %s normalized, got %s", YouTubeQualityMP3256, normalized) + } +} + +func TestParseYouTubeQualityInput_PicksNearestSupportedBitrate(t *testing.T) { + _, opusBitrate, _ := parseYouTubeQualityInput("opus_999") + if opusBitrate != 256 { + t.Fatalf("expected opus normalization to 256, got %d", opusBitrate) + } + + _, mp3Bitrate, _ := parseYouTubeQualityInput("mp3_1") + if mp3Bitrate != 128 { + t.Fatalf("expected mp3 normalization to 128, got %d", mp3Bitrate) + } +} diff --git a/icon_android.png b/icon_android.png new file mode 100644 index 00000000..f8cc9338 Binary files /dev/null and b/icon_android.png differ diff --git a/icon_foreground_android.png b/icon_foreground_android.png new file mode 100644 index 00000000..95a2da61 Binary files /dev/null and b/icon_foreground_android.png differ diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 6c6a9ab6..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] @@ -127,6 +241,13 @@ import Gobackend // Import Go framework GobackendSetDownloadDirectory(path, &error) if let error = error { throw error } return nil + + case "setNetworkCompatibilityOptions", "setSongLinkNetworkOptions": + let args = call.arguments as! [String: Any] + let allowHTTP = args["allow_http"] as? Bool ?? false + let insecureTLS = args["insecure_tls"] as? Bool ?? false + GobackendSetNetworkCompatibilityOptions(allowHTTP, insecureTLS) + return nil case "checkDuplicate": let args = call.arguments as! [String: Any] @@ -275,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 @@ -833,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/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png index 8e22d802..288d7ffa 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png index 714b5b4a..1967ef76 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png index e4924417..fd15ff97 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png index 8615e6d3..f5cb5270 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png index 613554e3..ca280234 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png index d7e5cfa3..85b3cd51 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png index 2df88649..e34cd643 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png index e4924417..fd15ff97 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png index 20036583..bd83c86d 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png index 088115a6..4eadb79e 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png index 26e91124..eba0c613 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png index f16eb5be..c0c2baac 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png index 19c79776..b99ad6d0 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png index 35727e31..c4298a3b 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png index 088115a6..4eadb79e 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png index c7fc4edf..c4363ca4 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png index 4c27a15d..b61e41d5 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png index c727fe1d..b656689e 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png index 5d82aba8..b18d03d1 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png index b9b4261d..cbf1ca53 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png index 110c2d38..fcc9e1ed 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ 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 e3d1f6fd..48a1c1e2 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.6.8'; - static const String buildNumber = '82'; + static const String version = '3.7.1'; + static const String buildNumber = '104'; static const String fullVersion = '$version+$buildNumber'; diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 7013a130..b321656f 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -130,12 +130,6 @@ abstract class AppLocalizations { /// **'SpotiFLAC'** String get appName; - /// App description shown in about page - /// - /// In en, this message translates to: - /// **'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'** - String get appDescription; - /// Bottom navigation - Home tab /// /// In en, this message translates to: @@ -148,12 +142,6 @@ abstract class AppLocalizations { /// **'Library'** String get navLibrary; - /// Bottom navigation - History tab (legacy) - /// - /// In en, this message translates to: - /// **'History'** - String get navHistory; - /// Bottom navigation - Settings tab /// /// In en, this message translates to: @@ -172,18 +160,6 @@ abstract class AppLocalizations { /// **'Home'** String get homeTitle; - /// Placeholder text in search box - /// - /// In en, this message translates to: - /// **'Paste Spotify URL or search...'** - String get homeSearchHint; - - /// Placeholder when extension search is active - /// - /// In en, this message translates to: - /// **'Search with {extensionName}...'** - String homeSearchHintExtension(String extensionName); - /// Subtitle shown below search box /// /// In en, this message translates to: @@ -202,24 +178,6 @@ abstract class AppLocalizations { /// **'Recent'** String get homeRecent; - /// History screen title - /// - /// In en, this message translates to: - /// **'History'** - String get historyTitle; - - /// Tab showing active downloads count - /// - /// In en, this message translates to: - /// **'Downloading ({count})'** - String historyDownloading(int count); - - /// Tab showing completed downloads - /// - /// In en, this message translates to: - /// **'Downloaded'** - String get historyDownloaded; - /// Filter chip - show all items /// /// In en, this message translates to: @@ -238,54 +196,6 @@ abstract class AppLocalizations { /// **'Singles'** String get historyFilterSingles; - /// Track count with plural form - /// - /// In en, this message translates to: - /// **'{count, plural, =1{1 track} other{{count} tracks}}'** - String historyTracksCount(int count); - - /// Album count with plural form - /// - /// In en, this message translates to: - /// **'{count, plural, =1{1 album} other{{count} albums}}'** - String historyAlbumsCount(int count); - - /// Empty state title - /// - /// In en, this message translates to: - /// **'No download history'** - String get historyNoDownloads; - - /// Empty state subtitle - /// - /// In en, this message translates to: - /// **'Downloaded tracks will appear here'** - String get historyNoDownloadsSubtitle; - - /// Empty state when filtering albums - /// - /// In en, this message translates to: - /// **'No album downloads'** - String get historyNoAlbums; - - /// Empty state subtitle for albums filter - /// - /// In en, this message translates to: - /// **'Download multiple tracks from an album to see them here'** - String get historyNoAlbumsSubtitle; - - /// Empty state when filtering singles - /// - /// In en, this message translates to: - /// **'No single downloads'** - String get historyNoSingles; - - /// Empty state subtitle for singles filter - /// - /// In en, this message translates to: - /// **'Single track downloads will appear here'** - String get historyNoSinglesSubtitle; - /// Search bar placeholder in history /// /// In en, this message translates to: @@ -334,48 +244,6 @@ abstract class AppLocalizations { /// **'Download'** String get downloadTitle; - /// Setting for download folder - /// - /// In en, this message translates to: - /// **'Download Location'** - String get downloadLocation; - - /// Subtitle for download location - /// - /// In en, this message translates to: - /// **'Choose where to save files'** - String get downloadLocationSubtitle; - - /// Shown when using default folder - /// - /// In en, this message translates to: - /// **'Default location'** - String get downloadLocationDefault; - - /// Setting for preferred download service (Tidal/Qobuz/Amazon) - /// - /// In en, this message translates to: - /// **'Default Service'** - String get downloadDefaultService; - - /// Subtitle for default service - /// - /// In en, this message translates to: - /// **'Service used for downloads'** - String get downloadDefaultServiceSubtitle; - - /// Setting for audio quality - /// - /// In en, this message translates to: - /// **'Default Quality'** - String get downloadDefaultQuality; - - /// Toggle to show quality picker - /// - /// In en, this message translates to: - /// **'Ask Quality Before Download'** - String get downloadAskQuality; - /// Subtitle for ask quality toggle /// /// In en, this message translates to: @@ -394,54 +262,12 @@ abstract class AppLocalizations { /// **'Folder Organization'** String get downloadFolderOrganization; - /// Toggle to separate single tracks - /// - /// In en, this message translates to: - /// **'Separate Singles'** - String get downloadSeparateSingles; - - /// Subtitle for separate singles toggle - /// - /// In en, this message translates to: - /// **'Put single tracks in a separate folder'** - String get downloadSeparateSinglesSubtitle; - - /// Audio quality option - highest available - /// - /// In en, this message translates to: - /// **'Best Available'** - String get qualityBest; - - /// Audio quality option - FLAC lossless - /// - /// In en, this message translates to: - /// **'FLAC'** - String get qualityFlac; - - /// Audio quality option - 320kbps MP3 - /// - /// In en, this message translates to: - /// **'320 kbps'** - String get quality320; - - /// Audio quality option - 128kbps MP3 - /// - /// In en, this message translates to: - /// **'128 kbps'** - String get quality128; - /// Appearance settings page title /// /// In en, this message translates to: /// **'Appearance'** String get appearanceTitle; - /// Theme mode setting - /// - /// In en, this message translates to: - /// **'Theme'** - String get appearanceTheme; - /// Follow system theme /// /// In en, this message translates to: @@ -472,12 +298,6 @@ abstract class AppLocalizations { /// **'Use colors from your wallpaper'** String get appearanceDynamicColorSubtitle; - /// Custom accent color picker - /// - /// In en, this message translates to: - /// **'Accent Color'** - String get appearanceAccentColor; - /// Layout style for history /// /// In en, this message translates to: @@ -502,12 +322,6 @@ abstract class AppLocalizations { /// **'Options'** String get optionsTitle; - /// Section for search provider settings - /// - /// In en, this message translates to: - /// **'Search Source'** - String get optionsSearchSource; - /// Main search provider setting /// /// In en, this message translates to: @@ -724,30 +538,6 @@ abstract class AppLocalizations { /// **'Extensions'** String get extensionsTitle; - /// Section header for installed extensions - /// - /// In en, this message translates to: - /// **'Installed Extensions'** - String get extensionsInstalled; - - /// Empty state title - /// - /// In en, this message translates to: - /// **'No extensions installed'** - String get extensionsNone; - - /// Empty state subtitle - /// - /// In en, this message translates to: - /// **'Install extensions from the Store tab'** - String get extensionsNoneSubtitle; - - /// Extension status - active - /// - /// In en, this message translates to: - /// **'Enabled'** - String get extensionsEnabled; - /// Extension status - inactive /// /// In en, this message translates to: @@ -772,12 +562,6 @@ abstract class AppLocalizations { /// **'Uninstall'** String get extensionsUninstall; - /// Use extension for search - /// - /// In en, this message translates to: - /// **'Set as Search Provider'** - String get extensionsSetAsSearch; - /// Store screen title /// /// In en, this message translates to: @@ -922,12 +706,6 @@ abstract class AppLocalizations { /// **'Social'** String get aboutSocial; - /// Section for support/donation links - /// - /// In en, this message translates to: - /// **'Support'** - String get aboutSupport; - /// Section for app info /// /// In en, this message translates to: @@ -958,18 +736,6 @@ abstract class AppLocalizations { /// **'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!'** String get aboutSjdonadoDesc; - /// Name of Amazon API service - DO NOT TRANSLATE - /// - /// In en, this message translates to: - /// **'DoubleDouble'** - String get aboutDoubleDouble; - - /// Credit for DoubleDouble API - /// - /// In en, this message translates to: - /// **'Amazing API for Amazon Music downloads. Thank you for making it free!'** - String get aboutDoubleDoubleDesc; - /// Name of Qobuz API service - DO NOT TRANSLATE /// /// In en, this message translates to: @@ -1000,42 +766,6 @@ abstract class AppLocalizations { /// **'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'** String get aboutAppDescription; - /// Album screen title - /// - /// In en, this message translates to: - /// **'Album'** - String get albumTitle; - - /// Album track count - /// - /// In en, this message translates to: - /// **'{count, plural, =1{1 track} other{{count} tracks}}'** - String albumTracks(int count); - - /// Button to download all tracks - /// - /// In en, this message translates to: - /// **'Download All'** - String get albumDownloadAll; - - /// Button to download remaining tracks - /// - /// In en, this message translates to: - /// **'Download Remaining'** - String get albumDownloadRemaining; - - /// Playlist screen title - /// - /// In en, this message translates to: - /// **'Playlist'** - String get playlistTitle; - - /// Artist screen title - /// - /// In en, this message translates to: - /// **'Artist'** - String get artistTitle; - /// Section header for artist albums /// /// In en, this message translates to: @@ -1054,12 +784,6 @@ abstract class AppLocalizations { /// **'Compilations'** String get artistCompilations; - /// Artist release count - /// - /// In en, this message translates to: - /// **'{count, plural, =1{1 release} other{{count} releases}}'** - String artistReleases(int count); - /// Section header for popular/top tracks /// /// In en, this message translates to: @@ -1072,48 +796,6 @@ abstract class AppLocalizations { /// **'{count} monthly listeners'** String artistMonthlyListeners(String count); - /// Track metadata screen title - /// - /// In en, this message translates to: - /// **'Track Info'** - String get trackMetadataTitle; - - /// Metadata field - artist name - /// - /// In en, this message translates to: - /// **'Artist'** - String get trackMetadataArtist; - - /// Metadata field - album name - /// - /// In en, this message translates to: - /// **'Album'** - String get trackMetadataAlbum; - - /// Metadata field - track length - /// - /// In en, this message translates to: - /// **'Duration'** - String get trackMetadataDuration; - - /// Metadata field - audio quality - /// - /// In en, this message translates to: - /// **'Quality'** - String get trackMetadataQuality; - - /// Metadata field - file location - /// - /// In en, this message translates to: - /// **'File Path'** - String get trackMetadataPath; - - /// Metadata field - download date - /// - /// In en, this message translates to: - /// **'Downloaded'** - String get trackMetadataDownloadedAt; - /// Metadata field - download service used /// /// In en, this message translates to: @@ -1138,78 +820,12 @@ abstract class AppLocalizations { /// **'Delete'** String get trackMetadataDelete; - /// Action button - download again - /// - /// In en, this message translates to: - /// **'Re-download'** - String get trackMetadataRedownload; - - /// Action button - open containing folder - /// - /// In en, this message translates to: - /// **'Open Folder'** - String get trackMetadataOpenFolder; - - /// Setup wizard title - /// - /// In en, this message translates to: - /// **'Welcome to SpotiFLAC'** - String get setupTitle; - - /// Setup wizard subtitle - /// - /// In en, this message translates to: - /// **'Let\'s get you started'** - String get setupSubtitle; - - /// Storage permission step title - /// - /// In en, this message translates to: - /// **'Storage Permission'** - String get setupStoragePermission; - - /// Explanation for storage permission - /// - /// In en, this message translates to: - /// **'Required to save downloaded files'** - String get setupStoragePermissionSubtitle; - - /// Status when permission granted - /// - /// In en, this message translates to: - /// **'Permission granted'** - String get setupStoragePermissionGranted; - - /// Status when permission denied - /// - /// In en, this message translates to: - /// **'Permission denied'** - String get setupStoragePermissionDenied; - /// Button to request permission /// /// In en, this message translates to: /// **'Grant Permission'** String get setupGrantPermission; - /// Download folder step title - /// - /// In en, this message translates to: - /// **'Download Location'** - String get setupDownloadLocation; - - /// Button to pick folder - /// - /// In en, this message translates to: - /// **'Choose Folder'** - String get setupChooseFolder; - - /// Continue to next step button - /// - /// In en, this message translates to: - /// **'Continue'** - String get setupContinue; - /// Skip current step button /// /// In en, this message translates to: @@ -1222,12 +838,6 @@ abstract class AppLocalizations { /// **'Storage Access Required'** String get setupStorageAccessRequired; - /// Explanation for storage access - /// - /// In en, this message translates to: - /// **'SpotiFLAC needs \"All files access\" permission to save music files to your chosen folder.'** - String get setupStorageAccessMessage; - /// Android 11+ specific explanation /// /// In en, this message translates to: @@ -1258,12 +868,6 @@ abstract class AppLocalizations { /// **'{permissionType} permission is required for the best experience. You can change this later in Settings.'** String setupPermissionRequiredMessage(String permissionType); - /// Folder selection step title - /// - /// In en, this message translates to: - /// **'Select Download Folder'** - String get setupSelectDownloadFolder; - /// Dialog title for default folder /// /// In en, this message translates to: @@ -1336,36 +940,6 @@ abstract class AppLocalizations { /// **'Download Spotify tracks in FLAC'** String get setupDownloadInFlac; - /// Setup step indicator - storage - /// - /// In en, this message translates to: - /// **'Storage'** - String get setupStepStorage; - - /// Setup step indicator - notification - /// - /// In en, this message translates to: - /// **'Notification'** - String get setupStepNotification; - - /// Setup step indicator - folder - /// - /// In en, this message translates to: - /// **'Folder'** - String get setupStepFolder; - - /// Setup step indicator - Spotify API - /// - /// In en, this message translates to: - /// **'Spotify'** - String get setupStepSpotify; - - /// Setup step indicator - permission - /// - /// In en, this message translates to: - /// **'Permission'** - String get setupStepPermission; - /// Success message for storage permission /// /// In en, this message translates to: @@ -1396,18 +970,6 @@ abstract class AppLocalizations { /// **'Enable Notifications'** String get setupNotificationEnable; - /// Explanation for notifications - /// - /// In en, this message translates to: - /// **'Get notified when downloads complete or require attention.'** - String get setupNotificationDescription; - - /// Success message for folder selection - /// - /// In en, this message translates to: - /// **'Download Folder Selected!'** - String get setupFolderSelected; - /// Button to choose folder /// /// In en, this message translates to: @@ -1420,84 +982,18 @@ abstract class AppLocalizations { /// **'Select a folder where your downloaded music will be saved.'** String get setupFolderDescription; - /// Button to change selected folder - /// - /// In en, this message translates to: - /// **'Change Folder'** - String get setupChangeFolder; - /// Button to select folder /// /// In en, this message translates to: /// **'Select Folder'** String get setupSelectFolder; - /// Spotify API step title - /// - /// In en, this message translates to: - /// **'Spotify API (Optional)'** - String get setupSpotifyApiOptional; - - /// Explanation for Spotify API - /// - /// In en, this message translates to: - /// **'Add your Spotify API credentials for better search results and access to Spotify-exclusive content.'** - String get setupSpotifyApiDescription; - - /// Toggle to enable Spotify API - /// - /// In en, this message translates to: - /// **'Use Spotify API'** - String get setupUseSpotifyApi; - - /// Prompt to enter credentials - /// - /// In en, this message translates to: - /// **'Enter your credentials below'** - String get setupEnterCredentialsBelow; - - /// Status when using Deezer - /// - /// In en, this message translates to: - /// **'Using Deezer (no account needed)'** - String get setupUsingDeezer; - - /// Placeholder for client ID field - /// - /// In en, this message translates to: - /// **'Enter Spotify Client ID'** - String get setupEnterClientId; - - /// Placeholder for client secret field - /// - /// In en, this message translates to: - /// **'Enter Spotify Client Secret'** - String get setupEnterClientSecret; - - /// Info about getting Spotify credentials - /// - /// In en, this message translates to: - /// **'Get your free API credentials from the Spotify Developer Dashboard.'** - String get setupGetFreeCredentials; - /// Button to enable notifications /// /// In en, this message translates to: /// **'Enable Notifications'** String get setupEnableNotifications; - /// Message after completing a step - /// - /// In en, this message translates to: - /// **'You can now proceed to the next step.'** - String get setupProceedToNextStep; - - /// Info about notification usage - /// - /// In en, this message translates to: - /// **'You will receive download progress notifications.'** - String get setupNotificationProgressDescription; - /// Detailed notification explanation /// /// In en, this message translates to: @@ -1510,12 +1006,6 @@ abstract class AppLocalizations { /// **'Skip for now'** String get setupSkipForNow; - /// Back button text - /// - /// In en, this message translates to: - /// **'Back'** - String get setupBack; - /// Next button text /// /// In en, this message translates to: @@ -1528,36 +1018,18 @@ abstract class AppLocalizations { /// **'Get Started'** String get setupGetStarted; - /// Skip setup and start app - /// - /// In en, this message translates to: - /// **'Skip & Start'** - String get setupSkipAndStart; - /// Instruction for file access permission /// /// In en, this message translates to: /// **'Please enable \"Allow access to manage all files\" in the next screen.'** String get setupAllowAccessToManageFiles; - /// Link text for Spotify developer portal - /// - /// In en, this message translates to: - /// **'Get credentials from developer.spotify.com'** - String get setupGetCredentialsFromSpotify; - /// Dialog button - cancel action /// /// In en, this message translates to: /// **'Cancel'** String get dialogCancel; - /// Dialog button - confirm/acknowledge - /// - /// In en, this message translates to: - /// **'OK'** - String get dialogOk; - /// Dialog button - save changes /// /// In en, this message translates to: @@ -1576,36 +1048,12 @@ abstract class AppLocalizations { /// **'Retry'** String get dialogRetry; - /// Dialog button - close dialog - /// - /// In en, this message translates to: - /// **'Close'** - String get dialogClose; - - /// Dialog button - confirm yes - /// - /// In en, this message translates to: - /// **'Yes'** - String get dialogYes; - - /// Dialog button - confirm no - /// - /// In en, this message translates to: - /// **'No'** - String get dialogNo; - /// Dialog button - clear items /// /// In en, this message translates to: /// **'Clear'** String get dialogClear; - /// Dialog button - confirm action - /// - /// In en, this message translates to: - /// **'Confirm'** - String get dialogConfirm; - /// Dialog button - action completed /// /// In en, this message translates to: @@ -1648,48 +1096,12 @@ abstract class AppLocalizations { /// **'You have unsaved changes. Do you want to discard them?'** String get dialogUnsavedChanges; - /// Dialog title - download error - /// - /// In en, this message translates to: - /// **'Download Failed'** - String get dialogDownloadFailed; - - /// Label for track name in error dialog - /// - /// In en, this message translates to: - /// **'Track:'** - String get dialogTrackLabel; - - /// Label for artist name in error dialog - /// - /// In en, this message translates to: - /// **'Artist:'** - String get dialogArtistLabel; - - /// Label for error message - /// - /// In en, this message translates to: - /// **'Error:'** - String get dialogErrorLabel; - /// Dialog title - clear all items /// /// In en, this message translates to: /// **'Clear All'** String get dialogClearAll; - /// Dialog message - clear downloads confirmation - /// - /// In en, this message translates to: - /// **'Are you sure you want to clear all downloads?'** - String get dialogClearAllDownloads; - - /// Dialog title - delete file confirmation - /// - /// In en, this message translates to: - /// **'Remove from device?'** - String get dialogRemoveFromDevice; - /// Dialog title - uninstall extension /// /// In en, this message translates to: @@ -1822,12 +1234,6 @@ abstract class AppLocalizations { /// **'View Queue'** String get snackbarViewQueue; - /// Snackbar - loading error - /// - /// In en, this message translates to: - /// **'Failed to load: {error}'** - String snackbarFailedToLoad(String error); - /// Snackbar - URL copied /// /// In en, this message translates to: @@ -1894,12 +1300,6 @@ abstract class AppLocalizations { /// **'Too many requests. Please wait a moment before searching again.'** String get errorRateLimitedMessage; - /// Error message - loading failed - /// - /// In en, this message translates to: - /// **'Failed to load {item}'** - String errorFailedToLoad(String item); - /// Error - search returned no results /// /// In en, this message translates to: @@ -1912,48 +1312,6 @@ abstract class AppLocalizations { /// **'Cannot load {item}: missing extension source'** String errorMissingExtensionSource(String item); - /// Download status - waiting in queue - /// - /// In en, this message translates to: - /// **'Queued'** - String get statusQueued; - - /// Download status - in progress - /// - /// In en, this message translates to: - /// **'Downloading'** - String get statusDownloading; - - /// Download status - writing metadata - /// - /// In en, this message translates to: - /// **'Finalizing'** - String get statusFinalizing; - - /// Download status - finished - /// - /// In en, this message translates to: - /// **'Completed'** - String get statusCompleted; - - /// Download status - error occurred - /// - /// In en, this message translates to: - /// **'Failed'** - String get statusFailed; - - /// Download status - already exists - /// - /// In en, this message translates to: - /// **'Skipped'** - String get statusSkipped; - - /// Download status - paused - /// - /// In en, this message translates to: - /// **'Paused'** - String get statusPaused; - /// Action button - pause download /// /// In en, this message translates to: @@ -1972,18 +1330,6 @@ abstract class AppLocalizations { /// **'Cancel'** String get actionCancel; - /// Action button - stop operation - /// - /// In en, this message translates to: - /// **'Stop'** - String get actionStop; - - /// Action button - enter selection mode - /// - /// In en, this message translates to: - /// **'Select'** - String get actionSelect; - /// Action button - select all items /// /// In en, this message translates to: @@ -1996,18 +1342,6 @@ abstract class AppLocalizations { /// **'Deselect'** String get actionDeselect; - /// Action button - paste from clipboard - /// - /// In en, this message translates to: - /// **'Paste'** - String get actionPaste; - - /// Action button - import CSV file - /// - /// In en, this message translates to: - /// **'Import CSV'** - String get actionImportCsv; - /// Action button - delete Spotify credentials /// /// In en, this message translates to: @@ -2032,18 +1366,6 @@ abstract class AppLocalizations { /// **'All tracks selected'** String get selectionAllSelected; - /// Hint - how to select items - /// - /// In en, this message translates to: - /// **'Tap tracks to select'** - String get selectionTapToSelect; - - /// Delete button with count - /// - /// In en, this message translates to: - /// **'Delete {count} {count, plural, =1{track} other{tracks}}'** - String selectionDeleteTracks(int count); - /// Placeholder when nothing selected /// /// In en, this message translates to: @@ -2092,66 +1414,12 @@ abstract class AppLocalizations { /// **'Play'** String get tooltipPlay; - /// Tooltip - cancel button - /// - /// In en, this message translates to: - /// **'Cancel'** - String get tooltipCancel; - - /// Tooltip - stop button - /// - /// In en, this message translates to: - /// **'Stop'** - String get tooltipStop; - - /// Tooltip - retry button - /// - /// In en, this message translates to: - /// **'Retry'** - String get tooltipRetry; - - /// Tooltip - remove button - /// - /// In en, this message translates to: - /// **'Remove'** - String get tooltipRemove; - - /// Tooltip - clear button - /// - /// In en, this message translates to: - /// **'Clear'** - String get tooltipClear; - - /// Tooltip - paste button - /// - /// In en, this message translates to: - /// **'Paste'** - String get tooltipPaste; - /// Setting title - filename pattern /// /// In en, this message translates to: /// **'Filename Format'** String get filenameFormat; - /// Preview of filename pattern - /// - /// In en, this message translates to: - /// **'Preview: {preview}'** - String filenameFormatPreview(String preview); - - /// Label for placeholder list - /// - /// In en, this message translates to: - /// **'Available placeholders:'** - String get filenameAvailablePlaceholders; - - /// Default filename format hint - /// - /// In en, this message translates to: - /// **'{artist} - {title}'** - String filenameHint(Object artist, Object title); - /// Toggle label for showing advanced filename tags /// /// In en, this message translates to: @@ -2164,12 +1432,6 @@ abstract class AppLocalizations { /// **'Enable formatted tags for track padding and date patterns'** String get filenameShowAdvancedTagsDescription; - /// Setting title - folder structure - /// - /// In en, this message translates to: - /// **'Folder Organization'** - String get folderOrganization; - /// Folder option - flat structure /// /// In en, this message translates to: @@ -2230,30 +1492,12 @@ abstract class AppLocalizations { /// **'Update Available'** String get updateAvailable; - /// Update available message - /// - /// In en, this message translates to: - /// **'Version {version} is available'** - String updateNewVersion(String version); - - /// Update button - download update - /// - /// In en, this message translates to: - /// **'Download'** - String get updateDownload; - /// Update button - dismiss /// /// In en, this message translates to: /// **'Later'** String get updateLater; - /// Link to changelog - /// - /// In en, this message translates to: - /// **'Changelog'** - String get updateChangelog; - /// Update status - initializing /// /// In en, this message translates to: @@ -2314,18 +1558,6 @@ abstract class AppLocalizations { /// **'Don\'t remind'** String get updateDontRemind; - /// Setting title - download provider order - /// - /// In en, this message translates to: - /// **'Provider Priority'** - String get providerPriority; - - /// Subtitle for provider priority - /// - /// In en, this message translates to: - /// **'Drag to reorder download providers'** - String get providerPrioritySubtitle; - /// Provider priority page title /// /// In en, this message translates to: @@ -2356,18 +1588,6 @@ abstract class AppLocalizations { /// **'Extension'** String get providerExtension; - /// Setting title - metadata provider order - /// - /// In en, this message translates to: - /// **'Metadata Provider Priority'** - String get metadataProviderPriority; - - /// Subtitle for metadata priority - /// - /// In en, this message translates to: - /// **'Order used when fetching track metadata'** - String get metadataProviderPrioritySubtitle; - /// Metadata priority page title /// /// In en, this message translates to: @@ -2404,30 +1624,6 @@ abstract class AppLocalizations { /// **'Logs'** String get logTitle; - /// Action - copy logs to clipboard - /// - /// In en, this message translates to: - /// **'Copy Logs'** - String get logCopy; - - /// Action - delete all logs - /// - /// In en, this message translates to: - /// **'Clear Logs'** - String get logClear; - - /// Action - share logs file - /// - /// In en, this message translates to: - /// **'Share Logs'** - String get logShare; - - /// Empty state title - /// - /// In en, this message translates to: - /// **'No logs yet'** - String get logEmpty; - /// Snackbar - logs copied /// /// In en, this message translates to: @@ -2476,30 +1672,6 @@ abstract class AppLocalizations { /// **'Are you sure you want to clear all logs?'** String get logClearLogsMessage; - /// Error category - ISP blocking - /// - /// In en, this message translates to: - /// **'ISP BLOCKING DETECTED'** - String get logIspBlocking; - - /// Error category - rate limiting - /// - /// In en, this message translates to: - /// **'RATE LIMITED'** - String get logRateLimited; - - /// Error category - network issues - /// - /// In en, this message translates to: - /// **'NETWORK ERROR'** - String get logNetworkError; - - /// Error category - missing tracks - /// - /// In en, this message translates to: - /// **'TRACK NOT FOUND'** - String get logTrackNotFound; - /// Filter dialog title /// /// In en, this message translates to: @@ -2518,72 +1690,6 @@ abstract class AppLocalizations { /// **'Logs will appear here as you use the app'** String get logNoLogsYetSubtitle; - /// Section header for error summary - /// - /// In en, this message translates to: - /// **'Issue Summary'** - String get logIssueSummary; - - /// ISP blocking explanation - /// - /// In en, this message translates to: - /// **'Your ISP may be blocking access to download services'** - String get logIspBlockingDescription; - - /// ISP blocking fix suggestion - /// - /// In en, this message translates to: - /// **'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8'** - String get logIspBlockingSuggestion; - - /// Rate limit explanation - /// - /// In en, this message translates to: - /// **'Too many requests to the service'** - String get logRateLimitedDescription; - - /// Rate limit fix suggestion - /// - /// In en, this message translates to: - /// **'Wait a few minutes before trying again'** - String get logRateLimitedSuggestion; - - /// Network error explanation - /// - /// In en, this message translates to: - /// **'Connection issues detected'** - String get logNetworkErrorDescription; - - /// Network error fix suggestion - /// - /// In en, this message translates to: - /// **'Check your internet connection'** - String get logNetworkErrorSuggestion; - - /// Track not found explanation - /// - /// In en, this message translates to: - /// **'Some tracks could not be found on download services'** - String get logTrackNotFoundDescription; - - /// Track not found explanation - /// - /// In en, this message translates to: - /// **'The track may not be available in lossless quality'** - String get logTrackNotFoundSuggestion; - - /// Error count display - /// - /// In en, this message translates to: - /// **'Total errors: {count}'** - String logTotalErrors(int count); - - /// Affected domains display - /// - /// In en, this message translates to: - /// **'Affected: {domains}'** - String logAffected(String domains); - /// Log count with filter active /// /// In en, this message translates to: @@ -2782,12 +1888,6 @@ abstract class AppLocalizations { /// **'App Language'** String get appearanceLanguage; - /// Language setting subtitle - /// - /// In en, this message translates to: - /// **'Choose your preferred language'** - String get appearanceLanguageSubtitle; - /// Appearance settings description /// /// In en, this message translates to: @@ -2830,12 +1930,6 @@ abstract class AppLocalizations { /// **'Press back again to exit'** String get pressBackAgainToExit; - /// Section header for track list - /// - /// In en, this message translates to: - /// **'Tracks'** - String get tracksHeader; - /// Download all button with count /// /// In en, this message translates to: @@ -3040,12 +2134,6 @@ abstract class AppLocalizations { /// **'This will permanently delete the downloaded file and remove it from your history.'** String get trackDeleteConfirmMessage; - /// Error opening file - /// - /// In en, this message translates to: - /// **'Cannot open: {message}'** - String trackCannotOpen(String message); - /// Relative date - today /// /// In en, this message translates to: @@ -3076,30 +2164,6 @@ abstract class AppLocalizations { /// **'{count} months ago'** String dateMonthsAgo(int count); - /// Download mode - one at a time - /// - /// In en, this message translates to: - /// **'Sequential'** - String get concurrentSequential; - - /// Download mode - 2 simultaneous - /// - /// In en, this message translates to: - /// **'2 Parallel'** - String get concurrentParallel2; - - /// Download mode - 3 simultaneous - /// - /// In en, this message translates to: - /// **'3 Parallel'** - String get concurrentParallel3; - - /// Tooltip for failed download - /// - /// In en, this message translates to: - /// **'Tap to see error details'** - String get tapToSeeError; - /// Store filter - all extensions /// /// In en, this message translates to: @@ -3142,24 +2206,6 @@ abstract class AppLocalizations { /// **'Clear filters'** String get storeClearFilters; - /// Empty state when no extensions match filters - /// - /// In en, this message translates to: - /// **'No extensions found'** - String get storeNoResults; - - /// Extension capability - provider priority - /// - /// In en, this message translates to: - /// **'Provider Priority'** - String get extensionProviderPriority; - - /// Button to install extension - /// - /// In en, this message translates to: - /// **'Install Extension'** - String get extensionInstallButton; - /// Default search provider option /// /// In en, this message translates to: @@ -3436,66 +2482,6 @@ abstract class AppLocalizations { /// **'24-bit / up to 192kHz'** String get qualityHiResFlacMaxSubtitle; - /// Quality option - lossy format (MP3/Opus) - /// - /// In en, this message translates to: - /// **'Lossy'** - String get qualityLossy; - - /// Technical spec for lossy MP3 - /// - /// In en, this message translates to: - /// **'MP3 320kbps (converted from FLAC)'** - String get qualityLossyMp3Subtitle; - - /// Technical spec for lossy Opus - /// - /// In en, this message translates to: - /// **'Opus 128kbps (converted from FLAC)'** - String get qualityLossyOpusSubtitle; - - /// Setting - enable lossy quality option - /// - /// In en, this message translates to: - /// **'Enable Lossy Option'** - String get enableLossyOption; - - /// Subtitle when lossy is enabled - /// - /// In en, this message translates to: - /// **'Lossy quality option is available'** - String get enableLossyOptionSubtitleOn; - - /// Subtitle when lossy is disabled - /// - /// In en, this message translates to: - /// **'Downloads FLAC then converts to lossy format'** - String get enableLossyOptionSubtitleOff; - - /// Setting - choose lossy format - /// - /// In en, this message translates to: - /// **'Lossy Format'** - String get lossyFormat; - - /// Description for lossy format picker - /// - /// In en, this message translates to: - /// **'Choose the lossy format for conversion'** - String get lossyFormatDescription; - - /// MP3 format description - /// - /// In en, this message translates to: - /// **'320kbps, best compatibility'** - String get lossyFormatMp3Subtitle; - - /// Opus format description - /// - /// In en, this message translates to: - /// **'128kbps, better quality at smaller size'** - String get lossyFormatOpusSubtitle; - /// Note about quality availability /// /// In en, this message translates to: @@ -3508,6 +2494,18 @@ abstract class AppLocalizations { /// **'YouTube provides lossy audio only. Not part of lossless fallback.'** String get youtubeQualityNote; + /// Title for YouTube Opus bitrate setting + /// + /// In en, this message translates to: + /// **'YouTube Opus Bitrate'** + String get youtubeOpusBitrateTitle; + + /// Title for YouTube MP3 bitrate setting + /// + /// In en, this message translates to: + /// **'YouTube MP3 Bitrate'** + String get youtubeMp3BitrateTitle; + /// Setting - show quality picker /// /// In en, this message translates to: @@ -3538,18 +2536,6 @@ abstract class AppLocalizations { /// **'Use Album Artist for folders'** String get downloadUseAlbumArtistForFolders; - /// Subtitle when Album Artist is used for folder naming - /// - /// In en, this message translates to: - /// **'Artist folders use Album Artist when available'** - String get downloadUseAlbumArtistForFoldersAlbumSubtitle; - - /// Subtitle when Track Artist is used for folder naming - /// - /// In en, this message translates to: - /// **'Artist folders use Track Artist only'** - String get downloadUseAlbumArtistForFoldersTrackSubtitle; - /// Setting - strip featured artists from folder name /// /// In en, this message translates to: @@ -3568,18 +2554,6 @@ abstract class AppLocalizations { /// **'Full artist string used for folder name'** String get downloadUsePrimaryArtistOnlyDisabled; - /// Setting - output file format - /// - /// In en, this message translates to: - /// **'Save Format'** - String get downloadSaveFormat; - - /// Dialog title - choose download service - /// - /// In en, this message translates to: - /// **'Select Service'** - String get downloadSelectService; - /// Dialog title - choose audio quality /// /// In en, this message translates to: @@ -3592,96 +2566,6 @@ abstract class AppLocalizations { /// **'Download From'** String get downloadFrom; - /// Label - default quality setting - /// - /// In en, this message translates to: - /// **'Default Quality'** - String get downloadDefaultQualityLabel; - - /// Quality option - highest available - /// - /// In en, this message translates to: - /// **'Best available'** - String get downloadBestAvailable; - - /// Folder option - no organization - /// - /// In en, this message translates to: - /// **'None'** - String get folderNone; - - /// Subtitle for no folder organization - /// - /// In en, this message translates to: - /// **'Save all files directly to download folder'** - String get folderNoneSubtitle; - - /// Folder option - by artist - /// - /// In en, this message translates to: - /// **'Artist'** - String get folderArtist; - - /// Folder structure example - /// - /// In en, this message translates to: - /// **'Artist Name/filename'** - String get folderArtistSubtitle; - - /// Folder option - by album - /// - /// In en, this message translates to: - /// **'Album'** - String get folderAlbum; - - /// Folder structure example - /// - /// In en, this message translates to: - /// **'Album Name/filename'** - String get folderAlbumSubtitle; - - /// Folder option - nested - /// - /// In en, this message translates to: - /// **'Artist/Album'** - String get folderArtistAlbum; - - /// Folder structure example - /// - /// In en, this message translates to: - /// **'Artist Name/Album Name/filename'** - String get folderArtistAlbumSubtitle; - - /// Service name - DO NOT TRANSLATE - /// - /// In en, this message translates to: - /// **'Tidal'** - String get serviceTidal; - - /// Service name - DO NOT TRANSLATE - /// - /// In en, this message translates to: - /// **'Qobuz'** - String get serviceQobuz; - - /// Service name - DO NOT TRANSLATE - /// - /// In en, this message translates to: - /// **'Amazon'** - String get serviceAmazon; - - /// Service name - DO NOT TRANSLATE - /// - /// In en, this message translates to: - /// **'Deezer'** - String get serviceDeezer; - - /// Service name - DO NOT TRANSLATE - /// - /// In en, this message translates to: - /// **'Spotify'** - String get serviceSpotify; - /// Theme option - pure black /// /// In en, this message translates to: @@ -3694,24 +2578,6 @@ abstract class AppLocalizations { /// **'Pure black background'** String get appearanceAmoledDarkSubtitle; - /// Color picker dialog title - /// - /// In en, this message translates to: - /// **'Choose Accent Color'** - String get appearanceChooseAccentColor; - - /// Theme picker dialog title - /// - /// In en, this message translates to: - /// **'Theme Mode'** - String get appearanceChooseTheme; - - /// Queue screen title - /// - /// In en, this message translates to: - /// **'Download Queue'** - String get queueTitle; - /// Button - clear all queue items /// /// In en, this message translates to: @@ -3724,30 +2590,6 @@ abstract class AppLocalizations { /// **'Are you sure you want to clear all downloads?'** String get queueClearAllMessage; - /// Button - export failed downloads to TXT - /// - /// In en, this message translates to: - /// **'Export'** - String get queueExportFailed; - - /// Success message after exporting failed downloads - /// - /// In en, this message translates to: - /// **'Failed downloads exported to TXT file'** - String get queueExportFailedSuccess; - - /// Action to clear failed downloads after export - /// - /// In en, this message translates to: - /// **'Clear Failed'** - String get queueExportFailedClear; - - /// Error message when export fails - /// - /// In en, this message translates to: - /// **'Failed to export downloads'** - String get queueExportFailedError; - /// Setting toggle for auto-export /// /// In en, this message translates to: @@ -3784,54 +2626,6 @@ abstract class AppLocalizations { /// **'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.'** String get settingsDownloadNetworkSubtitle; - /// Empty queue state title - /// - /// In en, this message translates to: - /// **'No downloads in queue'** - String get queueEmpty; - - /// Empty queue state subtitle - /// - /// In en, this message translates to: - /// **'Add tracks from the home screen'** - String get queueEmptySubtitle; - - /// Button - clear finished downloads - /// - /// In en, this message translates to: - /// **'Clear completed'** - String get queueClearCompleted; - - /// Error dialog title - /// - /// In en, this message translates to: - /// **'Download Failed'** - String get queueDownloadFailed; - - /// Label in error dialog - /// - /// In en, this message translates to: - /// **'Track:'** - String get queueTrackLabel; - - /// Label in error dialog - /// - /// In en, this message translates to: - /// **'Artist:'** - String get queueArtistLabel; - - /// Label in error dialog - /// - /// In en, this message translates to: - /// **'Error:'** - String get queueErrorLabel; - - /// Fallback error message - /// - /// In en, this message translates to: - /// **'Unknown error'** - String get queueUnknownError; - /// Album folder option /// /// In en, this message translates to: @@ -3904,18 +2698,6 @@ abstract class AppLocalizations { /// **'Delete {count} {count, plural, =1{track} other{tracks}} from this album?\n\nThis will also delete the files from storage.'** String downloadedAlbumDeleteMessage(int count); - /// Section header for tracks - /// - /// In en, this message translates to: - /// **'Tracks'** - String get downloadedAlbumTracksHeader; - - /// Downloaded tracks count badge - /// - /// In en, this message translates to: - /// **'{count} downloaded'** - String downloadedAlbumDownloadedCount(int count); - /// Selection count indicator /// /// In en, this message translates to: @@ -3952,12 +2734,6 @@ abstract class AppLocalizations { /// **'Disc {discNumber}'** String downloadedAlbumDiscHeader(int discNumber); - /// Extension capability - utility functions - /// - /// In en, this message translates to: - /// **'Utility Functions'** - String get utilityFunctions; - /// Recent access item type - artist /// /// In en, this message translates to: @@ -4000,12 +2776,6 @@ abstract class AppLocalizations { /// **'Playlist: {name}'** String recentPlaylistInfo(String name); - /// Generic error message format - /// - /// In en, this message translates to: - /// **'Error: {message}'** - String errorGeneric(String message); - /// Button - download artist discography /// /// In en, this message translates to: @@ -4180,12 +2950,6 @@ abstract class AppLocalizations { /// **'Local Library'** String get libraryTitle; - /// Section header for library status - /// - /// In en, this message translates to: - /// **'Library Status'** - String get libraryStatus; - /// Section header for scan settings /// /// In en, this message translates to: @@ -4300,11 +3064,11 @@ abstract class AppLocalizations { /// **'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.'** String get libraryAboutDescription; - /// Track count in library + /// Unit label for tracks count (without the number itself) /// /// In en, this message translates to: - /// **'{count} tracks'** - String libraryTracksCount(int count); + /// **'{count, plural, =1{track} other{tracks}}'** + String libraryTracksUnit(int count); /// Last scan time display /// @@ -4450,36 +3214,6 @@ abstract class AppLocalizations { /// **'Format'** String get libraryFilterFormat; - /// Filter section - date range - /// - /// In en, this message translates to: - /// **'Date Added'** - String get libraryFilterDate; - - /// Filter option - today only - /// - /// In en, this message translates to: - /// **'Today'** - String get libraryFilterDateToday; - - /// Filter option - this week - /// - /// In en, this message translates to: - /// **'This Week'** - String get libraryFilterDateWeek; - - /// Filter option - this month - /// - /// In en, this message translates to: - /// **'This Month'** - String get libraryFilterDateMonth; - - /// Filter option - this year - /// - /// In en, this message translates to: - /// **'This Year'** - String get libraryFilterDateYear; - /// Filter section - sort order /// /// In en, this message translates to: @@ -4498,12 +3232,6 @@ abstract class AppLocalizations { /// **'Oldest'** String get libraryFilterSortOldest; - /// Badge showing number of active filters - /// - /// In en, this message translates to: - /// **'{count} filter(s) active'** - String libraryFilterActive(int count); - /// Relative time - less than a minute ago /// /// In en, this message translates to: @@ -4522,114 +3250,6 @@ abstract class AppLocalizations { /// **'{count, plural, =1{1 hour ago} other{{count} hours ago}}'** String timeHoursAgo(int count); - /// Dialog title when switching storage mode - /// - /// In en, this message translates to: - /// **'Switch Storage Mode'** - String get storageSwitchTitle; - - /// Dialog title when switching to SAF - /// - /// In en, this message translates to: - /// **'Switch to SAF Storage?'** - String get storageSwitchToSafTitle; - - /// Dialog title when switching to app storage - /// - /// In en, this message translates to: - /// **'Switch to App Storage?'** - String get storageSwitchToAppTitle; - - /// Explanation when switching to SAF - /// - /// In en, this message translates to: - /// **'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.'** - String get storageSwitchToSafMessage; - - /// Explanation when switching to app storage - /// - /// In en, this message translates to: - /// **'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.'** - String get storageSwitchToAppMessage; - - /// Section header for existing downloads info - /// - /// In en, this message translates to: - /// **'Existing Downloads'** - String get storageSwitchExistingDownloads; - - /// Info about existing downloads count - /// - /// In en, this message translates to: - /// **'{count} tracks in {mode} storage'** - String storageSwitchExistingDownloadsInfo(int count, String mode); - - /// Section header for new downloads info - /// - /// In en, this message translates to: - /// **'New Downloads'** - String get storageSwitchNewDownloads; - - /// Shows where new downloads will go - /// - /// In en, this message translates to: - /// **'Will be saved to: {location}'** - String storageSwitchNewDownloadsLocation(String location); - - /// Button to proceed with storage switch - /// - /// In en, this message translates to: - /// **'Continue'** - String get storageSwitchContinue; - - /// Button to select SAF folder - /// - /// In en, this message translates to: - /// **'Select SAF Folder'** - String get storageSwitchSelectFolder; - - /// Label for app storage mode - /// - /// In en, this message translates to: - /// **'App Storage'** - String get storageAppStorage; - - /// Label for SAF storage mode - /// - /// In en, this message translates to: - /// **'SAF Storage'** - String get storageSafStorage; - - /// Badge showing storage mode for a track - /// - /// In en, this message translates to: - /// **'Storage: {mode}'** - String storageModeBadge(String mode); - - /// Section title for storage stats - /// - /// In en, this message translates to: - /// **'Storage Statistics'** - String get storageStatsTitle; - - /// Count of tracks in app storage - /// - /// In en, this message translates to: - /// **'{count} tracks in App Storage'** - String storageStatsAppCount(int count); - - /// Count of tracks in SAF storage - /// - /// In en, this message translates to: - /// **'{count} tracks in SAF Storage'** - String storageStatsSafCount(int count); - - /// Info when user has files in both storage modes - /// - /// In en, this message translates to: - /// **'Your files are stored in multiple locations'** - String get storageModeInfo; - /// Tutorial welcome page title /// /// In en, this message translates to: @@ -4672,24 +3292,6 @@ abstract class AppLocalizations { /// **'There are two easy ways to find music you want to download.'** String get tutorialSearchDesc; - /// Tutorial search tip 1 - /// - /// In en, this message translates to: - /// **'Paste a Spotify or Deezer URL directly in the search box'** - String get tutorialSearchTip1; - - /// Tutorial search tip 2 - /// - /// In en, this message translates to: - /// **'Or type the song name, artist, or album to search'** - String get tutorialSearchTip2; - - /// Tutorial search tip 3 - /// - /// In en, this message translates to: - /// **'Supports tracks, albums, playlists, and artist pages'** - String get tutorialSearchTip3; - /// Tutorial download page title /// /// In en, this message translates to: @@ -4702,24 +3304,6 @@ abstract class AppLocalizations { /// **'Downloading music is simple and fast. Here\'s how it works.'** String get tutorialDownloadDesc; - /// Tutorial download tip 1 - /// - /// In en, this message translates to: - /// **'Tap the download button next to any track to start downloading'** - String get tutorialDownloadTip1; - - /// Tutorial download tip 2 - /// - /// In en, this message translates to: - /// **'Choose your preferred quality (FLAC, Hi-Res, or MP3)'** - String get tutorialDownloadTip2; - - /// Tutorial download tip 3 - /// - /// In en, this message translates to: - /// **'Download entire albums or playlists with one tap'** - String get tutorialDownloadTip3; - /// Tutorial library page title /// /// In en, this message translates to: @@ -4816,12 +3400,6 @@ abstract class AppLocalizations { /// **'You\'re all set! Start downloading your favorite music now.'** String get tutorialReadyMessage; - /// Example label in tutorial - /// - /// In en, this message translates to: - /// **'EXAMPLE'** - String get tutorialExample; - /// Button to force a complete rescan of library /// /// In en, this message translates to: @@ -5089,15 +3667,9 @@ abstract class AppLocalizations { /// Menu action - re-embed metadata into audio file /// /// In en, this message translates to: - /// **'Re-enrich Metadata'** + /// **'Re-enrich'** String get trackReEnrich; - /// Subtitle for re-enrich metadata action - /// - /// In en, this message translates to: - /// **'Re-embed metadata without re-downloading'** - String get trackReEnrichSubtitle; - /// Subtitle for re-enrich metadata action for local items /// /// In en, this message translates to: @@ -5221,6 +3793,316 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Conversion failed'** String get trackConvertFailed; + + /// Generic action button - create + /// + /// In en, this message translates to: + /// **'Create'** + String get actionCreate; + + /// Library section title for custom folders + /// + /// In en, this message translates to: + /// **'My folders'** + String get collectionFoldersTitle; + + /// Custom folder for saved tracks to download later + /// + /// In en, this message translates to: + /// **'Wishlist'** + String get collectionWishlist; + + /// Custom folder for favorite tracks + /// + /// In en, this message translates to: + /// **'Loved'** + String get collectionLoved; + + /// Custom user playlists folder + /// + /// In en, this message translates to: + /// **'Playlists'** + String get collectionPlaylists; + + /// Single playlist label + /// + /// In en, this message translates to: + /// **'Playlist'** + String get collectionPlaylist; + + /// Action to add a track to user playlist + /// + /// In en, this message translates to: + /// **'Add to playlist'** + String get collectionAddToPlaylist; + + /// Action to create a new playlist + /// + /// In en, this message translates to: + /// **'Create playlist'** + String get collectionCreatePlaylist; + + /// Empty state title when user has no playlists + /// + /// In en, this message translates to: + /// **'No playlists yet'** + String get collectionNoPlaylistsYet; + + /// Empty state subtitle when user has no playlists + /// + /// In en, this message translates to: + /// **'Create a playlist to start categorizing tracks'** + String get collectionNoPlaylistsSubtitle; + + /// Track count label for custom playlists + /// + /// In en, this message translates to: + /// **'{count, plural, =1{1 track} other{{count} tracks}}'** + String collectionPlaylistTracks(int count); + + /// Snackbar after adding track to playlist + /// + /// In en, this message translates to: + /// **'Added to \"{playlistName}\"'** + String collectionAddedToPlaylist(String playlistName); + + /// Snackbar when track already exists in playlist + /// + /// In en, this message translates to: + /// **'Already in \"{playlistName}\"'** + String collectionAlreadyInPlaylist(String playlistName); + + /// Snackbar after creating playlist + /// + /// In en, this message translates to: + /// **'Playlist created'** + String get collectionPlaylistCreated; + + /// Hint text for playlist name input + /// + /// In en, this message translates to: + /// **'Playlist name'** + String get collectionPlaylistNameHint; + + /// Validation error for empty playlist name + /// + /// In en, this message translates to: + /// **'Playlist name is required'** + String get collectionPlaylistNameRequired; + + /// Action to rename playlist + /// + /// In en, this message translates to: + /// **'Rename playlist'** + String get collectionRenamePlaylist; + + /// Action to delete playlist + /// + /// In en, this message translates to: + /// **'Delete playlist'** + String get collectionDeletePlaylist; + + /// Confirmation message for deleting playlist + /// + /// In en, this message translates to: + /// **'Delete \"{playlistName}\" and all tracks inside it?'** + String collectionDeletePlaylistMessage(String playlistName); + + /// Snackbar after deleting playlist + /// + /// In en, this message translates to: + /// **'Playlist deleted'** + String get collectionPlaylistDeleted; + + /// Snackbar after renaming playlist + /// + /// In en, this message translates to: + /// **'Playlist renamed'** + String get collectionPlaylistRenamed; + + /// Wishlist empty state title + /// + /// In en, this message translates to: + /// **'Wishlist is empty'** + String get collectionWishlistEmptyTitle; + + /// Wishlist empty state subtitle + /// + /// In en, this message translates to: + /// **'Tap + on tracks to save what you want to download later'** + String get collectionWishlistEmptySubtitle; + + /// Loved empty state title + /// + /// In en, this message translates to: + /// **'Loved folder is empty'** + String get collectionLovedEmptyTitle; + + /// Loved empty state subtitle + /// + /// In en, this message translates to: + /// **'Tap love on tracks to keep your favorites'** + String get collectionLovedEmptySubtitle; + + /// Playlist empty state title + /// + /// In en, this message translates to: + /// **'Playlist is empty'** + String get collectionPlaylistEmptyTitle; + + /// Playlist empty state subtitle + /// + /// In en, this message translates to: + /// **'Long-press + on any track to add it here'** + String get collectionPlaylistEmptySubtitle; + + /// Tooltip for removing track from playlist + /// + /// In en, this message translates to: + /// **'Remove from playlist'** + String get collectionRemoveFromPlaylist; + + /// Tooltip for removing track from wishlist/loved folder + /// + /// In en, this message translates to: + /// **'Remove from folder'** + String get collectionRemoveFromFolder; + + /// Snackbar after removing a track from a collection + /// + /// In en, this message translates to: + /// **'\"{trackName}\" removed'** + String collectionRemoved(String trackName); + + /// Snackbar after adding track to loved folder + /// + /// In en, this message translates to: + /// **'\"{trackName}\" added to Loved'** + String collectionAddedToLoved(String trackName); + + /// Snackbar after removing track from loved folder + /// + /// In en, this message translates to: + /// **'\"{trackName}\" removed from Loved'** + String collectionRemovedFromLoved(String trackName); + + /// Snackbar after adding track to wishlist + /// + /// In en, this message translates to: + /// **'\"{trackName}\" added to Wishlist'** + String collectionAddedToWishlist(String trackName); + + /// Snackbar after removing track from wishlist + /// + /// In en, this message translates to: + /// **'\"{trackName}\" removed from Wishlist'** + String collectionRemovedFromWishlist(String trackName); + + /// Bottom sheet action label - add track to loved folder + /// + /// In en, this message translates to: + /// **'Add to Loved'** + String get trackOptionAddToLoved; + + /// Bottom sheet action label - remove track from loved folder + /// + /// In en, this message translates to: + /// **'Remove from Loved'** + String get trackOptionRemoveFromLoved; + + /// Bottom sheet action label - add track to wishlist + /// + /// In en, this message translates to: + /// **'Add to Wishlist'** + String get trackOptionAddToWishlist; + + /// Bottom sheet action label - remove track from wishlist + /// + /// In en, this message translates to: + /// **'Remove from Wishlist'** + String get trackOptionRemoveFromWishlist; + + /// Bottom sheet action to pick a custom cover image for a playlist + /// + /// In en, this message translates to: + /// **'Change cover image'** + String get collectionPlaylistChangeCover; + + /// Bottom sheet action to remove custom cover image from a playlist + /// + /// In en, this message translates to: + /// **'Remove cover image'** + String get collectionPlaylistRemoveCover; + + /// Share button text with count in selection mode + /// + /// In en, this message translates to: + /// **'Share {count} {count, plural, =1{track} other{tracks}}'** + String selectionShareCount(int count); + + /// Snackbar when no selected files exist on disk + /// + /// In en, this message translates to: + /// **'No shareable files found'** + String get selectionShareNoFiles; + + /// Convert button text with count in selection mode + /// + /// In en, this message translates to: + /// **'Convert {count} {count, plural, =1{track} other{tracks}}'** + String selectionConvertCount(int count); + + /// Snackbar when no selected tracks support conversion + /// + /// In en, this message translates to: + /// **'No convertible tracks selected'** + String get selectionConvertNoConvertible; + + /// Confirmation dialog title for batch conversion + /// + /// In en, this message translates to: + /// **'Batch Convert'** + String get selectionBatchConvertConfirmTitle; + + /// Confirmation dialog message for batch conversion + /// + /// In en, this message translates to: + /// **'Convert {count} {count, plural, =1{track} other{tracks}} to {format} at {bitrate}?\n\nOriginal files will be deleted after conversion.'** + String selectionBatchConvertConfirmMessage( + int count, + String format, + String bitrate, + ); + + /// Snackbar during batch conversion progress + /// + /// In en, this message translates to: + /// **'Converting {current} of {total}...'** + String selectionBatchConvertProgress(int current, int total); + + /// Snackbar after batch conversion completes + /// + /// In en, this message translates to: + /// **'Converted {success} of {total} tracks to {format}'** + String selectionBatchConvertSuccess(int success, int total, String format); + + /// Downloaded tracks count badge + /// + /// In en, this message translates to: + /// **'{count} downloaded'** + String downloadedAlbumDownloadedCount(int count); + + /// Subtitle when Album Artist is used for folder naming + /// + /// In en, this message translates to: + /// **'Artist folders use Album Artist when available'** + String get downloadUseAlbumArtistForFoldersAlbumSubtitle; + + /// Subtitle when Track Artist is used for folder naming + /// + /// In en, this message translates to: + /// **'Artist folders use Track Artist only'** + String get downloadUseAlbumArtistForFoldersTrackSubtitle; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index a89201ef..2b846be4 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -11,19 +11,12 @@ class AppLocalizationsDe extends AppLocalizations { @override String get appName => 'SpotiFLAC'; - @override - String get appDescription => - 'Lade Spotify-Titel in verlustfreier Qualität von Tidal, Qobuz und Amazon Music herunter.'; - @override String get navHome => 'Startseite'; @override String get navLibrary => 'Archiv'; - @override - String get navHistory => 'Verlauf'; - @override String get navSettings => 'Einstellungen'; @@ -33,14 +26,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get homeTitle => 'Startseite'; - @override - String get homeSearchHint => 'Spotify-URL einfügen oder suchen...'; - - @override - String homeSearchHintExtension(String extensionName) { - return 'Mit $extensionName suchen...'; - } - @override String get homeSubtitle => 'Spotify-Link einfügen oder nach Namen suchen'; @@ -51,17 +36,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get homeRecent => 'Zuletzt'; - @override - String get historyTitle => 'Verlauf'; - - @override - String historyDownloading(int count) { - return 'Wird heruntergeladen ($count)'; - } - - @override - String get historyDownloaded => 'Heruntergeladen'; - @override String get historyFilterAll => 'Alle'; @@ -71,49 +45,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get historyFilterSingles => 'Singles'; - @override - String historyTracksCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count Titel', - one: '1 Titel', - ); - return '$_temp0'; - } - - @override - String historyAlbumsCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count Alben', - one: '1 Album', - ); - return '$_temp0'; - } - - @override - String get historyNoDownloads => 'Kein Download-Verlauf'; - - @override - String get historyNoDownloadsSubtitle => - 'Heruntergeladene Titel werden hier angezeigt'; - - @override - String get historyNoAlbums => 'Keine Album-Downloads'; - - @override - String get historyNoAlbumsSubtitle => - 'Lade mehrere Titel eines Albums herunter, um sie hier zu sehen'; - - @override - String get historyNoSingles => 'Keine Einzel-Downloads'; - - @override - String get historyNoSinglesSubtitle => - 'Einzelne Titel-Downloads werden hier angezeigt'; - @override String get historySearchHint => 'Suchverlauf...'; @@ -138,27 +69,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get downloadTitle => 'Herunterladen'; - @override - String get downloadLocation => 'Download-Speicherort'; - - @override - String get downloadLocationSubtitle => 'Wähle den Speicherort der Dateien'; - - @override - String get downloadLocationDefault => 'Standard-Speicherort'; - - @override - String get downloadDefaultService => 'Standard-Dienst'; - - @override - String get downloadDefaultServiceSubtitle => 'Dienst für Downloads'; - - @override - String get downloadDefaultQuality => 'Standard-Qualität'; - - @override - String get downloadAskQuality => 'Qualität vor Download abfragen'; - @override String get downloadAskQualitySubtitle => 'Qualitätsauswahl für jeden Download anzeigen'; @@ -169,31 +79,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get downloadFolderOrganization => 'Ordnerstruktur'; - @override - String get downloadSeparateSingles => 'Singles trennen'; - - @override - String get downloadSeparateSinglesSubtitle => - 'Einzelne Titel in separatem Ordner speichern'; - - @override - String get qualityBest => 'Beste Qualität'; - - @override - String get qualityFlac => 'FLAC'; - - @override - String get quality320 => '320 kbps'; - - @override - String get quality128 => '128 kbps'; - @override String get appearanceTitle => 'Erscheinungsbild'; - @override - String get appearanceTheme => 'Design'; - @override String get appearanceThemeSystem => 'System'; @@ -210,9 +98,6 @@ class AppLocalizationsDe extends AppLocalizations { String get appearanceDynamicColorSubtitle => 'Farben von Ihrem Hintergrundbild verwenden'; - @override - String get appearanceAccentColor => 'Akzentfarbe'; - @override String get appearanceHistoryView => 'Verlaufsansicht'; @@ -225,9 +110,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get optionsTitle => 'Optionen'; - @override - String get optionsSearchSource => 'Suchquelle'; - @override String get optionsPrimaryProvider => 'Primärer Anbieter'; @@ -358,19 +240,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get extensionsTitle => 'Erweiterungen'; - @override - String get extensionsInstalled => 'Installierte Erweiterungen'; - - @override - String get extensionsNone => 'Keine Erweiterungen installiert'; - - @override - String get extensionsNoneSubtitle => - 'Erweiterungen aus dem Store-Tab installieren'; - - @override - String get extensionsEnabled => 'Aktiviert'; - @override String get extensionsDisabled => 'Deaktiviert'; @@ -387,9 +256,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get extensionsUninstall => 'Deinstallieren'; - @override - String get extensionsSetAsSearch => 'Als Suchanbieter festlegen'; - @override String get storeTitle => 'Erweiterungs-Store'; @@ -465,9 +331,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get aboutSocial => 'Sozial'; - @override - String get aboutSupport => 'Support'; - @override String get aboutApp => 'App'; @@ -486,13 +349,6 @@ class AppLocalizationsDe extends AppLocalizations { String get aboutSjdonadoDesc => 'Ersteller von I Don\'t Have Spotify (IDHS). Der Fallback-Link-Resolver, der den Tag rettete!'; - @override - String get aboutDoubleDouble => 'DoubleDouble'; - - @override - String get aboutDoubleDoubleDesc => - 'Wundervolle API für Amazon Musik-Downloads.'; - @override String get aboutDabMusic => 'DAB Music'; @@ -511,32 +367,6 @@ class AppLocalizationsDe extends AppLocalizations { String get aboutAppDescription => 'Lade Spotify-Titel in verlustfreier Qualität von Tidal, Qobuz und Amazon Music herunter.'; - @override - String get albumTitle => 'Album'; - - @override - String albumTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count Songs', - one: '1 Song', - ); - return '$_temp0'; - } - - @override - String get albumDownloadAll => 'Alle Herunterladen'; - - @override - String get albumDownloadRemaining => 'Downloads verbleibend'; - - @override - String get playlistTitle => 'Playlist'; - - @override - String get artistTitle => 'Künstler'; - @override String get artistAlbums => 'Alben'; @@ -546,17 +376,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get artistCompilations => 'Zusammenstellungen'; - @override - String artistReleases(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count Veröffentlichungen', - one: '1 Veröffentlichung', - ); - return '$_temp0'; - } - @override String get artistPopular => 'Beliebt'; @@ -565,27 +384,6 @@ class AppLocalizationsDe extends AppLocalizations { return '$count monatliche Hörer'; } - @override - String get trackMetadataTitle => 'Titel Info'; - - @override - String get trackMetadataArtist => 'Künstler'; - - @override - String get trackMetadataAlbum => 'Album'; - - @override - String get trackMetadataDuration => 'Länge'; - - @override - String get trackMetadataQuality => 'Qualität'; - - @override - String get trackMetadataPath => 'Dateipfad'; - - @override - String get trackMetadataDownloadedAt => 'Heruntergeladen'; - @override String get trackMetadataService => 'Anbieter'; @@ -598,53 +396,15 @@ class AppLocalizationsDe extends AppLocalizations { @override String get trackMetadataDelete => 'Löschen'; - @override - String get trackMetadataRedownload => 'Erneut herunterladen'; - - @override - String get trackMetadataOpenFolder => 'Ordner öffnen'; - - @override - String get setupTitle => 'Willkommen bei SpotiFLAC'; - - @override - String get setupSubtitle => 'Los geht\'s'; - - @override - String get setupStoragePermission => 'Speicherberechtigung'; - - @override - String get setupStoragePermissionSubtitle => - 'Benötigt um heruntergeladene Dateien zu Speichern'; - - @override - String get setupStoragePermissionGranted => 'Berechtigung erteilt'; - - @override - String get setupStoragePermissionDenied => 'Berechtigung verweigert'; - @override String get setupGrantPermission => 'Berechtigung erlauben'; - @override - String get setupDownloadLocation => 'Speicherort'; - - @override - String get setupChooseFolder => 'Ordner wählen'; - - @override - String get setupContinue => 'Fortfahren'; - @override String get setupSkip => 'Vorerst überspringen'; @override String get setupStorageAccessRequired => 'Speicherzugriff erforderlich'; - @override - String get setupStorageAccessMessage => - 'SpotiFLAC benötigt die Berechtigung \"Auf alle Dateien zugreifen\", um Musikdateien in deinen gewählten Ordner zu speichern.'; - @override String get setupStorageAccessMessageAndroid11 => 'Android 11+ benötigt die Berechtigung „Auf alle Dateien“, um Dateien im ausgewählten Download-Ordner zu speichern.'; @@ -666,9 +426,6 @@ class AppLocalizationsDe extends AppLocalizations { return '$permissionType Berechtigung ist erforderlich für\ndie beste Benutzererfahrung. Für kannst dies später in den Einstellungen ändern.'; } - @override - String get setupSelectDownloadFolder => 'Wähle Download-Ordner aus'; - @override String get setupUseDefaultFolder => 'Als Standardordner verwenden?'; @@ -711,21 +468,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get setupDownloadInFlac => 'Spotify Titel in FLAC herunterladen'; - @override - String get setupStepStorage => 'Speicherort'; - - @override - String get setupStepNotification => 'Benachrichtigung'; - - @override - String get setupStepFolder => 'Ordner'; - - @override - String get setupStepSpotify => 'Spotify'; - - @override - String get setupStepPermission => 'Berechtigung'; - @override String get setupStorageGranted => 'Speicherberechtigung erlaubt!'; @@ -743,13 +485,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get setupNotificationEnable => 'Benachrichtigungen aktivieren'; - @override - String get setupNotificationDescription => - 'Benachrichtigt werden, wenn Downloads abgeschlossen sind.'; - - @override - String get setupFolderSelected => 'Download Ordner ausgewählt!'; - @override String get setupFolderChoose => 'Speicherort auwählen'; @@ -757,49 +492,12 @@ class AppLocalizationsDe extends AppLocalizations { String get setupFolderDescription => 'Wähle einen Ordner, in dem die heruntergeladene Musik gespeichert wird.'; - @override - String get setupChangeFolder => 'Ordner ändern'; - @override String get setupSelectFolder => 'Ordner wählen'; - @override - String get setupSpotifyApiOptional => 'Spotify-API (optional)'; - - @override - String get setupSpotifyApiDescription => - 'Füge deine Spotify-API-Zugangsdaten für bessere Suchergebnisse und den Zugriff auf Spotify-exklusive Inhalte hinzu.'; - - @override - String get setupUseSpotifyApi => 'Spotify-API verwenden'; - - @override - String get setupEnterCredentialsBelow => 'Gib deine Anmeldedaten unten ein'; - - @override - String get setupUsingDeezer => 'Deezer verwenden (kein Konto erforderlich)'; - - @override - String get setupEnterClientId => 'Spotify-Client-ID eingeben'; - - @override - String get setupEnterClientSecret => 'Spotify Client-Secret eingeben'; - - @override - String get setupGetFreeCredentials => - 'Hole dir kostenlose API-Anmeldeinformationen aus dem Spotify-Entwickler-Dashboard.'; - @override String get setupEnableNotifications => 'Benachrichtigungen aktivieren'; - @override - String get setupProceedToNextStep => - 'Du kannst mit dem nächsten Schritt fortfahren.'; - - @override - String get setupNotificationProgressDescription => - 'Du erhältst Benachrichtigungen über den Download-Fortschritt.'; - @override String get setupNotificationBackgroundDescription => 'Werde benachrichtigt über Download-Fortschritt und -Fertigstellung. Dies hilft Ihnen, Downloads zu verfolgen, wenn die App im Hintergrund ist.'; @@ -807,32 +505,19 @@ class AppLocalizationsDe extends AppLocalizations { @override String get setupSkipForNow => 'Vorerst überspringen'; - @override - String get setupBack => 'Zurück'; - @override String get setupNext => 'Weiter'; @override String get setupGetStarted => 'Los geht‘s'; - @override - String get setupSkipAndStart => 'Überspringen & Starten'; - @override String get setupAllowAccessToManageFiles => 'Bitte aktiviere \"Zugriff auf alle Dateien erlauben\" auf dem nächsten Bildschirm.'; - @override - String get setupGetCredentialsFromSpotify => - 'Zugangsdaten von developer.spotify.com erhalten'; - @override String get dialogCancel => 'Abbrechen'; - @override - String get dialogOk => 'OK'; - @override String get dialogSave => 'Speichern'; @@ -842,21 +527,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get dialogRetry => 'Wiederholen'; - @override - String get dialogClose => 'Schließen'; - - @override - String get dialogYes => 'Ja'; - - @override - String get dialogNo => 'Nein'; - @override String get dialogClear => 'Leeren'; - @override - String get dialogConfirm => 'Bestätigen'; - @override String get dialogDone => 'Fertig'; @@ -879,28 +552,9 @@ class AppLocalizationsDe extends AppLocalizations { String get dialogUnsavedChanges => 'Hast du noch nicht alle Änderungen gespeichert. Möchtest du die Änderungen verwerfen?'; - @override - String get dialogDownloadFailed => 'Download fehlgeschlagen'; - - @override - String get dialogTrackLabel => 'Titel:'; - - @override - String get dialogArtistLabel => 'Künstler:'; - - @override - String get dialogErrorLabel => 'Fehler:'; - @override String get dialogClearAll => 'Alles löschen'; - @override - String get dialogClearAllDownloads => - 'Bist du dir sicher, dass du alle Downloads löschen möchten?'; - - @override - String get dialogRemoveFromDevice => 'Vom Gerät entfernen?'; - @override String get dialogRemoveExtension => 'Erweiterung entfernen'; @@ -1001,11 +655,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get snackbarViewQueue => 'Warteschlange anzeigen'; - @override - String snackbarFailedToLoad(String error) { - return 'Fehler beim Laden: $error'; - } - @override String snackbarUrlCopied(String platform) { return '$platform URL in die Zwischenablage kopiert'; @@ -1049,11 +698,6 @@ class AppLocalizationsDe extends AppLocalizations { String get errorRateLimitedMessage => 'Zu viele Anfragen. Bitte warte einen Moment, bevor du es erneut suchst.'; - @override - String errorFailedToLoad(String item) { - return 'Fehler beim Laden von: $item'; - } - @override String get errorNoTracksFound => 'Keine Titel gefunden'; @@ -1062,27 +706,6 @@ class AppLocalizationsDe extends AppLocalizations { return 'Kann $item nicht lade wegen fehlender Erweiterungsquelle'; } - @override - String get statusQueued => 'In der Warteschlange'; - - @override - String get statusDownloading => 'Wird heruntergeladen'; - - @override - String get statusFinalizing => 'Wird fertiggestellt'; - - @override - String get statusCompleted => 'Beendet'; - - @override - String get statusFailed => 'Fehlgeschlagen'; - - @override - String get statusSkipped => 'Übersprungen'; - - @override - String get statusPaused => 'Pausiert'; - @override String get actionPause => 'Pause'; @@ -1092,24 +715,12 @@ class AppLocalizationsDe extends AppLocalizations { @override String get actionCancel => 'Abbrechen'; - @override - String get actionStop => 'Beenden'; - - @override - String get actionSelect => 'Wähle'; - @override String get actionSelectAll => 'Alles Auswählen'; @override String get actionDeselect => 'Alle abwählen'; - @override - String get actionPaste => 'Einfügen'; - - @override - String get actionImportCsv => 'CSV-Datei importieren'; - @override String get actionRemoveCredentials => 'Anmeldedaten entfernen'; @@ -1124,20 +735,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get selectionAllSelected => 'Alle Titel sind ausgewählt'; - @override - String get selectionTapToSelect => 'Tippe auf Titel zum Auswählen'; - - @override - String selectionDeleteTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: 'Titel', - one: 'Titel', - ); - return 'Lösche $count $_temp0'; - } - @override String get selectionSelectToDelete => 'Titel zum Löschen auswählen'; @@ -1164,40 +761,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get tooltipPlay => 'Abspielen'; - @override - String get tooltipCancel => 'Abbrechen'; - - @override - String get tooltipStop => 'Beenden'; - - @override - String get tooltipRetry => 'Wiederholen'; - - @override - String get tooltipRemove => 'Entfernen'; - - @override - String get tooltipClear => 'Leeren'; - - @override - String get tooltipPaste => 'Einfügen'; - @override String get filenameFormat => 'Dateinamenformat'; - @override - String filenameFormatPreview(String preview) { - return 'Vorschau: $preview'; - } - - @override - String get filenameAvailablePlaceholders => 'Verfügbare Platzhalter:'; - - @override - String filenameHint(Object artist, Object title) { - return '$artist - $title'; - } - @override String get filenameShowAdvancedTags => 'Show advanced tags'; @@ -1205,9 +771,6 @@ class AppLocalizationsDe extends AppLocalizations { String get filenameShowAdvancedTagsDescription => 'Enable formatted tags for track padding and date patterns'; - @override - String get folderOrganization => 'Ordnerstruktur'; - @override String get folderOrganizationNone => 'Keine Organisation'; @@ -1242,20 +805,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get updateAvailable => 'Update verfügbar'; - @override - String updateNewVersion(String version) { - return 'Version $version ist verfügbar'; - } - - @override - String get updateDownload => 'Herunterladen'; - @override String get updateLater => 'Später'; - @override - String get updateChangelog => 'Änderungsverlauf'; - @override String get updateStartingDownload => 'Download wird gestartet...'; @@ -1287,13 +839,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get updateDontRemind => 'Nicht erinnern'; - @override - String get providerPriority => 'Anbieterpriorität'; - - @override - String get providerPrioritySubtitle => - 'Ziehen, um Download-Anbieter neu anzuordnen'; - @override String get providerPriorityTitle => 'Anbieterpriorität'; @@ -1311,13 +856,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get providerExtension => 'Erweiterung'; - @override - String get metadataProviderPriority => 'Priorität des Metadaten-Anbieters'; - - @override - String get metadataProviderPrioritySubtitle => - 'Reihenfolge beim Abrufen von Titelmetadaten'; - @override String get metadataProviderPriorityTitle => 'Metadaten Priorität'; @@ -1338,18 +876,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get logTitle => 'Protokolle'; - @override - String get logCopy => 'Protokolle kopieren'; - - @override - String get logClear => 'Protokolle löschen'; - - @override - String get logShare => 'Protokolle teilen'; - - @override - String get logEmpty => 'Keine Protokolle bisher'; - @override String get logCopied => 'Protokolle in Zwischenablage kopiert'; @@ -1375,18 +901,6 @@ class AppLocalizationsDe extends AppLocalizations { String get logClearLogsMessage => 'Bist du dir sicher, dass Sie alle Protokolle löschen möchtest?'; - @override - String get logIspBlocking => 'ISP BLOCKIERUNG ERKANNT'; - - @override - String get logRateLimited => 'LIMIT ERKANNT'; - - @override - String get logNetworkError => 'NETZWERKFEHLER'; - - @override - String get logTrackNotFound => 'TITEL NICHT GEFUNDEN'; - @override String get logFilterBySeverity => 'Protokolle nach Schweregrad filtern'; @@ -1397,48 +911,6 @@ class AppLocalizationsDe extends AppLocalizations { String get logNoLogsYetSubtitle => 'Protokolle werden hier angezeigt, während du die App benutzt'; - @override - String get logIssueSummary => 'Problemübersicht'; - - @override - String get logIspBlockingDescription => - 'Ihr ISP blockiert möglicherweise den Zugriff auf den Download Dienst'; - - @override - String get logIspBlockingSuggestion => - 'Versuche es einem VPN oder ändere DNS auf 1.1.1.1 oder 8.8.8.8'; - - @override - String get logRateLimitedDescription => 'Zu viele Anfragen an den Dienst'; - - @override - String get logRateLimitedSuggestion => - 'Warte ein paar Minuten, bevor du es erneut versuchst'; - - @override - String get logNetworkErrorDescription => 'Verbindungsprobleme erkannt'; - - @override - String get logNetworkErrorSuggestion => 'Überprüfe deine Internetverbindung'; - - @override - String get logTrackNotFoundDescription => - 'Einige Titel konnten auf Download-Diensten nicht gefunden werden'; - - @override - String get logTrackNotFoundSuggestion => - 'Der Titel ist möglicherweise nicht in verlustfreier Qualität verfügbar'; - - @override - String logTotalErrors(int count) { - return 'Gesamte Fehler: $count'; - } - - @override - String logAffected(String domains) { - return 'Betroffen: $domains'; - } - @override String logEntriesFiltered(int count) { return 'Einträge ($count gefiltert)'; @@ -1546,9 +1018,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get appearanceLanguage => 'App Sprache'; - @override - String get appearanceLanguageSubtitle => 'Choose your preferred language'; - @override String get settingsAppearanceSubtitle => 'Theme, colors, display'; @@ -1570,9 +1039,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get pressBackAgainToExit => 'Press back again to exit'; - @override - String get tracksHeader => 'Tracks'; - @override String downloadAllCount(int count) { return 'Download All ($count)'; @@ -1686,11 +1152,6 @@ class AppLocalizationsDe extends AppLocalizations { String get trackDeleteConfirmMessage => 'This will permanently delete the downloaded file and remove it from your history.'; - @override - String trackCannotOpen(String message) { - return 'Cannot open: $message'; - } - @override String get dateToday => 'Today'; @@ -1712,18 +1173,6 @@ class AppLocalizationsDe extends AppLocalizations { return '$count months ago'; } - @override - String get concurrentSequential => 'Sequential'; - - @override - String get concurrentParallel2 => '2 Parallel'; - - @override - String get concurrentParallel3 => '3 Parallel'; - - @override - String get tapToSeeError => 'Tap to see error details'; - @override String get storeFilterAll => 'All'; @@ -1745,15 +1194,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get storeClearFilters => 'Clear filters'; - @override - String get storeNoResults => 'No extensions found'; - - @override - String get extensionProviderPriority => 'Provider Priority'; - - @override - String get extensionInstallButton => 'Install Extension'; - @override String get extensionDefaultProvider => 'Default (Deezer/Spotify)'; @@ -1904,38 +1344,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; - @override - String get qualityLossy => 'Lossy'; - - @override - String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)'; - - @override - String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)'; - - @override - String get enableLossyOption => 'Enable Lossy Option'; - - @override - String get enableLossyOptionSubtitleOn => 'Lossy quality option is available'; - - @override - String get enableLossyOptionSubtitleOff => - 'Downloads FLAC then converts to lossy format'; - - @override - String get lossyFormat => 'Lossy Format'; - - @override - String get lossyFormatDescription => 'Choose the lossy format for conversion'; - - @override - String get lossyFormatMp3Subtitle => '320kbps, best compatibility'; - - @override - String get lossyFormatOpusSubtitle => - '128kbps, better quality at smaller size'; - @override String get qualityNote => 'Actual quality depends on track availability from the service'; @@ -1944,6 +1352,12 @@ class AppLocalizationsDe extends AppLocalizations { String get youtubeQualityNote => 'YouTube provides lossy audio only. Not part of lossless fallback.'; + @override + String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; + + @override + String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; + @override String get downloadAskBeforeDownload => 'Ask Before Download'; @@ -1959,14 +1373,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders'; - @override - String get downloadUseAlbumArtistForFoldersAlbumSubtitle => - 'Artist folders use Album Artist when available'; - - @override - String get downloadUseAlbumArtistForFoldersTrackSubtitle => - 'Artist folders use Track Artist only'; - @override String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders'; @@ -1978,78 +1384,18 @@ class AppLocalizationsDe extends AppLocalizations { String get downloadUsePrimaryArtistOnlyDisabled => 'Full artist string used for folder name'; - @override - String get downloadSaveFormat => 'Save Format'; - - @override - String get downloadSelectService => 'Select Service'; - @override String get downloadSelectQuality => 'Select Quality'; @override String get downloadFrom => 'Download From'; - @override - String get downloadDefaultQualityLabel => 'Default Quality'; - - @override - String get downloadBestAvailable => 'Best available'; - - @override - String get folderNone => 'None'; - - @override - String get folderNoneSubtitle => 'Save all files directly to download folder'; - - @override - String get folderArtist => 'Artist'; - - @override - String get folderArtistSubtitle => 'Artist Name/filename'; - - @override - String get folderAlbum => 'Album'; - - @override - String get folderAlbumSubtitle => 'Album Name/filename'; - - @override - String get folderArtistAlbum => 'Artist/Album'; - - @override - String get folderArtistAlbumSubtitle => 'Artist Name/Album Name/filename'; - - @override - String get serviceTidal => 'Tidal'; - - @override - String get serviceQobuz => 'Qobuz'; - - @override - String get serviceAmazon => 'Amazon'; - - @override - String get serviceDeezer => 'Deezer'; - - @override - String get serviceSpotify => 'Spotify'; - @override String get appearanceAmoledDark => 'AMOLED Dark'; @override String get appearanceAmoledDarkSubtitle => 'Pure black background'; - @override - String get appearanceChooseAccentColor => 'Choose Accent Color'; - - @override - String get appearanceChooseTheme => 'Theme Mode'; - - @override - String get queueTitle => 'Download Queue'; - @override String get queueClearAll => 'Clear All'; @@ -2057,19 +1403,6 @@ class AppLocalizationsDe extends AppLocalizations { String get queueClearAllMessage => 'Are you sure you want to clear all downloads?'; - @override - String get queueExportFailed => 'Export'; - - @override - String get queueExportFailedSuccess => - 'Failed downloads exported to TXT file'; - - @override - String get queueExportFailedClear => 'Clear Failed'; - - @override - String get queueExportFailedError => 'Failed to export downloads'; - @override String get settingsAutoExportFailed => 'Auto-export failed downloads'; @@ -2090,30 +1423,6 @@ class AppLocalizationsDe extends AppLocalizations { String get settingsDownloadNetworkSubtitle => 'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.'; - @override - String get queueEmpty => 'No downloads in queue'; - - @override - String get queueEmptySubtitle => 'Add tracks from the home screen'; - - @override - String get queueClearCompleted => 'Clear completed'; - - @override - String get queueDownloadFailed => 'Download Failed'; - - @override - String get queueTrackLabel => 'Track:'; - - @override - String get queueArtistLabel => 'Artist:'; - - @override - String get queueErrorLabel => 'Error:'; - - @override - String get queueUnknownError => 'Unknown error'; - @override String get albumFolderArtistAlbum => 'Artist / Album'; @@ -2160,14 +1469,6 @@ class AppLocalizationsDe extends AppLocalizations { return 'Delete $count $_temp0 from this album?\n\nThis will also delete the files from storage.'; } - @override - String get downloadedAlbumTracksHeader => 'Tracks'; - - @override - String downloadedAlbumDownloadedCount(int count) { - return '$count downloaded'; - } - @override String downloadedAlbumSelectedCount(int count) { return '$count selected'; @@ -2198,9 +1499,6 @@ class AppLocalizationsDe extends AppLocalizations { return 'Disc $discNumber'; } - @override - String get utilityFunctions => 'Utility Functions'; - @override String get recentTypeArtist => 'Artist'; @@ -2224,11 +1522,6 @@ class AppLocalizationsDe extends AppLocalizations { return 'Playlist: $name'; } - @override - String errorGeneric(String message) { - return 'Error: $message'; - } - @override String get discographyDownload => 'Download Discography'; @@ -2334,9 +1627,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get libraryTitle => 'Local Library'; - @override - String get libraryStatus => 'Library Status'; - @override String get libraryScanSettings => 'Scan Settings'; @@ -2400,8 +1690,14 @@ class AppLocalizationsDe extends AppLocalizations { 'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.'; @override - String libraryTracksCount(int count) { - return '$count tracks'; + String libraryTracksUnit(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return '$_temp0'; } @override @@ -2483,21 +1779,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get libraryFilterFormat => 'Format'; - @override - String get libraryFilterDate => 'Date Added'; - - @override - String get libraryFilterDateToday => 'Today'; - - @override - String get libraryFilterDateWeek => 'This Week'; - - @override - String get libraryFilterDateMonth => 'This Month'; - - @override - String get libraryFilterDateYear => 'This Year'; - @override String get libraryFilterSort => 'Sort'; @@ -2507,11 +1788,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get libraryFilterSortOldest => 'Oldest'; - @override - String libraryFilterActive(int count) { - return '$count filter(s) active'; - } - @override String get timeJustNow => 'Just now'; @@ -2537,72 +1813,6 @@ class AppLocalizationsDe extends AppLocalizations { return '$_temp0'; } - @override - String get storageSwitchTitle => 'Switch Storage Mode'; - - @override - String get storageSwitchToSafTitle => 'Switch to SAF Storage?'; - - @override - String get storageSwitchToAppTitle => 'Switch to App Storage?'; - - @override - String get storageSwitchToSafMessage => - 'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.'; - - @override - String get storageSwitchToAppMessage => - 'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.'; - - @override - String get storageSwitchExistingDownloads => 'Existing Downloads'; - - @override - String storageSwitchExistingDownloadsInfo(int count, String mode) { - return '$count tracks in $mode storage'; - } - - @override - String get storageSwitchNewDownloads => 'New Downloads'; - - @override - String storageSwitchNewDownloadsLocation(String location) { - return 'Will be saved to: $location'; - } - - @override - String get storageSwitchContinue => 'Continue'; - - @override - String get storageSwitchSelectFolder => 'Select SAF Folder'; - - @override - String get storageAppStorage => 'App Storage'; - - @override - String get storageSafStorage => 'SAF Storage'; - - @override - String storageModeBadge(String mode) { - return 'Storage: $mode'; - } - - @override - String get storageStatsTitle => 'Storage Statistics'; - - @override - String storageStatsAppCount(int count) { - return '$count tracks in App Storage'; - } - - @override - String storageStatsSafCount(int count) { - return '$count tracks in SAF Storage'; - } - - @override - String get storageModeInfo => 'Your files are stored in multiple locations'; - @override String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!'; @@ -2629,18 +1839,6 @@ class AppLocalizationsDe extends AppLocalizations { String get tutorialSearchDesc => 'There are two easy ways to find music you want to download.'; - @override - String get tutorialSearchTip1 => - 'Paste a Spotify or Deezer URL directly in the search box'; - - @override - String get tutorialSearchTip2 => - 'Or type the song name, artist, or album to search'; - - @override - String get tutorialSearchTip3 => - 'Supports tracks, albums, playlists, and artist pages'; - @override String get tutorialDownloadTitle => 'Downloading Music'; @@ -2648,18 +1846,6 @@ class AppLocalizationsDe extends AppLocalizations { String get tutorialDownloadDesc => 'Downloading music is simple and fast. Here\'s how it works.'; - @override - String get tutorialDownloadTip1 => - 'Tap the download button next to any track to start downloading'; - - @override - String get tutorialDownloadTip2 => - 'Choose your preferred quality (FLAC, Hi-Res, or MP3)'; - - @override - String get tutorialDownloadTip3 => - 'Download entire albums or playlists with one tap'; - @override String get tutorialLibraryTitle => 'Your Library'; @@ -2720,9 +1906,6 @@ class AppLocalizationsDe extends AppLocalizations { String get tutorialReadyMessage => 'You\'re all set! Start downloading your favorite music now.'; - @override - String get tutorialExample => 'EXAMPLE'; - @override String get libraryForceFullScan => 'Force Full Scan'; @@ -2883,11 +2066,7 @@ class AppLocalizationsDe extends AppLocalizations { String get trackSaveLyricsProgress => 'Saving lyrics...'; @override - String get trackReEnrich => 'Re-enrich Metadata'; - - @override - String get trackReEnrichSubtitle => - 'Re-embed metadata without re-downloading'; + String get trackReEnrich => 'Re-enrich'; @override String get trackReEnrichOnlineSubtitle => @@ -2963,4 +2142,221 @@ class AppLocalizationsDe extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + + @override + String get actionCreate => 'Create'; + + @override + String get collectionFoldersTitle => 'My folders'; + + @override + String get collectionWishlist => 'Wishlist'; + + @override + String get collectionLoved => 'Loved'; + + @override + String get collectionPlaylists => 'Playlists'; + + @override + String get collectionPlaylist => 'Playlist'; + + @override + String get collectionAddToPlaylist => 'Add to playlist'; + + @override + String get collectionCreatePlaylist => 'Create playlist'; + + @override + String get collectionNoPlaylistsYet => 'No playlists yet'; + + @override + String get collectionNoPlaylistsSubtitle => + 'Create a playlist to start categorizing tracks'; + + @override + String collectionPlaylistTracks(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String collectionAddedToPlaylist(String playlistName) { + return 'Added to \"$playlistName\"'; + } + + @override + String collectionAlreadyInPlaylist(String playlistName) { + return 'Already in \"$playlistName\"'; + } + + @override + String get collectionPlaylistCreated => 'Playlist created'; + + @override + String get collectionPlaylistNameHint => 'Playlist name'; + + @override + String get collectionPlaylistNameRequired => 'Playlist name is required'; + + @override + String get collectionRenamePlaylist => 'Rename playlist'; + + @override + String get collectionDeletePlaylist => 'Delete playlist'; + + @override + String collectionDeletePlaylistMessage(String playlistName) { + return 'Delete \"$playlistName\" and all tracks inside it?'; + } + + @override + String get collectionPlaylistDeleted => 'Playlist deleted'; + + @override + String get collectionPlaylistRenamed => 'Playlist renamed'; + + @override + String get collectionWishlistEmptyTitle => 'Wishlist is empty'; + + @override + String get collectionWishlistEmptySubtitle => + 'Tap + on tracks to save what you want to download later'; + + @override + String get collectionLovedEmptyTitle => 'Loved folder is empty'; + + @override + String get collectionLovedEmptySubtitle => + 'Tap love on tracks to keep your favorites'; + + @override + String get collectionPlaylistEmptyTitle => 'Playlist is empty'; + + @override + String get collectionPlaylistEmptySubtitle => + 'Long-press + on any track to add it here'; + + @override + String get collectionRemoveFromPlaylist => 'Remove from playlist'; + + @override + String get collectionRemoveFromFolder => 'Remove from folder'; + + @override + String collectionRemoved(String trackName) { + return '\"$trackName\" removed'; + } + + @override + String collectionAddedToLoved(String trackName) { + return '\"$trackName\" added to Loved'; + } + + @override + String collectionRemovedFromLoved(String trackName) { + return '\"$trackName\" removed from Loved'; + } + + @override + String collectionAddedToWishlist(String trackName) { + return '\"$trackName\" added to Wishlist'; + } + + @override + String collectionRemovedFromWishlist(String trackName) { + return '\"$trackName\" removed from Wishlist'; + } + + @override + String get trackOptionAddToLoved => 'Add to Loved'; + + @override + String get trackOptionRemoveFromLoved => 'Remove from Loved'; + + @override + String get trackOptionAddToWishlist => 'Add to Wishlist'; + + @override + String get trackOptionRemoveFromWishlist => 'Remove from Wishlist'; + + @override + String get collectionPlaylistChangeCover => 'Change cover image'; + + @override + String get collectionPlaylistRemoveCover => 'Remove cover image'; + + @override + String selectionShareCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Share $count $_temp0'; + } + + @override + String get selectionShareNoFiles => 'No shareable files found'; + + @override + String selectionConvertCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0'; + } + + @override + String get selectionConvertNoConvertible => 'No convertible tracks selected'; + + @override + String get selectionBatchConvertConfirmTitle => 'Batch Convert'; + + @override + String selectionBatchConvertConfirmMessage( + int count, + String format, + String bitrate, + ) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.'; + } + + @override + String selectionBatchConvertProgress(int current, int total) { + return 'Converting $current of $total...'; + } + + @override + String selectionBatchConvertSuccess(int success, int total, String format) { + return 'Converted $success of $total tracks to $format'; + } + + @override + String downloadedAlbumDownloadedCount(int count) { + return '$count downloaded'; + } + + @override + String get downloadUseAlbumArtistForFoldersAlbumSubtitle => + 'Artist folders use Album Artist when available'; + + @override + String get downloadUseAlbumArtistForFoldersTrackSubtitle => + 'Artist folders use Track Artist only'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 2f3eefea..8f244a7f 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -11,19 +11,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get appName => 'SpotiFLAC'; - @override - String get appDescription => - 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; - @override String get navHome => 'Home'; @override String get navLibrary => 'Library'; - @override - String get navHistory => 'History'; - @override String get navSettings => 'Settings'; @@ -33,14 +26,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get homeTitle => 'Home'; - @override - String get homeSearchHint => 'Paste Spotify URL or search...'; - - @override - String homeSearchHintExtension(String extensionName) { - return 'Search with $extensionName...'; - } - @override String get homeSubtitle => 'Paste a Spotify link or search by name'; @@ -50,17 +35,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get homeRecent => 'Recent'; - @override - String get historyTitle => 'History'; - - @override - String historyDownloading(int count) { - return 'Downloading ($count)'; - } - - @override - String get historyDownloaded => 'Downloaded'; - @override String get historyFilterAll => 'All'; @@ -70,48 +44,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get historyFilterSingles => 'Singles'; - @override - String historyTracksCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count tracks', - one: '1 track', - ); - return '$_temp0'; - } - - @override - String historyAlbumsCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count albums', - one: '1 album', - ); - return '$_temp0'; - } - - @override - String get historyNoDownloads => 'No download history'; - - @override - String get historyNoDownloadsSubtitle => 'Downloaded tracks will appear here'; - - @override - String get historyNoAlbums => 'No album downloads'; - - @override - String get historyNoAlbumsSubtitle => - 'Download multiple tracks from an album to see them here'; - - @override - String get historyNoSingles => 'No single downloads'; - - @override - String get historyNoSinglesSubtitle => - 'Single track downloads will appear here'; - @override String get historySearchHint => 'Search history...'; @@ -136,27 +68,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get downloadTitle => 'Download'; - @override - String get downloadLocation => 'Download Location'; - - @override - String get downloadLocationSubtitle => 'Choose where to save files'; - - @override - String get downloadLocationDefault => 'Default location'; - - @override - String get downloadDefaultService => 'Default Service'; - - @override - String get downloadDefaultServiceSubtitle => 'Service used for downloads'; - - @override - String get downloadDefaultQuality => 'Default Quality'; - - @override - String get downloadAskQuality => 'Ask Quality Before Download'; - @override String get downloadAskQualitySubtitle => 'Show quality picker for each download'; @@ -167,31 +78,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get downloadFolderOrganization => 'Folder Organization'; - @override - String get downloadSeparateSingles => 'Separate Singles'; - - @override - String get downloadSeparateSinglesSubtitle => - 'Put single tracks in a separate folder'; - - @override - String get qualityBest => 'Best Available'; - - @override - String get qualityFlac => 'FLAC'; - - @override - String get quality320 => '320 kbps'; - - @override - String get quality128 => '128 kbps'; - @override String get appearanceTitle => 'Appearance'; - @override - String get appearanceTheme => 'Theme'; - @override String get appearanceThemeSystem => 'System'; @@ -207,9 +96,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get appearanceDynamicColorSubtitle => 'Use colors from your wallpaper'; - @override - String get appearanceAccentColor => 'Accent Color'; - @override String get appearanceHistoryView => 'History View'; @@ -222,9 +108,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get optionsTitle => 'Options'; - @override - String get optionsSearchSource => 'Search Source'; - @override String get optionsPrimaryProvider => 'Primary Provider'; @@ -350,18 +233,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get extensionsTitle => 'Extensions'; - @override - String get extensionsInstalled => 'Installed Extensions'; - - @override - String get extensionsNone => 'No extensions installed'; - - @override - String get extensionsNoneSubtitle => 'Install extensions from the Store tab'; - - @override - String get extensionsEnabled => 'Enabled'; - @override String get extensionsDisabled => 'Disabled'; @@ -378,9 +249,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get extensionsUninstall => 'Uninstall'; - @override - String get extensionsSetAsSearch => 'Set as Search Provider'; - @override String get storeTitle => 'Extension Store'; @@ -454,9 +322,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get aboutSocial => 'Social'; - @override - String get aboutSupport => 'Support'; - @override String get aboutApp => 'App'; @@ -475,13 +340,6 @@ class AppLocalizationsEn extends AppLocalizations { String get aboutSjdonadoDesc => 'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!'; - @override - String get aboutDoubleDouble => 'DoubleDouble'; - - @override - String get aboutDoubleDoubleDesc => - 'Amazing API for Amazon Music downloads. Thank you for making it free!'; - @override String get aboutDabMusic => 'DAB Music'; @@ -500,32 +358,6 @@ class AppLocalizationsEn extends AppLocalizations { String get aboutAppDescription => 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; - @override - String get albumTitle => 'Album'; - - @override - String albumTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count tracks', - one: '1 track', - ); - return '$_temp0'; - } - - @override - String get albumDownloadAll => 'Download All'; - - @override - String get albumDownloadRemaining => 'Download Remaining'; - - @override - String get playlistTitle => 'Playlist'; - - @override - String get artistTitle => 'Artist'; - @override String get artistAlbums => 'Albums'; @@ -535,17 +367,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get artistCompilations => 'Compilations'; - @override - String artistReleases(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count releases', - one: '1 release', - ); - return '$_temp0'; - } - @override String get artistPopular => 'Popular'; @@ -554,27 +375,6 @@ class AppLocalizationsEn extends AppLocalizations { return '$count monthly listeners'; } - @override - String get trackMetadataTitle => 'Track Info'; - - @override - String get trackMetadataArtist => 'Artist'; - - @override - String get trackMetadataAlbum => 'Album'; - - @override - String get trackMetadataDuration => 'Duration'; - - @override - String get trackMetadataQuality => 'Quality'; - - @override - String get trackMetadataPath => 'File Path'; - - @override - String get trackMetadataDownloadedAt => 'Downloaded'; - @override String get trackMetadataService => 'Service'; @@ -587,53 +387,15 @@ class AppLocalizationsEn extends AppLocalizations { @override String get trackMetadataDelete => 'Delete'; - @override - String get trackMetadataRedownload => 'Re-download'; - - @override - String get trackMetadataOpenFolder => 'Open Folder'; - - @override - String get setupTitle => 'Welcome to SpotiFLAC'; - - @override - String get setupSubtitle => 'Let\'s get you started'; - - @override - String get setupStoragePermission => 'Storage Permission'; - - @override - String get setupStoragePermissionSubtitle => - 'Required to save downloaded files'; - - @override - String get setupStoragePermissionGranted => 'Permission granted'; - - @override - String get setupStoragePermissionDenied => 'Permission denied'; - @override String get setupGrantPermission => 'Grant Permission'; - @override - String get setupDownloadLocation => 'Download Location'; - - @override - String get setupChooseFolder => 'Choose Folder'; - - @override - String get setupContinue => 'Continue'; - @override String get setupSkip => 'Skip for now'; @override String get setupStorageAccessRequired => 'Storage Access Required'; - @override - String get setupStorageAccessMessage => - 'SpotiFLAC needs \"All files access\" permission to save music files to your chosen folder.'; - @override String get setupStorageAccessMessageAndroid11 => 'Android 11+ requires \"All files access\" permission to save files to your chosen download folder.'; @@ -655,9 +417,6 @@ class AppLocalizationsEn extends AppLocalizations { return '$permissionType permission is required for the best experience. You can change this later in Settings.'; } - @override - String get setupSelectDownloadFolder => 'Select Download Folder'; - @override String get setupUseDefaultFolder => 'Use Default Folder?'; @@ -699,21 +458,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get setupDownloadInFlac => 'Download Spotify tracks in FLAC'; - @override - String get setupStepStorage => 'Storage'; - - @override - String get setupStepNotification => 'Notification'; - - @override - String get setupStepFolder => 'Folder'; - - @override - String get setupStepSpotify => 'Spotify'; - - @override - String get setupStepPermission => 'Permission'; - @override String get setupStorageGranted => 'Storage Permission Granted!'; @@ -730,13 +474,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get setupNotificationEnable => 'Enable Notifications'; - @override - String get setupNotificationDescription => - 'Get notified when downloads complete or require attention.'; - - @override - String get setupFolderSelected => 'Download Folder Selected!'; - @override String get setupFolderChoose => 'Choose Download Folder'; @@ -744,48 +481,12 @@ class AppLocalizationsEn extends AppLocalizations { String get setupFolderDescription => 'Select a folder where your downloaded music will be saved.'; - @override - String get setupChangeFolder => 'Change Folder'; - @override String get setupSelectFolder => 'Select Folder'; - @override - String get setupSpotifyApiOptional => 'Spotify API (Optional)'; - - @override - String get setupSpotifyApiDescription => - 'Add your Spotify API credentials for better search results and access to Spotify-exclusive content.'; - - @override - String get setupUseSpotifyApi => 'Use Spotify API'; - - @override - String get setupEnterCredentialsBelow => 'Enter your credentials below'; - - @override - String get setupUsingDeezer => 'Using Deezer (no account needed)'; - - @override - String get setupEnterClientId => 'Enter Spotify Client ID'; - - @override - String get setupEnterClientSecret => 'Enter Spotify Client Secret'; - - @override - String get setupGetFreeCredentials => - 'Get your free API credentials from the Spotify Developer Dashboard.'; - @override String get setupEnableNotifications => 'Enable Notifications'; - @override - String get setupProceedToNextStep => 'You can now proceed to the next step.'; - - @override - String get setupNotificationProgressDescription => - 'You will receive download progress notifications.'; - @override String get setupNotificationBackgroundDescription => 'Get notified about download progress and completion. This helps you track downloads when the app is in background.'; @@ -793,32 +494,19 @@ class AppLocalizationsEn extends AppLocalizations { @override String get setupSkipForNow => 'Skip for now'; - @override - String get setupBack => 'Back'; - @override String get setupNext => 'Next'; @override String get setupGetStarted => 'Get Started'; - @override - String get setupSkipAndStart => 'Skip & Start'; - @override String get setupAllowAccessToManageFiles => 'Please enable \"Allow access to manage all files\" in the next screen.'; - @override - String get setupGetCredentialsFromSpotify => - 'Get credentials from developer.spotify.com'; - @override String get dialogCancel => 'Cancel'; - @override - String get dialogOk => 'OK'; - @override String get dialogSave => 'Save'; @@ -828,21 +516,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get dialogRetry => 'Retry'; - @override - String get dialogClose => 'Close'; - - @override - String get dialogYes => 'Yes'; - - @override - String get dialogNo => 'No'; - @override String get dialogClear => 'Clear'; - @override - String get dialogConfirm => 'Confirm'; - @override String get dialogDone => 'Done'; @@ -865,28 +541,9 @@ class AppLocalizationsEn extends AppLocalizations { String get dialogUnsavedChanges => 'You have unsaved changes. Do you want to discard them?'; - @override - String get dialogDownloadFailed => 'Download Failed'; - - @override - String get dialogTrackLabel => 'Track:'; - - @override - String get dialogArtistLabel => 'Artist:'; - - @override - String get dialogErrorLabel => 'Error:'; - @override String get dialogClearAll => 'Clear All'; - @override - String get dialogClearAllDownloads => - 'Are you sure you want to clear all downloads?'; - - @override - String get dialogRemoveFromDevice => 'Remove from device?'; - @override String get dialogRemoveExtension => 'Remove Extension'; @@ -987,11 +644,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get snackbarViewQueue => 'View Queue'; - @override - String snackbarFailedToLoad(String error) { - return 'Failed to load: $error'; - } - @override String snackbarUrlCopied(String platform) { return '$platform URL copied to clipboard'; @@ -1033,11 +685,6 @@ class AppLocalizationsEn extends AppLocalizations { String get errorRateLimitedMessage => 'Too many requests. Please wait a moment before searching again.'; - @override - String errorFailedToLoad(String item) { - return 'Failed to load $item'; - } - @override String get errorNoTracksFound => 'No tracks found'; @@ -1046,27 +693,6 @@ class AppLocalizationsEn extends AppLocalizations { return 'Cannot load $item: missing extension source'; } - @override - String get statusQueued => 'Queued'; - - @override - String get statusDownloading => 'Downloading'; - - @override - String get statusFinalizing => 'Finalizing'; - - @override - String get statusCompleted => 'Completed'; - - @override - String get statusFailed => 'Failed'; - - @override - String get statusSkipped => 'Skipped'; - - @override - String get statusPaused => 'Paused'; - @override String get actionPause => 'Pause'; @@ -1076,24 +702,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get actionCancel => 'Cancel'; - @override - String get actionStop => 'Stop'; - - @override - String get actionSelect => 'Select'; - @override String get actionSelectAll => 'Select All'; @override String get actionDeselect => 'Deselect'; - @override - String get actionPaste => 'Paste'; - - @override - String get actionImportCsv => 'Import CSV'; - @override String get actionRemoveCredentials => 'Remove Credentials'; @@ -1108,20 +722,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get selectionAllSelected => 'All tracks selected'; - @override - String get selectionTapToSelect => 'Tap tracks to select'; - - @override - String selectionDeleteTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: 'tracks', - one: 'track', - ); - return 'Delete $count $_temp0'; - } - @override String get selectionSelectToDelete => 'Select tracks to delete'; @@ -1148,40 +748,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get tooltipPlay => 'Play'; - @override - String get tooltipCancel => 'Cancel'; - - @override - String get tooltipStop => 'Stop'; - - @override - String get tooltipRetry => 'Retry'; - - @override - String get tooltipRemove => 'Remove'; - - @override - String get tooltipClear => 'Clear'; - - @override - String get tooltipPaste => 'Paste'; - @override String get filenameFormat => 'Filename Format'; - @override - String filenameFormatPreview(String preview) { - return 'Preview: $preview'; - } - - @override - String get filenameAvailablePlaceholders => 'Available placeholders:'; - - @override - String filenameHint(Object artist, Object title) { - return '$artist - $title'; - } - @override String get filenameShowAdvancedTags => 'Show advanced tags'; @@ -1189,9 +758,6 @@ class AppLocalizationsEn extends AppLocalizations { String get filenameShowAdvancedTagsDescription => 'Enable formatted tags for track padding and date patterns'; - @override - String get folderOrganization => 'Folder Organization'; - @override String get folderOrganizationNone => 'No organization'; @@ -1226,20 +792,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get updateAvailable => 'Update Available'; - @override - String updateNewVersion(String version) { - return 'Version $version is available'; - } - - @override - String get updateDownload => 'Download'; - @override String get updateLater => 'Later'; - @override - String get updateChangelog => 'Changelog'; - @override String get updateStartingDownload => 'Starting download...'; @@ -1270,12 +825,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get updateDontRemind => 'Don\'t remind'; - @override - String get providerPriority => 'Provider Priority'; - - @override - String get providerPrioritySubtitle => 'Drag to reorder download providers'; - @override String get providerPriorityTitle => 'Provider Priority'; @@ -1293,13 +842,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get providerExtension => 'Extension'; - @override - String get metadataProviderPriority => 'Metadata Provider Priority'; - - @override - String get metadataProviderPrioritySubtitle => - 'Order used when fetching track metadata'; - @override String get metadataProviderPriorityTitle => 'Metadata Priority'; @@ -1320,18 +862,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get logTitle => 'Logs'; - @override - String get logCopy => 'Copy Logs'; - - @override - String get logClear => 'Clear Logs'; - - @override - String get logShare => 'Share Logs'; - - @override - String get logEmpty => 'No logs yet'; - @override String get logCopied => 'Logs copied to clipboard'; @@ -1356,18 +886,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get logClearLogsMessage => 'Are you sure you want to clear all logs?'; - @override - String get logIspBlocking => 'ISP BLOCKING DETECTED'; - - @override - String get logRateLimited => 'RATE LIMITED'; - - @override - String get logNetworkError => 'NETWORK ERROR'; - - @override - String get logTrackNotFound => 'TRACK NOT FOUND'; - @override String get logFilterBySeverity => 'Filter logs by severity'; @@ -1377,48 +895,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get logNoLogsYetSubtitle => 'Logs will appear here as you use the app'; - @override - String get logIssueSummary => 'Issue Summary'; - - @override - String get logIspBlockingDescription => - 'Your ISP may be blocking access to download services'; - - @override - String get logIspBlockingSuggestion => - 'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8'; - - @override - String get logRateLimitedDescription => 'Too many requests to the service'; - - @override - String get logRateLimitedSuggestion => - 'Wait a few minutes before trying again'; - - @override - String get logNetworkErrorDescription => 'Connection issues detected'; - - @override - String get logNetworkErrorSuggestion => 'Check your internet connection'; - - @override - String get logTrackNotFoundDescription => - 'Some tracks could not be found on download services'; - - @override - String get logTrackNotFoundSuggestion => - 'The track may not be available in lossless quality'; - - @override - String logTotalErrors(int count) { - return 'Total errors: $count'; - } - - @override - String logAffected(String domains) { - return 'Affected: $domains'; - } - @override String logEntriesFiltered(int count) { return 'Entries ($count filtered)'; @@ -1525,9 +1001,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get appearanceLanguage => 'App Language'; - @override - String get appearanceLanguageSubtitle => 'Choose your preferred language'; - @override String get settingsAppearanceSubtitle => 'Theme, colors, display'; @@ -1549,9 +1022,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get pressBackAgainToExit => 'Press back again to exit'; - @override - String get tracksHeader => 'Tracks'; - @override String downloadAllCount(int count) { return 'Download All ($count)'; @@ -1665,11 +1135,6 @@ class AppLocalizationsEn extends AppLocalizations { String get trackDeleteConfirmMessage => 'This will permanently delete the downloaded file and remove it from your history.'; - @override - String trackCannotOpen(String message) { - return 'Cannot open: $message'; - } - @override String get dateToday => 'Today'; @@ -1691,18 +1156,6 @@ class AppLocalizationsEn extends AppLocalizations { return '$count months ago'; } - @override - String get concurrentSequential => 'Sequential'; - - @override - String get concurrentParallel2 => '2 Parallel'; - - @override - String get concurrentParallel3 => '3 Parallel'; - - @override - String get tapToSeeError => 'Tap to see error details'; - @override String get storeFilterAll => 'All'; @@ -1724,15 +1177,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get storeClearFilters => 'Clear filters'; - @override - String get storeNoResults => 'No extensions found'; - - @override - String get extensionProviderPriority => 'Provider Priority'; - - @override - String get extensionInstallButton => 'Install Extension'; - @override String get extensionDefaultProvider => 'Default (Deezer/Spotify)'; @@ -1883,38 +1327,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; - @override - String get qualityLossy => 'Lossy'; - - @override - String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)'; - - @override - String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)'; - - @override - String get enableLossyOption => 'Enable Lossy Option'; - - @override - String get enableLossyOptionSubtitleOn => 'Lossy quality option is available'; - - @override - String get enableLossyOptionSubtitleOff => - 'Downloads FLAC then converts to lossy format'; - - @override - String get lossyFormat => 'Lossy Format'; - - @override - String get lossyFormatDescription => 'Choose the lossy format for conversion'; - - @override - String get lossyFormatMp3Subtitle => '320kbps, best compatibility'; - - @override - String get lossyFormatOpusSubtitle => - '128kbps, better quality at smaller size'; - @override String get qualityNote => 'Actual quality depends on track availability from the service'; @@ -1923,6 +1335,12 @@ class AppLocalizationsEn extends AppLocalizations { String get youtubeQualityNote => 'YouTube provides lossy audio only. Not part of lossless fallback.'; + @override + String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; + + @override + String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; + @override String get downloadAskBeforeDownload => 'Ask Before Download'; @@ -1938,14 +1356,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders'; - @override - String get downloadUseAlbumArtistForFoldersAlbumSubtitle => - 'Artist folders use Album Artist when available'; - - @override - String get downloadUseAlbumArtistForFoldersTrackSubtitle => - 'Artist folders use Track Artist only'; - @override String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders'; @@ -1957,78 +1367,18 @@ class AppLocalizationsEn extends AppLocalizations { String get downloadUsePrimaryArtistOnlyDisabled => 'Full artist string used for folder name'; - @override - String get downloadSaveFormat => 'Save Format'; - - @override - String get downloadSelectService => 'Select Service'; - @override String get downloadSelectQuality => 'Select Quality'; @override String get downloadFrom => 'Download From'; - @override - String get downloadDefaultQualityLabel => 'Default Quality'; - - @override - String get downloadBestAvailable => 'Best available'; - - @override - String get folderNone => 'None'; - - @override - String get folderNoneSubtitle => 'Save all files directly to download folder'; - - @override - String get folderArtist => 'Artist'; - - @override - String get folderArtistSubtitle => 'Artist Name/filename'; - - @override - String get folderAlbum => 'Album'; - - @override - String get folderAlbumSubtitle => 'Album Name/filename'; - - @override - String get folderArtistAlbum => 'Artist/Album'; - - @override - String get folderArtistAlbumSubtitle => 'Artist Name/Album Name/filename'; - - @override - String get serviceTidal => 'Tidal'; - - @override - String get serviceQobuz => 'Qobuz'; - - @override - String get serviceAmazon => 'Amazon'; - - @override - String get serviceDeezer => 'Deezer'; - - @override - String get serviceSpotify => 'Spotify'; - @override String get appearanceAmoledDark => 'AMOLED Dark'; @override String get appearanceAmoledDarkSubtitle => 'Pure black background'; - @override - String get appearanceChooseAccentColor => 'Choose Accent Color'; - - @override - String get appearanceChooseTheme => 'Theme Mode'; - - @override - String get queueTitle => 'Download Queue'; - @override String get queueClearAll => 'Clear All'; @@ -2036,19 +1386,6 @@ class AppLocalizationsEn extends AppLocalizations { String get queueClearAllMessage => 'Are you sure you want to clear all downloads?'; - @override - String get queueExportFailed => 'Export'; - - @override - String get queueExportFailedSuccess => - 'Failed downloads exported to TXT file'; - - @override - String get queueExportFailedClear => 'Clear Failed'; - - @override - String get queueExportFailedError => 'Failed to export downloads'; - @override String get settingsAutoExportFailed => 'Auto-export failed downloads'; @@ -2069,30 +1406,6 @@ class AppLocalizationsEn extends AppLocalizations { String get settingsDownloadNetworkSubtitle => 'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.'; - @override - String get queueEmpty => 'No downloads in queue'; - - @override - String get queueEmptySubtitle => 'Add tracks from the home screen'; - - @override - String get queueClearCompleted => 'Clear completed'; - - @override - String get queueDownloadFailed => 'Download Failed'; - - @override - String get queueTrackLabel => 'Track:'; - - @override - String get queueArtistLabel => 'Artist:'; - - @override - String get queueErrorLabel => 'Error:'; - - @override - String get queueUnknownError => 'Unknown error'; - @override String get albumFolderArtistAlbum => 'Artist / Album'; @@ -2139,14 +1452,6 @@ class AppLocalizationsEn extends AppLocalizations { return 'Delete $count $_temp0 from this album?\n\nThis will also delete the files from storage.'; } - @override - String get downloadedAlbumTracksHeader => 'Tracks'; - - @override - String downloadedAlbumDownloadedCount(int count) { - return '$count downloaded'; - } - @override String downloadedAlbumSelectedCount(int count) { return '$count selected'; @@ -2177,9 +1482,6 @@ class AppLocalizationsEn extends AppLocalizations { return 'Disc $discNumber'; } - @override - String get utilityFunctions => 'Utility Functions'; - @override String get recentTypeArtist => 'Artist'; @@ -2203,11 +1505,6 @@ class AppLocalizationsEn extends AppLocalizations { return 'Playlist: $name'; } - @override - String errorGeneric(String message) { - return 'Error: $message'; - } - @override String get discographyDownload => 'Download Discography'; @@ -2313,9 +1610,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get libraryTitle => 'Local Library'; - @override - String get libraryStatus => 'Library Status'; - @override String get libraryScanSettings => 'Scan Settings'; @@ -2379,8 +1673,14 @@ class AppLocalizationsEn extends AppLocalizations { 'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.'; @override - String libraryTracksCount(int count) { - return '$count tracks'; + String libraryTracksUnit(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return '$_temp0'; } @override @@ -2462,21 +1762,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get libraryFilterFormat => 'Format'; - @override - String get libraryFilterDate => 'Date Added'; - - @override - String get libraryFilterDateToday => 'Today'; - - @override - String get libraryFilterDateWeek => 'This Week'; - - @override - String get libraryFilterDateMonth => 'This Month'; - - @override - String get libraryFilterDateYear => 'This Year'; - @override String get libraryFilterSort => 'Sort'; @@ -2486,11 +1771,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get libraryFilterSortOldest => 'Oldest'; - @override - String libraryFilterActive(int count) { - return '$count filter(s) active'; - } - @override String get timeJustNow => 'Just now'; @@ -2516,72 +1796,6 @@ class AppLocalizationsEn extends AppLocalizations { return '$_temp0'; } - @override - String get storageSwitchTitle => 'Switch Storage Mode'; - - @override - String get storageSwitchToSafTitle => 'Switch to SAF Storage?'; - - @override - String get storageSwitchToAppTitle => 'Switch to App Storage?'; - - @override - String get storageSwitchToSafMessage => - 'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.'; - - @override - String get storageSwitchToAppMessage => - 'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.'; - - @override - String get storageSwitchExistingDownloads => 'Existing Downloads'; - - @override - String storageSwitchExistingDownloadsInfo(int count, String mode) { - return '$count tracks in $mode storage'; - } - - @override - String get storageSwitchNewDownloads => 'New Downloads'; - - @override - String storageSwitchNewDownloadsLocation(String location) { - return 'Will be saved to: $location'; - } - - @override - String get storageSwitchContinue => 'Continue'; - - @override - String get storageSwitchSelectFolder => 'Select SAF Folder'; - - @override - String get storageAppStorage => 'App Storage'; - - @override - String get storageSafStorage => 'SAF Storage'; - - @override - String storageModeBadge(String mode) { - return 'Storage: $mode'; - } - - @override - String get storageStatsTitle => 'Storage Statistics'; - - @override - String storageStatsAppCount(int count) { - return '$count tracks in App Storage'; - } - - @override - String storageStatsSafCount(int count) { - return '$count tracks in SAF Storage'; - } - - @override - String get storageModeInfo => 'Your files are stored in multiple locations'; - @override String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!'; @@ -2608,18 +1822,6 @@ class AppLocalizationsEn extends AppLocalizations { String get tutorialSearchDesc => 'There are two easy ways to find music you want to download.'; - @override - String get tutorialSearchTip1 => - 'Paste a Spotify or Deezer URL directly in the search box'; - - @override - String get tutorialSearchTip2 => - 'Or type the song name, artist, or album to search'; - - @override - String get tutorialSearchTip3 => - 'Supports tracks, albums, playlists, and artist pages'; - @override String get tutorialDownloadTitle => 'Downloading Music'; @@ -2627,18 +1829,6 @@ class AppLocalizationsEn extends AppLocalizations { String get tutorialDownloadDesc => 'Downloading music is simple and fast. Here\'s how it works.'; - @override - String get tutorialDownloadTip1 => - 'Tap the download button next to any track to start downloading'; - - @override - String get tutorialDownloadTip2 => - 'Choose your preferred quality (FLAC, Hi-Res, or MP3)'; - - @override - String get tutorialDownloadTip3 => - 'Download entire albums or playlists with one tap'; - @override String get tutorialLibraryTitle => 'Your Library'; @@ -2699,9 +1889,6 @@ class AppLocalizationsEn extends AppLocalizations { String get tutorialReadyMessage => 'You\'re all set! Start downloading your favorite music now.'; - @override - String get tutorialExample => 'EXAMPLE'; - @override String get libraryForceFullScan => 'Force Full Scan'; @@ -2862,11 +2049,7 @@ class AppLocalizationsEn extends AppLocalizations { String get trackSaveLyricsProgress => 'Saving lyrics...'; @override - String get trackReEnrich => 'Re-enrich Metadata'; - - @override - String get trackReEnrichSubtitle => - 'Re-embed metadata without re-downloading'; + String get trackReEnrich => 'Re-enrich'; @override String get trackReEnrichOnlineSubtitle => @@ -2942,4 +2125,221 @@ class AppLocalizationsEn extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + + @override + String get actionCreate => 'Create'; + + @override + String get collectionFoldersTitle => 'My folders'; + + @override + String get collectionWishlist => 'Wishlist'; + + @override + String get collectionLoved => 'Loved'; + + @override + String get collectionPlaylists => 'Playlists'; + + @override + String get collectionPlaylist => 'Playlist'; + + @override + String get collectionAddToPlaylist => 'Add to playlist'; + + @override + String get collectionCreatePlaylist => 'Create playlist'; + + @override + String get collectionNoPlaylistsYet => 'No playlists yet'; + + @override + String get collectionNoPlaylistsSubtitle => + 'Create a playlist to start categorizing tracks'; + + @override + String collectionPlaylistTracks(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String collectionAddedToPlaylist(String playlistName) { + return 'Added to \"$playlistName\"'; + } + + @override + String collectionAlreadyInPlaylist(String playlistName) { + return 'Already in \"$playlistName\"'; + } + + @override + String get collectionPlaylistCreated => 'Playlist created'; + + @override + String get collectionPlaylistNameHint => 'Playlist name'; + + @override + String get collectionPlaylistNameRequired => 'Playlist name is required'; + + @override + String get collectionRenamePlaylist => 'Rename playlist'; + + @override + String get collectionDeletePlaylist => 'Delete playlist'; + + @override + String collectionDeletePlaylistMessage(String playlistName) { + return 'Delete \"$playlistName\" and all tracks inside it?'; + } + + @override + String get collectionPlaylistDeleted => 'Playlist deleted'; + + @override + String get collectionPlaylistRenamed => 'Playlist renamed'; + + @override + String get collectionWishlistEmptyTitle => 'Wishlist is empty'; + + @override + String get collectionWishlistEmptySubtitle => + 'Tap + on tracks to save what you want to download later'; + + @override + String get collectionLovedEmptyTitle => 'Loved folder is empty'; + + @override + String get collectionLovedEmptySubtitle => + 'Tap love on tracks to keep your favorites'; + + @override + String get collectionPlaylistEmptyTitle => 'Playlist is empty'; + + @override + String get collectionPlaylistEmptySubtitle => + 'Long-press + on any track to add it here'; + + @override + String get collectionRemoveFromPlaylist => 'Remove from playlist'; + + @override + String get collectionRemoveFromFolder => 'Remove from folder'; + + @override + String collectionRemoved(String trackName) { + return '\"$trackName\" removed'; + } + + @override + String collectionAddedToLoved(String trackName) { + return '\"$trackName\" added to Loved'; + } + + @override + String collectionRemovedFromLoved(String trackName) { + return '\"$trackName\" removed from Loved'; + } + + @override + String collectionAddedToWishlist(String trackName) { + return '\"$trackName\" added to Wishlist'; + } + + @override + String collectionRemovedFromWishlist(String trackName) { + return '\"$trackName\" removed from Wishlist'; + } + + @override + String get trackOptionAddToLoved => 'Add to Loved'; + + @override + String get trackOptionRemoveFromLoved => 'Remove from Loved'; + + @override + String get trackOptionAddToWishlist => 'Add to Wishlist'; + + @override + String get trackOptionRemoveFromWishlist => 'Remove from Wishlist'; + + @override + String get collectionPlaylistChangeCover => 'Change cover image'; + + @override + String get collectionPlaylistRemoveCover => 'Remove cover image'; + + @override + String selectionShareCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Share $count $_temp0'; + } + + @override + String get selectionShareNoFiles => 'No shareable files found'; + + @override + String selectionConvertCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0'; + } + + @override + String get selectionConvertNoConvertible => 'No convertible tracks selected'; + + @override + String get selectionBatchConvertConfirmTitle => 'Batch Convert'; + + @override + String selectionBatchConvertConfirmMessage( + int count, + String format, + String bitrate, + ) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.'; + } + + @override + String selectionBatchConvertProgress(int current, int total) { + return 'Converting $current of $total...'; + } + + @override + String selectionBatchConvertSuccess(int success, int total, String format) { + return 'Converted $success of $total tracks to $format'; + } + + @override + String downloadedAlbumDownloadedCount(int count) { + return '$count downloaded'; + } + + @override + String get downloadUseAlbumArtistForFoldersAlbumSubtitle => + 'Artist folders use Album Artist when available'; + + @override + String get downloadUseAlbumArtistForFoldersTrackSubtitle => + 'Artist folders use Track Artist only'; } diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 75613f96..58ae25af 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -11,19 +11,12 @@ class AppLocalizationsEs extends AppLocalizations { @override String get appName => 'SpotiFLAC'; - @override - String get appDescription => - 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; - @override String get navHome => 'Home'; @override String get navLibrary => 'Library'; - @override - String get navHistory => 'History'; - @override String get navSettings => 'Settings'; @@ -33,14 +26,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get homeTitle => 'Home'; - @override - String get homeSearchHint => 'Paste Spotify URL or search...'; - - @override - String homeSearchHintExtension(String extensionName) { - return 'Search with $extensionName...'; - } - @override String get homeSubtitle => 'Paste a Spotify link or search by name'; @@ -50,17 +35,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get homeRecent => 'Recent'; - @override - String get historyTitle => 'History'; - - @override - String historyDownloading(int count) { - return 'Downloading ($count)'; - } - - @override - String get historyDownloaded => 'Downloaded'; - @override String get historyFilterAll => 'All'; @@ -70,48 +44,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get historyFilterSingles => 'Singles'; - @override - String historyTracksCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count tracks', - one: '1 track', - ); - return '$_temp0'; - } - - @override - String historyAlbumsCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count albums', - one: '1 album', - ); - return '$_temp0'; - } - - @override - String get historyNoDownloads => 'No download history'; - - @override - String get historyNoDownloadsSubtitle => 'Downloaded tracks will appear here'; - - @override - String get historyNoAlbums => 'No album downloads'; - - @override - String get historyNoAlbumsSubtitle => - 'Download multiple tracks from an album to see them here'; - - @override - String get historyNoSingles => 'No single downloads'; - - @override - String get historyNoSinglesSubtitle => - 'Single track downloads will appear here'; - @override String get historySearchHint => 'Search history...'; @@ -136,27 +68,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get downloadTitle => 'Download'; - @override - String get downloadLocation => 'Download Location'; - - @override - String get downloadLocationSubtitle => 'Choose where to save files'; - - @override - String get downloadLocationDefault => 'Default location'; - - @override - String get downloadDefaultService => 'Default Service'; - - @override - String get downloadDefaultServiceSubtitle => 'Service used for downloads'; - - @override - String get downloadDefaultQuality => 'Default Quality'; - - @override - String get downloadAskQuality => 'Ask Quality Before Download'; - @override String get downloadAskQualitySubtitle => 'Show quality picker for each download'; @@ -167,31 +78,9 @@ class AppLocalizationsEs extends AppLocalizations { @override String get downloadFolderOrganization => 'Folder Organization'; - @override - String get downloadSeparateSingles => 'Separate Singles'; - - @override - String get downloadSeparateSinglesSubtitle => - 'Put single tracks in a separate folder'; - - @override - String get qualityBest => 'Best Available'; - - @override - String get qualityFlac => 'FLAC'; - - @override - String get quality320 => '320 kbps'; - - @override - String get quality128 => '128 kbps'; - @override String get appearanceTitle => 'Appearance'; - @override - String get appearanceTheme => 'Theme'; - @override String get appearanceThemeSystem => 'System'; @@ -207,9 +96,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get appearanceDynamicColorSubtitle => 'Use colors from your wallpaper'; - @override - String get appearanceAccentColor => 'Accent Color'; - @override String get appearanceHistoryView => 'History View'; @@ -222,9 +108,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get optionsTitle => 'Options'; - @override - String get optionsSearchSource => 'Search Source'; - @override String get optionsPrimaryProvider => 'Primary Provider'; @@ -350,18 +233,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get extensionsTitle => 'Extensions'; - @override - String get extensionsInstalled => 'Installed Extensions'; - - @override - String get extensionsNone => 'No extensions installed'; - - @override - String get extensionsNoneSubtitle => 'Install extensions from the Store tab'; - - @override - String get extensionsEnabled => 'Enabled'; - @override String get extensionsDisabled => 'Disabled'; @@ -378,9 +249,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get extensionsUninstall => 'Uninstall'; - @override - String get extensionsSetAsSearch => 'Set as Search Provider'; - @override String get storeTitle => 'Extension Store'; @@ -454,9 +322,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get aboutSocial => 'Social'; - @override - String get aboutSupport => 'Support'; - @override String get aboutApp => 'App'; @@ -475,13 +340,6 @@ class AppLocalizationsEs extends AppLocalizations { String get aboutSjdonadoDesc => 'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!'; - @override - String get aboutDoubleDouble => 'DoubleDouble'; - - @override - String get aboutDoubleDoubleDesc => - 'Amazing API for Amazon Music downloads. Thank you for making it free!'; - @override String get aboutDabMusic => 'DAB Music'; @@ -500,32 +358,6 @@ class AppLocalizationsEs extends AppLocalizations { String get aboutAppDescription => 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; - @override - String get albumTitle => 'Album'; - - @override - String albumTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count tracks', - one: '1 track', - ); - return '$_temp0'; - } - - @override - String get albumDownloadAll => 'Download All'; - - @override - String get albumDownloadRemaining => 'Download Remaining'; - - @override - String get playlistTitle => 'Playlist'; - - @override - String get artistTitle => 'Artist'; - @override String get artistAlbums => 'Albums'; @@ -535,17 +367,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get artistCompilations => 'Compilations'; - @override - String artistReleases(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count releases', - one: '1 release', - ); - return '$_temp0'; - } - @override String get artistPopular => 'Popular'; @@ -554,27 +375,6 @@ class AppLocalizationsEs extends AppLocalizations { return '$count monthly listeners'; } - @override - String get trackMetadataTitle => 'Track Info'; - - @override - String get trackMetadataArtist => 'Artist'; - - @override - String get trackMetadataAlbum => 'Album'; - - @override - String get trackMetadataDuration => 'Duration'; - - @override - String get trackMetadataQuality => 'Quality'; - - @override - String get trackMetadataPath => 'File Path'; - - @override - String get trackMetadataDownloadedAt => 'Downloaded'; - @override String get trackMetadataService => 'Service'; @@ -587,53 +387,15 @@ class AppLocalizationsEs extends AppLocalizations { @override String get trackMetadataDelete => 'Delete'; - @override - String get trackMetadataRedownload => 'Re-download'; - - @override - String get trackMetadataOpenFolder => 'Open Folder'; - - @override - String get setupTitle => 'Welcome to SpotiFLAC'; - - @override - String get setupSubtitle => 'Let\'s get you started'; - - @override - String get setupStoragePermission => 'Storage Permission'; - - @override - String get setupStoragePermissionSubtitle => - 'Required to save downloaded files'; - - @override - String get setupStoragePermissionGranted => 'Permission granted'; - - @override - String get setupStoragePermissionDenied => 'Permission denied'; - @override String get setupGrantPermission => 'Grant Permission'; - @override - String get setupDownloadLocation => 'Download Location'; - - @override - String get setupChooseFolder => 'Choose Folder'; - - @override - String get setupContinue => 'Continue'; - @override String get setupSkip => 'Skip for now'; @override String get setupStorageAccessRequired => 'Storage Access Required'; - @override - String get setupStorageAccessMessage => - 'SpotiFLAC needs \"All files access\" permission to save music files to your chosen folder.'; - @override String get setupStorageAccessMessageAndroid11 => 'Android 11+ requires \"All files access\" permission to save files to your chosen download folder.'; @@ -655,9 +417,6 @@ class AppLocalizationsEs extends AppLocalizations { return '$permissionType permission is required for the best experience. You can change this later in Settings.'; } - @override - String get setupSelectDownloadFolder => 'Select Download Folder'; - @override String get setupUseDefaultFolder => 'Use Default Folder?'; @@ -699,21 +458,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get setupDownloadInFlac => 'Download Spotify tracks in FLAC'; - @override - String get setupStepStorage => 'Storage'; - - @override - String get setupStepNotification => 'Notification'; - - @override - String get setupStepFolder => 'Folder'; - - @override - String get setupStepSpotify => 'Spotify'; - - @override - String get setupStepPermission => 'Permission'; - @override String get setupStorageGranted => 'Storage Permission Granted!'; @@ -730,13 +474,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get setupNotificationEnable => 'Enable Notifications'; - @override - String get setupNotificationDescription => - 'Get notified when downloads complete or require attention.'; - - @override - String get setupFolderSelected => 'Download Folder Selected!'; - @override String get setupFolderChoose => 'Choose Download Folder'; @@ -744,48 +481,12 @@ class AppLocalizationsEs extends AppLocalizations { String get setupFolderDescription => 'Select a folder where your downloaded music will be saved.'; - @override - String get setupChangeFolder => 'Change Folder'; - @override String get setupSelectFolder => 'Select Folder'; - @override - String get setupSpotifyApiOptional => 'Spotify API (Optional)'; - - @override - String get setupSpotifyApiDescription => - 'Add your Spotify API credentials for better search results and access to Spotify-exclusive content.'; - - @override - String get setupUseSpotifyApi => 'Use Spotify API'; - - @override - String get setupEnterCredentialsBelow => 'Enter your credentials below'; - - @override - String get setupUsingDeezer => 'Using Deezer (no account needed)'; - - @override - String get setupEnterClientId => 'Enter Spotify Client ID'; - - @override - String get setupEnterClientSecret => 'Enter Spotify Client Secret'; - - @override - String get setupGetFreeCredentials => - 'Get your free API credentials from the Spotify Developer Dashboard.'; - @override String get setupEnableNotifications => 'Enable Notifications'; - @override - String get setupProceedToNextStep => 'You can now proceed to the next step.'; - - @override - String get setupNotificationProgressDescription => - 'You will receive download progress notifications.'; - @override String get setupNotificationBackgroundDescription => 'Get notified about download progress and completion. This helps you track downloads when the app is in background.'; @@ -793,32 +494,19 @@ class AppLocalizationsEs extends AppLocalizations { @override String get setupSkipForNow => 'Skip for now'; - @override - String get setupBack => 'Back'; - @override String get setupNext => 'Next'; @override String get setupGetStarted => 'Get Started'; - @override - String get setupSkipAndStart => 'Skip & Start'; - @override String get setupAllowAccessToManageFiles => 'Please enable \"Allow access to manage all files\" in the next screen.'; - @override - String get setupGetCredentialsFromSpotify => - 'Get credentials from developer.spotify.com'; - @override String get dialogCancel => 'Cancel'; - @override - String get dialogOk => 'OK'; - @override String get dialogSave => 'Save'; @@ -828,21 +516,9 @@ class AppLocalizationsEs extends AppLocalizations { @override String get dialogRetry => 'Retry'; - @override - String get dialogClose => 'Close'; - - @override - String get dialogYes => 'Yes'; - - @override - String get dialogNo => 'No'; - @override String get dialogClear => 'Clear'; - @override - String get dialogConfirm => 'Confirm'; - @override String get dialogDone => 'Done'; @@ -865,28 +541,9 @@ class AppLocalizationsEs extends AppLocalizations { String get dialogUnsavedChanges => 'You have unsaved changes. Do you want to discard them?'; - @override - String get dialogDownloadFailed => 'Download Failed'; - - @override - String get dialogTrackLabel => 'Track:'; - - @override - String get dialogArtistLabel => 'Artist:'; - - @override - String get dialogErrorLabel => 'Error:'; - @override String get dialogClearAll => 'Clear All'; - @override - String get dialogClearAllDownloads => - 'Are you sure you want to clear all downloads?'; - - @override - String get dialogRemoveFromDevice => 'Remove from device?'; - @override String get dialogRemoveExtension => 'Remove Extension'; @@ -987,11 +644,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get snackbarViewQueue => 'View Queue'; - @override - String snackbarFailedToLoad(String error) { - return 'Failed to load: $error'; - } - @override String snackbarUrlCopied(String platform) { return '$platform URL copied to clipboard'; @@ -1033,11 +685,6 @@ class AppLocalizationsEs extends AppLocalizations { String get errorRateLimitedMessage => 'Too many requests. Please wait a moment before searching again.'; - @override - String errorFailedToLoad(String item) { - return 'Failed to load $item'; - } - @override String get errorNoTracksFound => 'No tracks found'; @@ -1046,27 +693,6 @@ class AppLocalizationsEs extends AppLocalizations { return 'Cannot load $item: missing extension source'; } - @override - String get statusQueued => 'Queued'; - - @override - String get statusDownloading => 'Downloading'; - - @override - String get statusFinalizing => 'Finalizing'; - - @override - String get statusCompleted => 'Completed'; - - @override - String get statusFailed => 'Failed'; - - @override - String get statusSkipped => 'Skipped'; - - @override - String get statusPaused => 'Paused'; - @override String get actionPause => 'Pause'; @@ -1076,24 +702,12 @@ class AppLocalizationsEs extends AppLocalizations { @override String get actionCancel => 'Cancel'; - @override - String get actionStop => 'Stop'; - - @override - String get actionSelect => 'Select'; - @override String get actionSelectAll => 'Select All'; @override String get actionDeselect => 'Deselect'; - @override - String get actionPaste => 'Paste'; - - @override - String get actionImportCsv => 'Import CSV'; - @override String get actionRemoveCredentials => 'Remove Credentials'; @@ -1108,20 +722,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get selectionAllSelected => 'All tracks selected'; - @override - String get selectionTapToSelect => 'Tap tracks to select'; - - @override - String selectionDeleteTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: 'tracks', - one: 'track', - ); - return 'Delete $count $_temp0'; - } - @override String get selectionSelectToDelete => 'Select tracks to delete'; @@ -1148,40 +748,9 @@ class AppLocalizationsEs extends AppLocalizations { @override String get tooltipPlay => 'Play'; - @override - String get tooltipCancel => 'Cancel'; - - @override - String get tooltipStop => 'Stop'; - - @override - String get tooltipRetry => 'Retry'; - - @override - String get tooltipRemove => 'Remove'; - - @override - String get tooltipClear => 'Clear'; - - @override - String get tooltipPaste => 'Paste'; - @override String get filenameFormat => 'Filename Format'; - @override - String filenameFormatPreview(String preview) { - return 'Preview: $preview'; - } - - @override - String get filenameAvailablePlaceholders => 'Available placeholders:'; - - @override - String filenameHint(Object artist, Object title) { - return '$artist - $title'; - } - @override String get filenameShowAdvancedTags => 'Show advanced tags'; @@ -1189,9 +758,6 @@ class AppLocalizationsEs extends AppLocalizations { String get filenameShowAdvancedTagsDescription => 'Enable formatted tags for track padding and date patterns'; - @override - String get folderOrganization => 'Folder Organization'; - @override String get folderOrganizationNone => 'No organization'; @@ -1226,20 +792,9 @@ class AppLocalizationsEs extends AppLocalizations { @override String get updateAvailable => 'Update Available'; - @override - String updateNewVersion(String version) { - return 'Version $version is available'; - } - - @override - String get updateDownload => 'Download'; - @override String get updateLater => 'Later'; - @override - String get updateChangelog => 'Changelog'; - @override String get updateStartingDownload => 'Starting download...'; @@ -1270,12 +825,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get updateDontRemind => 'Don\'t remind'; - @override - String get providerPriority => 'Provider Priority'; - - @override - String get providerPrioritySubtitle => 'Drag to reorder download providers'; - @override String get providerPriorityTitle => 'Provider Priority'; @@ -1293,13 +842,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get providerExtension => 'Extension'; - @override - String get metadataProviderPriority => 'Metadata Provider Priority'; - - @override - String get metadataProviderPrioritySubtitle => - 'Order used when fetching track metadata'; - @override String get metadataProviderPriorityTitle => 'Metadata Priority'; @@ -1320,18 +862,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get logTitle => 'Logs'; - @override - String get logCopy => 'Copy Logs'; - - @override - String get logClear => 'Clear Logs'; - - @override - String get logShare => 'Share Logs'; - - @override - String get logEmpty => 'No logs yet'; - @override String get logCopied => 'Logs copied to clipboard'; @@ -1356,18 +886,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get logClearLogsMessage => 'Are you sure you want to clear all logs?'; - @override - String get logIspBlocking => 'ISP BLOCKING DETECTED'; - - @override - String get logRateLimited => 'RATE LIMITED'; - - @override - String get logNetworkError => 'NETWORK ERROR'; - - @override - String get logTrackNotFound => 'TRACK NOT FOUND'; - @override String get logFilterBySeverity => 'Filter logs by severity'; @@ -1377,48 +895,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get logNoLogsYetSubtitle => 'Logs will appear here as you use the app'; - @override - String get logIssueSummary => 'Issue Summary'; - - @override - String get logIspBlockingDescription => - 'Your ISP may be blocking access to download services'; - - @override - String get logIspBlockingSuggestion => - 'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8'; - - @override - String get logRateLimitedDescription => 'Too many requests to the service'; - - @override - String get logRateLimitedSuggestion => - 'Wait a few minutes before trying again'; - - @override - String get logNetworkErrorDescription => 'Connection issues detected'; - - @override - String get logNetworkErrorSuggestion => 'Check your internet connection'; - - @override - String get logTrackNotFoundDescription => - 'Some tracks could not be found on download services'; - - @override - String get logTrackNotFoundSuggestion => - 'The track may not be available in lossless quality'; - - @override - String logTotalErrors(int count) { - return 'Total errors: $count'; - } - - @override - String logAffected(String domains) { - return 'Affected: $domains'; - } - @override String logEntriesFiltered(int count) { return 'Entries ($count filtered)'; @@ -1525,9 +1001,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get appearanceLanguage => 'App Language'; - @override - String get appearanceLanguageSubtitle => 'Choose your preferred language'; - @override String get settingsAppearanceSubtitle => 'Theme, colors, display'; @@ -1549,9 +1022,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get pressBackAgainToExit => 'Press back again to exit'; - @override - String get tracksHeader => 'Tracks'; - @override String downloadAllCount(int count) { return 'Download All ($count)'; @@ -1665,11 +1135,6 @@ class AppLocalizationsEs extends AppLocalizations { String get trackDeleteConfirmMessage => 'This will permanently delete the downloaded file and remove it from your history.'; - @override - String trackCannotOpen(String message) { - return 'Cannot open: $message'; - } - @override String get dateToday => 'Today'; @@ -1691,18 +1156,6 @@ class AppLocalizationsEs extends AppLocalizations { return '$count months ago'; } - @override - String get concurrentSequential => 'Sequential'; - - @override - String get concurrentParallel2 => '2 Parallel'; - - @override - String get concurrentParallel3 => '3 Parallel'; - - @override - String get tapToSeeError => 'Tap to see error details'; - @override String get storeFilterAll => 'All'; @@ -1724,15 +1177,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get storeClearFilters => 'Clear filters'; - @override - String get storeNoResults => 'No extensions found'; - - @override - String get extensionProviderPriority => 'Provider Priority'; - - @override - String get extensionInstallButton => 'Install Extension'; - @override String get extensionDefaultProvider => 'Default (Deezer/Spotify)'; @@ -1883,38 +1327,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; - @override - String get qualityLossy => 'Lossy'; - - @override - String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)'; - - @override - String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)'; - - @override - String get enableLossyOption => 'Enable Lossy Option'; - - @override - String get enableLossyOptionSubtitleOn => 'Lossy quality option is available'; - - @override - String get enableLossyOptionSubtitleOff => - 'Downloads FLAC then converts to lossy format'; - - @override - String get lossyFormat => 'Lossy Format'; - - @override - String get lossyFormatDescription => 'Choose the lossy format for conversion'; - - @override - String get lossyFormatMp3Subtitle => '320kbps, best compatibility'; - - @override - String get lossyFormatOpusSubtitle => - '128kbps, better quality at smaller size'; - @override String get qualityNote => 'Actual quality depends on track availability from the service'; @@ -1923,6 +1335,12 @@ class AppLocalizationsEs extends AppLocalizations { String get youtubeQualityNote => 'YouTube provides lossy audio only. Not part of lossless fallback.'; + @override + String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; + + @override + String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; + @override String get downloadAskBeforeDownload => 'Ask Before Download'; @@ -1938,14 +1356,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders'; - @override - String get downloadUseAlbumArtistForFoldersAlbumSubtitle => - 'Artist folders use Album Artist when available'; - - @override - String get downloadUseAlbumArtistForFoldersTrackSubtitle => - 'Artist folders use Track Artist only'; - @override String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders'; @@ -1957,78 +1367,18 @@ class AppLocalizationsEs extends AppLocalizations { String get downloadUsePrimaryArtistOnlyDisabled => 'Full artist string used for folder name'; - @override - String get downloadSaveFormat => 'Save Format'; - - @override - String get downloadSelectService => 'Select Service'; - @override String get downloadSelectQuality => 'Select Quality'; @override String get downloadFrom => 'Download From'; - @override - String get downloadDefaultQualityLabel => 'Default Quality'; - - @override - String get downloadBestAvailable => 'Best available'; - - @override - String get folderNone => 'None'; - - @override - String get folderNoneSubtitle => 'Save all files directly to download folder'; - - @override - String get folderArtist => 'Artist'; - - @override - String get folderArtistSubtitle => 'Artist Name/filename'; - - @override - String get folderAlbum => 'Album'; - - @override - String get folderAlbumSubtitle => 'Album Name/filename'; - - @override - String get folderArtistAlbum => 'Artist/Album'; - - @override - String get folderArtistAlbumSubtitle => 'Artist Name/Album Name/filename'; - - @override - String get serviceTidal => 'Tidal'; - - @override - String get serviceQobuz => 'Qobuz'; - - @override - String get serviceAmazon => 'Amazon'; - - @override - String get serviceDeezer => 'Deezer'; - - @override - String get serviceSpotify => 'Spotify'; - @override String get appearanceAmoledDark => 'AMOLED Dark'; @override String get appearanceAmoledDarkSubtitle => 'Pure black background'; - @override - String get appearanceChooseAccentColor => 'Choose Accent Color'; - - @override - String get appearanceChooseTheme => 'Theme Mode'; - - @override - String get queueTitle => 'Download Queue'; - @override String get queueClearAll => 'Clear All'; @@ -2036,19 +1386,6 @@ class AppLocalizationsEs extends AppLocalizations { String get queueClearAllMessage => 'Are you sure you want to clear all downloads?'; - @override - String get queueExportFailed => 'Export'; - - @override - String get queueExportFailedSuccess => - 'Failed downloads exported to TXT file'; - - @override - String get queueExportFailedClear => 'Clear Failed'; - - @override - String get queueExportFailedError => 'Failed to export downloads'; - @override String get settingsAutoExportFailed => 'Auto-export failed downloads'; @@ -2069,30 +1406,6 @@ class AppLocalizationsEs extends AppLocalizations { String get settingsDownloadNetworkSubtitle => 'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.'; - @override - String get queueEmpty => 'No downloads in queue'; - - @override - String get queueEmptySubtitle => 'Add tracks from the home screen'; - - @override - String get queueClearCompleted => 'Clear completed'; - - @override - String get queueDownloadFailed => 'Download Failed'; - - @override - String get queueTrackLabel => 'Track:'; - - @override - String get queueArtistLabel => 'Artist:'; - - @override - String get queueErrorLabel => 'Error:'; - - @override - String get queueUnknownError => 'Unknown error'; - @override String get albumFolderArtistAlbum => 'Artist / Album'; @@ -2139,14 +1452,6 @@ class AppLocalizationsEs extends AppLocalizations { return 'Delete $count $_temp0 from this album?\n\nThis will also delete the files from storage.'; } - @override - String get downloadedAlbumTracksHeader => 'Tracks'; - - @override - String downloadedAlbumDownloadedCount(int count) { - return '$count downloaded'; - } - @override String downloadedAlbumSelectedCount(int count) { return '$count selected'; @@ -2177,9 +1482,6 @@ class AppLocalizationsEs extends AppLocalizations { return 'Disc $discNumber'; } - @override - String get utilityFunctions => 'Utility Functions'; - @override String get recentTypeArtist => 'Artist'; @@ -2203,11 +1505,6 @@ class AppLocalizationsEs extends AppLocalizations { return 'Playlist: $name'; } - @override - String errorGeneric(String message) { - return 'Error: $message'; - } - @override String get discographyDownload => 'Download Discography'; @@ -2313,9 +1610,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get libraryTitle => 'Local Library'; - @override - String get libraryStatus => 'Library Status'; - @override String get libraryScanSettings => 'Scan Settings'; @@ -2379,8 +1673,14 @@ class AppLocalizationsEs extends AppLocalizations { 'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.'; @override - String libraryTracksCount(int count) { - return '$count tracks'; + String libraryTracksUnit(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return '$_temp0'; } @override @@ -2462,21 +1762,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get libraryFilterFormat => 'Format'; - @override - String get libraryFilterDate => 'Date Added'; - - @override - String get libraryFilterDateToday => 'Today'; - - @override - String get libraryFilterDateWeek => 'This Week'; - - @override - String get libraryFilterDateMonth => 'This Month'; - - @override - String get libraryFilterDateYear => 'This Year'; - @override String get libraryFilterSort => 'Sort'; @@ -2486,11 +1771,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get libraryFilterSortOldest => 'Oldest'; - @override - String libraryFilterActive(int count) { - return '$count filter(s) active'; - } - @override String get timeJustNow => 'Just now'; @@ -2516,72 +1796,6 @@ class AppLocalizationsEs extends AppLocalizations { return '$_temp0'; } - @override - String get storageSwitchTitle => 'Switch Storage Mode'; - - @override - String get storageSwitchToSafTitle => 'Switch to SAF Storage?'; - - @override - String get storageSwitchToAppTitle => 'Switch to App Storage?'; - - @override - String get storageSwitchToSafMessage => - 'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.'; - - @override - String get storageSwitchToAppMessage => - 'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.'; - - @override - String get storageSwitchExistingDownloads => 'Existing Downloads'; - - @override - String storageSwitchExistingDownloadsInfo(int count, String mode) { - return '$count tracks in $mode storage'; - } - - @override - String get storageSwitchNewDownloads => 'New Downloads'; - - @override - String storageSwitchNewDownloadsLocation(String location) { - return 'Will be saved to: $location'; - } - - @override - String get storageSwitchContinue => 'Continue'; - - @override - String get storageSwitchSelectFolder => 'Select SAF Folder'; - - @override - String get storageAppStorage => 'App Storage'; - - @override - String get storageSafStorage => 'SAF Storage'; - - @override - String storageModeBadge(String mode) { - return 'Storage: $mode'; - } - - @override - String get storageStatsTitle => 'Storage Statistics'; - - @override - String storageStatsAppCount(int count) { - return '$count tracks in App Storage'; - } - - @override - String storageStatsSafCount(int count) { - return '$count tracks in SAF Storage'; - } - - @override - String get storageModeInfo => 'Your files are stored in multiple locations'; - @override String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!'; @@ -2608,18 +1822,6 @@ class AppLocalizationsEs extends AppLocalizations { String get tutorialSearchDesc => 'There are two easy ways to find music you want to download.'; - @override - String get tutorialSearchTip1 => - 'Paste a Spotify or Deezer URL directly in the search box'; - - @override - String get tutorialSearchTip2 => - 'Or type the song name, artist, or album to search'; - - @override - String get tutorialSearchTip3 => - 'Supports tracks, albums, playlists, and artist pages'; - @override String get tutorialDownloadTitle => 'Downloading Music'; @@ -2627,18 +1829,6 @@ class AppLocalizationsEs extends AppLocalizations { String get tutorialDownloadDesc => 'Downloading music is simple and fast. Here\'s how it works.'; - @override - String get tutorialDownloadTip1 => - 'Tap the download button next to any track to start downloading'; - - @override - String get tutorialDownloadTip2 => - 'Choose your preferred quality (FLAC, Hi-Res, or MP3)'; - - @override - String get tutorialDownloadTip3 => - 'Download entire albums or playlists with one tap'; - @override String get tutorialLibraryTitle => 'Your Library'; @@ -2699,9 +1889,6 @@ class AppLocalizationsEs extends AppLocalizations { String get tutorialReadyMessage => 'You\'re all set! Start downloading your favorite music now.'; - @override - String get tutorialExample => 'EXAMPLE'; - @override String get libraryForceFullScan => 'Force Full Scan'; @@ -2862,11 +2049,7 @@ class AppLocalizationsEs extends AppLocalizations { String get trackSaveLyricsProgress => 'Saving lyrics...'; @override - String get trackReEnrich => 'Re-enrich Metadata'; - - @override - String get trackReEnrichSubtitle => - 'Re-embed metadata without re-downloading'; + String get trackReEnrich => 'Re-enrich'; @override String get trackReEnrichOnlineSubtitle => @@ -2942,6 +2125,223 @@ class AppLocalizationsEs extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + + @override + String get actionCreate => 'Create'; + + @override + String get collectionFoldersTitle => 'My folders'; + + @override + String get collectionWishlist => 'Wishlist'; + + @override + String get collectionLoved => 'Loved'; + + @override + String get collectionPlaylists => 'Playlists'; + + @override + String get collectionPlaylist => 'Playlist'; + + @override + String get collectionAddToPlaylist => 'Add to playlist'; + + @override + String get collectionCreatePlaylist => 'Create playlist'; + + @override + String get collectionNoPlaylistsYet => 'No playlists yet'; + + @override + String get collectionNoPlaylistsSubtitle => + 'Create a playlist to start categorizing tracks'; + + @override + String collectionPlaylistTracks(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String collectionAddedToPlaylist(String playlistName) { + return 'Added to \"$playlistName\"'; + } + + @override + String collectionAlreadyInPlaylist(String playlistName) { + return 'Already in \"$playlistName\"'; + } + + @override + String get collectionPlaylistCreated => 'Playlist created'; + + @override + String get collectionPlaylistNameHint => 'Playlist name'; + + @override + String get collectionPlaylistNameRequired => 'Playlist name is required'; + + @override + String get collectionRenamePlaylist => 'Rename playlist'; + + @override + String get collectionDeletePlaylist => 'Delete playlist'; + + @override + String collectionDeletePlaylistMessage(String playlistName) { + return 'Delete \"$playlistName\" and all tracks inside it?'; + } + + @override + String get collectionPlaylistDeleted => 'Playlist deleted'; + + @override + String get collectionPlaylistRenamed => 'Playlist renamed'; + + @override + String get collectionWishlistEmptyTitle => 'Wishlist is empty'; + + @override + String get collectionWishlistEmptySubtitle => + 'Tap + on tracks to save what you want to download later'; + + @override + String get collectionLovedEmptyTitle => 'Loved folder is empty'; + + @override + String get collectionLovedEmptySubtitle => + 'Tap love on tracks to keep your favorites'; + + @override + String get collectionPlaylistEmptyTitle => 'Playlist is empty'; + + @override + String get collectionPlaylistEmptySubtitle => + 'Long-press + on any track to add it here'; + + @override + String get collectionRemoveFromPlaylist => 'Remove from playlist'; + + @override + String get collectionRemoveFromFolder => 'Remove from folder'; + + @override + String collectionRemoved(String trackName) { + return '\"$trackName\" removed'; + } + + @override + String collectionAddedToLoved(String trackName) { + return '\"$trackName\" added to Loved'; + } + + @override + String collectionRemovedFromLoved(String trackName) { + return '\"$trackName\" removed from Loved'; + } + + @override + String collectionAddedToWishlist(String trackName) { + return '\"$trackName\" added to Wishlist'; + } + + @override + String collectionRemovedFromWishlist(String trackName) { + return '\"$trackName\" removed from Wishlist'; + } + + @override + String get trackOptionAddToLoved => 'Add to Loved'; + + @override + String get trackOptionRemoveFromLoved => 'Remove from Loved'; + + @override + String get trackOptionAddToWishlist => 'Add to Wishlist'; + + @override + String get trackOptionRemoveFromWishlist => 'Remove from Wishlist'; + + @override + String get collectionPlaylistChangeCover => 'Change cover image'; + + @override + String get collectionPlaylistRemoveCover => 'Remove cover image'; + + @override + String selectionShareCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Share $count $_temp0'; + } + + @override + String get selectionShareNoFiles => 'No shareable files found'; + + @override + String selectionConvertCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0'; + } + + @override + String get selectionConvertNoConvertible => 'No convertible tracks selected'; + + @override + String get selectionBatchConvertConfirmTitle => 'Batch Convert'; + + @override + String selectionBatchConvertConfirmMessage( + int count, + String format, + String bitrate, + ) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.'; + } + + @override + String selectionBatchConvertProgress(int current, int total) { + return 'Converting $current of $total...'; + } + + @override + String selectionBatchConvertSuccess(int success, int total, String format) { + return 'Converted $success of $total tracks to $format'; + } + + @override + String downloadedAlbumDownloadedCount(int count) { + return '$count downloaded'; + } + + @override + String get downloadUseAlbumArtistForFoldersAlbumSubtitle => + 'Artist folders use Album Artist when available'; + + @override + String get downloadUseAlbumArtistForFoldersTrackSubtitle => + 'Artist folders use Track Artist only'; } /// The translations for Spanish Castilian, as used in Spain (`es_ES`). @@ -2951,19 +2351,12 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get appName => 'SpotiFLAC'; - @override - String get appDescription => - 'Descargue pistas de Spotify con calidad sin pérdida de Tidal, Qobuz y Amazon Music.'; - @override String get navHome => 'Inicio'; @override String get navLibrary => 'Biblioteca'; - @override - String get navHistory => 'Historial'; - @override String get navSettings => 'Ajustes'; @@ -2973,14 +2366,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get homeTitle => 'Inicio'; - @override - String get homeSearchHint => 'Pegar URL Spotify o buscar...'; - - @override - String homeSearchHintExtension(String extensionName) { - return 'Buscar con $extensionName...'; - } - @override String get homeSubtitle => 'Pegar enlace de Spotify o buscar por nombre'; @@ -2991,17 +2376,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get homeRecent => 'Recientes'; - @override - String get historyTitle => 'Historial'; - - @override - String historyDownloading(int count) { - return 'Descargando ($count)'; - } - - @override - String get historyDownloaded => 'Descargado'; - @override String get historyFilterAll => 'Todo'; @@ -3011,49 +2385,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get historyFilterSingles => 'Pistas'; - @override - String historyTracksCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count pistas', - one: '1 pista', - ); - return '$_temp0'; - } - - @override - String historyAlbumsCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count álbumes', - one: '1 álbum', - ); - return '$_temp0'; - } - - @override - String get historyNoDownloads => 'No hay historial de descargas'; - - @override - String get historyNoDownloadsSubtitle => - 'Las pistas descargadas aparecerán aquí'; - - @override - String get historyNoAlbums => 'No hay descargas de álbum'; - - @override - String get historyNoAlbumsSubtitle => - 'Descargar múltiples pistas de un álbum para verlas aquí'; - - @override - String get historyNoSingles => 'No hay descargas'; - - @override - String get historyNoSinglesSubtitle => - 'Las descargas de una sola pista aparecerán aquí'; - @override String get historySearchHint => 'Buscar en historial...'; @@ -3078,27 +2409,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get downloadTitle => 'Descargar'; - @override - String get downloadLocation => 'Ubicación de descarga'; - - @override - String get downloadLocationSubtitle => 'Elija dónde guardar los archivos'; - - @override - String get downloadLocationDefault => 'Ubicación predeterminada'; - - @override - String get downloadDefaultService => 'Servicio por defecto'; - - @override - String get downloadDefaultServiceSubtitle => 'Servicio usado para descargas'; - - @override - String get downloadDefaultQuality => 'Calidad por defecto'; - - @override - String get downloadAskQuality => 'Preguntar calidad antes de descargar'; - @override String get downloadAskQualitySubtitle => 'Mostrar selector de calidad para cada descarga'; @@ -3109,31 +2419,9 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get downloadFolderOrganization => 'Organización de carpetas'; - @override - String get downloadSeparateSingles => 'Separar Pistas'; - - @override - String get downloadSeparateSinglesSubtitle => - 'Colocar pistas individuales en una carpeta separada'; - - @override - String get qualityBest => 'Mejor disponible'; - - @override - String get qualityFlac => 'FLAC'; - - @override - String get quality320 => '320 kbps'; - - @override - String get quality128 => '128 kbps'; - @override String get appearanceTitle => 'Apariencia'; - @override - String get appearanceTheme => 'Tema'; - @override String get appearanceThemeSystem => 'Sistema'; @@ -3150,9 +2438,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { String get appearanceDynamicColorSubtitle => 'Usar colores de tu fondo de pantalla'; - @override - String get appearanceAccentColor => 'Color Secundario'; - @override String get appearanceHistoryView => 'Vista de Historial'; @@ -3165,9 +2450,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get optionsTitle => 'Opciones'; - @override - String get optionsSearchSource => 'Buscar Fuente'; - @override String get optionsPrimaryProvider => 'Proveedor Principal'; @@ -3298,19 +2580,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get extensionsTitle => 'Extensiones'; - @override - String get extensionsInstalled => 'Extensiones instaladas'; - - @override - String get extensionsNone => 'No hay extensiones instaladas'; - - @override - String get extensionsNoneSubtitle => - 'Instalar extensiones desde la pestaña Tienda'; - - @override - String get extensionsEnabled => 'Habilitado'; - @override String get extensionsDisabled => 'Deshabilitado'; @@ -3327,9 +2596,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get extensionsUninstall => 'Desinstalar'; - @override - String get extensionsSetAsSearch => 'Establecer como proveedor de búsqueda'; - @override String get storeTitle => 'Tienda de extensiones'; @@ -3405,9 +2671,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get aboutSocial => 'Redes sociales'; - @override - String get aboutSupport => 'Soporte'; - @override String get aboutApp => 'Aplicación'; @@ -3426,13 +2689,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { String get aboutSjdonadoDesc => 'Creador de I No tengo Spotify (IDHS). ¡La solución de enlace de reserva que salva el día!'; - @override - String get aboutDoubleDouble => 'DoubleDouble'; - - @override - String get aboutDoubleDoubleDesc => - 'API increible para descargas de Amazon Music. ¡Gracias por hacerla gratis!'; - @override String get aboutDabMusic => 'Música DAB'; @@ -3451,32 +2707,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { String get aboutAppDescription => 'Descarga pistas de Spotify con calidad sin pérdida de Tidal, Qobuz y Amazon Music.'; - @override - String get albumTitle => 'Álbum'; - - @override - String albumTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count pistas', - one: '1 pista', - ); - return '$_temp0'; - } - - @override - String get albumDownloadAll => 'Descargar Todo'; - - @override - String get albumDownloadRemaining => 'Descargas Restantes'; - - @override - String get playlistTitle => 'Lista de reproducción'; - - @override - String get artistTitle => 'Artista'; - @override String get artistAlbums => 'Álbumes'; @@ -3486,17 +2716,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get artistCompilations => 'Compilaciones'; - @override - String artistReleases(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count lanzamientos', - one: '1 lanzamiento', - ); - return '$_temp0'; - } - @override String get artistPopular => 'Populares'; @@ -3505,27 +2724,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { return '$count oyentes mensuales'; } - @override - String get trackMetadataTitle => 'Información de pista'; - - @override - String get trackMetadataArtist => 'Artista'; - - @override - String get trackMetadataAlbum => 'Álbum'; - - @override - String get trackMetadataDuration => 'Duración'; - - @override - String get trackMetadataQuality => 'Calidad'; - - @override - String get trackMetadataPath => 'Ruta del archivo'; - - @override - String get trackMetadataDownloadedAt => 'Descargado'; - @override String get trackMetadataService => 'Servicio'; @@ -3538,53 +2736,15 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get trackMetadataDelete => 'Eliminar'; - @override - String get trackMetadataRedownload => 'Volver a descargar'; - - @override - String get trackMetadataOpenFolder => 'Abrir carpeta'; - - @override - String get setupTitle => 'Bienvenido a SpotiFLAC'; - - @override - String get setupSubtitle => 'Comencemos'; - - @override - String get setupStoragePermission => 'Permiso de almacenamiento'; - - @override - String get setupStoragePermissionSubtitle => - 'Necesario para guardar los archivos descargados'; - - @override - String get setupStoragePermissionGranted => 'Permiso aprobado'; - - @override - String get setupStoragePermissionDenied => 'Permiso denegado'; - @override String get setupGrantPermission => 'Conceder permiso'; - @override - String get setupDownloadLocation => 'Ubicación de descarga'; - - @override - String get setupChooseFolder => 'Seleccionar Carpeta'; - - @override - String get setupContinue => 'Continuar'; - @override String get setupSkip => 'Omitir por ahora'; @override String get setupStorageAccessRequired => 'Acceso al almacenamiento requerido'; - @override - String get setupStorageAccessMessage => - 'SpotiFLAC necesita permiso de \"Todos los archivos de acceso\" para guardar los archivos de música en la carpeta elegida.'; - @override String get setupStorageAccessMessageAndroid11 => 'Android 11+ requiere permiso \"Todos los archivos de acceso\" para guardar los archivos en la carpeta de descargas elegida.'; @@ -3606,9 +2766,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { return 'Se requiere un permiso $permissionType para la mejor experiencia. Puedes cambiar esto más tarde en ajustes.'; } - @override - String get setupSelectDownloadFolder => 'Seleccionar carpeta de descarga'; - @override String get setupUseDefaultFolder => '¿Usar carpeta por defecto?'; @@ -3651,21 +2808,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get setupDownloadInFlac => 'Descargar pistas de Spotify en FLAC'; - @override - String get setupStepStorage => 'Almacenamiento'; - - @override - String get setupStepNotification => 'Notificación'; - - @override - String get setupStepFolder => 'Carpeta'; - - @override - String get setupStepSpotify => 'Spotify'; - - @override - String get setupStepPermission => 'Permiso'; - @override String get setupStorageGranted => '¡Permiso de almacenamiento concedido!'; @@ -3683,13 +2825,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get setupNotificationEnable => 'Activar notificaciones'; - @override - String get setupNotificationDescription => - 'Recibe notificaciones cuando las descargas completen o requieran atención.'; - - @override - String get setupFolderSelected => '¡Carpeta de descarga seleccionada!'; - @override String get setupFolderChoose => 'Cambiar carpeta de descargas'; @@ -3697,50 +2832,12 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { String get setupFolderDescription => 'Seleccione una carpeta donde se guardará la música descargada.'; - @override - String get setupChangeFolder => 'Cambiar carpeta'; - @override String get setupSelectFolder => 'Seleccionar Carpeta'; - @override - String get setupSpotifyApiOptional => 'API de Spotify (opcional)'; - - @override - String get setupSpotifyApiDescription => - 'Añade tus credenciales de la API de Spotify para mejores resultados de búsqueda y acceso al contenido exclusivo de Spotify.'; - - @override - String get setupUseSpotifyApi => 'Usar API de Spotify'; - - @override - String get setupEnterCredentialsBelow => - 'Ingresa tus credenciales a continuación'; - - @override - String get setupUsingDeezer => 'Usando Deezer (no se necesita cuenta)'; - - @override - String get setupEnterClientId => 'Introduzca el ID de cliente de Spotify'; - - @override - String get setupEnterClientSecret => 'Ingresa el Client Secret de Spotify'; - - @override - String get setupGetFreeCredentials => - 'Obtén tus credenciales gratuitas de la API desde el Spotify Developer Dashboard.'; - @override String get setupEnableNotifications => 'Activar notificaciones'; - @override - String get setupProceedToNextStep => - 'Ahora puedes continuar con el siguiente paso.'; - - @override - String get setupNotificationProgressDescription => - 'Recibirás notificaciones de progreso de descargas.'; - @override String get setupNotificationBackgroundDescription => 'Recibe notificaciones sobre el progreso de la descarga y la finalización. Esto te ayuda a rastrear las descargas cuando la aplicación está en segundo plano.'; @@ -3748,32 +2845,19 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get setupSkipForNow => 'Omitir por ahora'; - @override - String get setupBack => 'Atrás'; - @override String get setupNext => 'Siguiente'; @override String get setupGetStarted => 'Empezar'; - @override - String get setupSkipAndStart => 'Saltar y empezar'; - @override String get setupAllowAccessToManageFiles => 'Por favor, activa \"Permitir el acceso para gestionar todos los archivos\" en la siguiente pantalla.'; - @override - String get setupGetCredentialsFromSpotify => - 'Obtener credenciales de developer.spotify.com'; - @override String get dialogCancel => 'Cancelar'; - @override - String get dialogOk => 'Aceptar'; - @override String get dialogSave => 'Guardar'; @@ -3783,21 +2867,9 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get dialogRetry => 'Volver a intentar'; - @override - String get dialogClose => 'Cerrar'; - - @override - String get dialogYes => 'Sí'; - - @override - String get dialogNo => 'No'; - @override String get dialogClear => 'Borrar'; - @override - String get dialogConfirm => 'Confirmar'; - @override String get dialogDone => 'Hecho'; @@ -3820,28 +2892,9 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { String get dialogUnsavedChanges => 'Tienes cambios sin guardar. ¿Quieres descartarlos?'; - @override - String get dialogDownloadFailed => 'Descarga fallida'; - - @override - String get dialogTrackLabel => 'Pista:'; - - @override - String get dialogArtistLabel => 'Artista:'; - - @override - String get dialogErrorLabel => 'Error:'; - @override String get dialogClearAll => 'Eliminar todo'; - @override - String get dialogClearAllDownloads => - '¿Estás seguro de que quieres borrar todas las descargas?'; - - @override - String get dialogRemoveFromDevice => '¿Eliminar del dispositivo?'; - @override String get dialogRemoveExtension => 'Eliminar extensión'; @@ -3942,11 +2995,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get snackbarViewQueue => 'Ver cola'; - @override - String snackbarFailedToLoad(String error) { - return 'Error al cargar: $error'; - } - @override String snackbarUrlCopied(String platform) { return 'URL $platform copiada al portapapeles'; @@ -3989,11 +3037,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { String get errorRateLimitedMessage => 'Demasiadas solicitudes. Por favor, espere un momento antes de buscar de nuevo.'; - @override - String errorFailedToLoad(String item) { - return 'Error al cargar $item'; - } - @override String get errorNoTracksFound => 'No se encontraron pistas'; @@ -4002,27 +3045,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { return 'No se puede cargar $item: falta una fuente de extensión'; } - @override - String get statusQueued => 'En cola'; - - @override - String get statusDownloading => 'Descargando'; - - @override - String get statusFinalizing => 'Finalizando'; - - @override - String get statusCompleted => 'Completado'; - - @override - String get statusFailed => 'Error'; - - @override - String get statusSkipped => 'Omitido'; - - @override - String get statusPaused => 'Pausado'; - @override String get actionPause => 'Pausar'; @@ -4032,24 +3054,12 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get actionCancel => 'Cancelar'; - @override - String get actionStop => 'Detener'; - - @override - String get actionSelect => 'Seleccionar'; - @override String get actionSelectAll => 'Seleccionar Todo'; @override String get actionDeselect => 'Deseleccionar'; - @override - String get actionPaste => 'Pegar'; - - @override - String get actionImportCsv => 'Importar CSV'; - @override String get actionRemoveCredentials => 'Eliminar credenciales'; @@ -4064,20 +3074,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get selectionAllSelected => 'Todas las pistas seleccionadas'; - @override - String get selectionTapToSelect => 'Toca las pistas para seleccionar'; - - @override - String selectionDeleteTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: 'pistas', - one: 'pista', - ); - return '¡Eliminar $count $_temp0'; - } - @override String get selectionSelectToDelete => 'Seleccionar pistas a eliminar'; @@ -4104,43 +3100,9 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get tooltipPlay => 'Reproducir'; - @override - String get tooltipCancel => 'Cancelar'; - - @override - String get tooltipStop => 'Detener'; - - @override - String get tooltipRetry => 'Volver a intentar'; - - @override - String get tooltipRemove => 'Eliminar'; - - @override - String get tooltipClear => 'Borrar'; - - @override - String get tooltipPaste => 'Pegar'; - @override String get filenameFormat => 'Formato del nombre del archivo'; - @override - String filenameFormatPreview(String preview) { - return 'Vista previa: $preview'; - } - - @override - String get filenameAvailablePlaceholders => 'Marcadores disponibles:'; - - @override - String filenameHint(Object artist, Object title) { - return '$artist - $title'; - } - - @override - String get folderOrganization => 'Organización de carpetas'; - @override String get folderOrganizationNone => 'Ninguna organización'; @@ -4176,20 +3138,9 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get updateAvailable => 'Actualización Disponible'; - @override - String updateNewVersion(String version) { - return 'Versión $version está disponible'; - } - - @override - String get updateDownload => 'Descargar'; - @override String get updateLater => 'Más tarde'; - @override - String get updateChangelog => 'Historial de cambios'; - @override String get updateStartingDownload => 'Iniciando descarga...'; @@ -4220,13 +3171,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get updateDontRemind => 'No recordar'; - @override - String get providerPriority => 'Prioridad del proveedor'; - - @override - String get providerPrioritySubtitle => - 'Arrastre para reordenar los proveedores de descarga'; - @override String get providerPriorityTitle => 'Prioridad del proveedor'; @@ -4244,13 +3188,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get providerExtension => 'Extensión'; - @override - String get metadataProviderPriority => 'Prioridad del proveedor de metadatos'; - - @override - String get metadataProviderPrioritySubtitle => - 'Orden usado al recuperar metadatos de la pista'; - @override String get metadataProviderPriorityTitle => 'Prioridad de los metadatos'; @@ -4271,18 +3208,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get logTitle => 'Registros'; - @override - String get logCopy => 'Copiar Registros'; - - @override - String get logClear => 'Limpiar registros'; - - @override - String get logShare => 'Compartir Registros'; - - @override - String get logEmpty => 'No hay registros aún'; - @override String get logCopied => 'Registros copiados al portapapeles'; @@ -4308,18 +3233,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { String get logClearLogsMessage => '¿Estás seguro que deseas limpiar todos los registros?'; - @override - String get logIspBlocking => 'BLOQUEO POR EL ISP DETECTADO'; - - @override - String get logRateLimited => 'TASA LIMITADA'; - - @override - String get logNetworkError => 'ERROR DE RED'; - - @override - String get logTrackNotFound => 'PISTA NO ENCONTRADA'; - @override String get logFilterBySeverity => 'Filtrar los registros por gravedad'; @@ -4330,48 +3243,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { String get logNoLogsYetSubtitle => 'Los registros aparecerán aquí mientras usas la aplicación'; - @override - String get logIssueSummary => 'Resumen de Incidencias'; - - @override - String get logIspBlockingDescription => - 'Tu ISP puede estar bloqueando el acceso a los servicios de descarga'; - - @override - String get logIspBlockingSuggestion => - 'Intente usar una VPN o cambie el DNS a 1.1.1.1 o 8.8.8.8'; - - @override - String get logRateLimitedDescription => 'Demasiadas solicitudes al servicio'; - - @override - String get logRateLimitedSuggestion => - 'Espere unos minutos antes de volver a intentarlo'; - - @override - String get logNetworkErrorDescription => 'Problemas de conexión detectados'; - - @override - String get logNetworkErrorSuggestion => 'Comprueba tu conexión a internet'; - - @override - String get logTrackNotFoundDescription => - 'No se pudieron encontrar algunas pistas en los servicios de descarga'; - - @override - String get logTrackNotFoundSuggestion => - 'La pista puede no estar disponible en calidad sin pérdida'; - - @override - String logTotalErrors(int count) { - return 'Total de errores: $count'; - } - - @override - String logAffected(String domains) { - return 'Afectado: $domains'; - } - @override String logEntriesFiltered(int count) { return 'Entradas ($count filtradas)'; @@ -4479,9 +3350,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get appearanceLanguage => 'Idioma de la aplicación'; - @override - String get appearanceLanguageSubtitle => 'Elija su idioma preferido'; - @override String get settingsAppearanceSubtitle => 'Tema, colores, pantalla'; @@ -4507,9 +3375,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get pressBackAgainToExit => 'Presione de nuevo para salir'; - @override - String get tracksHeader => 'Pistas'; - @override String downloadAllCount(int count) { return 'Descargar Todo ($count)'; @@ -4624,11 +3489,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { String get trackDeleteConfirmMessage => 'Esto eliminará permanentemente el archivo descargado y lo eliminará de tu historial.'; - @override - String trackCannotOpen(String message) { - return 'No se puede abrir: $message'; - } - @override String get dateToday => 'Hoy'; @@ -4650,18 +3510,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { return '$count meses atrás'; } - @override - String get concurrentSequential => 'Secuencial'; - - @override - String get concurrentParallel2 => '2 simultáneamente'; - - @override - String get concurrentParallel3 => '3 simultáneamente'; - - @override - String get tapToSeeError => 'Pulse para ver los detalles del error'; - @override String get storeFilterAll => 'Todo'; @@ -4683,15 +3531,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get storeClearFilters => 'Limpiar filtros'; - @override - String get storeNoResults => 'No se encontraron extensiones'; - - @override - String get extensionProviderPriority => 'Prioridad del proveedor'; - - @override - String get extensionInstallButton => 'Instalar extensión'; - @override String get extensionDefaultProvider => 'Por defecto (Deezer/Spotify)'; @@ -4845,39 +3684,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get qualityHiResFlacMaxSubtitle => '24 bits / hasta 192kHz'; - @override - String get qualityLossy => 'Con pérdidas'; - - @override - String get qualityLossyMp3Subtitle => 'MP3 320kbps (convertido desde FLAC)'; - - @override - String get qualityLossyOpusSubtitle => 'Opus 128kbps (convertido de FLAC)'; - - @override - String get enableLossyOption => 'Habilitar opción con pérdida'; - - @override - String get enableLossyOptionSubtitleOn => - 'La opción de calidad con pérdida está disponible'; - - @override - String get enableLossyOptionSubtitleOff => - 'Descargas FLAC y luego se convierten en formato con pérdida'; - - @override - String get lossyFormat => 'Formato con Perdido'; - - @override - String get lossyFormatDescription => - 'Elegir el formato con pérdida para la conversión'; - - @override - String get lossyFormatMp3Subtitle => '320kbps, mejor compatibilidad'; - - @override - String get lossyFormatOpusSubtitle => '128kbps, mejor calidad a menor tamaño'; - @override String get qualityNote => 'La calidad real depende de la disponibilidad de la pista del servicio'; @@ -4901,14 +3707,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders'; - @override - String get downloadUseAlbumArtistForFoldersAlbumSubtitle => - 'Artist folders use Album Artist when available'; - - @override - String get downloadUseAlbumArtistForFoldersTrackSubtitle => - 'Artist folders use Track Artist only'; - @override String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders'; @@ -4920,80 +3718,18 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { String get downloadUsePrimaryArtistOnlyDisabled => 'Full artist string used for folder name'; - @override - String get downloadSaveFormat => 'Guardar Formato'; - - @override - String get downloadSelectService => 'Seleccionar Servicio'; - @override String get downloadSelectQuality => 'Seleccionar Calidad'; @override String get downloadFrom => 'Descargar Desde'; - @override - String get downloadDefaultQualityLabel => 'Calidad por Defecto'; - - @override - String get downloadBestAvailable => 'La mejor disponible'; - - @override - String get folderNone => 'Ninguna'; - - @override - String get folderNoneSubtitle => - 'Guardar todos los archivos directamente para descargar la carpeta'; - - @override - String get folderArtist => 'Artista'; - - @override - String get folderArtistSubtitle => 'Nombre del Artista/nombre de archivo'; - - @override - String get folderAlbum => 'Álbum'; - - @override - String get folderAlbumSubtitle => 'Nombre del álbum/nombre de archivo'; - - @override - String get folderArtistAlbum => 'Artista/Álbum'; - - @override - String get folderArtistAlbumSubtitle => - 'Nombre del Artista/Nombre del Álbum/Nombre del Archivo'; - - @override - String get serviceTidal => 'Tidal'; - - @override - String get serviceQobuz => 'Qobuz'; - - @override - String get serviceAmazon => 'Amazon'; - - @override - String get serviceDeezer => 'Deezer'; - - @override - String get serviceSpotify => 'Spotify'; - @override String get appearanceAmoledDark => 'AMOLED Oscuro'; @override String get appearanceAmoledDarkSubtitle => 'Fondo negro puro'; - @override - String get appearanceChooseAccentColor => 'Elegir color principal'; - - @override - String get appearanceChooseTheme => 'Modo de tema'; - - @override - String get queueTitle => 'Descargas en proceso'; - @override String get queueClearAll => 'Eliminar todo'; @@ -5001,19 +3737,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { String get queueClearAllMessage => '¿Estás seguro de que quieres borrar todas las descargas?'; - @override - String get queueExportFailed => 'Exportar'; - - @override - String get queueExportFailedSuccess => - 'Descarga fallida exportada al archivo TXT'; - - @override - String get queueExportFailedClear => 'Limpieza Fallida'; - - @override - String get queueExportFailedError => 'Error al exportar descargas'; - @override String get settingsAutoExportFailed => 'Autoexportar descargas fallidas'; @@ -5034,30 +3757,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { String get settingsDownloadNetworkSubtitle => 'Elegir qué red usar para descargas. Cuando se establece en WiFi solamente, las descargas se detendrán en los datos móviles.'; - @override - String get queueEmpty => 'No hay descargas en cola'; - - @override - String get queueEmptySubtitle => 'Añadir pistas desde la pantalla de inicio'; - - @override - String get queueClearCompleted => 'Limpiar tareas finalizadas'; - - @override - String get queueDownloadFailed => 'Descarga fallida'; - - @override - String get queueTrackLabel => 'Pista:'; - - @override - String get queueArtistLabel => 'Artista:'; - - @override - String get queueErrorLabel => 'Error:'; - - @override - String get queueUnknownError => 'Error desconocido'; - @override String get albumFolderArtistAlbum => 'Artista / Álbum'; @@ -5105,14 +3804,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { return '¿Eliminar $count $_temp0 del historial?\n\nEsto también eliminará los archivos del almacenamiento.'; } - @override - String get downloadedAlbumTracksHeader => 'Pistas'; - - @override - String downloadedAlbumDownloadedCount(int count) { - return '$count descargado'; - } - @override String downloadedAlbumSelectedCount(int count) { return '$count seleccionado'; @@ -5143,9 +3834,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { return 'Disco $discNumber'; } - @override - String get utilityFunctions => 'Funciones de utilidad'; - @override String get recentTypeArtist => 'Artista'; @@ -5169,11 +3857,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { return 'Lista de reproducción: $name'; } - @override - String errorGeneric(String message) { - return 'Error: $message'; - } - @override String get discographyDownload => 'Descargar Discografía'; @@ -5279,9 +3962,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get libraryTitle => 'Local Library'; - @override - String get libraryStatus => 'Library Status'; - @override String get libraryScanSettings => 'Scan Settings'; @@ -5344,11 +4024,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { String get libraryAboutDescription => 'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.'; - @override - String libraryTracksCount(int count) { - return '$count tracks'; - } - @override String libraryLastScanned(String time) { return 'Last scanned: $time'; @@ -5428,21 +4103,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get libraryFilterFormat => 'Format'; - @override - String get libraryFilterDate => 'Date Added'; - - @override - String get libraryFilterDateToday => 'Today'; - - @override - String get libraryFilterDateWeek => 'This Week'; - - @override - String get libraryFilterDateMonth => 'This Month'; - - @override - String get libraryFilterDateYear => 'This Year'; - @override String get libraryFilterSort => 'Sort'; @@ -5452,11 +4112,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get libraryFilterSortOldest => 'Oldest'; - @override - String libraryFilterActive(int count) { - return '$count filter(s) active'; - } - @override String get timeJustNow => 'Just now'; @@ -5482,72 +4137,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { return '$_temp0'; } - @override - String get storageSwitchTitle => 'Switch Storage Mode'; - - @override - String get storageSwitchToSafTitle => 'Switch to SAF Storage?'; - - @override - String get storageSwitchToAppTitle => 'Switch to App Storage?'; - - @override - String get storageSwitchToSafMessage => - 'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.'; - - @override - String get storageSwitchToAppMessage => - 'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.'; - - @override - String get storageSwitchExistingDownloads => 'Existing Downloads'; - - @override - String storageSwitchExistingDownloadsInfo(int count, String mode) { - return '$count tracks in $mode storage'; - } - - @override - String get storageSwitchNewDownloads => 'New Downloads'; - - @override - String storageSwitchNewDownloadsLocation(String location) { - return 'Will be saved to: $location'; - } - - @override - String get storageSwitchContinue => 'Continue'; - - @override - String get storageSwitchSelectFolder => 'Select SAF Folder'; - - @override - String get storageAppStorage => 'App Storage'; - - @override - String get storageSafStorage => 'SAF Storage'; - - @override - String storageModeBadge(String mode) { - return 'Storage: $mode'; - } - - @override - String get storageStatsTitle => 'Storage Statistics'; - - @override - String storageStatsAppCount(int count) { - return '$count tracks in App Storage'; - } - - @override - String storageStatsSafCount(int count) { - return '$count tracks in SAF Storage'; - } - - @override - String get storageModeInfo => 'Your files are stored in multiple locations'; - @override String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!'; @@ -5574,18 +4163,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { String get tutorialSearchDesc => 'There are two easy ways to find music you want to download.'; - @override - String get tutorialSearchTip1 => - 'Paste a Spotify or Deezer URL directly in the search box'; - - @override - String get tutorialSearchTip2 => - 'Or type the song name, artist, or album to search'; - - @override - String get tutorialSearchTip3 => - 'Supports tracks, albums, playlists, and artist pages'; - @override String get tutorialDownloadTitle => 'Downloading Music'; @@ -5593,18 +4170,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { String get tutorialDownloadDesc => 'Downloading music is simple and fast. Here\'s how it works.'; - @override - String get tutorialDownloadTip1 => - 'Tap the download button next to any track to start downloading'; - - @override - String get tutorialDownloadTip2 => - 'Choose your preferred quality (FLAC, Hi-Res, or MP3)'; - - @override - String get tutorialDownloadTip3 => - 'Download entire albums or playlists with one tap'; - @override String get tutorialLibraryTitle => 'Your Library'; @@ -5665,9 +4230,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { String get tutorialReadyMessage => 'You\'re all set! Start downloading your favorite music now.'; - @override - String get tutorialExample => 'EXAMPLE'; - @override String get libraryForceFullScan => 'Force Full Scan'; @@ -5828,11 +4390,7 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { String get trackSaveLyricsProgress => 'Saving lyrics...'; @override - String get trackReEnrich => 'Re-enrich Metadata'; - - @override - String get trackReEnrichSubtitle => - 'Re-embed metadata without re-downloading'; + String get trackReEnrich => 'Re-enrich'; @override String get trackReEnrichOnlineSubtitle => @@ -5908,4 +4466,17 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get trackConvertFailed => 'Conversion failed'; + + @override + String downloadedAlbumDownloadedCount(int count) { + return '$count descargado'; + } + + @override + String get downloadUseAlbumArtistForFoldersAlbumSubtitle => + 'Artist folders use Album Artist when available'; + + @override + String get downloadUseAlbumArtistForFoldersTrackSubtitle => + 'Artist folders use Track Artist only'; } diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 4e381dee..adf0d48d 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -11,19 +11,12 @@ class AppLocalizationsFr extends AppLocalizations { @override String get appName => 'SpotiFLAC'; - @override - String get appDescription => - 'Téléchargez des pistes Spotify en qualité sans perte de Tidal, Qobuz et Amazon Music.'; - @override String get navHome => 'Accueil'; @override String get navLibrary => 'Bibliothèques'; - @override - String get navHistory => 'Historique'; - @override String get navSettings => 'Paramètres'; @@ -33,14 +26,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get homeTitle => 'Accueil'; - @override - String get homeSearchHint => 'Coller l\'URL Spotify ou rechercher...'; - - @override - String homeSearchHintExtension(String extensionName) { - return 'Rechercher avec $extensionName...'; - } - @override String get homeSubtitle => 'Coller un lien Spotify ou rechercher par nom'; @@ -50,17 +35,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get homeRecent => 'Récent'; - @override - String get historyTitle => 'Historique'; - - @override - String historyDownloading(int count) { - return 'Téléchargement ($count)'; - } - - @override - String get historyDownloaded => 'Téléchargé'; - @override String get historyFilterAll => 'Tous'; @@ -70,49 +44,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get historyFilterSingles => 'Titres'; - @override - String historyTracksCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count tracks', - one: '1 track', - ); - return '$_temp0'; - } - - @override - String historyAlbumsCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count albums', - one: '1 album', - ); - return '$_temp0'; - } - - @override - String get historyNoDownloads => 'Pas d\'historique de téléchargement'; - - @override - String get historyNoDownloadsSubtitle => - 'Les pistes téléchargées apparaîtront ici'; - - @override - String get historyNoAlbums => 'Pas de téléchargement d\'album'; - - @override - String get historyNoAlbumsSubtitle => - 'Téléchargez plusieurs titres d\'un album pour les voir ici'; - - @override - String get historyNoSingles => 'Pas de téléchargements uniques'; - - @override - String get historyNoSinglesSubtitle => - 'Les téléchargements de pistes uniques apparaîtront ici'; - @override String get historySearchHint => 'Historique de recherche...'; @@ -137,30 +68,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get downloadTitle => 'Télécharger'; - @override - String get downloadLocation => 'Télécharger Localisation'; - - @override - String get downloadLocationSubtitle => - 'Choisissez où enregistrer des fichiers'; - - @override - String get downloadLocationDefault => 'Localisation par défaut'; - - @override - String get downloadDefaultService => 'Service par défaut'; - - @override - String get downloadDefaultServiceSubtitle => - 'Service utilisé pour les téléchargements'; - - @override - String get downloadDefaultQuality => 'Qualité par défaut'; - - @override - String get downloadAskQuality => - 'Demandez La Qualité Avant Le Téléchargement'; - @override String get downloadAskQualitySubtitle => 'Afficher le sélecteur de qualité pour chaque téléchargement'; @@ -171,31 +78,9 @@ class AppLocalizationsFr extends AppLocalizations { @override String get downloadFolderOrganization => 'Organisation du dossier'; - @override - String get downloadSeparateSingles => 'Titres séparés'; - - @override - String get downloadSeparateSinglesSubtitle => - 'Mettre des pistes uniques dans un dossier séparé'; - - @override - String get qualityBest => 'Meilleur Disponible'; - - @override - String get qualityFlac => 'FLAC'; - - @override - String get quality320 => '320 kbps'; - - @override - String get quality128 => '128 kbps'; - @override String get appearanceTitle => 'Apparence'; - @override - String get appearanceTheme => 'Thème'; - @override String get appearanceThemeSystem => 'Système'; @@ -212,9 +97,6 @@ class AppLocalizationsFr extends AppLocalizations { String get appearanceDynamicColorSubtitle => 'Utilisez les couleurs de votre fond d\'écran'; - @override - String get appearanceAccentColor => 'Couleur d\'accent'; - @override String get appearanceHistoryView => 'Historique Vue'; @@ -227,9 +109,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get optionsTitle => 'Options'; - @override - String get optionsSearchSource => 'Recherche Source'; - @override String get optionsPrimaryProvider => 'Fournisseur principal'; @@ -356,18 +235,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get extensionsTitle => 'Extensions'; - @override - String get extensionsInstalled => 'Installed Extensions'; - - @override - String get extensionsNone => 'No extensions installed'; - - @override - String get extensionsNoneSubtitle => 'Install extensions from the Store tab'; - - @override - String get extensionsEnabled => 'Enabled'; - @override String get extensionsDisabled => 'Disabled'; @@ -384,9 +251,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get extensionsUninstall => 'Désinstaller'; - @override - String get extensionsSetAsSearch => 'Défini comme fournisseur de recherche'; - @override String get storeTitle => 'Magasin d\'extension'; @@ -460,9 +324,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get aboutSocial => 'Social'; - @override - String get aboutSupport => 'Support'; - @override String get aboutApp => 'App'; @@ -481,13 +342,6 @@ class AppLocalizationsFr extends AppLocalizations { String get aboutSjdonadoDesc => 'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!'; - @override - String get aboutDoubleDouble => 'DoubleDouble'; - - @override - String get aboutDoubleDoubleDesc => - 'Amazing API for Amazon Music downloads. Thank you for making it free!'; - @override String get aboutDabMusic => 'DAB Music'; @@ -506,32 +360,6 @@ class AppLocalizationsFr extends AppLocalizations { String get aboutAppDescription => 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; - @override - String get albumTitle => 'Album'; - - @override - String albumTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count tracks', - one: '1 track', - ); - return '$_temp0'; - } - - @override - String get albumDownloadAll => 'Download All'; - - @override - String get albumDownloadRemaining => 'Download Remaining'; - - @override - String get playlistTitle => 'Playlist'; - - @override - String get artistTitle => 'Artist'; - @override String get artistAlbums => 'Albums'; @@ -541,17 +369,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get artistCompilations => 'Compilations'; - @override - String artistReleases(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count releases', - one: '1 release', - ); - return '$_temp0'; - } - @override String get artistPopular => 'Popular'; @@ -560,27 +377,6 @@ class AppLocalizationsFr extends AppLocalizations { return '$count monthly listeners'; } - @override - String get trackMetadataTitle => 'Track Info'; - - @override - String get trackMetadataArtist => 'Artist'; - - @override - String get trackMetadataAlbum => 'Album'; - - @override - String get trackMetadataDuration => 'Duration'; - - @override - String get trackMetadataQuality => ''; - - @override - String get trackMetadataPath => 'File Path'; - - @override - String get trackMetadataDownloadedAt => 'Downloaded'; - @override String get trackMetadataService => 'Service'; @@ -593,53 +389,15 @@ class AppLocalizationsFr extends AppLocalizations { @override String get trackMetadataDelete => 'Supprimer'; - @override - String get trackMetadataRedownload => 'Re-télécharger'; - - @override - String get trackMetadataOpenFolder => 'Dossier ouvert'; - - @override - String get setupTitle => 'Bienvenue chez SpotiFLAC'; - - @override - String get setupSubtitle => 'On va commencer'; - - @override - String get setupStoragePermission => 'Permission de stockage'; - - @override - String get setupStoragePermissionSubtitle => - 'Requis pour enregistrer les fichiers téléchargés'; - - @override - String get setupStoragePermissionGranted => 'Permission accordée'; - - @override - String get setupStoragePermissionDenied => 'Permission refusée'; - @override String get setupGrantPermission => 'Grant Permission'; - @override - String get setupDownloadLocation => 'Download Location'; - - @override - String get setupChooseFolder => 'Choose Folder'; - - @override - String get setupContinue => 'Continue'; - @override String get setupSkip => 'Skip for now'; @override String get setupStorageAccessRequired => 'Storage Access Required'; - @override - String get setupStorageAccessMessage => - 'SpotiFLAC needs \"All files access\" permission to save music files to your chosen folder.'; - @override String get setupStorageAccessMessageAndroid11 => 'Android 11+ requires \"All files access\" permission to save files to your chosen download folder.'; @@ -661,9 +419,6 @@ class AppLocalizationsFr extends AppLocalizations { return '$permissionType permission is required for the best experience. You can change this later in Settings.'; } - @override - String get setupSelectDownloadFolder => 'Select Download Folder'; - @override String get setupUseDefaultFolder => 'Use Default Folder?'; @@ -705,21 +460,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get setupDownloadInFlac => 'Download Spotify tracks in FLAC'; - @override - String get setupStepStorage => 'Storage'; - - @override - String get setupStepNotification => 'Notification'; - - @override - String get setupStepFolder => 'Folder'; - - @override - String get setupStepSpotify => 'Spotify'; - - @override - String get setupStepPermission => 'Permission'; - @override String get setupStorageGranted => 'Storage Permission Granted!'; @@ -736,13 +476,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get setupNotificationEnable => 'Enable Notifications'; - @override - String get setupNotificationDescription => - 'Get notified when downloads complete or require attention.'; - - @override - String get setupFolderSelected => 'Dossier de téléchargement sélectionné!'; - @override String get setupFolderChoose => 'Choisissez le dossier pour télécharger'; @@ -750,48 +483,12 @@ class AppLocalizationsFr extends AppLocalizations { String get setupFolderDescription => 'Sélectionnez un dossier dans lequel votre musique téléchargée sera enregistrée.'; - @override - String get setupChangeFolder => 'Change Folder'; - @override String get setupSelectFolder => 'Select Folder'; - @override - String get setupSpotifyApiOptional => 'Spotify API (Optional)'; - - @override - String get setupSpotifyApiDescription => - 'Add your Spotify API credentials for better search results and access to Spotify-exclusive content.'; - - @override - String get setupUseSpotifyApi => 'Use Spotify API'; - - @override - String get setupEnterCredentialsBelow => 'Enter your credentials below'; - - @override - String get setupUsingDeezer => 'Using Deezer (no account needed)'; - - @override - String get setupEnterClientId => 'Enter Spotify Client ID'; - - @override - String get setupEnterClientSecret => 'Enter Spotify Client Secret'; - - @override - String get setupGetFreeCredentials => - 'Get your free API credentials from the Spotify Developer Dashboard.'; - @override String get setupEnableNotifications => 'Enable Notifications'; - @override - String get setupProceedToNextStep => 'You can now proceed to the next step.'; - - @override - String get setupNotificationProgressDescription => - 'You will receive download progress notifications.'; - @override String get setupNotificationBackgroundDescription => 'Get notified about download progress and completion. This helps you track downloads when the app is in background.'; @@ -799,32 +496,19 @@ class AppLocalizationsFr extends AppLocalizations { @override String get setupSkipForNow => 'Skip for now'; - @override - String get setupBack => 'Back'; - @override String get setupNext => 'Next'; @override String get setupGetStarted => 'Get Started'; - @override - String get setupSkipAndStart => 'Skip & Start'; - @override String get setupAllowAccessToManageFiles => 'Please enable \"Allow access to manage all files\" in the next screen.'; - @override - String get setupGetCredentialsFromSpotify => - 'Get credentials from developer.spotify.com'; - @override String get dialogCancel => 'Cancel'; - @override - String get dialogOk => 'OK'; - @override String get dialogSave => 'Save'; @@ -834,21 +518,9 @@ class AppLocalizationsFr extends AppLocalizations { @override String get dialogRetry => 'Retry'; - @override - String get dialogClose => 'Close'; - - @override - String get dialogYes => 'Yes'; - - @override - String get dialogNo => 'No'; - @override String get dialogClear => 'Clear'; - @override - String get dialogConfirm => 'Confirm'; - @override String get dialogDone => 'Done'; @@ -871,28 +543,9 @@ class AppLocalizationsFr extends AppLocalizations { String get dialogUnsavedChanges => 'You have unsaved changes. Do you want to discard them?'; - @override - String get dialogDownloadFailed => 'Download Failed'; - - @override - String get dialogTrackLabel => 'Track:'; - - @override - String get dialogArtistLabel => 'Artist:'; - - @override - String get dialogErrorLabel => 'Error:'; - @override String get dialogClearAll => 'Clear All'; - @override - String get dialogClearAllDownloads => - 'Are you sure you want to clear all downloads?'; - - @override - String get dialogRemoveFromDevice => 'Remove from device?'; - @override String get dialogRemoveExtension => 'Remove Extension'; @@ -993,11 +646,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get snackbarViewQueue => 'View Queue'; - @override - String snackbarFailedToLoad(String error) { - return 'Failed to load: $error'; - } - @override String snackbarUrlCopied(String platform) { return '$platform URL copied to clipboard'; @@ -1039,11 +687,6 @@ class AppLocalizationsFr extends AppLocalizations { String get errorRateLimitedMessage => 'Too many requests. Please wait a moment before searching again.'; - @override - String errorFailedToLoad(String item) { - return 'Failed to load $item'; - } - @override String get errorNoTracksFound => 'No tracks found'; @@ -1052,27 +695,6 @@ class AppLocalizationsFr extends AppLocalizations { return 'Cannot load $item: missing extension source'; } - @override - String get statusQueued => 'Queued'; - - @override - String get statusDownloading => 'Downloading'; - - @override - String get statusFinalizing => 'Finalizing'; - - @override - String get statusCompleted => 'Completed'; - - @override - String get statusFailed => 'Failed'; - - @override - String get statusSkipped => 'Skipped'; - - @override - String get statusPaused => 'Paused'; - @override String get actionPause => 'Pause'; @@ -1082,24 +704,12 @@ class AppLocalizationsFr extends AppLocalizations { @override String get actionCancel => 'Cancel'; - @override - String get actionStop => 'Stop'; - - @override - String get actionSelect => 'Select'; - @override String get actionSelectAll => 'Select All'; @override String get actionDeselect => 'Deselect'; - @override - String get actionPaste => 'Paste'; - - @override - String get actionImportCsv => 'Import CSV'; - @override String get actionRemoveCredentials => 'Remove Credentials'; @@ -1114,20 +724,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get selectionAllSelected => 'All tracks selected'; - @override - String get selectionTapToSelect => 'Tap tracks to select'; - - @override - String selectionDeleteTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: 'tracks', - one: 'track', - ); - return 'Delete $count $_temp0'; - } - @override String get selectionSelectToDelete => 'Select tracks to delete'; @@ -1154,40 +750,9 @@ class AppLocalizationsFr extends AppLocalizations { @override String get tooltipPlay => 'Play'; - @override - String get tooltipCancel => 'Cancel'; - - @override - String get tooltipStop => 'Stop'; - - @override - String get tooltipRetry => 'Retry'; - - @override - String get tooltipRemove => 'Remove'; - - @override - String get tooltipClear => 'Clear'; - - @override - String get tooltipPaste => 'Paste'; - @override String get filenameFormat => 'Filename Format'; - @override - String filenameFormatPreview(String preview) { - return 'Preview: $preview'; - } - - @override - String get filenameAvailablePlaceholders => 'Available placeholders:'; - - @override - String filenameHint(Object artist, Object title) { - return '$artist - $title'; - } - @override String get filenameShowAdvancedTags => 'Show advanced tags'; @@ -1195,9 +760,6 @@ class AppLocalizationsFr extends AppLocalizations { String get filenameShowAdvancedTagsDescription => 'Enable formatted tags for track padding and date patterns'; - @override - String get folderOrganization => 'Folder Organization'; - @override String get folderOrganizationNone => 'No organization'; @@ -1232,20 +794,9 @@ class AppLocalizationsFr extends AppLocalizations { @override String get updateAvailable => 'Update Available'; - @override - String updateNewVersion(String version) { - return 'Version $version is available'; - } - - @override - String get updateDownload => 'Download'; - @override String get updateLater => 'Later'; - @override - String get updateChangelog => 'Changelog'; - @override String get updateStartingDownload => 'Starting download...'; @@ -1276,12 +827,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get updateDontRemind => 'Don\'t remind'; - @override - String get providerPriority => 'Provider Priority'; - - @override - String get providerPrioritySubtitle => 'Drag to reorder download providers'; - @override String get providerPriorityTitle => 'Provider Priority'; @@ -1299,13 +844,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get providerExtension => 'Extension'; - @override - String get metadataProviderPriority => 'Metadata Provider Priority'; - - @override - String get metadataProviderPrioritySubtitle => - 'Order used when fetching track metadata'; - @override String get metadataProviderPriorityTitle => 'Metadata Priority'; @@ -1326,18 +864,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get logTitle => 'Logs'; - @override - String get logCopy => 'Copy Logs'; - - @override - String get logClear => 'Clear Logs'; - - @override - String get logShare => 'Share Logs'; - - @override - String get logEmpty => 'No logs yet'; - @override String get logCopied => 'Logs copied to clipboard'; @@ -1362,18 +888,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get logClearLogsMessage => 'Are you sure you want to clear all logs?'; - @override - String get logIspBlocking => 'ISP BLOCKING DETECTED'; - - @override - String get logRateLimited => 'RATE LIMITED'; - - @override - String get logNetworkError => 'NETWORK ERROR'; - - @override - String get logTrackNotFound => 'TRACK NOT FOUND'; - @override String get logFilterBySeverity => 'Filter logs by severity'; @@ -1383,48 +897,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get logNoLogsYetSubtitle => 'Logs will appear here as you use the app'; - @override - String get logIssueSummary => 'Issue Summary'; - - @override - String get logIspBlockingDescription => - 'Your ISP may be blocking access to download services'; - - @override - String get logIspBlockingSuggestion => - 'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8'; - - @override - String get logRateLimitedDescription => 'Too many requests to the service'; - - @override - String get logRateLimitedSuggestion => - 'Wait a few minutes before trying again'; - - @override - String get logNetworkErrorDescription => 'Connection issues detected'; - - @override - String get logNetworkErrorSuggestion => 'Check your internet connection'; - - @override - String get logTrackNotFoundDescription => - 'Some tracks could not be found on download services'; - - @override - String get logTrackNotFoundSuggestion => - 'The track may not be available in lossless quality'; - - @override - String logTotalErrors(int count) { - return 'Total errors: $count'; - } - - @override - String logAffected(String domains) { - return 'Affected: $domains'; - } - @override String logEntriesFiltered(int count) { return 'Entries ($count filtered)'; @@ -1531,9 +1003,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get appearanceLanguage => 'App Language'; - @override - String get appearanceLanguageSubtitle => 'Choose your preferred language'; - @override String get settingsAppearanceSubtitle => 'Theme, colors, display'; @@ -1555,9 +1024,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get pressBackAgainToExit => 'Press back again to exit'; - @override - String get tracksHeader => 'Tracks'; - @override String downloadAllCount(int count) { return 'Download All ($count)'; @@ -1671,11 +1137,6 @@ class AppLocalizationsFr extends AppLocalizations { String get trackDeleteConfirmMessage => 'This will permanently delete the downloaded file and remove it from your history.'; - @override - String trackCannotOpen(String message) { - return 'Cannot open: $message'; - } - @override String get dateToday => 'Today'; @@ -1697,18 +1158,6 @@ class AppLocalizationsFr extends AppLocalizations { return '$count months ago'; } - @override - String get concurrentSequential => 'Sequential'; - - @override - String get concurrentParallel2 => '2 Parallel'; - - @override - String get concurrentParallel3 => '3 Parallel'; - - @override - String get tapToSeeError => 'Tap to see error details'; - @override String get storeFilterAll => 'All'; @@ -1730,15 +1179,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get storeClearFilters => 'Clear filters'; - @override - String get storeNoResults => 'No extensions found'; - - @override - String get extensionProviderPriority => 'Provider Priority'; - - @override - String get extensionInstallButton => 'Install Extension'; - @override String get extensionDefaultProvider => 'Default (Deezer/Spotify)'; @@ -1889,38 +1329,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; - @override - String get qualityLossy => 'Lossy'; - - @override - String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)'; - - @override - String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)'; - - @override - String get enableLossyOption => 'Enable Lossy Option'; - - @override - String get enableLossyOptionSubtitleOn => 'Lossy quality option is available'; - - @override - String get enableLossyOptionSubtitleOff => - 'Downloads FLAC then converts to lossy format'; - - @override - String get lossyFormat => 'Lossy Format'; - - @override - String get lossyFormatDescription => 'Choose the lossy format for conversion'; - - @override - String get lossyFormatMp3Subtitle => '320kbps, best compatibility'; - - @override - String get lossyFormatOpusSubtitle => - '128kbps, better quality at smaller size'; - @override String get qualityNote => 'Actual quality depends on track availability from the service'; @@ -1929,6 +1337,12 @@ class AppLocalizationsFr extends AppLocalizations { String get youtubeQualityNote => 'YouTube provides lossy audio only. Not part of lossless fallback.'; + @override + String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; + + @override + String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; + @override String get downloadAskBeforeDownload => 'Ask Before Download'; @@ -1944,14 +1358,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders'; - @override - String get downloadUseAlbumArtistForFoldersAlbumSubtitle => - 'Artist folders use Album Artist when available'; - - @override - String get downloadUseAlbumArtistForFoldersTrackSubtitle => - 'Artist folders use Track Artist only'; - @override String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders'; @@ -1963,78 +1369,18 @@ class AppLocalizationsFr extends AppLocalizations { String get downloadUsePrimaryArtistOnlyDisabled => 'Full artist string used for folder name'; - @override - String get downloadSaveFormat => 'Save Format'; - - @override - String get downloadSelectService => 'Select Service'; - @override String get downloadSelectQuality => 'Select Quality'; @override String get downloadFrom => 'Download From'; - @override - String get downloadDefaultQualityLabel => 'Default Quality'; - - @override - String get downloadBestAvailable => 'Best available'; - - @override - String get folderNone => 'None'; - - @override - String get folderNoneSubtitle => 'Save all files directly to download folder'; - - @override - String get folderArtist => 'Artist'; - - @override - String get folderArtistSubtitle => 'Artist Name/filename'; - - @override - String get folderAlbum => 'Album'; - - @override - String get folderAlbumSubtitle => 'Album Name/filename'; - - @override - String get folderArtistAlbum => 'Artist/Album'; - - @override - String get folderArtistAlbumSubtitle => 'Artist Name/Album Name/filename'; - - @override - String get serviceTidal => 'Tidal'; - - @override - String get serviceQobuz => 'Qobuz'; - - @override - String get serviceAmazon => 'Amazon'; - - @override - String get serviceDeezer => 'Deezer'; - - @override - String get serviceSpotify => 'Spotify'; - @override String get appearanceAmoledDark => 'AMOLED Dark'; @override String get appearanceAmoledDarkSubtitle => 'Pure black background'; - @override - String get appearanceChooseAccentColor => 'Choose Accent Color'; - - @override - String get appearanceChooseTheme => 'Theme Mode'; - - @override - String get queueTitle => 'Download Queue'; - @override String get queueClearAll => 'Clear All'; @@ -2042,19 +1388,6 @@ class AppLocalizationsFr extends AppLocalizations { String get queueClearAllMessage => 'Are you sure you want to clear all downloads?'; - @override - String get queueExportFailed => 'Export'; - - @override - String get queueExportFailedSuccess => - 'Failed downloads exported to TXT file'; - - @override - String get queueExportFailedClear => 'Clear Failed'; - - @override - String get queueExportFailedError => 'Failed to export downloads'; - @override String get settingsAutoExportFailed => 'Auto-export failed downloads'; @@ -2075,30 +1408,6 @@ class AppLocalizationsFr extends AppLocalizations { String get settingsDownloadNetworkSubtitle => 'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.'; - @override - String get queueEmpty => 'No downloads in queue'; - - @override - String get queueEmptySubtitle => 'Add tracks from the home screen'; - - @override - String get queueClearCompleted => 'Clear completed'; - - @override - String get queueDownloadFailed => 'Download Failed'; - - @override - String get queueTrackLabel => 'Track:'; - - @override - String get queueArtistLabel => 'Artist:'; - - @override - String get queueErrorLabel => 'Error:'; - - @override - String get queueUnknownError => 'Unknown error'; - @override String get albumFolderArtistAlbum => 'Artist / Album'; @@ -2145,14 +1454,6 @@ class AppLocalizationsFr extends AppLocalizations { return 'Delete $count $_temp0 from this album?\n\nThis will also delete the files from storage.'; } - @override - String get downloadedAlbumTracksHeader => 'Tracks'; - - @override - String downloadedAlbumDownloadedCount(int count) { - return '$count downloaded'; - } - @override String downloadedAlbumSelectedCount(int count) { return '$count selected'; @@ -2183,9 +1484,6 @@ class AppLocalizationsFr extends AppLocalizations { return 'Disc $discNumber'; } - @override - String get utilityFunctions => 'Utility Functions'; - @override String get recentTypeArtist => 'Artist'; @@ -2209,11 +1507,6 @@ class AppLocalizationsFr extends AppLocalizations { return 'Playlist: $name'; } - @override - String errorGeneric(String message) { - return 'Error: $message'; - } - @override String get discographyDownload => 'Download Discography'; @@ -2319,9 +1612,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get libraryTitle => 'Local Library'; - @override - String get libraryStatus => 'Library Status'; - @override String get libraryScanSettings => 'Scan Settings'; @@ -2385,8 +1675,14 @@ class AppLocalizationsFr extends AppLocalizations { 'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.'; @override - String libraryTracksCount(int count) { - return '$count tracks'; + String libraryTracksUnit(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return '$_temp0'; } @override @@ -2468,21 +1764,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get libraryFilterFormat => 'Format'; - @override - String get libraryFilterDate => 'Date Added'; - - @override - String get libraryFilterDateToday => 'Today'; - - @override - String get libraryFilterDateWeek => 'This Week'; - - @override - String get libraryFilterDateMonth => 'This Month'; - - @override - String get libraryFilterDateYear => 'This Year'; - @override String get libraryFilterSort => 'Sort'; @@ -2492,11 +1773,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get libraryFilterSortOldest => 'Oldest'; - @override - String libraryFilterActive(int count) { - return '$count filter(s) active'; - } - @override String get timeJustNow => 'Just now'; @@ -2522,72 +1798,6 @@ class AppLocalizationsFr extends AppLocalizations { return '$_temp0'; } - @override - String get storageSwitchTitle => 'Switch Storage Mode'; - - @override - String get storageSwitchToSafTitle => 'Switch to SAF Storage?'; - - @override - String get storageSwitchToAppTitle => 'Switch to App Storage?'; - - @override - String get storageSwitchToSafMessage => - 'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.'; - - @override - String get storageSwitchToAppMessage => - 'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.'; - - @override - String get storageSwitchExistingDownloads => 'Existing Downloads'; - - @override - String storageSwitchExistingDownloadsInfo(int count, String mode) { - return '$count tracks in $mode storage'; - } - - @override - String get storageSwitchNewDownloads => 'New Downloads'; - - @override - String storageSwitchNewDownloadsLocation(String location) { - return 'Will be saved to: $location'; - } - - @override - String get storageSwitchContinue => 'Continue'; - - @override - String get storageSwitchSelectFolder => 'Select SAF Folder'; - - @override - String get storageAppStorage => 'App Storage'; - - @override - String get storageSafStorage => 'SAF Storage'; - - @override - String storageModeBadge(String mode) { - return 'Storage: $mode'; - } - - @override - String get storageStatsTitle => 'Storage Statistics'; - - @override - String storageStatsAppCount(int count) { - return '$count tracks in App Storage'; - } - - @override - String storageStatsSafCount(int count) { - return '$count tracks in SAF Storage'; - } - - @override - String get storageModeInfo => 'Your files are stored in multiple locations'; - @override String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!'; @@ -2614,18 +1824,6 @@ class AppLocalizationsFr extends AppLocalizations { String get tutorialSearchDesc => 'There are two easy ways to find music you want to download.'; - @override - String get tutorialSearchTip1 => - 'Paste a Spotify or Deezer URL directly in the search box'; - - @override - String get tutorialSearchTip2 => - 'Or type the song name, artist, or album to search'; - - @override - String get tutorialSearchTip3 => - 'Supports tracks, albums, playlists, and artist pages'; - @override String get tutorialDownloadTitle => 'Downloading Music'; @@ -2633,18 +1831,6 @@ class AppLocalizationsFr extends AppLocalizations { String get tutorialDownloadDesc => 'Downloading music is simple and fast. Here\'s how it works.'; - @override - String get tutorialDownloadTip1 => - 'Tap the download button next to any track to start downloading'; - - @override - String get tutorialDownloadTip2 => - 'Choose your preferred quality (FLAC, Hi-Res, or MP3)'; - - @override - String get tutorialDownloadTip3 => - 'Download entire albums or playlists with one tap'; - @override String get tutorialLibraryTitle => 'Your Library'; @@ -2705,9 +1891,6 @@ class AppLocalizationsFr extends AppLocalizations { String get tutorialReadyMessage => 'You\'re all set! Start downloading your favorite music now.'; - @override - String get tutorialExample => 'EXAMPLE'; - @override String get libraryForceFullScan => 'Force Full Scan'; @@ -2868,11 +2051,7 @@ class AppLocalizationsFr extends AppLocalizations { String get trackSaveLyricsProgress => 'Saving lyrics...'; @override - String get trackReEnrich => 'Re-enrich Metadata'; - - @override - String get trackReEnrichSubtitle => - 'Re-embed metadata without re-downloading'; + String get trackReEnrich => 'Re-enrich'; @override String get trackReEnrichOnlineSubtitle => @@ -2948,4 +2127,221 @@ class AppLocalizationsFr extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + + @override + String get actionCreate => 'Create'; + + @override + String get collectionFoldersTitle => 'My folders'; + + @override + String get collectionWishlist => 'Wishlist'; + + @override + String get collectionLoved => 'Loved'; + + @override + String get collectionPlaylists => 'Playlists'; + + @override + String get collectionPlaylist => 'Playlist'; + + @override + String get collectionAddToPlaylist => 'Add to playlist'; + + @override + String get collectionCreatePlaylist => 'Create playlist'; + + @override + String get collectionNoPlaylistsYet => 'No playlists yet'; + + @override + String get collectionNoPlaylistsSubtitle => + 'Create a playlist to start categorizing tracks'; + + @override + String collectionPlaylistTracks(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String collectionAddedToPlaylist(String playlistName) { + return 'Added to \"$playlistName\"'; + } + + @override + String collectionAlreadyInPlaylist(String playlistName) { + return 'Already in \"$playlistName\"'; + } + + @override + String get collectionPlaylistCreated => 'Playlist created'; + + @override + String get collectionPlaylistNameHint => 'Playlist name'; + + @override + String get collectionPlaylistNameRequired => 'Playlist name is required'; + + @override + String get collectionRenamePlaylist => 'Rename playlist'; + + @override + String get collectionDeletePlaylist => 'Delete playlist'; + + @override + String collectionDeletePlaylistMessage(String playlistName) { + return 'Delete \"$playlistName\" and all tracks inside it?'; + } + + @override + String get collectionPlaylistDeleted => 'Playlist deleted'; + + @override + String get collectionPlaylistRenamed => 'Playlist renamed'; + + @override + String get collectionWishlistEmptyTitle => 'Wishlist is empty'; + + @override + String get collectionWishlistEmptySubtitle => + 'Tap + on tracks to save what you want to download later'; + + @override + String get collectionLovedEmptyTitle => 'Loved folder is empty'; + + @override + String get collectionLovedEmptySubtitle => + 'Tap love on tracks to keep your favorites'; + + @override + String get collectionPlaylistEmptyTitle => 'Playlist is empty'; + + @override + String get collectionPlaylistEmptySubtitle => + 'Long-press + on any track to add it here'; + + @override + String get collectionRemoveFromPlaylist => 'Remove from playlist'; + + @override + String get collectionRemoveFromFolder => 'Remove from folder'; + + @override + String collectionRemoved(String trackName) { + return '\"$trackName\" removed'; + } + + @override + String collectionAddedToLoved(String trackName) { + return '\"$trackName\" added to Loved'; + } + + @override + String collectionRemovedFromLoved(String trackName) { + return '\"$trackName\" removed from Loved'; + } + + @override + String collectionAddedToWishlist(String trackName) { + return '\"$trackName\" added to Wishlist'; + } + + @override + String collectionRemovedFromWishlist(String trackName) { + return '\"$trackName\" removed from Wishlist'; + } + + @override + String get trackOptionAddToLoved => 'Add to Loved'; + + @override + String get trackOptionRemoveFromLoved => 'Remove from Loved'; + + @override + String get trackOptionAddToWishlist => 'Add to Wishlist'; + + @override + String get trackOptionRemoveFromWishlist => 'Remove from Wishlist'; + + @override + String get collectionPlaylistChangeCover => 'Change cover image'; + + @override + String get collectionPlaylistRemoveCover => 'Remove cover image'; + + @override + String selectionShareCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Share $count $_temp0'; + } + + @override + String get selectionShareNoFiles => 'No shareable files found'; + + @override + String selectionConvertCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0'; + } + + @override + String get selectionConvertNoConvertible => 'No convertible tracks selected'; + + @override + String get selectionBatchConvertConfirmTitle => 'Batch Convert'; + + @override + String selectionBatchConvertConfirmMessage( + int count, + String format, + String bitrate, + ) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.'; + } + + @override + String selectionBatchConvertProgress(int current, int total) { + return 'Converting $current of $total...'; + } + + @override + String selectionBatchConvertSuccess(int success, int total, String format) { + return 'Converted $success of $total tracks to $format'; + } + + @override + String downloadedAlbumDownloadedCount(int count) { + return '$count downloaded'; + } + + @override + String get downloadUseAlbumArtistForFoldersAlbumSubtitle => + 'Artist folders use Album Artist when available'; + + @override + String get downloadUseAlbumArtistForFoldersTrackSubtitle => + 'Artist folders use Track Artist only'; } diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index 3afb4c16..50ca83db 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -11,19 +11,12 @@ class AppLocalizationsHi extends AppLocalizations { @override String get appName => 'SpotiFlac'; - @override - String get appDescription => - 'स्पॉटीफाई ट्रैक डाउनलोड करें टाइडल, क्वाबज एवं एवं अमेजन म्यूजिक से उच्चतम क्वालिटी में।'; - @override String get navHome => 'होम'; @override String get navLibrary => 'Library'; - @override - String get navHistory => 'इतिहास'; - @override String get navSettings => 'विकल्प'; @@ -33,14 +26,6 @@ class AppLocalizationsHi extends AppLocalizations { @override String get homeTitle => 'Home'; - @override - String get homeSearchHint => 'Paste Spotify URL or search...'; - - @override - String homeSearchHintExtension(String extensionName) { - return 'Search with $extensionName...'; - } - @override String get homeSubtitle => 'Paste a Spotify link or search by name'; @@ -50,17 +35,6 @@ class AppLocalizationsHi extends AppLocalizations { @override String get homeRecent => 'Recent'; - @override - String get historyTitle => 'History'; - - @override - String historyDownloading(int count) { - return 'Downloading ($count)'; - } - - @override - String get historyDownloaded => 'Downloaded'; - @override String get historyFilterAll => 'All'; @@ -70,48 +44,6 @@ class AppLocalizationsHi extends AppLocalizations { @override String get historyFilterSingles => 'Singles'; - @override - String historyTracksCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count tracks', - one: '1 track', - ); - return '$_temp0'; - } - - @override - String historyAlbumsCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count albums', - one: '1 album', - ); - return '$_temp0'; - } - - @override - String get historyNoDownloads => 'No download history'; - - @override - String get historyNoDownloadsSubtitle => 'Downloaded tracks will appear here'; - - @override - String get historyNoAlbums => 'No album downloads'; - - @override - String get historyNoAlbumsSubtitle => - 'Download multiple tracks from an album to see them here'; - - @override - String get historyNoSingles => 'No single downloads'; - - @override - String get historyNoSinglesSubtitle => - 'Single track downloads will appear here'; - @override String get historySearchHint => 'Search history...'; @@ -136,27 +68,6 @@ class AppLocalizationsHi extends AppLocalizations { @override String get downloadTitle => 'Download'; - @override - String get downloadLocation => 'Download Location'; - - @override - String get downloadLocationSubtitle => 'Choose where to save files'; - - @override - String get downloadLocationDefault => 'Default location'; - - @override - String get downloadDefaultService => 'Default Service'; - - @override - String get downloadDefaultServiceSubtitle => 'Service used for downloads'; - - @override - String get downloadDefaultQuality => 'Default Quality'; - - @override - String get downloadAskQuality => 'Ask Quality Before Download'; - @override String get downloadAskQualitySubtitle => 'Show quality picker for each download'; @@ -167,31 +78,9 @@ class AppLocalizationsHi extends AppLocalizations { @override String get downloadFolderOrganization => 'Folder Organization'; - @override - String get downloadSeparateSingles => 'Separate Singles'; - - @override - String get downloadSeparateSinglesSubtitle => - 'Put single tracks in a separate folder'; - - @override - String get qualityBest => 'Best Available'; - - @override - String get qualityFlac => 'FLAC'; - - @override - String get quality320 => '320 kbps'; - - @override - String get quality128 => '128 kbps'; - @override String get appearanceTitle => 'दिखावट'; - @override - String get appearanceTheme => 'Theme'; - @override String get appearanceThemeSystem => 'System'; @@ -207,9 +96,6 @@ class AppLocalizationsHi extends AppLocalizations { @override String get appearanceDynamicColorSubtitle => 'वॉलपेपर से रंग इस्तेमाल करें'; - @override - String get appearanceAccentColor => 'Accent Color'; - @override String get appearanceHistoryView => 'History View'; @@ -222,9 +108,6 @@ class AppLocalizationsHi extends AppLocalizations { @override String get optionsTitle => 'Options'; - @override - String get optionsSearchSource => 'Search Source'; - @override String get optionsPrimaryProvider => 'Primary Provider'; @@ -350,18 +233,6 @@ class AppLocalizationsHi extends AppLocalizations { @override String get extensionsTitle => 'Extensions'; - @override - String get extensionsInstalled => 'Installed Extensions'; - - @override - String get extensionsNone => 'No extensions installed'; - - @override - String get extensionsNoneSubtitle => 'Install extensions from the Store tab'; - - @override - String get extensionsEnabled => 'Enabled'; - @override String get extensionsDisabled => 'Disabled'; @@ -378,9 +249,6 @@ class AppLocalizationsHi extends AppLocalizations { @override String get extensionsUninstall => 'Uninstall'; - @override - String get extensionsSetAsSearch => 'Set as Search Provider'; - @override String get storeTitle => 'Extension Store'; @@ -454,9 +322,6 @@ class AppLocalizationsHi extends AppLocalizations { @override String get aboutSocial => 'Social'; - @override - String get aboutSupport => 'Support'; - @override String get aboutApp => 'App'; @@ -475,13 +340,6 @@ class AppLocalizationsHi extends AppLocalizations { String get aboutSjdonadoDesc => 'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!'; - @override - String get aboutDoubleDouble => 'DoubleDouble'; - - @override - String get aboutDoubleDoubleDesc => - 'Amazing API for Amazon Music downloads. Thank you for making it free!'; - @override String get aboutDabMusic => 'DAB Music'; @@ -500,32 +358,6 @@ class AppLocalizationsHi extends AppLocalizations { String get aboutAppDescription => 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; - @override - String get albumTitle => 'Album'; - - @override - String albumTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count tracks', - one: '1 track', - ); - return '$_temp0'; - } - - @override - String get albumDownloadAll => 'Download All'; - - @override - String get albumDownloadRemaining => 'Download Remaining'; - - @override - String get playlistTitle => 'Playlist'; - - @override - String get artistTitle => 'Artist'; - @override String get artistAlbums => 'Albums'; @@ -535,17 +367,6 @@ class AppLocalizationsHi extends AppLocalizations { @override String get artistCompilations => 'Compilations'; - @override - String artistReleases(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count releases', - one: '1 release', - ); - return '$_temp0'; - } - @override String get artistPopular => 'Popular'; @@ -554,27 +375,6 @@ class AppLocalizationsHi extends AppLocalizations { return '$count monthly listeners'; } - @override - String get trackMetadataTitle => 'Track Info'; - - @override - String get trackMetadataArtist => 'Artist'; - - @override - String get trackMetadataAlbum => 'Album'; - - @override - String get trackMetadataDuration => 'Duration'; - - @override - String get trackMetadataQuality => 'Quality'; - - @override - String get trackMetadataPath => 'File Path'; - - @override - String get trackMetadataDownloadedAt => 'Downloaded'; - @override String get trackMetadataService => 'Service'; @@ -587,53 +387,15 @@ class AppLocalizationsHi extends AppLocalizations { @override String get trackMetadataDelete => 'Delete'; - @override - String get trackMetadataRedownload => 'Re-download'; - - @override - String get trackMetadataOpenFolder => 'Open Folder'; - - @override - String get setupTitle => 'Welcome to SpotiFLAC'; - - @override - String get setupSubtitle => 'Let\'s get you started'; - - @override - String get setupStoragePermission => 'Storage Permission'; - - @override - String get setupStoragePermissionSubtitle => - 'Required to save downloaded files'; - - @override - String get setupStoragePermissionGranted => 'Permission granted'; - - @override - String get setupStoragePermissionDenied => 'Permission denied'; - @override String get setupGrantPermission => 'Grant Permission'; - @override - String get setupDownloadLocation => 'Download Location'; - - @override - String get setupChooseFolder => 'Choose Folder'; - - @override - String get setupContinue => 'Continue'; - @override String get setupSkip => 'Skip for now'; @override String get setupStorageAccessRequired => 'Storage Access Required'; - @override - String get setupStorageAccessMessage => - 'SpotiFLAC needs \"All files access\" permission to save music files to your chosen folder.'; - @override String get setupStorageAccessMessageAndroid11 => 'Android 11+ requires \"All files access\" permission to save files to your chosen download folder.'; @@ -655,9 +417,6 @@ class AppLocalizationsHi extends AppLocalizations { return '$permissionType permission is required for the best experience. You can change this later in Settings.'; } - @override - String get setupSelectDownloadFolder => 'Select Download Folder'; - @override String get setupUseDefaultFolder => 'Use Default Folder?'; @@ -699,21 +458,6 @@ class AppLocalizationsHi extends AppLocalizations { @override String get setupDownloadInFlac => 'Download Spotify tracks in FLAC'; - @override - String get setupStepStorage => 'Storage'; - - @override - String get setupStepNotification => 'Notification'; - - @override - String get setupStepFolder => 'Folder'; - - @override - String get setupStepSpotify => 'Spotify'; - - @override - String get setupStepPermission => 'Permission'; - @override String get setupStorageGranted => 'Storage Permission Granted!'; @@ -730,13 +474,6 @@ class AppLocalizationsHi extends AppLocalizations { @override String get setupNotificationEnable => 'Enable Notifications'; - @override - String get setupNotificationDescription => - 'Get notified when downloads complete or require attention.'; - - @override - String get setupFolderSelected => 'Download Folder Selected!'; - @override String get setupFolderChoose => 'Choose Download Folder'; @@ -744,48 +481,12 @@ class AppLocalizationsHi extends AppLocalizations { String get setupFolderDescription => 'Select a folder where your downloaded music will be saved.'; - @override - String get setupChangeFolder => 'Change Folder'; - @override String get setupSelectFolder => 'Select Folder'; - @override - String get setupSpotifyApiOptional => 'Spotify API (Optional)'; - - @override - String get setupSpotifyApiDescription => - 'Add your Spotify API credentials for better search results and access to Spotify-exclusive content.'; - - @override - String get setupUseSpotifyApi => 'Use Spotify API'; - - @override - String get setupEnterCredentialsBelow => 'Enter your credentials below'; - - @override - String get setupUsingDeezer => 'Using Deezer (no account needed)'; - - @override - String get setupEnterClientId => 'Enter Spotify Client ID'; - - @override - String get setupEnterClientSecret => 'Enter Spotify Client Secret'; - - @override - String get setupGetFreeCredentials => - 'Get your free API credentials from the Spotify Developer Dashboard.'; - @override String get setupEnableNotifications => 'Enable Notifications'; - @override - String get setupProceedToNextStep => 'You can now proceed to the next step.'; - - @override - String get setupNotificationProgressDescription => - 'You will receive download progress notifications.'; - @override String get setupNotificationBackgroundDescription => 'Get notified about download progress and completion. This helps you track downloads when the app is in background.'; @@ -793,32 +494,19 @@ class AppLocalizationsHi extends AppLocalizations { @override String get setupSkipForNow => 'Skip for now'; - @override - String get setupBack => 'Back'; - @override String get setupNext => 'Next'; @override String get setupGetStarted => 'Get Started'; - @override - String get setupSkipAndStart => 'Skip & Start'; - @override String get setupAllowAccessToManageFiles => 'Please enable \"Allow access to manage all files\" in the next screen.'; - @override - String get setupGetCredentialsFromSpotify => - 'Get credentials from developer.spotify.com'; - @override String get dialogCancel => 'Cancel'; - @override - String get dialogOk => 'OK'; - @override String get dialogSave => 'Save'; @@ -828,21 +516,9 @@ class AppLocalizationsHi extends AppLocalizations { @override String get dialogRetry => 'Retry'; - @override - String get dialogClose => 'Close'; - - @override - String get dialogYes => 'Yes'; - - @override - String get dialogNo => 'No'; - @override String get dialogClear => 'Clear'; - @override - String get dialogConfirm => 'Confirm'; - @override String get dialogDone => 'Done'; @@ -865,28 +541,9 @@ class AppLocalizationsHi extends AppLocalizations { String get dialogUnsavedChanges => 'You have unsaved changes. Do you want to discard them?'; - @override - String get dialogDownloadFailed => 'Download Failed'; - - @override - String get dialogTrackLabel => 'Track:'; - - @override - String get dialogArtistLabel => 'Artist:'; - - @override - String get dialogErrorLabel => 'Error:'; - @override String get dialogClearAll => 'Clear All'; - @override - String get dialogClearAllDownloads => - 'Are you sure you want to clear all downloads?'; - - @override - String get dialogRemoveFromDevice => 'Remove from device?'; - @override String get dialogRemoveExtension => 'Remove Extension'; @@ -987,11 +644,6 @@ class AppLocalizationsHi extends AppLocalizations { @override String get snackbarViewQueue => 'View Queue'; - @override - String snackbarFailedToLoad(String error) { - return 'Failed to load: $error'; - } - @override String snackbarUrlCopied(String platform) { return '$platform URL copied to clipboard'; @@ -1033,11 +685,6 @@ class AppLocalizationsHi extends AppLocalizations { String get errorRateLimitedMessage => 'Too many requests. Please wait a moment before searching again.'; - @override - String errorFailedToLoad(String item) { - return 'Failed to load $item'; - } - @override String get errorNoTracksFound => 'No tracks found'; @@ -1046,27 +693,6 @@ class AppLocalizationsHi extends AppLocalizations { return 'Cannot load $item: missing extension source'; } - @override - String get statusQueued => 'Queued'; - - @override - String get statusDownloading => 'Downloading'; - - @override - String get statusFinalizing => 'Finalizing'; - - @override - String get statusCompleted => 'Completed'; - - @override - String get statusFailed => 'Failed'; - - @override - String get statusSkipped => 'Skipped'; - - @override - String get statusPaused => 'Paused'; - @override String get actionPause => 'Pause'; @@ -1076,24 +702,12 @@ class AppLocalizationsHi extends AppLocalizations { @override String get actionCancel => 'Cancel'; - @override - String get actionStop => 'Stop'; - - @override - String get actionSelect => 'Select'; - @override String get actionSelectAll => 'Select All'; @override String get actionDeselect => 'Deselect'; - @override - String get actionPaste => 'Paste'; - - @override - String get actionImportCsv => 'Import CSV'; - @override String get actionRemoveCredentials => 'Remove Credentials'; @@ -1108,20 +722,6 @@ class AppLocalizationsHi extends AppLocalizations { @override String get selectionAllSelected => 'All tracks selected'; - @override - String get selectionTapToSelect => 'Tap tracks to select'; - - @override - String selectionDeleteTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: 'tracks', - one: 'track', - ); - return 'Delete $count $_temp0'; - } - @override String get selectionSelectToDelete => 'Select tracks to delete'; @@ -1148,40 +748,9 @@ class AppLocalizationsHi extends AppLocalizations { @override String get tooltipPlay => 'Play'; - @override - String get tooltipCancel => 'Cancel'; - - @override - String get tooltipStop => 'Stop'; - - @override - String get tooltipRetry => 'Retry'; - - @override - String get tooltipRemove => 'Remove'; - - @override - String get tooltipClear => 'Clear'; - - @override - String get tooltipPaste => 'Paste'; - @override String get filenameFormat => 'Filename Format'; - @override - String filenameFormatPreview(String preview) { - return 'Preview: $preview'; - } - - @override - String get filenameAvailablePlaceholders => 'Available placeholders:'; - - @override - String filenameHint(Object artist, Object title) { - return '$artist - $title'; - } - @override String get filenameShowAdvancedTags => 'Show advanced tags'; @@ -1189,9 +758,6 @@ class AppLocalizationsHi extends AppLocalizations { String get filenameShowAdvancedTagsDescription => 'Enable formatted tags for track padding and date patterns'; - @override - String get folderOrganization => 'Folder Organization'; - @override String get folderOrganizationNone => 'No organization'; @@ -1226,20 +792,9 @@ class AppLocalizationsHi extends AppLocalizations { @override String get updateAvailable => 'Update Available'; - @override - String updateNewVersion(String version) { - return 'Version $version is available'; - } - - @override - String get updateDownload => 'Download'; - @override String get updateLater => 'Later'; - @override - String get updateChangelog => 'Changelog'; - @override String get updateStartingDownload => 'Starting download...'; @@ -1270,12 +825,6 @@ class AppLocalizationsHi extends AppLocalizations { @override String get updateDontRemind => 'Don\'t remind'; - @override - String get providerPriority => 'Provider Priority'; - - @override - String get providerPrioritySubtitle => 'Drag to reorder download providers'; - @override String get providerPriorityTitle => 'Provider Priority'; @@ -1293,13 +842,6 @@ class AppLocalizationsHi extends AppLocalizations { @override String get providerExtension => 'Extension'; - @override - String get metadataProviderPriority => 'Metadata Provider Priority'; - - @override - String get metadataProviderPrioritySubtitle => - 'Order used when fetching track metadata'; - @override String get metadataProviderPriorityTitle => 'Metadata Priority'; @@ -1320,18 +862,6 @@ class AppLocalizationsHi extends AppLocalizations { @override String get logTitle => 'Logs'; - @override - String get logCopy => 'Copy Logs'; - - @override - String get logClear => 'Clear Logs'; - - @override - String get logShare => 'Share Logs'; - - @override - String get logEmpty => 'No logs yet'; - @override String get logCopied => 'Logs copied to clipboard'; @@ -1356,18 +886,6 @@ class AppLocalizationsHi extends AppLocalizations { @override String get logClearLogsMessage => 'Are you sure you want to clear all logs?'; - @override - String get logIspBlocking => 'ISP BLOCKING DETECTED'; - - @override - String get logRateLimited => 'RATE LIMITED'; - - @override - String get logNetworkError => 'NETWORK ERROR'; - - @override - String get logTrackNotFound => 'TRACK NOT FOUND'; - @override String get logFilterBySeverity => 'Filter logs by severity'; @@ -1377,48 +895,6 @@ class AppLocalizationsHi extends AppLocalizations { @override String get logNoLogsYetSubtitle => 'Logs will appear here as you use the app'; - @override - String get logIssueSummary => 'Issue Summary'; - - @override - String get logIspBlockingDescription => - 'Your ISP may be blocking access to download services'; - - @override - String get logIspBlockingSuggestion => - 'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8'; - - @override - String get logRateLimitedDescription => 'Too many requests to the service'; - - @override - String get logRateLimitedSuggestion => - 'Wait a few minutes before trying again'; - - @override - String get logNetworkErrorDescription => 'Connection issues detected'; - - @override - String get logNetworkErrorSuggestion => 'Check your internet connection'; - - @override - String get logTrackNotFoundDescription => - 'Some tracks could not be found on download services'; - - @override - String get logTrackNotFoundSuggestion => - 'The track may not be available in lossless quality'; - - @override - String logTotalErrors(int count) { - return 'Total errors: $count'; - } - - @override - String logAffected(String domains) { - return 'Affected: $domains'; - } - @override String logEntriesFiltered(int count) { return 'Entries ($count filtered)'; @@ -1525,9 +1001,6 @@ class AppLocalizationsHi extends AppLocalizations { @override String get appearanceLanguage => 'App Language'; - @override - String get appearanceLanguageSubtitle => 'Choose your preferred language'; - @override String get settingsAppearanceSubtitle => 'Theme, colors, display'; @@ -1549,9 +1022,6 @@ class AppLocalizationsHi extends AppLocalizations { @override String get pressBackAgainToExit => 'Press back again to exit'; - @override - String get tracksHeader => 'Tracks'; - @override String downloadAllCount(int count) { return 'Download All ($count)'; @@ -1665,11 +1135,6 @@ class AppLocalizationsHi extends AppLocalizations { String get trackDeleteConfirmMessage => 'This will permanently delete the downloaded file and remove it from your history.'; - @override - String trackCannotOpen(String message) { - return 'Cannot open: $message'; - } - @override String get dateToday => 'Today'; @@ -1691,18 +1156,6 @@ class AppLocalizationsHi extends AppLocalizations { return '$count months ago'; } - @override - String get concurrentSequential => 'Sequential'; - - @override - String get concurrentParallel2 => '2 Parallel'; - - @override - String get concurrentParallel3 => '3 Parallel'; - - @override - String get tapToSeeError => 'Tap to see error details'; - @override String get storeFilterAll => 'All'; @@ -1724,15 +1177,6 @@ class AppLocalizationsHi extends AppLocalizations { @override String get storeClearFilters => 'Clear filters'; - @override - String get storeNoResults => 'No extensions found'; - - @override - String get extensionProviderPriority => 'Provider Priority'; - - @override - String get extensionInstallButton => 'Install Extension'; - @override String get extensionDefaultProvider => 'Default (Deezer/Spotify)'; @@ -1883,38 +1327,6 @@ class AppLocalizationsHi extends AppLocalizations { @override String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; - @override - String get qualityLossy => 'Lossy'; - - @override - String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)'; - - @override - String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)'; - - @override - String get enableLossyOption => 'Enable Lossy Option'; - - @override - String get enableLossyOptionSubtitleOn => 'Lossy quality option is available'; - - @override - String get enableLossyOptionSubtitleOff => - 'Downloads FLAC then converts to lossy format'; - - @override - String get lossyFormat => 'Lossy Format'; - - @override - String get lossyFormatDescription => 'Choose the lossy format for conversion'; - - @override - String get lossyFormatMp3Subtitle => '320kbps, best compatibility'; - - @override - String get lossyFormatOpusSubtitle => - '128kbps, better quality at smaller size'; - @override String get qualityNote => 'Actual quality depends on track availability from the service'; @@ -1923,6 +1335,12 @@ class AppLocalizationsHi extends AppLocalizations { String get youtubeQualityNote => 'YouTube provides lossy audio only. Not part of lossless fallback.'; + @override + String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; + + @override + String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; + @override String get downloadAskBeforeDownload => 'Ask Before Download'; @@ -1938,14 +1356,6 @@ class AppLocalizationsHi extends AppLocalizations { @override String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders'; - @override - String get downloadUseAlbumArtistForFoldersAlbumSubtitle => - 'Artist folders use Album Artist when available'; - - @override - String get downloadUseAlbumArtistForFoldersTrackSubtitle => - 'Artist folders use Track Artist only'; - @override String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders'; @@ -1957,78 +1367,18 @@ class AppLocalizationsHi extends AppLocalizations { String get downloadUsePrimaryArtistOnlyDisabled => 'Full artist string used for folder name'; - @override - String get downloadSaveFormat => 'Save Format'; - - @override - String get downloadSelectService => 'Select Service'; - @override String get downloadSelectQuality => 'Select Quality'; @override String get downloadFrom => 'Download From'; - @override - String get downloadDefaultQualityLabel => 'Default Quality'; - - @override - String get downloadBestAvailable => 'Best available'; - - @override - String get folderNone => 'None'; - - @override - String get folderNoneSubtitle => 'Save all files directly to download folder'; - - @override - String get folderArtist => 'Artist'; - - @override - String get folderArtistSubtitle => 'Artist Name/filename'; - - @override - String get folderAlbum => 'Album'; - - @override - String get folderAlbumSubtitle => 'Album Name/filename'; - - @override - String get folderArtistAlbum => 'Artist/Album'; - - @override - String get folderArtistAlbumSubtitle => 'Artist Name/Album Name/filename'; - - @override - String get serviceTidal => 'Tidal'; - - @override - String get serviceQobuz => 'Qobuz'; - - @override - String get serviceAmazon => 'Amazon'; - - @override - String get serviceDeezer => 'Deezer'; - - @override - String get serviceSpotify => 'Spotify'; - @override String get appearanceAmoledDark => 'AMOLED Dark'; @override String get appearanceAmoledDarkSubtitle => 'Pure black background'; - @override - String get appearanceChooseAccentColor => 'Choose Accent Color'; - - @override - String get appearanceChooseTheme => 'Theme Mode'; - - @override - String get queueTitle => 'Download Queue'; - @override String get queueClearAll => 'Clear All'; @@ -2036,19 +1386,6 @@ class AppLocalizationsHi extends AppLocalizations { String get queueClearAllMessage => 'Are you sure you want to clear all downloads?'; - @override - String get queueExportFailed => 'Export'; - - @override - String get queueExportFailedSuccess => - 'Failed downloads exported to TXT file'; - - @override - String get queueExportFailedClear => 'Clear Failed'; - - @override - String get queueExportFailedError => 'Failed to export downloads'; - @override String get settingsAutoExportFailed => 'Auto-export failed downloads'; @@ -2069,30 +1406,6 @@ class AppLocalizationsHi extends AppLocalizations { String get settingsDownloadNetworkSubtitle => 'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.'; - @override - String get queueEmpty => 'No downloads in queue'; - - @override - String get queueEmptySubtitle => 'Add tracks from the home screen'; - - @override - String get queueClearCompleted => 'Clear completed'; - - @override - String get queueDownloadFailed => 'Download Failed'; - - @override - String get queueTrackLabel => 'Track:'; - - @override - String get queueArtistLabel => 'Artist:'; - - @override - String get queueErrorLabel => 'Error:'; - - @override - String get queueUnknownError => 'Unknown error'; - @override String get albumFolderArtistAlbum => 'Artist / Album'; @@ -2139,14 +1452,6 @@ class AppLocalizationsHi extends AppLocalizations { return 'Delete $count $_temp0 from this album?\n\nThis will also delete the files from storage.'; } - @override - String get downloadedAlbumTracksHeader => 'Tracks'; - - @override - String downloadedAlbumDownloadedCount(int count) { - return '$count downloaded'; - } - @override String downloadedAlbumSelectedCount(int count) { return '$count selected'; @@ -2177,9 +1482,6 @@ class AppLocalizationsHi extends AppLocalizations { return 'Disc $discNumber'; } - @override - String get utilityFunctions => 'Utility Functions'; - @override String get recentTypeArtist => 'Artist'; @@ -2203,11 +1505,6 @@ class AppLocalizationsHi extends AppLocalizations { return 'Playlist: $name'; } - @override - String errorGeneric(String message) { - return 'Error: $message'; - } - @override String get discographyDownload => 'Download Discography'; @@ -2313,9 +1610,6 @@ class AppLocalizationsHi extends AppLocalizations { @override String get libraryTitle => 'Local Library'; - @override - String get libraryStatus => 'Library Status'; - @override String get libraryScanSettings => 'Scan Settings'; @@ -2379,8 +1673,14 @@ class AppLocalizationsHi extends AppLocalizations { 'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.'; @override - String libraryTracksCount(int count) { - return '$count tracks'; + String libraryTracksUnit(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return '$_temp0'; } @override @@ -2462,21 +1762,6 @@ class AppLocalizationsHi extends AppLocalizations { @override String get libraryFilterFormat => 'Format'; - @override - String get libraryFilterDate => 'Date Added'; - - @override - String get libraryFilterDateToday => 'Today'; - - @override - String get libraryFilterDateWeek => 'This Week'; - - @override - String get libraryFilterDateMonth => 'This Month'; - - @override - String get libraryFilterDateYear => 'This Year'; - @override String get libraryFilterSort => 'Sort'; @@ -2486,11 +1771,6 @@ class AppLocalizationsHi extends AppLocalizations { @override String get libraryFilterSortOldest => 'Oldest'; - @override - String libraryFilterActive(int count) { - return '$count filter(s) active'; - } - @override String get timeJustNow => 'Just now'; @@ -2516,72 +1796,6 @@ class AppLocalizationsHi extends AppLocalizations { return '$_temp0'; } - @override - String get storageSwitchTitle => 'Switch Storage Mode'; - - @override - String get storageSwitchToSafTitle => 'Switch to SAF Storage?'; - - @override - String get storageSwitchToAppTitle => 'Switch to App Storage?'; - - @override - String get storageSwitchToSafMessage => - 'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.'; - - @override - String get storageSwitchToAppMessage => - 'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.'; - - @override - String get storageSwitchExistingDownloads => 'Existing Downloads'; - - @override - String storageSwitchExistingDownloadsInfo(int count, String mode) { - return '$count tracks in $mode storage'; - } - - @override - String get storageSwitchNewDownloads => 'New Downloads'; - - @override - String storageSwitchNewDownloadsLocation(String location) { - return 'Will be saved to: $location'; - } - - @override - String get storageSwitchContinue => 'Continue'; - - @override - String get storageSwitchSelectFolder => 'Select SAF Folder'; - - @override - String get storageAppStorage => 'App Storage'; - - @override - String get storageSafStorage => 'SAF Storage'; - - @override - String storageModeBadge(String mode) { - return 'Storage: $mode'; - } - - @override - String get storageStatsTitle => 'Storage Statistics'; - - @override - String storageStatsAppCount(int count) { - return '$count tracks in App Storage'; - } - - @override - String storageStatsSafCount(int count) { - return '$count tracks in SAF Storage'; - } - - @override - String get storageModeInfo => 'Your files are stored in multiple locations'; - @override String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!'; @@ -2608,18 +1822,6 @@ class AppLocalizationsHi extends AppLocalizations { String get tutorialSearchDesc => 'There are two easy ways to find music you want to download.'; - @override - String get tutorialSearchTip1 => - 'Paste a Spotify or Deezer URL directly in the search box'; - - @override - String get tutorialSearchTip2 => - 'Or type the song name, artist, or album to search'; - - @override - String get tutorialSearchTip3 => - 'Supports tracks, albums, playlists, and artist pages'; - @override String get tutorialDownloadTitle => 'Downloading Music'; @@ -2627,18 +1829,6 @@ class AppLocalizationsHi extends AppLocalizations { String get tutorialDownloadDesc => 'Downloading music is simple and fast. Here\'s how it works.'; - @override - String get tutorialDownloadTip1 => - 'Tap the download button next to any track to start downloading'; - - @override - String get tutorialDownloadTip2 => - 'Choose your preferred quality (FLAC, Hi-Res, or MP3)'; - - @override - String get tutorialDownloadTip3 => - 'Download entire albums or playlists with one tap'; - @override String get tutorialLibraryTitle => 'Your Library'; @@ -2699,9 +1889,6 @@ class AppLocalizationsHi extends AppLocalizations { String get tutorialReadyMessage => 'You\'re all set! Start downloading your favorite music now.'; - @override - String get tutorialExample => 'EXAMPLE'; - @override String get libraryForceFullScan => 'Force Full Scan'; @@ -2862,11 +2049,7 @@ class AppLocalizationsHi extends AppLocalizations { String get trackSaveLyricsProgress => 'Saving lyrics...'; @override - String get trackReEnrich => 'Re-enrich Metadata'; - - @override - String get trackReEnrichSubtitle => - 'Re-embed metadata without re-downloading'; + String get trackReEnrich => 'Re-enrich'; @override String get trackReEnrichOnlineSubtitle => @@ -2942,4 +2125,221 @@ class AppLocalizationsHi extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + + @override + String get actionCreate => 'Create'; + + @override + String get collectionFoldersTitle => 'My folders'; + + @override + String get collectionWishlist => 'Wishlist'; + + @override + String get collectionLoved => 'Loved'; + + @override + String get collectionPlaylists => 'Playlists'; + + @override + String get collectionPlaylist => 'Playlist'; + + @override + String get collectionAddToPlaylist => 'Add to playlist'; + + @override + String get collectionCreatePlaylist => 'Create playlist'; + + @override + String get collectionNoPlaylistsYet => 'No playlists yet'; + + @override + String get collectionNoPlaylistsSubtitle => + 'Create a playlist to start categorizing tracks'; + + @override + String collectionPlaylistTracks(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String collectionAddedToPlaylist(String playlistName) { + return 'Added to \"$playlistName\"'; + } + + @override + String collectionAlreadyInPlaylist(String playlistName) { + return 'Already in \"$playlistName\"'; + } + + @override + String get collectionPlaylistCreated => 'Playlist created'; + + @override + String get collectionPlaylistNameHint => 'Playlist name'; + + @override + String get collectionPlaylistNameRequired => 'Playlist name is required'; + + @override + String get collectionRenamePlaylist => 'Rename playlist'; + + @override + String get collectionDeletePlaylist => 'Delete playlist'; + + @override + String collectionDeletePlaylistMessage(String playlistName) { + return 'Delete \"$playlistName\" and all tracks inside it?'; + } + + @override + String get collectionPlaylistDeleted => 'Playlist deleted'; + + @override + String get collectionPlaylistRenamed => 'Playlist renamed'; + + @override + String get collectionWishlistEmptyTitle => 'Wishlist is empty'; + + @override + String get collectionWishlistEmptySubtitle => + 'Tap + on tracks to save what you want to download later'; + + @override + String get collectionLovedEmptyTitle => 'Loved folder is empty'; + + @override + String get collectionLovedEmptySubtitle => + 'Tap love on tracks to keep your favorites'; + + @override + String get collectionPlaylistEmptyTitle => 'Playlist is empty'; + + @override + String get collectionPlaylistEmptySubtitle => + 'Long-press + on any track to add it here'; + + @override + String get collectionRemoveFromPlaylist => 'Remove from playlist'; + + @override + String get collectionRemoveFromFolder => 'Remove from folder'; + + @override + String collectionRemoved(String trackName) { + return '\"$trackName\" removed'; + } + + @override + String collectionAddedToLoved(String trackName) { + return '\"$trackName\" added to Loved'; + } + + @override + String collectionRemovedFromLoved(String trackName) { + return '\"$trackName\" removed from Loved'; + } + + @override + String collectionAddedToWishlist(String trackName) { + return '\"$trackName\" added to Wishlist'; + } + + @override + String collectionRemovedFromWishlist(String trackName) { + return '\"$trackName\" removed from Wishlist'; + } + + @override + String get trackOptionAddToLoved => 'Add to Loved'; + + @override + String get trackOptionRemoveFromLoved => 'Remove from Loved'; + + @override + String get trackOptionAddToWishlist => 'Add to Wishlist'; + + @override + String get trackOptionRemoveFromWishlist => 'Remove from Wishlist'; + + @override + String get collectionPlaylistChangeCover => 'Change cover image'; + + @override + String get collectionPlaylistRemoveCover => 'Remove cover image'; + + @override + String selectionShareCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Share $count $_temp0'; + } + + @override + String get selectionShareNoFiles => 'No shareable files found'; + + @override + String selectionConvertCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0'; + } + + @override + String get selectionConvertNoConvertible => 'No convertible tracks selected'; + + @override + String get selectionBatchConvertConfirmTitle => 'Batch Convert'; + + @override + String selectionBatchConvertConfirmMessage( + int count, + String format, + String bitrate, + ) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.'; + } + + @override + String selectionBatchConvertProgress(int current, int total) { + return 'Converting $current of $total...'; + } + + @override + String selectionBatchConvertSuccess(int success, int total, String format) { + return 'Converted $success of $total tracks to $format'; + } + + @override + String downloadedAlbumDownloadedCount(int count) { + return '$count downloaded'; + } + + @override + String get downloadUseAlbumArtistForFoldersAlbumSubtitle => + 'Artist folders use Album Artist when available'; + + @override + String get downloadUseAlbumArtistForFoldersTrackSubtitle => + 'Artist folders use Track Artist only'; } diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 16fde0a6..ff3af0fa 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -11,19 +11,12 @@ class AppLocalizationsId extends AppLocalizations { @override String get appName => 'SpotiFLAC'; - @override - String get appDescription => - 'Unduh lagu Spotify dalam kualitas lossless dari Tidal, Qobuz, dan Amazon Music.'; - @override String get navHome => 'Beranda'; @override String get navLibrary => 'Library'; - @override - String get navHistory => 'Riwayat'; - @override String get navSettings => 'Pengaturan'; @@ -33,14 +26,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get homeTitle => 'Beranda'; - @override - String get homeSearchHint => 'Tempel URL Spotify atau cari...'; - - @override - String homeSearchHintExtension(String extensionName) { - return 'Cari dengan $extensionName...'; - } - @override String get homeSubtitle => 'Tempel link Spotify atau cari berdasarkan nama'; @@ -50,17 +35,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get homeRecent => 'Terbaru'; - @override - String get historyTitle => 'Riwayat'; - - @override - String historyDownloading(int count) { - return 'Mengunduh ($count)'; - } - - @override - String get historyDownloaded => 'Terunduh'; - @override String get historyFilterAll => 'Semua'; @@ -70,49 +44,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get historyFilterSingles => 'Single'; - @override - String historyTracksCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count lagu', - one: '1 lagu', - ); - return '$_temp0'; - } - - @override - String historyAlbumsCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count album', - one: '1 album', - ); - return '$_temp0'; - } - - @override - String get historyNoDownloads => 'Tidak ada riwayat unduhan'; - - @override - String get historyNoDownloadsSubtitle => - 'Lagu yang diunduh akan muncul di sini'; - - @override - String get historyNoAlbums => 'Tidak ada unduhan album'; - - @override - String get historyNoAlbumsSubtitle => - 'Unduh beberapa lagu dari album untuk melihatnya di sini'; - - @override - String get historyNoSingles => 'Tidak ada unduhan single'; - - @override - String get historyNoSinglesSubtitle => - 'Unduhan lagu satuan akan muncul di sini'; - @override String get historySearchHint => 'Search history...'; @@ -137,28 +68,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get downloadTitle => 'Unduhan'; - @override - String get downloadLocation => 'Lokasi Unduhan'; - - @override - String get downloadLocationSubtitle => 'Pilih tempat menyimpan file'; - - @override - String get downloadLocationDefault => 'Lokasi default'; - - @override - String get downloadDefaultService => 'Layanan Default'; - - @override - String get downloadDefaultServiceSubtitle => - 'Layanan yang digunakan untuk unduhan'; - - @override - String get downloadDefaultQuality => 'Kualitas Default'; - - @override - String get downloadAskQuality => 'Tanya Kualitas Sebelum Unduh'; - @override String get downloadAskQualitySubtitle => 'Tampilkan pemilih kualitas untuk setiap unduhan'; @@ -169,31 +78,9 @@ class AppLocalizationsId extends AppLocalizations { @override String get downloadFolderOrganization => 'Organisasi Folder'; - @override - String get downloadSeparateSingles => 'Pisahkan Single'; - - @override - String get downloadSeparateSinglesSubtitle => - 'Letakkan lagu satuan di folder terpisah'; - - @override - String get qualityBest => 'Terbaik'; - - @override - String get qualityFlac => 'FLAC'; - - @override - String get quality320 => '320 kbps'; - - @override - String get quality128 => '128 kbps'; - @override String get appearanceTitle => 'Tampilan'; - @override - String get appearanceTheme => 'Tema'; - @override String get appearanceThemeSystem => 'Sistem'; @@ -210,9 +97,6 @@ class AppLocalizationsId extends AppLocalizations { String get appearanceDynamicColorSubtitle => 'Gunakan warna dari wallpaper Anda'; - @override - String get appearanceAccentColor => 'Warna Aksen'; - @override String get appearanceHistoryView => 'Tampilan Riwayat'; @@ -225,9 +109,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get optionsTitle => 'Opsi'; - @override - String get optionsSearchSource => 'Sumber Pencarian'; - @override String get optionsPrimaryProvider => 'Provider Utama'; @@ -354,18 +235,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get extensionsTitle => 'Ekstensi'; - @override - String get extensionsInstalled => 'Ekstensi Terpasang'; - - @override - String get extensionsNone => 'Tidak ada ekstensi terpasang'; - - @override - String get extensionsNoneSubtitle => 'Pasang ekstensi dari tab Toko'; - - @override - String get extensionsEnabled => 'Aktif'; - @override String get extensionsDisabled => 'Nonaktif'; @@ -382,9 +251,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get extensionsUninstall => 'Copot'; - @override - String get extensionsSetAsSearch => 'Jadikan Provider Pencarian'; - @override String get storeTitle => 'Toko Ekstensi'; @@ -459,9 +325,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get aboutSocial => 'Social'; - @override - String get aboutSupport => 'Dukungan'; - @override String get aboutApp => 'Aplikasi'; @@ -480,13 +343,6 @@ class AppLocalizationsId extends AppLocalizations { String get aboutSjdonadoDesc => 'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!'; - @override - String get aboutDoubleDouble => 'DoubleDouble'; - - @override - String get aboutDoubleDoubleDesc => - 'API luar biasa untuk unduhan Amazon Music. Terima kasih sudah membuatnya gratis!'; - @override String get aboutDabMusic => 'DAB Music'; @@ -505,32 +361,6 @@ class AppLocalizationsId extends AppLocalizations { String get aboutAppDescription => 'Unduh lagu Spotify dalam kualitas lossless dari Tidal, Qobuz, dan Amazon Music.'; - @override - String get albumTitle => 'Album'; - - @override - String albumTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count lagu', - one: '1 lagu', - ); - return '$_temp0'; - } - - @override - String get albumDownloadAll => 'Unduh Semua'; - - @override - String get albumDownloadRemaining => 'Unduh Sisanya'; - - @override - String get playlistTitle => 'Playlist'; - - @override - String get artistTitle => 'Artis'; - @override String get artistAlbums => 'Album'; @@ -540,17 +370,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get artistCompilations => 'Kompilasi'; - @override - String artistReleases(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count rilis', - one: '1 rilis', - ); - return '$_temp0'; - } - @override String get artistPopular => 'Populer'; @@ -559,27 +378,6 @@ class AppLocalizationsId extends AppLocalizations { return '$count pendengar bulanan'; } - @override - String get trackMetadataTitle => 'Info Lagu'; - - @override - String get trackMetadataArtist => 'Artis'; - - @override - String get trackMetadataAlbum => 'Album'; - - @override - String get trackMetadataDuration => 'Durasi'; - - @override - String get trackMetadataQuality => 'Kualitas'; - - @override - String get trackMetadataPath => 'Lokasi File'; - - @override - String get trackMetadataDownloadedAt => 'Diunduh'; - @override String get trackMetadataService => 'Layanan'; @@ -592,53 +390,15 @@ class AppLocalizationsId extends AppLocalizations { @override String get trackMetadataDelete => 'Hapus'; - @override - String get trackMetadataRedownload => 'Unduh ulang'; - - @override - String get trackMetadataOpenFolder => 'Buka Folder'; - - @override - String get setupTitle => 'Selamat Datang di SpotiFLAC'; - - @override - String get setupSubtitle => 'Mari mulai pengaturan'; - - @override - String get setupStoragePermission => 'Izin Penyimpanan'; - - @override - String get setupStoragePermissionSubtitle => - 'Diperlukan untuk menyimpan file unduhan'; - - @override - String get setupStoragePermissionGranted => 'Izin diberikan'; - - @override - String get setupStoragePermissionDenied => 'Izin ditolak'; - @override String get setupGrantPermission => 'Berikan Izin'; - @override - String get setupDownloadLocation => 'Lokasi Unduhan'; - - @override - String get setupChooseFolder => 'Pilih Folder'; - - @override - String get setupContinue => 'Lanjutkan'; - @override String get setupSkip => 'Lewati untuk sekarang'; @override String get setupStorageAccessRequired => 'Akses Penyimpanan Diperlukan'; - @override - String get setupStorageAccessMessage => - 'SpotiFLAC membutuhkan izin \"Akses semua file\" untuk menyimpan file musik ke folder pilihan Anda.'; - @override String get setupStorageAccessMessageAndroid11 => 'Android 11+ memerlukan izin \"Akses semua file\" untuk menyimpan file ke folder unduhan pilihan Anda.'; @@ -660,9 +420,6 @@ class AppLocalizationsId extends AppLocalizations { return 'Izin $permissionType diperlukan untuk pengalaman terbaik. Anda dapat mengubahnya nanti di Pengaturan.'; } - @override - String get setupSelectDownloadFolder => 'Pilih Folder Unduhan'; - @override String get setupUseDefaultFolder => 'Gunakan Folder Default?'; @@ -704,21 +461,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get setupDownloadInFlac => 'Unduh lagu Spotify dalam format FLAC'; - @override - String get setupStepStorage => 'Penyimpanan'; - - @override - String get setupStepNotification => 'Notifikasi'; - - @override - String get setupStepFolder => 'Folder'; - - @override - String get setupStepSpotify => 'Spotify'; - - @override - String get setupStepPermission => 'Izin'; - @override String get setupStorageGranted => 'Izin Penyimpanan Diberikan!'; @@ -735,13 +477,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get setupNotificationEnable => 'Aktifkan Notifikasi'; - @override - String get setupNotificationDescription => - 'Dapatkan pemberitahuan saat unduhan selesai atau membutuhkan perhatian.'; - - @override - String get setupFolderSelected => 'Folder Unduhan Dipilih!'; - @override String get setupFolderChoose => 'Pilih Folder Unduhan'; @@ -749,49 +484,12 @@ class AppLocalizationsId extends AppLocalizations { String get setupFolderDescription => 'Pilih folder tempat musik yang diunduh akan disimpan.'; - @override - String get setupChangeFolder => 'Ubah Folder'; - @override String get setupSelectFolder => 'Pilih Folder'; - @override - String get setupSpotifyApiOptional => 'Spotify API (Opsional)'; - - @override - String get setupSpotifyApiDescription => - 'Tambahkan kredensial Spotify API untuk hasil pencarian lebih baik dan akses ke konten eksklusif Spotify.'; - - @override - String get setupUseSpotifyApi => 'Gunakan Spotify API'; - - @override - String get setupEnterCredentialsBelow => 'Masukkan kredensial Anda di bawah'; - - @override - String get setupUsingDeezer => 'Menggunakan Deezer (tidak perlu akun)'; - - @override - String get setupEnterClientId => 'Masukkan Spotify Client ID'; - - @override - String get setupEnterClientSecret => 'Masukkan Spotify Client Secret'; - - @override - String get setupGetFreeCredentials => - 'Dapatkan kredensial API gratis dari Spotify Developer Dashboard.'; - @override String get setupEnableNotifications => 'Aktifkan Notifikasi'; - @override - String get setupProceedToNextStep => - 'Anda dapat melanjutkan ke langkah berikutnya.'; - - @override - String get setupNotificationProgressDescription => - 'Anda akan menerima notifikasi progres unduhan.'; - @override String get setupNotificationBackgroundDescription => 'Dapatkan notifikasi tentang progres dan penyelesaian unduhan. Ini membantu Anda melacak unduhan saat aplikasi di latar belakang.'; @@ -799,32 +497,19 @@ class AppLocalizationsId extends AppLocalizations { @override String get setupSkipForNow => 'Lewati untuk sekarang'; - @override - String get setupBack => 'Kembali'; - @override String get setupNext => 'Lanjut'; @override String get setupGetStarted => 'Mulai'; - @override - String get setupSkipAndStart => 'Lewati & Mulai'; - @override String get setupAllowAccessToManageFiles => 'Harap aktifkan \"Izinkan akses untuk mengelola semua file\" di layar berikutnya.'; - @override - String get setupGetCredentialsFromSpotify => - 'Dapatkan kredensial dari developer.spotify.com'; - @override String get dialogCancel => 'Batal'; - @override - String get dialogOk => 'OK'; - @override String get dialogSave => 'Simpan'; @@ -834,21 +519,9 @@ class AppLocalizationsId extends AppLocalizations { @override String get dialogRetry => 'Coba Lagi'; - @override - String get dialogClose => 'Tutup'; - - @override - String get dialogYes => 'Ya'; - - @override - String get dialogNo => 'Tidak'; - @override String get dialogClear => 'Hapus'; - @override - String get dialogConfirm => 'Konfirmasi'; - @override String get dialogDone => 'Selesai'; @@ -871,28 +544,9 @@ class AppLocalizationsId extends AppLocalizations { String get dialogUnsavedChanges => 'Anda memiliki perubahan yang belum disimpan. Apakah Anda ingin membuangnya?'; - @override - String get dialogDownloadFailed => 'Unduhan Gagal'; - - @override - String get dialogTrackLabel => 'Lagu:'; - - @override - String get dialogArtistLabel => 'Artis:'; - - @override - String get dialogErrorLabel => 'Error:'; - @override String get dialogClearAll => 'Hapus Semua'; - @override - String get dialogClearAllDownloads => - 'Apakah Anda yakin ingin menghapus semua unduhan?'; - - @override - String get dialogRemoveFromDevice => 'Hapus dari perangkat?'; - @override String get dialogRemoveExtension => 'Hapus Ekstensi'; @@ -993,11 +647,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get snackbarViewQueue => 'Lihat Antrian'; - @override - String snackbarFailedToLoad(String error) { - return 'Gagal memuat: $error'; - } - @override String snackbarUrlCopied(String platform) { return 'URL $platform disalin ke clipboard'; @@ -1039,11 +688,6 @@ class AppLocalizationsId extends AppLocalizations { String get errorRateLimitedMessage => 'Terlalu banyak permintaan. Harap tunggu sebentar sebelum mencari lagi.'; - @override - String errorFailedToLoad(String item) { - return 'Gagal memuat $item'; - } - @override String get errorNoTracksFound => 'Tidak ada lagu ditemukan'; @@ -1052,27 +696,6 @@ class AppLocalizationsId extends AppLocalizations { return 'Tidak dapat memuat $item: sumber ekstensi tidak ada'; } - @override - String get statusQueued => 'Mengantri'; - - @override - String get statusDownloading => 'Mengunduh'; - - @override - String get statusFinalizing => 'Menyelesaikan'; - - @override - String get statusCompleted => 'Selesai'; - - @override - String get statusFailed => 'Gagal'; - - @override - String get statusSkipped => 'Dilewati'; - - @override - String get statusPaused => 'Dijeda'; - @override String get actionPause => 'Jeda'; @@ -1082,24 +705,12 @@ class AppLocalizationsId extends AppLocalizations { @override String get actionCancel => 'Batal'; - @override - String get actionStop => 'Hentikan'; - - @override - String get actionSelect => 'Pilih'; - @override String get actionSelectAll => 'Pilih Semua'; @override String get actionDeselect => 'Batal Pilih'; - @override - String get actionPaste => 'Tempel'; - - @override - String get actionImportCsv => 'Impor CSV'; - @override String get actionRemoveCredentials => 'Hapus Kredensial'; @@ -1114,20 +725,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get selectionAllSelected => 'Semua lagu dipilih'; - @override - String get selectionTapToSelect => 'Ketuk lagu untuk memilih'; - - @override - String selectionDeleteTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: 'lagu', - one: 'lagu', - ); - return 'Hapus $count $_temp0'; - } - @override String get selectionSelectToDelete => 'Pilih lagu untuk dihapus'; @@ -1154,40 +751,9 @@ class AppLocalizationsId extends AppLocalizations { @override String get tooltipPlay => 'Putar'; - @override - String get tooltipCancel => 'Batal'; - - @override - String get tooltipStop => 'Hentikan'; - - @override - String get tooltipRetry => 'Coba Lagi'; - - @override - String get tooltipRemove => 'Hapus'; - - @override - String get tooltipClear => 'Hapus'; - - @override - String get tooltipPaste => 'Tempel'; - @override String get filenameFormat => 'Format Nama File'; - @override - String filenameFormatPreview(String preview) { - return 'Pratinjau: $preview'; - } - - @override - String get filenameAvailablePlaceholders => 'Placeholder yang tersedia:'; - - @override - String filenameHint(Object artist, Object title) { - return '$artist - $title'; - } - @override String get filenameShowAdvancedTags => 'Tampilkan tag lanjutan'; @@ -1195,9 +761,6 @@ class AppLocalizationsId extends AppLocalizations { String get filenameShowAdvancedTagsDescription => 'Aktifkan tag format untuk padding nomor lagu dan pola tanggal'; - @override - String get folderOrganization => 'Organisasi Folder'; - @override String get folderOrganizationNone => 'Tidak ada'; @@ -1232,20 +795,9 @@ class AppLocalizationsId extends AppLocalizations { @override String get updateAvailable => 'Pembaruan Tersedia'; - @override - String updateNewVersion(String version) { - return 'Versi $version tersedia'; - } - - @override - String get updateDownload => 'Unduh'; - @override String get updateLater => 'Nanti'; - @override - String get updateChangelog => 'Log Perubahan'; - @override String get updateStartingDownload => 'Memulai unduhan...'; @@ -1276,13 +828,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get updateDontRemind => 'Jangan ingatkan'; - @override - String get providerPriority => 'Prioritas Provider'; - - @override - String get providerPrioritySubtitle => - 'Seret untuk mengatur ulang provider unduhan'; - @override String get providerPriorityTitle => 'Prioritas Provider'; @@ -1300,13 +845,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get providerExtension => 'Ekstensi'; - @override - String get metadataProviderPriority => 'Prioritas Provider Metadata'; - - @override - String get metadataProviderPrioritySubtitle => - 'Urutan yang digunakan saat mengambil metadata lagu'; - @override String get metadataProviderPriorityTitle => 'Prioritas Metadata'; @@ -1327,18 +865,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get logTitle => 'Log'; - @override - String get logCopy => 'Salin Log'; - - @override - String get logClear => 'Hapus Log'; - - @override - String get logShare => 'Bagikan Log'; - - @override - String get logEmpty => 'Belum ada log'; - @override String get logCopied => 'Log disalin ke clipboard'; @@ -1364,18 +890,6 @@ class AppLocalizationsId extends AppLocalizations { String get logClearLogsMessage => 'Apakah Anda yakin ingin menghapus semua log?'; - @override - String get logIspBlocking => 'PEMBLOKIRAN ISP TERDETEKSI'; - - @override - String get logRateLimited => 'DIBATASI'; - - @override - String get logNetworkError => 'ERROR JARINGAN'; - - @override - String get logTrackNotFound => 'LAGU TIDAK DITEMUKAN'; - @override String get logFilterBySeverity => 'Filter log berdasarkan tingkat keparahan'; @@ -1386,49 +900,6 @@ class AppLocalizationsId extends AppLocalizations { String get logNoLogsYetSubtitle => 'Log akan muncul di sini saat Anda menggunakan aplikasi'; - @override - String get logIssueSummary => 'Ringkasan Masalah'; - - @override - String get logIspBlockingDescription => - 'ISP Anda mungkin memblokir akses ke layanan unduhan'; - - @override - String get logIspBlockingSuggestion => - 'Coba gunakan VPN atau ubah DNS ke 1.1.1.1 atau 8.8.8.8'; - - @override - String get logRateLimitedDescription => - 'Terlalu banyak permintaan ke layanan'; - - @override - String get logRateLimitedSuggestion => - 'Tunggu beberapa menit sebelum mencoba lagi'; - - @override - String get logNetworkErrorDescription => 'Masalah koneksi terdeteksi'; - - @override - String get logNetworkErrorSuggestion => 'Periksa koneksi internet Anda'; - - @override - String get logTrackNotFoundDescription => - 'Beberapa lagu tidak dapat ditemukan di layanan unduhan'; - - @override - String get logTrackNotFoundSuggestion => - 'Lagu mungkin tidak tersedia dalam kualitas lossless'; - - @override - String logTotalErrors(int count) { - return 'Total error: $count'; - } - - @override - String logAffected(String domains) { - return 'Terpengaruh: $domains'; - } - @override String logEntriesFiltered(int count) { return 'Entri ($count difilter)'; @@ -1535,9 +1006,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get appearanceLanguage => 'Bahasa Aplikasi'; - @override - String get appearanceLanguageSubtitle => 'Pilih bahasa yang kamu inginkan'; - @override String get settingsAppearanceSubtitle => 'Tema, warna, tampilan'; @@ -1559,9 +1027,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get pressBackAgainToExit => 'Tekan kembali sekali lagi untuk keluar'; - @override - String get tracksHeader => 'Lagu'; - @override String downloadAllCount(int count) { return 'Unduh Semua ($count)'; @@ -1675,11 +1140,6 @@ class AppLocalizationsId extends AppLocalizations { String get trackDeleteConfirmMessage => 'Ini akan menghapus file unduhan secara permanen dan menghapusnya dari riwayat Anda.'; - @override - String trackCannotOpen(String message) { - return 'Tidak dapat membuka: $message'; - } - @override String get dateToday => 'Hari ini'; @@ -1701,18 +1161,6 @@ class AppLocalizationsId extends AppLocalizations { return '$count bulan lalu'; } - @override - String get concurrentSequential => 'Berurutan'; - - @override - String get concurrentParallel2 => '2 Paralel'; - - @override - String get concurrentParallel3 => '3 Paralel'; - - @override - String get tapToSeeError => 'Ketuk untuk melihat detail error'; - @override String get storeFilterAll => 'Semua'; @@ -1734,15 +1182,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get storeClearFilters => 'Hapus filter'; - @override - String get storeNoResults => 'Tidak ada ekstensi ditemukan'; - - @override - String get extensionProviderPriority => 'Prioritas Provider'; - - @override - String get extensionInstallButton => 'Pasang Ekstensi'; - @override String get extensionDefaultProvider => 'Default (Deezer/Spotify)'; @@ -1895,38 +1334,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get qualityHiResFlacMaxSubtitle => '24-bit / hingga 192kHz'; - @override - String get qualityLossy => 'Lossy'; - - @override - String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)'; - - @override - String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)'; - - @override - String get enableLossyOption => 'Enable Lossy Option'; - - @override - String get enableLossyOptionSubtitleOn => 'Lossy quality option is available'; - - @override - String get enableLossyOptionSubtitleOff => - 'Downloads FLAC then converts to lossy format'; - - @override - String get lossyFormat => 'Lossy Format'; - - @override - String get lossyFormatDescription => 'Choose the lossy format for conversion'; - - @override - String get lossyFormatMp3Subtitle => '320kbps, best compatibility'; - - @override - String get lossyFormatOpusSubtitle => - '128kbps, better quality at smaller size'; - @override String get qualityNote => 'Kualitas sebenarnya tergantung ketersediaan lagu dari layanan'; @@ -1935,6 +1342,12 @@ class AppLocalizationsId extends AppLocalizations { String get youtubeQualityNote => 'YouTube provides lossy audio only. Not part of lossless fallback.'; + @override + String get youtubeOpusBitrateTitle => 'Bitrate Opus YouTube'; + + @override + String get youtubeMp3BitrateTitle => 'Bitrate MP3 YouTube'; + @override String get downloadAskBeforeDownload => 'Tanya Sebelum Unduh'; @@ -1950,14 +1363,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders'; - @override - String get downloadUseAlbumArtistForFoldersAlbumSubtitle => - 'Artist folders use Album Artist when available'; - - @override - String get downloadUseAlbumArtistForFoldersTrackSubtitle => - 'Artist folders use Track Artist only'; - @override String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders'; @@ -1969,79 +1374,18 @@ class AppLocalizationsId extends AppLocalizations { String get downloadUsePrimaryArtistOnlyDisabled => 'Full artist string used for folder name'; - @override - String get downloadSaveFormat => 'Simpan Format'; - - @override - String get downloadSelectService => 'Pilih Layanan'; - @override String get downloadSelectQuality => 'Pilih Kualitas'; @override String get downloadFrom => 'Unduh Dari'; - @override - String get downloadDefaultQualityLabel => 'Kualitas Default'; - - @override - String get downloadBestAvailable => 'Terbaik tersedia'; - - @override - String get folderNone => 'Tidak ada'; - - @override - String get folderNoneSubtitle => - 'Simpan semua file langsung ke folder unduhan'; - - @override - String get folderArtist => 'Artis'; - - @override - String get folderArtistSubtitle => 'Nama Artis/namafile'; - - @override - String get folderAlbum => 'Album'; - - @override - String get folderAlbumSubtitle => 'Nama Album/namafile'; - - @override - String get folderArtistAlbum => 'Artis/Album'; - - @override - String get folderArtistAlbumSubtitle => 'Nama Artis/Nama Album/namafile'; - - @override - String get serviceTidal => 'Tidal'; - - @override - String get serviceQobuz => 'Qobuz'; - - @override - String get serviceAmazon => 'Amazon'; - - @override - String get serviceDeezer => 'Deezer'; - - @override - String get serviceSpotify => 'Spotify'; - @override String get appearanceAmoledDark => 'AMOLED Gelap'; @override String get appearanceAmoledDarkSubtitle => 'Latar belakang hitam murni'; - @override - String get appearanceChooseAccentColor => 'Pilih Warna Aksen'; - - @override - String get appearanceChooseTheme => 'Mode Tema'; - - @override - String get queueTitle => 'Antrian Unduhan'; - @override String get queueClearAll => 'Hapus Semua'; @@ -2049,19 +1393,6 @@ class AppLocalizationsId extends AppLocalizations { String get queueClearAllMessage => 'Apakah Anda yakin ingin menghapus semua unduhan?'; - @override - String get queueExportFailed => 'Export'; - - @override - String get queueExportFailedSuccess => - 'Failed downloads exported to TXT file'; - - @override - String get queueExportFailedClear => 'Clear Failed'; - - @override - String get queueExportFailedError => 'Failed to export downloads'; - @override String get settingsAutoExportFailed => 'Auto-export failed downloads'; @@ -2082,30 +1413,6 @@ class AppLocalizationsId extends AppLocalizations { String get settingsDownloadNetworkSubtitle => 'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.'; - @override - String get queueEmpty => 'Tidak ada unduhan dalam antrian'; - - @override - String get queueEmptySubtitle => 'Tambahkan lagu dari layar beranda'; - - @override - String get queueClearCompleted => 'Hapus yang selesai'; - - @override - String get queueDownloadFailed => 'Unduhan Gagal'; - - @override - String get queueTrackLabel => 'Lagu:'; - - @override - String get queueArtistLabel => 'Artis:'; - - @override - String get queueErrorLabel => 'Error:'; - - @override - String get queueUnknownError => 'Error tidak diketahui'; - @override String get albumFolderArtistAlbum => 'Artis / Album'; @@ -2152,14 +1459,6 @@ class AppLocalizationsId extends AppLocalizations { return 'Hapus $count $_temp0 dari album ini?\n\nIni juga akan menghapus file dari penyimpanan.'; } - @override - String get downloadedAlbumTracksHeader => 'Lagu'; - - @override - String downloadedAlbumDownloadedCount(int count) { - return '$count diunduh'; - } - @override String downloadedAlbumSelectedCount(int count) { return '$count dipilih'; @@ -2190,9 +1489,6 @@ class AppLocalizationsId extends AppLocalizations { return 'Disc $discNumber'; } - @override - String get utilityFunctions => 'Fungsi Utilitas'; - @override String get recentTypeArtist => 'Artis'; @@ -2216,11 +1512,6 @@ class AppLocalizationsId extends AppLocalizations { return 'Playlist: $name'; } - @override - String errorGeneric(String message) { - return 'Error: $message'; - } - @override String get discographyDownload => 'Download Discography'; @@ -2326,9 +1617,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get libraryTitle => 'Local Library'; - @override - String get libraryStatus => 'Library Status'; - @override String get libraryScanSettings => 'Scan Settings'; @@ -2392,8 +1680,14 @@ class AppLocalizationsId extends AppLocalizations { 'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.'; @override - String libraryTracksCount(int count) { - return '$count tracks'; + String libraryTracksUnit(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'trek', + one: 'trek', + ); + return '$_temp0'; } @override @@ -2475,21 +1769,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get libraryFilterFormat => 'Format'; - @override - String get libraryFilterDate => 'Date Added'; - - @override - String get libraryFilterDateToday => 'Today'; - - @override - String get libraryFilterDateWeek => 'This Week'; - - @override - String get libraryFilterDateMonth => 'This Month'; - - @override - String get libraryFilterDateYear => 'This Year'; - @override String get libraryFilterSort => 'Sort'; @@ -2499,11 +1778,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get libraryFilterSortOldest => 'Oldest'; - @override - String libraryFilterActive(int count) { - return '$count filter(s) active'; - } - @override String get timeJustNow => 'Just now'; @@ -2529,72 +1803,6 @@ class AppLocalizationsId extends AppLocalizations { return '$_temp0'; } - @override - String get storageSwitchTitle => 'Switch Storage Mode'; - - @override - String get storageSwitchToSafTitle => 'Switch to SAF Storage?'; - - @override - String get storageSwitchToAppTitle => 'Switch to App Storage?'; - - @override - String get storageSwitchToSafMessage => - 'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.'; - - @override - String get storageSwitchToAppMessage => - 'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.'; - - @override - String get storageSwitchExistingDownloads => 'Existing Downloads'; - - @override - String storageSwitchExistingDownloadsInfo(int count, String mode) { - return '$count tracks in $mode storage'; - } - - @override - String get storageSwitchNewDownloads => 'New Downloads'; - - @override - String storageSwitchNewDownloadsLocation(String location) { - return 'Will be saved to: $location'; - } - - @override - String get storageSwitchContinue => 'Continue'; - - @override - String get storageSwitchSelectFolder => 'Select SAF Folder'; - - @override - String get storageAppStorage => 'App Storage'; - - @override - String get storageSafStorage => 'SAF Storage'; - - @override - String storageModeBadge(String mode) { - return 'Storage: $mode'; - } - - @override - String get storageStatsTitle => 'Storage Statistics'; - - @override - String storageStatsAppCount(int count) { - return '$count tracks in App Storage'; - } - - @override - String storageStatsSafCount(int count) { - return '$count tracks in SAF Storage'; - } - - @override - String get storageModeInfo => 'Your files are stored in multiple locations'; - @override String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!'; @@ -2621,18 +1829,6 @@ class AppLocalizationsId extends AppLocalizations { String get tutorialSearchDesc => 'There are two easy ways to find music you want to download.'; - @override - String get tutorialSearchTip1 => - 'Paste a Spotify or Deezer URL directly in the search box'; - - @override - String get tutorialSearchTip2 => - 'Or type the song name, artist, or album to search'; - - @override - String get tutorialSearchTip3 => - 'Supports tracks, albums, playlists, and artist pages'; - @override String get tutorialDownloadTitle => 'Downloading Music'; @@ -2640,18 +1836,6 @@ class AppLocalizationsId extends AppLocalizations { String get tutorialDownloadDesc => 'Downloading music is simple and fast. Here\'s how it works.'; - @override - String get tutorialDownloadTip1 => - 'Tap the download button next to any track to start downloading'; - - @override - String get tutorialDownloadTip2 => - 'Choose your preferred quality (FLAC, Hi-Res, or MP3)'; - - @override - String get tutorialDownloadTip3 => - 'Download entire albums or playlists with one tap'; - @override String get tutorialLibraryTitle => 'Your Library'; @@ -2712,9 +1896,6 @@ class AppLocalizationsId extends AppLocalizations { String get tutorialReadyMessage => 'You\'re all set! Start downloading your favorite music now.'; - @override - String get tutorialExample => 'EXAMPLE'; - @override String get libraryForceFullScan => 'Force Full Scan'; @@ -2875,11 +2056,7 @@ class AppLocalizationsId extends AppLocalizations { String get trackSaveLyricsProgress => 'Saving lyrics...'; @override - String get trackReEnrich => 'Re-enrich Metadata'; - - @override - String get trackReEnrichSubtitle => - 'Re-embed metadata without re-downloading'; + String get trackReEnrich => 'Re-enrich'; @override String get trackReEnrichOnlineSubtitle => @@ -2955,4 +2132,222 @@ class AppLocalizationsId extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + + @override + String get actionCreate => 'Buat'; + + @override + String get collectionFoldersTitle => 'Folder saya'; + + @override + String get collectionWishlist => 'Wishlist'; + + @override + String get collectionLoved => 'Loved'; + + @override + String get collectionPlaylists => 'Playlist'; + + @override + String get collectionPlaylist => 'Playlist'; + + @override + String get collectionAddToPlaylist => 'Tambahkan ke playlist'; + + @override + String get collectionCreatePlaylist => 'Buat playlist'; + + @override + String get collectionNoPlaylistsYet => 'Belum ada playlist'; + + @override + String get collectionNoPlaylistsSubtitle => + 'Buat playlist untuk mulai mengategorikan lagu'; + + @override + String collectionPlaylistTracks(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count lagu', + one: '1 lagu', + ); + return '$_temp0'; + } + + @override + String collectionAddedToPlaylist(String playlistName) { + return 'Ditambahkan ke \"$playlistName\"'; + } + + @override + String collectionAlreadyInPlaylist(String playlistName) { + return 'Sudah ada di \"$playlistName\"'; + } + + @override + String get collectionPlaylistCreated => 'Playlist berhasil dibuat'; + + @override + String get collectionPlaylistNameHint => 'Nama playlist'; + + @override + String get collectionPlaylistNameRequired => 'Nama playlist wajib diisi'; + + @override + String get collectionRenamePlaylist => 'Ubah nama playlist'; + + @override + String get collectionDeletePlaylist => 'Hapus playlist'; + + @override + String collectionDeletePlaylistMessage(String playlistName) { + return 'Hapus \"$playlistName\" beserta semua lagunya?'; + } + + @override + String get collectionPlaylistDeleted => 'Playlist dihapus'; + + @override + String get collectionPlaylistRenamed => 'Nama playlist diperbarui'; + + @override + String get collectionWishlistEmptyTitle => 'Wishlist masih kosong'; + + @override + String get collectionWishlistEmptySubtitle => + 'Tap + di lagu untuk menyimpan yang ingin diunduh nanti'; + + @override + String get collectionLovedEmptyTitle => 'Folder Loved masih kosong'; + + @override + String get collectionLovedEmptySubtitle => + 'Tap love di lagu untuk menyimpan favoritmu'; + + @override + String get collectionPlaylistEmptyTitle => 'Playlist masih kosong'; + + @override + String get collectionPlaylistEmptySubtitle => + 'Tekan lama tombol + pada lagu untuk menambahkannya ke sini'; + + @override + String get collectionRemoveFromPlaylist => 'Hapus dari playlist'; + + @override + String get collectionRemoveFromFolder => 'Hapus dari folder'; + + @override + String collectionRemoved(String trackName) { + return '\"$trackName\" dihapus'; + } + + @override + String collectionAddedToLoved(String trackName) { + return '\"$trackName\" ditambahkan ke Loved'; + } + + @override + String collectionRemovedFromLoved(String trackName) { + return '\"$trackName\" dihapus dari Loved'; + } + + @override + String collectionAddedToWishlist(String trackName) { + return '\"$trackName\" ditambahkan ke Wishlist'; + } + + @override + String collectionRemovedFromWishlist(String trackName) { + return '\"$trackName\" dihapus dari Wishlist'; + } + + @override + String get trackOptionAddToLoved => 'Tambahkan ke Loved'; + + @override + String get trackOptionRemoveFromLoved => 'Hapus dari Loved'; + + @override + String get trackOptionAddToWishlist => 'Tambahkan ke Wishlist'; + + @override + String get trackOptionRemoveFromWishlist => 'Hapus dari Wishlist'; + + @override + String get collectionPlaylistChangeCover => 'Ubah gambar sampul'; + + @override + String get collectionPlaylistRemoveCover => 'Hapus gambar sampul'; + + @override + String selectionShareCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'trek', + one: 'trek', + ); + return 'Bagikan $count $_temp0'; + } + + @override + String get selectionShareNoFiles => 'Tidak ada file yang dapat dibagikan'; + + @override + String selectionConvertCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'trek', + one: 'trek', + ); + return 'Konversi $count $_temp0'; + } + + @override + String get selectionConvertNoConvertible => + 'Tidak ada trek yang dapat dikonversi dipilih'; + + @override + String get selectionBatchConvertConfirmTitle => 'Konversi Massal'; + + @override + String selectionBatchConvertConfirmMessage( + int count, + String format, + String bitrate, + ) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'trek', + one: 'trek', + ); + return 'Konversi $count $_temp0 ke $format pada $bitrate?\n\nFile asli akan dihapus setelah konversi.'; + } + + @override + String selectionBatchConvertProgress(int current, int total) { + return 'Mengonversi $current dari $total...'; + } + + @override + String selectionBatchConvertSuccess(int success, int total, String format) { + return 'Berhasil mengonversi $success dari $total trek ke $format'; + } + + @override + String downloadedAlbumDownloadedCount(int count) { + return '$count diunduh'; + } + + @override + String get downloadUseAlbumArtistForFoldersAlbumSubtitle => + 'Artist folders use Album Artist when available'; + + @override + String get downloadUseAlbumArtistForFoldersTrackSubtitle => + 'Artist folders use Track Artist only'; } diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 370018ad..b473562c 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -11,19 +11,12 @@ class AppLocalizationsJa extends AppLocalizations { @override String get appName => 'SpotiFLAC'; - @override - String get appDescription => - 'Tidal、Qobuz、Amazon Music から Spotify のトラックをロスレス品質でダウンロードします。'; - @override String get navHome => 'ホーム'; @override String get navLibrary => 'Library'; - @override - String get navHistory => '履歴'; - @override String get navSettings => '設定'; @@ -33,14 +26,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get homeTitle => 'ホーム'; - @override - String get homeSearchHint => 'Spotify の URL を貼り付けまたは検索...'; - - @override - String homeSearchHintExtension(String extensionName) { - return '$extensionName で検索...'; - } - @override String get homeSubtitle => 'Spotify のリンクを貼り付けるか、名前で検索します'; @@ -50,17 +35,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get homeRecent => '最近'; - @override - String get historyTitle => '履歴'; - - @override - String historyDownloading(int count) { - return 'ダウンロード中 ($count)'; - } - - @override - String get historyDownloaded => 'ダウンロード済み'; - @override String get historyFilterAll => 'すべて'; @@ -70,48 +44,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get historyFilterSingles => 'シングル'; - @override - String historyTracksCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count 個のトラック', - one: '1 個のトラック', - ); - return '$_temp0'; - } - - @override - String historyAlbumsCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count 個のアルバム', - one: '1 個のアルバム', - ); - return '$_temp0'; - } - - @override - String get historyNoDownloads => 'ダウンロード履歴はありません'; - - @override - String get historyNoDownloadsSubtitle => 'ダウンロードしたトラックはここに表示されます'; - - @override - String get historyNoAlbums => 'アルバムのダウンロードはありません'; - - @override - String get historyNoAlbumsSubtitle => - 'Download multiple tracks from an album to see them here'; - - @override - String get historyNoSingles => 'シングルのダウンロードはありません'; - - @override - String get historyNoSinglesSubtitle => - 'Single track downloads will appear here'; - @override String get historySearchHint => '検索履歴...'; @@ -136,27 +68,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get downloadTitle => 'ダウンロード'; - @override - String get downloadLocation => 'ダウンロード先'; - - @override - String get downloadLocationSubtitle => 'ファイルの保存先を選択'; - - @override - String get downloadLocationDefault => 'デフォルトの場所'; - - @override - String get downloadDefaultService => 'デフォルトのサービス'; - - @override - String get downloadDefaultServiceSubtitle => 'ダウンロードに使用したサービス'; - - @override - String get downloadDefaultQuality => 'デフォルトの品質'; - - @override - String get downloadAskQuality => 'ダウンロード前に品質を確認する'; - @override String get downloadAskQualitySubtitle => 'Show quality picker for each download'; @@ -167,31 +78,9 @@ class AppLocalizationsJa extends AppLocalizations { @override String get downloadFolderOrganization => 'フォルダ構成'; - @override - String get downloadSeparateSingles => 'シングルを分割'; - - @override - String get downloadSeparateSinglesSubtitle => - 'Put single tracks in a separate folder'; - - @override - String get qualityBest => 'おすすめ'; - - @override - String get qualityFlac => 'FLAC'; - - @override - String get quality320 => '320 kbps'; - - @override - String get quality128 => '128 kbps'; - @override String get appearanceTitle => '外観'; - @override - String get appearanceTheme => 'テーマ'; - @override String get appearanceThemeSystem => 'システム'; @@ -207,9 +96,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get appearanceDynamicColorSubtitle => '壁紙の色を使用する'; - @override - String get appearanceAccentColor => 'アクセントカラー'; - @override String get appearanceHistoryView => '履歴の表示'; @@ -222,9 +108,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get optionsTitle => 'オプション'; - @override - String get optionsSearchSource => '検索ソース'; - @override String get optionsPrimaryProvider => 'プライマリーのプロバイダー'; @@ -347,18 +230,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get extensionsTitle => '拡張'; - @override - String get extensionsInstalled => 'インストール済みの拡張'; - - @override - String get extensionsNone => '拡張はインストールされていません'; - - @override - String get extensionsNoneSubtitle => 'ストアタブから拡張をインストール'; - - @override - String get extensionsEnabled => '有効'; - @override String get extensionsDisabled => '無効'; @@ -375,9 +246,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get extensionsUninstall => 'アンインストール'; - @override - String get extensionsSetAsSearch => '検索のプロバイダーを設定'; - @override String get storeTitle => '拡張ストア'; @@ -450,9 +318,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get aboutSocial => 'ソーシャル'; - @override - String get aboutSupport => 'サポート'; - @override String get aboutApp => 'アプリ'; @@ -471,13 +336,6 @@ class AppLocalizationsJa extends AppLocalizations { String get aboutSjdonadoDesc => 'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!'; - @override - String get aboutDoubleDouble => 'DoubleDouble'; - - @override - String get aboutDoubleDoubleDesc => - 'Amazing API for Amazon Music downloads. Thank you for making it free!'; - @override String get aboutDabMusic => 'DAB Music'; @@ -496,32 +354,6 @@ class AppLocalizationsJa extends AppLocalizations { String get aboutAppDescription => 'Tidal、Qobuz、Amazon Music から Spotify のトラックをロスレス品質でダウンロードします。'; - @override - String get albumTitle => 'アルバム'; - - @override - String albumTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count 個のトラック', - one: '1 個のトラック', - ); - return '$_temp0'; - } - - @override - String get albumDownloadAll => 'すべてダウンロード'; - - @override - String get albumDownloadRemaining => 'Download Remaining'; - - @override - String get playlistTitle => 'プレイリスト'; - - @override - String get artistTitle => 'アーティスト'; - @override String get artistAlbums => 'アルバム'; @@ -531,17 +363,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get artistCompilations => 'コンピレーション'; - @override - String artistReleases(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count 個のリリース', - one: '1 個のリリース', - ); - return '$_temp0'; - } - @override String get artistPopular => '人気'; @@ -550,27 +371,6 @@ class AppLocalizationsJa extends AppLocalizations { return '$count 人の月間リスナー'; } - @override - String get trackMetadataTitle => 'トラック情報'; - - @override - String get trackMetadataArtist => 'アーティスト'; - - @override - String get trackMetadataAlbum => 'アルバム'; - - @override - String get trackMetadataDuration => '再生時間'; - - @override - String get trackMetadataQuality => '品質'; - - @override - String get trackMetadataPath => 'ファイルパス'; - - @override - String get trackMetadataDownloadedAt => 'ダウンロード済み'; - @override String get trackMetadataService => 'サービス'; @@ -583,52 +383,15 @@ class AppLocalizationsJa extends AppLocalizations { @override String get trackMetadataDelete => '削除'; - @override - String get trackMetadataRedownload => '再ダウンロード'; - - @override - String get trackMetadataOpenFolder => 'フォルダを開く'; - - @override - String get setupTitle => 'SpotiFLAC へようこそ'; - - @override - String get setupSubtitle => 'Let\'s get you started'; - - @override - String get setupStoragePermission => 'ストレージの権限'; - - @override - String get setupStoragePermissionSubtitle => 'ダウンロードしたファイルを保存するために必要です'; - - @override - String get setupStoragePermissionGranted => '権限を許可しました'; - - @override - String get setupStoragePermissionDenied => '権限が拒否されました'; - @override String get setupGrantPermission => '権限を許可'; - @override - String get setupDownloadLocation => 'ダウンロード先'; - - @override - String get setupChooseFolder => 'フォルダを選択'; - - @override - String get setupContinue => '続行'; - @override String get setupSkip => '今はスキップ'; @override String get setupStorageAccessRequired => 'ストレージアクセスが必要です'; - @override - String get setupStorageAccessMessage => - 'SpotiFLAC needs \"All files access\" permission to save music files to your chosen folder.'; - @override String get setupStorageAccessMessageAndroid11 => 'Android 11+ requires \"All files access\" permission to save files to your chosen download folder.'; @@ -650,9 +413,6 @@ class AppLocalizationsJa extends AppLocalizations { return '最適な体験を得るには $permissionType の権限が必要です。この権限は設定で後から変更できます。'; } - @override - String get setupSelectDownloadFolder => 'ダウンロードフォルダを選択'; - @override String get setupUseDefaultFolder => 'デフォルトのフォルダを使用しますか?'; @@ -694,21 +454,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get setupDownloadInFlac => 'Spotify のトラックを FLAC でダウンロード'; - @override - String get setupStepStorage => 'ストレージ'; - - @override - String get setupStepNotification => '通知'; - - @override - String get setupStepFolder => 'フォルダ'; - - @override - String get setupStepSpotify => 'Spotify'; - - @override - String get setupStepPermission => '権限'; - @override String get setupStorageGranted => 'ストレージの権限が許可されました!'; @@ -725,13 +470,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get setupNotificationEnable => '通知を有効化する'; - @override - String get setupNotificationDescription => - 'Get notified when downloads complete or require attention.'; - - @override - String get setupFolderSelected => 'ダウンロードフォルダが選択済みです!'; - @override String get setupFolderChoose => 'ダウンロードフォルダを選択'; @@ -739,48 +477,12 @@ class AppLocalizationsJa extends AppLocalizations { String get setupFolderDescription => 'Select a folder where your downloaded music will be saved.'; - @override - String get setupChangeFolder => 'フォルダを変更'; - @override String get setupSelectFolder => 'フォルダを選択'; - @override - String get setupSpotifyApiOptional => 'Spotify API (任意)'; - - @override - String get setupSpotifyApiDescription => - 'Add your Spotify API credentials for better search results and access to Spotify-exclusive content.'; - - @override - String get setupUseSpotifyApi => 'Spotify API を使用する'; - - @override - String get setupEnterCredentialsBelow => '以下に認証情報を入力してください'; - - @override - String get setupUsingDeezer => 'Deezer を使用中 (アカウントは不要です)'; - - @override - String get setupEnterClientId => 'Spotify クライアント ID を入力'; - - @override - String get setupEnterClientSecret => 'Spotify クライアントシークレットを入力'; - - @override - String get setupGetFreeCredentials => - 'Spotify 開発者ダッシュボードから無料の API 認証情報を取得します。'; - @override String get setupEnableNotifications => '通知を有効化する'; - @override - String get setupProceedToNextStep => 'You can now proceed to the next step.'; - - @override - String get setupNotificationProgressDescription => - 'You will receive download progress notifications.'; - @override String get setupNotificationBackgroundDescription => 'Get notified about download progress and completion. This helps you track downloads when the app is in background.'; @@ -788,32 +490,19 @@ class AppLocalizationsJa extends AppLocalizations { @override String get setupSkipForNow => '今はスキップ'; - @override - String get setupBack => '戻る'; - @override String get setupNext => '次へ'; @override String get setupGetStarted => 'Get Started'; - @override - String get setupSkipAndStart => 'スキップと開始'; - @override String get setupAllowAccessToManageFiles => 'Please enable \"Allow access to manage all files\" in the next screen.'; - @override - String get setupGetCredentialsFromSpotify => - 'developer.spotify.com から認証情報を取得します'; - @override String get dialogCancel => 'キャンセル'; - @override - String get dialogOk => 'OK'; - @override String get dialogSave => '保存'; @@ -823,21 +512,9 @@ class AppLocalizationsJa extends AppLocalizations { @override String get dialogRetry => '再試行'; - @override - String get dialogClose => '閉じる'; - - @override - String get dialogYes => 'はい'; - - @override - String get dialogNo => 'いいえ'; - @override String get dialogClear => '消去'; - @override - String get dialogConfirm => '続行'; - @override String get dialogDone => '完了'; @@ -860,28 +537,9 @@ class AppLocalizationsJa extends AppLocalizations { String get dialogUnsavedChanges => 'You have unsaved changes. Do you want to discard them?'; - @override - String get dialogDownloadFailed => 'ダウンロードに失敗しました'; - - @override - String get dialogTrackLabel => 'トラック:'; - - @override - String get dialogArtistLabel => 'アーティスト:'; - - @override - String get dialogErrorLabel => 'エラー:'; - @override String get dialogClearAll => 'すべて消去'; - @override - String get dialogClearAllDownloads => - 'Are you sure you want to clear all downloads?'; - - @override - String get dialogRemoveFromDevice => 'デバイスから削除しますか?'; - @override String get dialogRemoveExtension => '拡張を削除'; @@ -982,11 +640,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get snackbarViewQueue => 'キューを表示'; - @override - String snackbarFailedToLoad(String error) { - return '読み込みに失敗: $error'; - } - @override String snackbarUrlCopied(String platform) { return '$platform の URL をクリップボードにコピーしました'; @@ -1027,11 +680,6 @@ class AppLocalizationsJa extends AppLocalizations { String get errorRateLimitedMessage => 'Too many requests. Please wait a moment before searching again.'; - @override - String errorFailedToLoad(String item) { - return 'Failed to load $item'; - } - @override String get errorNoTracksFound => 'トラックがありません'; @@ -1040,27 +688,6 @@ class AppLocalizationsJa extends AppLocalizations { return '$item を読み込めません: 拡張ソースがありません'; } - @override - String get statusQueued => 'キュー済み'; - - @override - String get statusDownloading => 'ダウンロード中'; - - @override - String get statusFinalizing => '終了処理中'; - - @override - String get statusCompleted => '完了しました'; - - @override - String get statusFailed => '失敗しました'; - - @override - String get statusSkipped => 'スキップしました'; - - @override - String get statusPaused => '一時停止中'; - @override String get actionPause => '一時停止'; @@ -1070,24 +697,12 @@ class AppLocalizationsJa extends AppLocalizations { @override String get actionCancel => 'キャンセル'; - @override - String get actionStop => '停止'; - - @override - String get actionSelect => '選択'; - @override String get actionSelectAll => 'すべて選択'; @override String get actionDeselect => '選択を解除'; - @override - String get actionPaste => '貼り付け'; - - @override - String get actionImportCsv => 'CSV をインポート'; - @override String get actionRemoveCredentials => '認証情報を削除'; @@ -1102,20 +717,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get selectionAllSelected => 'すべてのトラックを選択済み'; - @override - String get selectionTapToSelect => 'トラックをタップで選択'; - - @override - String selectionDeleteTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '個のトラック', - one: '個のトラック', - ); - return '$count $_temp0を削除'; - } - @override String get selectionSelectToDelete => 'トラックを選択で削除'; @@ -1142,40 +743,9 @@ class AppLocalizationsJa extends AppLocalizations { @override String get tooltipPlay => '再生'; - @override - String get tooltipCancel => 'キャンセル'; - - @override - String get tooltipStop => '停止'; - - @override - String get tooltipRetry => '再試行'; - - @override - String get tooltipRemove => '削除'; - - @override - String get tooltipClear => '消去'; - - @override - String get tooltipPaste => '貼り付け'; - @override String get filenameFormat => 'ファイル名の形式'; - @override - String filenameFormatPreview(String preview) { - return 'プレビュー: $preview'; - } - - @override - String get filenameAvailablePlaceholders => '利用可能なプレースホルダー:'; - - @override - String filenameHint(Object artist, Object title) { - return '$artist - $title'; - } - @override String get filenameShowAdvancedTags => 'Show advanced tags'; @@ -1183,9 +753,6 @@ class AppLocalizationsJa extends AppLocalizations { String get filenameShowAdvancedTagsDescription => 'Enable formatted tags for track padding and date patterns'; - @override - String get folderOrganization => 'フォルダ構成'; - @override String get folderOrganizationNone => '構成がありません'; @@ -1219,20 +786,9 @@ class AppLocalizationsJa extends AppLocalizations { @override String get updateAvailable => '更新が利用可能です'; - @override - String updateNewVersion(String version) { - return 'バージョン $version が利用可能です'; - } - - @override - String get updateDownload => 'ダウンロード'; - @override String get updateLater => '後で'; - @override - String get updateChangelog => '更新履歴'; - @override String get updateStartingDownload => 'ダウンロードを開始中...'; @@ -1263,12 +819,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get updateDontRemind => '通知しない'; - @override - String get providerPriority => 'プロバイダーの優先度'; - - @override - String get providerPrioritySubtitle => 'ドラッグでダウンロードプロバイダーを並べ替え'; - @override String get providerPriorityTitle => 'プロバイダーの優先度'; @@ -1286,13 +836,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get providerExtension => '拡張'; - @override - String get metadataProviderPriority => 'メタデータプロバイダーの優先度'; - - @override - String get metadataProviderPrioritySubtitle => - 'Order used when fetching track metadata'; - @override String get metadataProviderPriorityTitle => 'メタデータの優先度'; @@ -1313,18 +856,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get logTitle => 'ログ'; - @override - String get logCopy => 'ログをコピー'; - - @override - String get logClear => 'ログを消去'; - - @override - String get logShare => 'ログを共有'; - - @override - String get logEmpty => 'まだログはありません'; - @override String get logCopied => 'ログをクリップボードにコピーしました'; @@ -1349,18 +880,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get logClearLogsMessage => 'すべてのログを消去してもよろしいですか?'; - @override - String get logIspBlocking => 'ISP のブロックを検出しました'; - - @override - String get logRateLimited => 'レート制限'; - - @override - String get logNetworkError => 'ネットワークエラー'; - - @override - String get logTrackNotFound => 'トラックがありません'; - @override String get logFilterBySeverity => 'Filter logs by severity'; @@ -1370,48 +889,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get logNoLogsYetSubtitle => 'Logs will appear here as you use the app'; - @override - String get logIssueSummary => '問題の概要'; - - @override - String get logIspBlockingDescription => - 'ISP がダウンロードサービスのアクセスをブロックしている可能性があります'; - - @override - String get logIspBlockingSuggestion => - 'VPN を使用するか DNS を 1.1.1.1 または 8.8.8.8 に変更をお試しください'; - - @override - String get logRateLimitedDescription => 'サービスへのリクエストが多すぎます'; - - @override - String get logRateLimitedSuggestion => - 'Wait a few minutes before trying again'; - - @override - String get logNetworkErrorDescription => '接続の問題が検出されました'; - - @override - String get logNetworkErrorSuggestion => 'インターネット接続を確認してください'; - - @override - String get logTrackNotFoundDescription => - 'Some tracks could not be found on download services'; - - @override - String get logTrackNotFoundSuggestion => - 'The track may not be available in lossless quality'; - - @override - String logTotalErrors(int count) { - return 'エラーの合計: $count'; - } - - @override - String logAffected(String domains) { - return '影響: $domains'; - } - @override String logEntriesFiltered(int count) { return 'エントリー ($count 個をフィルター済み)'; @@ -1518,9 +995,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get appearanceLanguage => 'アプリの言語'; - @override - String get appearanceLanguageSubtitle => 'お好みの言語を選択してください'; - @override String get settingsAppearanceSubtitle => 'テーマ、カラー、画面'; @@ -1542,9 +1016,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get pressBackAgainToExit => 'Press back again to exit'; - @override - String get tracksHeader => 'トラック'; - @override String downloadAllCount(int count) { return 'すべてダウンロード ($count)'; @@ -1658,11 +1129,6 @@ class AppLocalizationsJa extends AppLocalizations { String get trackDeleteConfirmMessage => 'This will permanently delete the downloaded file and remove it from your history.'; - @override - String trackCannotOpen(String message) { - return '開けません: $message'; - } - @override String get dateToday => '今日'; @@ -1684,18 +1150,6 @@ class AppLocalizationsJa extends AppLocalizations { return '$count ヶ月前'; } - @override - String get concurrentSequential => 'Sequential'; - - @override - String get concurrentParallel2 => '2 並列'; - - @override - String get concurrentParallel3 => '3 並列'; - - @override - String get tapToSeeError => 'タップでエラーの詳細を表示'; - @override String get storeFilterAll => 'すべて'; @@ -1717,15 +1171,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get storeClearFilters => 'フィルターを消去'; - @override - String get storeNoResults => '拡張がありません'; - - @override - String get extensionProviderPriority => 'プロバイダーの優先度'; - - @override - String get extensionInstallButton => '拡張をインストール'; - @override String get extensionDefaultProvider => 'デフォルト (Deezer/Spotify)'; @@ -1872,38 +1317,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get qualityHiResFlacMaxSubtitle => '24-bit / 最大 192kHz'; - @override - String get qualityLossy => 'Lossy'; - - @override - String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)'; - - @override - String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)'; - - @override - String get enableLossyOption => 'Enable Lossy Option'; - - @override - String get enableLossyOptionSubtitleOn => 'Lossy quality option is available'; - - @override - String get enableLossyOptionSubtitleOff => - 'Downloads FLAC then converts to lossy format'; - - @override - String get lossyFormat => 'Lossy Format'; - - @override - String get lossyFormatDescription => 'Choose the lossy format for conversion'; - - @override - String get lossyFormatMp3Subtitle => '320kbps, best compatibility'; - - @override - String get lossyFormatOpusSubtitle => - '128kbps, better quality at smaller size'; - @override String get qualityNote => '実際の品質はサービスからのトラックの可用性に依存します'; @@ -1911,6 +1324,12 @@ class AppLocalizationsJa extends AppLocalizations { String get youtubeQualityNote => 'YouTube provides lossy audio only. Not part of lossless fallback.'; + @override + String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; + + @override + String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; + @override String get downloadAskBeforeDownload => 'ダウンロード前に確認する'; @@ -1926,14 +1345,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders'; - @override - String get downloadUseAlbumArtistForFoldersAlbumSubtitle => - 'Artist folders use Album Artist when available'; - - @override - String get downloadUseAlbumArtistForFoldersTrackSubtitle => - 'Artist folders use Track Artist only'; - @override String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders'; @@ -1945,97 +1356,24 @@ class AppLocalizationsJa extends AppLocalizations { String get downloadUsePrimaryArtistOnlyDisabled => 'Full artist string used for folder name'; - @override - String get downloadSaveFormat => '形式を保存'; - - @override - String get downloadSelectService => 'サービスを選択'; - @override String get downloadSelectQuality => '品質を選択'; @override String get downloadFrom => 'ダウンロード元'; - @override - String get downloadDefaultQualityLabel => 'デフォルトの品質'; - - @override - String get downloadBestAvailable => 'おすすめ'; - - @override - String get folderNone => 'なし'; - - @override - String get folderNoneSubtitle => 'すべてのファイルをダウンロードフォルダに保存します'; - - @override - String get folderArtist => 'アーティスト'; - - @override - String get folderArtistSubtitle => 'アーティスト名/ファイル名'; - - @override - String get folderAlbum => 'アルバム'; - - @override - String get folderAlbumSubtitle => 'アルバム名/ファイル名'; - - @override - String get folderArtistAlbum => 'アーティスト/アルバム'; - - @override - String get folderArtistAlbumSubtitle => 'アーティスト名/アルバム名/ファイル名'; - - @override - String get serviceTidal => 'Tidal'; - - @override - String get serviceQobuz => 'Qobuz'; - - @override - String get serviceAmazon => 'Amazon'; - - @override - String get serviceDeezer => 'Deezer'; - - @override - String get serviceSpotify => 'Spotify'; - @override String get appearanceAmoledDark => 'AMOLED ダーク'; @override String get appearanceAmoledDarkSubtitle => 'ピュアブラックの背景'; - @override - String get appearanceChooseAccentColor => 'アクセントカラーを選択'; - - @override - String get appearanceChooseTheme => 'テーマモード'; - - @override - String get queueTitle => 'ダウンロードキュー'; - @override String get queueClearAll => 'すべて消去'; @override String get queueClearAllMessage => 'すべてのダウンロードを消去してもよろしいですか?'; - @override - String get queueExportFailed => 'Export'; - - @override - String get queueExportFailedSuccess => - 'Failed downloads exported to TXT file'; - - @override - String get queueExportFailedClear => 'Clear Failed'; - - @override - String get queueExportFailedError => 'Failed to export downloads'; - @override String get settingsAutoExportFailed => 'Auto-export failed downloads'; @@ -2056,30 +1394,6 @@ class AppLocalizationsJa extends AppLocalizations { String get settingsDownloadNetworkSubtitle => 'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.'; - @override - String get queueEmpty => 'キューにダウンロードがありません'; - - @override - String get queueEmptySubtitle => 'ホーム画面からトラックを追加'; - - @override - String get queueClearCompleted => '完了済みを消去'; - - @override - String get queueDownloadFailed => 'ダウンロードに失敗しました'; - - @override - String get queueTrackLabel => 'トラック:'; - - @override - String get queueArtistLabel => 'アーティスト:'; - - @override - String get queueErrorLabel => 'エラー:'; - - @override - String get queueUnknownError => '不明なエラー'; - @override String get albumFolderArtistAlbum => 'アーティスト / アルバム'; @@ -2125,14 +1439,6 @@ class AppLocalizationsJa extends AppLocalizations { return 'Delete $count $_temp0 from this album?\n\nThis will also delete the files from storage.'; } - @override - String get downloadedAlbumTracksHeader => 'トラック'; - - @override - String downloadedAlbumDownloadedCount(int count) { - return '$count 個をダウンロード済み'; - } - @override String downloadedAlbumSelectedCount(int count) { return '$count 個を選択済み'; @@ -2163,9 +1469,6 @@ class AppLocalizationsJa extends AppLocalizations { return 'ディスク $discNumber'; } - @override - String get utilityFunctions => 'ユーティリティ機能'; - @override String get recentTypeArtist => 'アーティスト'; @@ -2189,11 +1492,6 @@ class AppLocalizationsJa extends AppLocalizations { return 'プレイリスト: $name'; } - @override - String errorGeneric(String message) { - return 'エラー: $message'; - } - @override String get discographyDownload => 'ディスコグラフィをダウンロード'; @@ -2299,9 +1597,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get libraryTitle => 'Local Library'; - @override - String get libraryStatus => 'Library Status'; - @override String get libraryScanSettings => 'Scan Settings'; @@ -2365,8 +1660,14 @@ class AppLocalizationsJa extends AppLocalizations { 'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.'; @override - String libraryTracksCount(int count) { - return '$count tracks'; + String libraryTracksUnit(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return '$_temp0'; } @override @@ -2448,21 +1749,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get libraryFilterFormat => 'Format'; - @override - String get libraryFilterDate => 'Date Added'; - - @override - String get libraryFilterDateToday => 'Today'; - - @override - String get libraryFilterDateWeek => 'This Week'; - - @override - String get libraryFilterDateMonth => 'This Month'; - - @override - String get libraryFilterDateYear => 'This Year'; - @override String get libraryFilterSort => 'Sort'; @@ -2472,11 +1758,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get libraryFilterSortOldest => 'Oldest'; - @override - String libraryFilterActive(int count) { - return '$count filter(s) active'; - } - @override String get timeJustNow => 'Just now'; @@ -2502,72 +1783,6 @@ class AppLocalizationsJa extends AppLocalizations { return '$_temp0'; } - @override - String get storageSwitchTitle => 'Switch Storage Mode'; - - @override - String get storageSwitchToSafTitle => 'Switch to SAF Storage?'; - - @override - String get storageSwitchToAppTitle => 'Switch to App Storage?'; - - @override - String get storageSwitchToSafMessage => - 'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.'; - - @override - String get storageSwitchToAppMessage => - 'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.'; - - @override - String get storageSwitchExistingDownloads => 'Existing Downloads'; - - @override - String storageSwitchExistingDownloadsInfo(int count, String mode) { - return '$count tracks in $mode storage'; - } - - @override - String get storageSwitchNewDownloads => 'New Downloads'; - - @override - String storageSwitchNewDownloadsLocation(String location) { - return 'Will be saved to: $location'; - } - - @override - String get storageSwitchContinue => 'Continue'; - - @override - String get storageSwitchSelectFolder => 'Select SAF Folder'; - - @override - String get storageAppStorage => 'App Storage'; - - @override - String get storageSafStorage => 'SAF Storage'; - - @override - String storageModeBadge(String mode) { - return 'Storage: $mode'; - } - - @override - String get storageStatsTitle => 'Storage Statistics'; - - @override - String storageStatsAppCount(int count) { - return '$count tracks in App Storage'; - } - - @override - String storageStatsSafCount(int count) { - return '$count tracks in SAF Storage'; - } - - @override - String get storageModeInfo => 'Your files are stored in multiple locations'; - @override String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!'; @@ -2594,18 +1809,6 @@ class AppLocalizationsJa extends AppLocalizations { String get tutorialSearchDesc => 'There are two easy ways to find music you want to download.'; - @override - String get tutorialSearchTip1 => - 'Paste a Spotify or Deezer URL directly in the search box'; - - @override - String get tutorialSearchTip2 => - 'Or type the song name, artist, or album to search'; - - @override - String get tutorialSearchTip3 => - 'Supports tracks, albums, playlists, and artist pages'; - @override String get tutorialDownloadTitle => 'Downloading Music'; @@ -2613,18 +1816,6 @@ class AppLocalizationsJa extends AppLocalizations { String get tutorialDownloadDesc => 'Downloading music is simple and fast. Here\'s how it works.'; - @override - String get tutorialDownloadTip1 => - 'Tap the download button next to any track to start downloading'; - - @override - String get tutorialDownloadTip2 => - 'Choose your preferred quality (FLAC, Hi-Res, or MP3)'; - - @override - String get tutorialDownloadTip3 => - 'Download entire albums or playlists with one tap'; - @override String get tutorialLibraryTitle => 'Your Library'; @@ -2685,9 +1876,6 @@ class AppLocalizationsJa extends AppLocalizations { String get tutorialReadyMessage => 'You\'re all set! Start downloading your favorite music now.'; - @override - String get tutorialExample => 'EXAMPLE'; - @override String get libraryForceFullScan => 'Force Full Scan'; @@ -2848,11 +2036,7 @@ class AppLocalizationsJa extends AppLocalizations { String get trackSaveLyricsProgress => 'Saving lyrics...'; @override - String get trackReEnrich => 'Re-enrich Metadata'; - - @override - String get trackReEnrichSubtitle => - 'Re-embed metadata without re-downloading'; + String get trackReEnrich => 'Re-enrich'; @override String get trackReEnrichOnlineSubtitle => @@ -2928,4 +2112,221 @@ class AppLocalizationsJa extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + + @override + String get actionCreate => 'Create'; + + @override + String get collectionFoldersTitle => 'My folders'; + + @override + String get collectionWishlist => 'Wishlist'; + + @override + String get collectionLoved => 'Loved'; + + @override + String get collectionPlaylists => 'Playlists'; + + @override + String get collectionPlaylist => 'Playlist'; + + @override + String get collectionAddToPlaylist => 'Add to playlist'; + + @override + String get collectionCreatePlaylist => 'Create playlist'; + + @override + String get collectionNoPlaylistsYet => 'No playlists yet'; + + @override + String get collectionNoPlaylistsSubtitle => + 'Create a playlist to start categorizing tracks'; + + @override + String collectionPlaylistTracks(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String collectionAddedToPlaylist(String playlistName) { + return 'Added to \"$playlistName\"'; + } + + @override + String collectionAlreadyInPlaylist(String playlistName) { + return 'Already in \"$playlistName\"'; + } + + @override + String get collectionPlaylistCreated => 'Playlist created'; + + @override + String get collectionPlaylistNameHint => 'Playlist name'; + + @override + String get collectionPlaylistNameRequired => 'Playlist name is required'; + + @override + String get collectionRenamePlaylist => 'Rename playlist'; + + @override + String get collectionDeletePlaylist => 'Delete playlist'; + + @override + String collectionDeletePlaylistMessage(String playlistName) { + return 'Delete \"$playlistName\" and all tracks inside it?'; + } + + @override + String get collectionPlaylistDeleted => 'Playlist deleted'; + + @override + String get collectionPlaylistRenamed => 'Playlist renamed'; + + @override + String get collectionWishlistEmptyTitle => 'Wishlist is empty'; + + @override + String get collectionWishlistEmptySubtitle => + 'Tap + on tracks to save what you want to download later'; + + @override + String get collectionLovedEmptyTitle => 'Loved folder is empty'; + + @override + String get collectionLovedEmptySubtitle => + 'Tap love on tracks to keep your favorites'; + + @override + String get collectionPlaylistEmptyTitle => 'Playlist is empty'; + + @override + String get collectionPlaylistEmptySubtitle => + 'Long-press + on any track to add it here'; + + @override + String get collectionRemoveFromPlaylist => 'Remove from playlist'; + + @override + String get collectionRemoveFromFolder => 'Remove from folder'; + + @override + String collectionRemoved(String trackName) { + return '\"$trackName\" removed'; + } + + @override + String collectionAddedToLoved(String trackName) { + return '\"$trackName\" added to Loved'; + } + + @override + String collectionRemovedFromLoved(String trackName) { + return '\"$trackName\" removed from Loved'; + } + + @override + String collectionAddedToWishlist(String trackName) { + return '\"$trackName\" added to Wishlist'; + } + + @override + String collectionRemovedFromWishlist(String trackName) { + return '\"$trackName\" removed from Wishlist'; + } + + @override + String get trackOptionAddToLoved => 'Add to Loved'; + + @override + String get trackOptionRemoveFromLoved => 'Remove from Loved'; + + @override + String get trackOptionAddToWishlist => 'Add to Wishlist'; + + @override + String get trackOptionRemoveFromWishlist => 'Remove from Wishlist'; + + @override + String get collectionPlaylistChangeCover => 'Change cover image'; + + @override + String get collectionPlaylistRemoveCover => 'Remove cover image'; + + @override + String selectionShareCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Share $count $_temp0'; + } + + @override + String get selectionShareNoFiles => 'No shareable files found'; + + @override + String selectionConvertCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0'; + } + + @override + String get selectionConvertNoConvertible => 'No convertible tracks selected'; + + @override + String get selectionBatchConvertConfirmTitle => 'Batch Convert'; + + @override + String selectionBatchConvertConfirmMessage( + int count, + String format, + String bitrate, + ) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.'; + } + + @override + String selectionBatchConvertProgress(int current, int total) { + return 'Converting $current of $total...'; + } + + @override + String selectionBatchConvertSuccess(int success, int total, String format) { + return 'Converted $success of $total tracks to $format'; + } + + @override + String downloadedAlbumDownloadedCount(int count) { + return '$count 個をダウンロード済み'; + } + + @override + String get downloadUseAlbumArtistForFoldersAlbumSubtitle => + 'Artist folders use Album Artist when available'; + + @override + String get downloadUseAlbumArtistForFoldersTrackSubtitle => + 'Artist folders use Track Artist only'; } diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index e97ac06c..f94d1f3f 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -11,19 +11,12 @@ class AppLocalizationsKo extends AppLocalizations { @override String get appName => 'SpotiFLAC'; - @override - String get appDescription => - 'Spotify 트랙을 Tidal, Qobuz, Amazon Music에서 무손실 음질로 다운로드하세요.'; - @override String get navHome => 'Home'; @override String get navLibrary => 'Library'; - @override - String get navHistory => 'History'; - @override String get navSettings => 'Settings'; @@ -33,14 +26,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get homeTitle => 'Home'; - @override - String get homeSearchHint => 'Spotify URL을 붙여 넣거나 검색'; - - @override - String homeSearchHintExtension(String extensionName) { - return '$extensionName에서 검색'; - } - @override String get homeSubtitle => 'Spotify URL을 붙여 넣거나 검색'; @@ -50,17 +35,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get homeRecent => '최근 기록'; - @override - String get historyTitle => '기록'; - - @override - String historyDownloading(int count) { - return '다운로드 중... $count'; - } - - @override - String get historyDownloaded => '다운로드 목록'; - @override String get historyFilterAll => 'All'; @@ -70,48 +44,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get historyFilterSingles => 'Singles'; - @override - String historyTracksCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '${count}tracks', - one: '1 track', - ); - return '$_temp0'; - } - - @override - String historyAlbumsCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count albums', - one: '1 album', - ); - return '$_temp0'; - } - - @override - String get historyNoDownloads => 'No download history'; - - @override - String get historyNoDownloadsSubtitle => 'Downloaded tracks will appear here'; - - @override - String get historyNoAlbums => 'No album downloads'; - - @override - String get historyNoAlbumsSubtitle => - 'Download multiple tracks from an album to see them here'; - - @override - String get historyNoSingles => 'No single downloads'; - - @override - String get historyNoSinglesSubtitle => - 'Single track downloads will appear here'; - @override String get historySearchHint => 'Search history...'; @@ -136,27 +68,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get downloadTitle => 'Download'; - @override - String get downloadLocation => 'Download Location'; - - @override - String get downloadLocationSubtitle => 'Choose where to save files'; - - @override - String get downloadLocationDefault => 'Default location'; - - @override - String get downloadDefaultService => 'Default Service'; - - @override - String get downloadDefaultServiceSubtitle => 'Service used for downloads'; - - @override - String get downloadDefaultQuality => 'Default Quality'; - - @override - String get downloadAskQuality => 'Ask Quality Before Download'; - @override String get downloadAskQualitySubtitle => 'Show quality picker for each download'; @@ -167,31 +78,9 @@ class AppLocalizationsKo extends AppLocalizations { @override String get downloadFolderOrganization => 'Folder Organization'; - @override - String get downloadSeparateSingles => 'Separate Singles'; - - @override - String get downloadSeparateSinglesSubtitle => - 'Put single tracks in a separate folder'; - - @override - String get qualityBest => 'Best Available'; - - @override - String get qualityFlac => 'FLAC'; - - @override - String get quality320 => '320 kbps'; - - @override - String get quality128 => '128 kbps'; - @override String get appearanceTitle => 'Appearance'; - @override - String get appearanceTheme => 'Theme'; - @override String get appearanceThemeSystem => 'System'; @@ -207,9 +96,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get appearanceDynamicColorSubtitle => 'Use colors from your wallpaper'; - @override - String get appearanceAccentColor => 'Accent Color'; - @override String get appearanceHistoryView => 'History View'; @@ -222,9 +108,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get optionsTitle => 'Options'; - @override - String get optionsSearchSource => 'Search Source'; - @override String get optionsPrimaryProvider => 'Primary Provider'; @@ -349,18 +232,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get extensionsTitle => 'Extensions'; - @override - String get extensionsInstalled => 'Installed Extensions'; - - @override - String get extensionsNone => 'No extensions installed'; - - @override - String get extensionsNoneSubtitle => 'Install extensions from the Store tab'; - - @override - String get extensionsEnabled => 'Enabled'; - @override String get extensionsDisabled => 'Disabled'; @@ -377,9 +248,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get extensionsUninstall => 'Uninstall'; - @override - String get extensionsSetAsSearch => 'Set as Search Provider'; - @override String get storeTitle => 'Extension Store'; @@ -453,9 +321,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get aboutSocial => 'Social'; - @override - String get aboutSupport => 'Support'; - @override String get aboutApp => 'App'; @@ -474,13 +339,6 @@ class AppLocalizationsKo extends AppLocalizations { String get aboutSjdonadoDesc => 'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!'; - @override - String get aboutDoubleDouble => 'DoubleDouble'; - - @override - String get aboutDoubleDoubleDesc => - 'Amazing API for Amazon Music downloads. Thank you for making it free!'; - @override String get aboutDabMusic => 'DAB Music'; @@ -499,32 +357,6 @@ class AppLocalizationsKo extends AppLocalizations { String get aboutAppDescription => 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; - @override - String get albumTitle => 'Album'; - - @override - String albumTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count tracks', - one: '1 track', - ); - return '$_temp0'; - } - - @override - String get albumDownloadAll => 'Download All'; - - @override - String get albumDownloadRemaining => 'Download Remaining'; - - @override - String get playlistTitle => 'Playlist'; - - @override - String get artistTitle => 'Artist'; - @override String get artistAlbums => 'Albums'; @@ -534,17 +366,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get artistCompilations => 'Compilations'; - @override - String artistReleases(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count releases', - one: '1 release', - ); - return '$_temp0'; - } - @override String get artistPopular => 'Popular'; @@ -553,27 +374,6 @@ class AppLocalizationsKo extends AppLocalizations { return '$count monthly listeners'; } - @override - String get trackMetadataTitle => 'Track Info'; - - @override - String get trackMetadataArtist => 'Artist'; - - @override - String get trackMetadataAlbum => 'Album'; - - @override - String get trackMetadataDuration => 'Duration'; - - @override - String get trackMetadataQuality => 'Quality'; - - @override - String get trackMetadataPath => 'File Path'; - - @override - String get trackMetadataDownloadedAt => 'Downloaded'; - @override String get trackMetadataService => 'Service'; @@ -586,53 +386,15 @@ class AppLocalizationsKo extends AppLocalizations { @override String get trackMetadataDelete => 'Delete'; - @override - String get trackMetadataRedownload => 'Re-download'; - - @override - String get trackMetadataOpenFolder => 'Open Folder'; - - @override - String get setupTitle => 'Welcome to SpotiFLAC'; - - @override - String get setupSubtitle => 'Let\'s get you started'; - - @override - String get setupStoragePermission => 'Storage Permission'; - - @override - String get setupStoragePermissionSubtitle => - 'Required to save downloaded files'; - - @override - String get setupStoragePermissionGranted => 'Permission granted'; - - @override - String get setupStoragePermissionDenied => 'Permission denied'; - @override String get setupGrantPermission => 'Grant Permission'; - @override - String get setupDownloadLocation => 'Download Location'; - - @override - String get setupChooseFolder => 'Choose Folder'; - - @override - String get setupContinue => 'Continue'; - @override String get setupSkip => 'Skip for now'; @override String get setupStorageAccessRequired => 'Storage Access Required'; - @override - String get setupStorageAccessMessage => - 'SpotiFLAC needs \"All files access\" permission to save music files to your chosen folder.'; - @override String get setupStorageAccessMessageAndroid11 => 'Android 11+ requires \"All files access\" permission to save files to your chosen download folder.'; @@ -654,9 +416,6 @@ class AppLocalizationsKo extends AppLocalizations { return '$permissionType permission is required for the best experience. You can change this later in Settings.'; } - @override - String get setupSelectDownloadFolder => 'Select Download Folder'; - @override String get setupUseDefaultFolder => 'Use Default Folder?'; @@ -698,21 +457,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get setupDownloadInFlac => 'Download Spotify tracks in FLAC'; - @override - String get setupStepStorage => 'Storage'; - - @override - String get setupStepNotification => 'Notification'; - - @override - String get setupStepFolder => 'Folder'; - - @override - String get setupStepSpotify => 'Spotify'; - - @override - String get setupStepPermission => 'Permission'; - @override String get setupStorageGranted => 'Storage Permission Granted!'; @@ -729,13 +473,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get setupNotificationEnable => 'Enable Notifications'; - @override - String get setupNotificationDescription => - 'Get notified when downloads complete or require attention.'; - - @override - String get setupFolderSelected => 'Download Folder Selected!'; - @override String get setupFolderChoose => 'Choose Download Folder'; @@ -743,48 +480,12 @@ class AppLocalizationsKo extends AppLocalizations { String get setupFolderDescription => 'Select a folder where your downloaded music will be saved.'; - @override - String get setupChangeFolder => 'Change Folder'; - @override String get setupSelectFolder => 'Select Folder'; - @override - String get setupSpotifyApiOptional => 'Spotify API (Optional)'; - - @override - String get setupSpotifyApiDescription => - 'Add your Spotify API credentials for better search results and access to Spotify-exclusive content.'; - - @override - String get setupUseSpotifyApi => 'Use Spotify API'; - - @override - String get setupEnterCredentialsBelow => 'Enter your credentials below'; - - @override - String get setupUsingDeezer => 'Using Deezer (no account needed)'; - - @override - String get setupEnterClientId => 'Enter Spotify Client ID'; - - @override - String get setupEnterClientSecret => 'Enter Spotify Client Secret'; - - @override - String get setupGetFreeCredentials => - 'Get your free API credentials from the Spotify Developer Dashboard.'; - @override String get setupEnableNotifications => 'Enable Notifications'; - @override - String get setupProceedToNextStep => 'You can now proceed to the next step.'; - - @override - String get setupNotificationProgressDescription => - 'You will receive download progress notifications.'; - @override String get setupNotificationBackgroundDescription => 'Get notified about download progress and completion. This helps you track downloads when the app is in background.'; @@ -792,32 +493,19 @@ class AppLocalizationsKo extends AppLocalizations { @override String get setupSkipForNow => 'Skip for now'; - @override - String get setupBack => 'Back'; - @override String get setupNext => 'Next'; @override String get setupGetStarted => 'Get Started'; - @override - String get setupSkipAndStart => 'Skip & Start'; - @override String get setupAllowAccessToManageFiles => 'Please enable \"Allow access to manage all files\" in the next screen.'; - @override - String get setupGetCredentialsFromSpotify => - 'Get credentials from developer.spotify.com'; - @override String get dialogCancel => 'Cancel'; - @override - String get dialogOk => 'OK'; - @override String get dialogSave => 'Save'; @@ -827,21 +515,9 @@ class AppLocalizationsKo extends AppLocalizations { @override String get dialogRetry => 'Retry'; - @override - String get dialogClose => 'Close'; - - @override - String get dialogYes => 'Yes'; - - @override - String get dialogNo => 'No'; - @override String get dialogClear => 'Clear'; - @override - String get dialogConfirm => 'Confirm'; - @override String get dialogDone => 'Done'; @@ -864,28 +540,9 @@ class AppLocalizationsKo extends AppLocalizations { String get dialogUnsavedChanges => 'You have unsaved changes. Do you want to discard them?'; - @override - String get dialogDownloadFailed => 'Download Failed'; - - @override - String get dialogTrackLabel => 'Track:'; - - @override - String get dialogArtistLabel => 'Artist:'; - - @override - String get dialogErrorLabel => 'Error:'; - @override String get dialogClearAll => 'Clear All'; - @override - String get dialogClearAllDownloads => - 'Are you sure you want to clear all downloads?'; - - @override - String get dialogRemoveFromDevice => 'Remove from device?'; - @override String get dialogRemoveExtension => 'Remove Extension'; @@ -986,11 +643,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get snackbarViewQueue => 'View Queue'; - @override - String snackbarFailedToLoad(String error) { - return 'Failed to load: $error'; - } - @override String snackbarUrlCopied(String platform) { return '$platform URL copied to clipboard'; @@ -1032,11 +684,6 @@ class AppLocalizationsKo extends AppLocalizations { String get errorRateLimitedMessage => 'Too many requests. Please wait a moment before searching again.'; - @override - String errorFailedToLoad(String item) { - return 'Failed to load $item'; - } - @override String get errorNoTracksFound => 'No tracks found'; @@ -1045,27 +692,6 @@ class AppLocalizationsKo extends AppLocalizations { return 'Cannot load $item: missing extension source'; } - @override - String get statusQueued => 'Queued'; - - @override - String get statusDownloading => 'Downloading'; - - @override - String get statusFinalizing => 'Finalizing'; - - @override - String get statusCompleted => 'Completed'; - - @override - String get statusFailed => 'Failed'; - - @override - String get statusSkipped => 'Skipped'; - - @override - String get statusPaused => 'Paused'; - @override String get actionPause => 'Pause'; @@ -1075,24 +701,12 @@ class AppLocalizationsKo extends AppLocalizations { @override String get actionCancel => 'Cancel'; - @override - String get actionStop => 'Stop'; - - @override - String get actionSelect => 'Select'; - @override String get actionSelectAll => 'Select All'; @override String get actionDeselect => 'Deselect'; - @override - String get actionPaste => 'Paste'; - - @override - String get actionImportCsv => 'Import CSV'; - @override String get actionRemoveCredentials => 'Remove Credentials'; @@ -1107,20 +721,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get selectionAllSelected => 'All tracks selected'; - @override - String get selectionTapToSelect => 'Tap tracks to select'; - - @override - String selectionDeleteTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: 'tracks', - one: 'track', - ); - return 'Delete $count $_temp0'; - } - @override String get selectionSelectToDelete => 'Select tracks to delete'; @@ -1147,40 +747,9 @@ class AppLocalizationsKo extends AppLocalizations { @override String get tooltipPlay => 'Play'; - @override - String get tooltipCancel => 'Cancel'; - - @override - String get tooltipStop => 'Stop'; - - @override - String get tooltipRetry => 'Retry'; - - @override - String get tooltipRemove => 'Remove'; - - @override - String get tooltipClear => 'Clear'; - - @override - String get tooltipPaste => 'Paste'; - @override String get filenameFormat => 'Filename Format'; - @override - String filenameFormatPreview(String preview) { - return 'Preview: $preview'; - } - - @override - String get filenameAvailablePlaceholders => 'Available placeholders:'; - - @override - String filenameHint(Object artist, Object title) { - return '$artist - $title'; - } - @override String get filenameShowAdvancedTags => 'Show advanced tags'; @@ -1188,9 +757,6 @@ class AppLocalizationsKo extends AppLocalizations { String get filenameShowAdvancedTagsDescription => 'Enable formatted tags for track padding and date patterns'; - @override - String get folderOrganization => 'Folder Organization'; - @override String get folderOrganizationNone => 'No organization'; @@ -1225,20 +791,9 @@ class AppLocalizationsKo extends AppLocalizations { @override String get updateAvailable => 'Update Available'; - @override - String updateNewVersion(String version) { - return 'Version $version is available'; - } - - @override - String get updateDownload => 'Download'; - @override String get updateLater => 'Later'; - @override - String get updateChangelog => 'Changelog'; - @override String get updateStartingDownload => 'Starting download...'; @@ -1269,12 +824,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get updateDontRemind => 'Don\'t remind'; - @override - String get providerPriority => 'Provider Priority'; - - @override - String get providerPrioritySubtitle => 'Drag to reorder download providers'; - @override String get providerPriorityTitle => 'Provider Priority'; @@ -1292,13 +841,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get providerExtension => 'Extension'; - @override - String get metadataProviderPriority => 'Metadata Provider Priority'; - - @override - String get metadataProviderPrioritySubtitle => - 'Order used when fetching track metadata'; - @override String get metadataProviderPriorityTitle => 'Metadata Priority'; @@ -1319,18 +861,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get logTitle => 'Logs'; - @override - String get logCopy => 'Copy Logs'; - - @override - String get logClear => 'Clear Logs'; - - @override - String get logShare => 'Share Logs'; - - @override - String get logEmpty => 'No logs yet'; - @override String get logCopied => 'Logs copied to clipboard'; @@ -1355,18 +885,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get logClearLogsMessage => 'Are you sure you want to clear all logs?'; - @override - String get logIspBlocking => 'ISP BLOCKING DETECTED'; - - @override - String get logRateLimited => 'RATE LIMITED'; - - @override - String get logNetworkError => 'NETWORK ERROR'; - - @override - String get logTrackNotFound => 'TRACK NOT FOUND'; - @override String get logFilterBySeverity => 'Filter logs by severity'; @@ -1376,48 +894,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get logNoLogsYetSubtitle => 'Logs will appear here as you use the app'; - @override - String get logIssueSummary => 'Issue Summary'; - - @override - String get logIspBlockingDescription => - 'Your ISP may be blocking access to download services'; - - @override - String get logIspBlockingSuggestion => - 'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8'; - - @override - String get logRateLimitedDescription => 'Too many requests to the service'; - - @override - String get logRateLimitedSuggestion => - 'Wait a few minutes before trying again'; - - @override - String get logNetworkErrorDescription => 'Connection issues detected'; - - @override - String get logNetworkErrorSuggestion => 'Check your internet connection'; - - @override - String get logTrackNotFoundDescription => - 'Some tracks could not be found on download services'; - - @override - String get logTrackNotFoundSuggestion => - 'The track may not be available in lossless quality'; - - @override - String logTotalErrors(int count) { - return 'Total errors: $count'; - } - - @override - String logAffected(String domains) { - return 'Affected: $domains'; - } - @override String logEntriesFiltered(int count) { return 'Entries ($count filtered)'; @@ -1524,9 +1000,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get appearanceLanguage => 'App Language'; - @override - String get appearanceLanguageSubtitle => 'Choose your preferred language'; - @override String get settingsAppearanceSubtitle => 'Theme, colors, display'; @@ -1548,9 +1021,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get pressBackAgainToExit => 'Press back again to exit'; - @override - String get tracksHeader => 'Tracks'; - @override String downloadAllCount(int count) { return 'Download All ($count)'; @@ -1664,11 +1134,6 @@ class AppLocalizationsKo extends AppLocalizations { String get trackDeleteConfirmMessage => 'This will permanently delete the downloaded file and remove it from your history.'; - @override - String trackCannotOpen(String message) { - return 'Cannot open: $message'; - } - @override String get dateToday => 'Today'; @@ -1690,18 +1155,6 @@ class AppLocalizationsKo extends AppLocalizations { return '$count months ago'; } - @override - String get concurrentSequential => 'Sequential'; - - @override - String get concurrentParallel2 => '2 Parallel'; - - @override - String get concurrentParallel3 => '3 Parallel'; - - @override - String get tapToSeeError => 'Tap to see error details'; - @override String get storeFilterAll => 'All'; @@ -1723,15 +1176,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get storeClearFilters => 'Clear filters'; - @override - String get storeNoResults => 'No extensions found'; - - @override - String get extensionProviderPriority => 'Provider Priority'; - - @override - String get extensionInstallButton => 'Install Extension'; - @override String get extensionDefaultProvider => 'Default (Deezer/Spotify)'; @@ -1882,38 +1326,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; - @override - String get qualityLossy => 'Lossy'; - - @override - String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)'; - - @override - String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)'; - - @override - String get enableLossyOption => 'Enable Lossy Option'; - - @override - String get enableLossyOptionSubtitleOn => 'Lossy quality option is available'; - - @override - String get enableLossyOptionSubtitleOff => - 'Downloads FLAC then converts to lossy format'; - - @override - String get lossyFormat => 'Lossy Format'; - - @override - String get lossyFormatDescription => 'Choose the lossy format for conversion'; - - @override - String get lossyFormatMp3Subtitle => '320kbps, best compatibility'; - - @override - String get lossyFormatOpusSubtitle => - '128kbps, better quality at smaller size'; - @override String get qualityNote => 'Actual quality depends on track availability from the service'; @@ -1922,6 +1334,12 @@ class AppLocalizationsKo extends AppLocalizations { String get youtubeQualityNote => 'YouTube provides lossy audio only. Not part of lossless fallback.'; + @override + String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; + + @override + String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; + @override String get downloadAskBeforeDownload => 'Ask Before Download'; @@ -1937,14 +1355,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders'; - @override - String get downloadUseAlbumArtistForFoldersAlbumSubtitle => - 'Artist folders use Album Artist when available'; - - @override - String get downloadUseAlbumArtistForFoldersTrackSubtitle => - 'Artist folders use Track Artist only'; - @override String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders'; @@ -1956,78 +1366,18 @@ class AppLocalizationsKo extends AppLocalizations { String get downloadUsePrimaryArtistOnlyDisabled => 'Full artist string used for folder name'; - @override - String get downloadSaveFormat => 'Save Format'; - - @override - String get downloadSelectService => 'Select Service'; - @override String get downloadSelectQuality => 'Select Quality'; @override String get downloadFrom => 'Download From'; - @override - String get downloadDefaultQualityLabel => 'Default Quality'; - - @override - String get downloadBestAvailable => 'Best available'; - - @override - String get folderNone => 'None'; - - @override - String get folderNoneSubtitle => 'Save all files directly to download folder'; - - @override - String get folderArtist => 'Artist'; - - @override - String get folderArtistSubtitle => 'Artist Name/filename'; - - @override - String get folderAlbum => 'Album'; - - @override - String get folderAlbumSubtitle => 'Album Name/filename'; - - @override - String get folderArtistAlbum => 'Artist/Album'; - - @override - String get folderArtistAlbumSubtitle => 'Artist Name/Album Name/filename'; - - @override - String get serviceTidal => 'Tidal'; - - @override - String get serviceQobuz => 'Qobuz'; - - @override - String get serviceAmazon => 'Amazon'; - - @override - String get serviceDeezer => 'Deezer'; - - @override - String get serviceSpotify => 'Spotify'; - @override String get appearanceAmoledDark => 'AMOLED Dark'; @override String get appearanceAmoledDarkSubtitle => 'Pure black background'; - @override - String get appearanceChooseAccentColor => 'Choose Accent Color'; - - @override - String get appearanceChooseTheme => 'Theme Mode'; - - @override - String get queueTitle => 'Download Queue'; - @override String get queueClearAll => 'Clear All'; @@ -2035,19 +1385,6 @@ class AppLocalizationsKo extends AppLocalizations { String get queueClearAllMessage => 'Are you sure you want to clear all downloads?'; - @override - String get queueExportFailed => 'Export'; - - @override - String get queueExportFailedSuccess => - 'Failed downloads exported to TXT file'; - - @override - String get queueExportFailedClear => 'Clear Failed'; - - @override - String get queueExportFailedError => 'Failed to export downloads'; - @override String get settingsAutoExportFailed => 'Auto-export failed downloads'; @@ -2068,30 +1405,6 @@ class AppLocalizationsKo extends AppLocalizations { String get settingsDownloadNetworkSubtitle => 'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.'; - @override - String get queueEmpty => 'No downloads in queue'; - - @override - String get queueEmptySubtitle => 'Add tracks from the home screen'; - - @override - String get queueClearCompleted => 'Clear completed'; - - @override - String get queueDownloadFailed => 'Download Failed'; - - @override - String get queueTrackLabel => 'Track:'; - - @override - String get queueArtistLabel => 'Artist:'; - - @override - String get queueErrorLabel => 'Error:'; - - @override - String get queueUnknownError => 'Unknown error'; - @override String get albumFolderArtistAlbum => 'Artist / Album'; @@ -2138,14 +1451,6 @@ class AppLocalizationsKo extends AppLocalizations { return 'Delete $count $_temp0 from this album?\n\nThis will also delete the files from storage.'; } - @override - String get downloadedAlbumTracksHeader => 'Tracks'; - - @override - String downloadedAlbumDownloadedCount(int count) { - return '$count downloaded'; - } - @override String downloadedAlbumSelectedCount(int count) { return '$count selected'; @@ -2176,9 +1481,6 @@ class AppLocalizationsKo extends AppLocalizations { return 'Disc $discNumber'; } - @override - String get utilityFunctions => 'Utility Functions'; - @override String get recentTypeArtist => 'Artist'; @@ -2202,11 +1504,6 @@ class AppLocalizationsKo extends AppLocalizations { return 'Playlist: $name'; } - @override - String errorGeneric(String message) { - return 'Error: $message'; - } - @override String get discographyDownload => 'Download Discography'; @@ -2312,9 +1609,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get libraryTitle => 'Local Library'; - @override - String get libraryStatus => 'Library Status'; - @override String get libraryScanSettings => 'Scan Settings'; @@ -2378,8 +1672,14 @@ class AppLocalizationsKo extends AppLocalizations { 'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.'; @override - String libraryTracksCount(int count) { - return '$count tracks'; + String libraryTracksUnit(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return '$_temp0'; } @override @@ -2461,21 +1761,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get libraryFilterFormat => 'Format'; - @override - String get libraryFilterDate => 'Date Added'; - - @override - String get libraryFilterDateToday => 'Today'; - - @override - String get libraryFilterDateWeek => 'This Week'; - - @override - String get libraryFilterDateMonth => 'This Month'; - - @override - String get libraryFilterDateYear => 'This Year'; - @override String get libraryFilterSort => 'Sort'; @@ -2485,11 +1770,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get libraryFilterSortOldest => 'Oldest'; - @override - String libraryFilterActive(int count) { - return '$count filter(s) active'; - } - @override String get timeJustNow => 'Just now'; @@ -2515,72 +1795,6 @@ class AppLocalizationsKo extends AppLocalizations { return '$_temp0'; } - @override - String get storageSwitchTitle => 'Switch Storage Mode'; - - @override - String get storageSwitchToSafTitle => 'Switch to SAF Storage?'; - - @override - String get storageSwitchToAppTitle => 'Switch to App Storage?'; - - @override - String get storageSwitchToSafMessage => - 'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.'; - - @override - String get storageSwitchToAppMessage => - 'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.'; - - @override - String get storageSwitchExistingDownloads => 'Existing Downloads'; - - @override - String storageSwitchExistingDownloadsInfo(int count, String mode) { - return '$count tracks in $mode storage'; - } - - @override - String get storageSwitchNewDownloads => 'New Downloads'; - - @override - String storageSwitchNewDownloadsLocation(String location) { - return 'Will be saved to: $location'; - } - - @override - String get storageSwitchContinue => 'Continue'; - - @override - String get storageSwitchSelectFolder => 'Select SAF Folder'; - - @override - String get storageAppStorage => 'App Storage'; - - @override - String get storageSafStorage => 'SAF Storage'; - - @override - String storageModeBadge(String mode) { - return 'Storage: $mode'; - } - - @override - String get storageStatsTitle => 'Storage Statistics'; - - @override - String storageStatsAppCount(int count) { - return '$count tracks in App Storage'; - } - - @override - String storageStatsSafCount(int count) { - return '$count tracks in SAF Storage'; - } - - @override - String get storageModeInfo => 'Your files are stored in multiple locations'; - @override String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!'; @@ -2607,18 +1821,6 @@ class AppLocalizationsKo extends AppLocalizations { String get tutorialSearchDesc => 'There are two easy ways to find music you want to download.'; - @override - String get tutorialSearchTip1 => - 'Paste a Spotify or Deezer URL directly in the search box'; - - @override - String get tutorialSearchTip2 => - 'Or type the song name, artist, or album to search'; - - @override - String get tutorialSearchTip3 => - 'Supports tracks, albums, playlists, and artist pages'; - @override String get tutorialDownloadTitle => 'Downloading Music'; @@ -2626,18 +1828,6 @@ class AppLocalizationsKo extends AppLocalizations { String get tutorialDownloadDesc => 'Downloading music is simple and fast. Here\'s how it works.'; - @override - String get tutorialDownloadTip1 => - 'Tap the download button next to any track to start downloading'; - - @override - String get tutorialDownloadTip2 => - 'Choose your preferred quality (FLAC, Hi-Res, or MP3)'; - - @override - String get tutorialDownloadTip3 => - 'Download entire albums or playlists with one tap'; - @override String get tutorialLibraryTitle => 'Your Library'; @@ -2698,9 +1888,6 @@ class AppLocalizationsKo extends AppLocalizations { String get tutorialReadyMessage => 'You\'re all set! Start downloading your favorite music now.'; - @override - String get tutorialExample => 'EXAMPLE'; - @override String get libraryForceFullScan => 'Force Full Scan'; @@ -2861,11 +2048,7 @@ class AppLocalizationsKo extends AppLocalizations { String get trackSaveLyricsProgress => 'Saving lyrics...'; @override - String get trackReEnrich => 'Re-enrich Metadata'; - - @override - String get trackReEnrichSubtitle => - 'Re-embed metadata without re-downloading'; + String get trackReEnrich => 'Re-enrich'; @override String get trackReEnrichOnlineSubtitle => @@ -2941,4 +2124,221 @@ class AppLocalizationsKo extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + + @override + String get actionCreate => 'Create'; + + @override + String get collectionFoldersTitle => 'My folders'; + + @override + String get collectionWishlist => 'Wishlist'; + + @override + String get collectionLoved => 'Loved'; + + @override + String get collectionPlaylists => 'Playlists'; + + @override + String get collectionPlaylist => 'Playlist'; + + @override + String get collectionAddToPlaylist => 'Add to playlist'; + + @override + String get collectionCreatePlaylist => 'Create playlist'; + + @override + String get collectionNoPlaylistsYet => 'No playlists yet'; + + @override + String get collectionNoPlaylistsSubtitle => + 'Create a playlist to start categorizing tracks'; + + @override + String collectionPlaylistTracks(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String collectionAddedToPlaylist(String playlistName) { + return 'Added to \"$playlistName\"'; + } + + @override + String collectionAlreadyInPlaylist(String playlistName) { + return 'Already in \"$playlistName\"'; + } + + @override + String get collectionPlaylistCreated => 'Playlist created'; + + @override + String get collectionPlaylistNameHint => 'Playlist name'; + + @override + String get collectionPlaylistNameRequired => 'Playlist name is required'; + + @override + String get collectionRenamePlaylist => 'Rename playlist'; + + @override + String get collectionDeletePlaylist => 'Delete playlist'; + + @override + String collectionDeletePlaylistMessage(String playlistName) { + return 'Delete \"$playlistName\" and all tracks inside it?'; + } + + @override + String get collectionPlaylistDeleted => 'Playlist deleted'; + + @override + String get collectionPlaylistRenamed => 'Playlist renamed'; + + @override + String get collectionWishlistEmptyTitle => 'Wishlist is empty'; + + @override + String get collectionWishlistEmptySubtitle => + 'Tap + on tracks to save what you want to download later'; + + @override + String get collectionLovedEmptyTitle => 'Loved folder is empty'; + + @override + String get collectionLovedEmptySubtitle => + 'Tap love on tracks to keep your favorites'; + + @override + String get collectionPlaylistEmptyTitle => 'Playlist is empty'; + + @override + String get collectionPlaylistEmptySubtitle => + 'Long-press + on any track to add it here'; + + @override + String get collectionRemoveFromPlaylist => 'Remove from playlist'; + + @override + String get collectionRemoveFromFolder => 'Remove from folder'; + + @override + String collectionRemoved(String trackName) { + return '\"$trackName\" removed'; + } + + @override + String collectionAddedToLoved(String trackName) { + return '\"$trackName\" added to Loved'; + } + + @override + String collectionRemovedFromLoved(String trackName) { + return '\"$trackName\" removed from Loved'; + } + + @override + String collectionAddedToWishlist(String trackName) { + return '\"$trackName\" added to Wishlist'; + } + + @override + String collectionRemovedFromWishlist(String trackName) { + return '\"$trackName\" removed from Wishlist'; + } + + @override + String get trackOptionAddToLoved => 'Add to Loved'; + + @override + String get trackOptionRemoveFromLoved => 'Remove from Loved'; + + @override + String get trackOptionAddToWishlist => 'Add to Wishlist'; + + @override + String get trackOptionRemoveFromWishlist => 'Remove from Wishlist'; + + @override + String get collectionPlaylistChangeCover => 'Change cover image'; + + @override + String get collectionPlaylistRemoveCover => 'Remove cover image'; + + @override + String selectionShareCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Share $count $_temp0'; + } + + @override + String get selectionShareNoFiles => 'No shareable files found'; + + @override + String selectionConvertCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0'; + } + + @override + String get selectionConvertNoConvertible => 'No convertible tracks selected'; + + @override + String get selectionBatchConvertConfirmTitle => 'Batch Convert'; + + @override + String selectionBatchConvertConfirmMessage( + int count, + String format, + String bitrate, + ) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.'; + } + + @override + String selectionBatchConvertProgress(int current, int total) { + return 'Converting $current of $total...'; + } + + @override + String selectionBatchConvertSuccess(int success, int total, String format) { + return 'Converted $success of $total tracks to $format'; + } + + @override + String downloadedAlbumDownloadedCount(int count) { + return '$count downloaded'; + } + + @override + String get downloadUseAlbumArtistForFoldersAlbumSubtitle => + 'Artist folders use Album Artist when available'; + + @override + String get downloadUseAlbumArtistForFoldersTrackSubtitle => + 'Artist folders use Track Artist only'; } diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index b0bd7b60..be1be644 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -11,19 +11,12 @@ class AppLocalizationsNl extends AppLocalizations { @override String get appName => 'SpotiFLAC'; - @override - String get appDescription => - 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; - @override String get navHome => 'Home'; @override String get navLibrary => 'Library'; - @override - String get navHistory => 'History'; - @override String get navSettings => 'Settings'; @@ -33,14 +26,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get homeTitle => 'Home'; - @override - String get homeSearchHint => 'Paste Spotify URL or search...'; - - @override - String homeSearchHintExtension(String extensionName) { - return 'Search with $extensionName...'; - } - @override String get homeSubtitle => 'Paste a Spotify link or search by name'; @@ -50,17 +35,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get homeRecent => 'Recent'; - @override - String get historyTitle => 'History'; - - @override - String historyDownloading(int count) { - return 'Downloading ($count)'; - } - - @override - String get historyDownloaded => 'Downloaded'; - @override String get historyFilterAll => 'All'; @@ -70,48 +44,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get historyFilterSingles => 'Singles'; - @override - String historyTracksCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count tracks', - one: '1 track', - ); - return '$_temp0'; - } - - @override - String historyAlbumsCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count albums', - one: '1 album', - ); - return '$_temp0'; - } - - @override - String get historyNoDownloads => 'No download history'; - - @override - String get historyNoDownloadsSubtitle => 'Downloaded tracks will appear here'; - - @override - String get historyNoAlbums => 'No album downloads'; - - @override - String get historyNoAlbumsSubtitle => - 'Download multiple tracks from an album to see them here'; - - @override - String get historyNoSingles => 'No single downloads'; - - @override - String get historyNoSinglesSubtitle => - 'Single track downloads will appear here'; - @override String get historySearchHint => 'Search history...'; @@ -136,27 +68,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get downloadTitle => 'Download'; - @override - String get downloadLocation => 'Download Location'; - - @override - String get downloadLocationSubtitle => 'Choose where to save files'; - - @override - String get downloadLocationDefault => 'Default location'; - - @override - String get downloadDefaultService => 'Default Service'; - - @override - String get downloadDefaultServiceSubtitle => 'Service used for downloads'; - - @override - String get downloadDefaultQuality => 'Default Quality'; - - @override - String get downloadAskQuality => 'Ask Quality Before Download'; - @override String get downloadAskQualitySubtitle => 'Show quality picker for each download'; @@ -167,31 +78,9 @@ class AppLocalizationsNl extends AppLocalizations { @override String get downloadFolderOrganization => 'Folder Organization'; - @override - String get downloadSeparateSingles => 'Separate Singles'; - - @override - String get downloadSeparateSinglesSubtitle => - 'Put single tracks in a separate folder'; - - @override - String get qualityBest => 'Best Available'; - - @override - String get qualityFlac => 'FLAC'; - - @override - String get quality320 => '320 kbps'; - - @override - String get quality128 => '128 kbps'; - @override String get appearanceTitle => 'Appearance'; - @override - String get appearanceTheme => 'Theme'; - @override String get appearanceThemeSystem => 'System'; @@ -207,9 +96,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get appearanceDynamicColorSubtitle => 'Use colors from your wallpaper'; - @override - String get appearanceAccentColor => 'Accent Color'; - @override String get appearanceHistoryView => 'History View'; @@ -222,9 +108,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get optionsTitle => 'Options'; - @override - String get optionsSearchSource => 'Search Source'; - @override String get optionsPrimaryProvider => 'Primary Provider'; @@ -350,18 +233,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get extensionsTitle => 'Extensions'; - @override - String get extensionsInstalled => 'Installed Extensions'; - - @override - String get extensionsNone => 'No extensions installed'; - - @override - String get extensionsNoneSubtitle => 'Install extensions from the Store tab'; - - @override - String get extensionsEnabled => 'Enabled'; - @override String get extensionsDisabled => 'Disabled'; @@ -378,9 +249,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get extensionsUninstall => 'Uninstall'; - @override - String get extensionsSetAsSearch => 'Set as Search Provider'; - @override String get storeTitle => 'Extension Store'; @@ -454,9 +322,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get aboutSocial => 'Social'; - @override - String get aboutSupport => 'Support'; - @override String get aboutApp => 'App'; @@ -475,13 +340,6 @@ class AppLocalizationsNl extends AppLocalizations { String get aboutSjdonadoDesc => 'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!'; - @override - String get aboutDoubleDouble => 'DoubleDouble'; - - @override - String get aboutDoubleDoubleDesc => - 'Amazing API for Amazon Music downloads. Thank you for making it free!'; - @override String get aboutDabMusic => 'DAB Music'; @@ -500,32 +358,6 @@ class AppLocalizationsNl extends AppLocalizations { String get aboutAppDescription => 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; - @override - String get albumTitle => 'Album'; - - @override - String albumTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count tracks', - one: '1 track', - ); - return '$_temp0'; - } - - @override - String get albumDownloadAll => 'Download All'; - - @override - String get albumDownloadRemaining => 'Download Remaining'; - - @override - String get playlistTitle => 'Playlist'; - - @override - String get artistTitle => 'Artist'; - @override String get artistAlbums => 'Albums'; @@ -535,17 +367,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get artistCompilations => 'Compilations'; - @override - String artistReleases(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count releases', - one: '1 release', - ); - return '$_temp0'; - } - @override String get artistPopular => 'Popular'; @@ -554,27 +375,6 @@ class AppLocalizationsNl extends AppLocalizations { return '$count monthly listeners'; } - @override - String get trackMetadataTitle => 'Track Info'; - - @override - String get trackMetadataArtist => 'Artist'; - - @override - String get trackMetadataAlbum => 'Album'; - - @override - String get trackMetadataDuration => 'Duration'; - - @override - String get trackMetadataQuality => 'Quality'; - - @override - String get trackMetadataPath => 'File Path'; - - @override - String get trackMetadataDownloadedAt => 'Downloaded'; - @override String get trackMetadataService => 'Service'; @@ -587,53 +387,15 @@ class AppLocalizationsNl extends AppLocalizations { @override String get trackMetadataDelete => 'Delete'; - @override - String get trackMetadataRedownload => 'Re-download'; - - @override - String get trackMetadataOpenFolder => 'Open Folder'; - - @override - String get setupTitle => 'Welcome to SpotiFLAC'; - - @override - String get setupSubtitle => 'Let\'s get you started'; - - @override - String get setupStoragePermission => 'Storage Permission'; - - @override - String get setupStoragePermissionSubtitle => - 'Required to save downloaded files'; - - @override - String get setupStoragePermissionGranted => 'Permission granted'; - - @override - String get setupStoragePermissionDenied => 'Permission denied'; - @override String get setupGrantPermission => 'Grant Permission'; - @override - String get setupDownloadLocation => 'Download Location'; - - @override - String get setupChooseFolder => 'Choose Folder'; - - @override - String get setupContinue => 'Continue'; - @override String get setupSkip => 'Skip for now'; @override String get setupStorageAccessRequired => 'Storage Access Required'; - @override - String get setupStorageAccessMessage => - 'SpotiFLAC needs \"All files access\" permission to save music files to your chosen folder.'; - @override String get setupStorageAccessMessageAndroid11 => 'Android 11+ requires \"All files access\" permission to save files to your chosen download folder.'; @@ -655,9 +417,6 @@ class AppLocalizationsNl extends AppLocalizations { return '$permissionType permission is required for the best experience. You can change this later in Settings.'; } - @override - String get setupSelectDownloadFolder => 'Select Download Folder'; - @override String get setupUseDefaultFolder => 'Use Default Folder?'; @@ -699,21 +458,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get setupDownloadInFlac => 'Download Spotify tracks in FLAC'; - @override - String get setupStepStorage => 'Storage'; - - @override - String get setupStepNotification => 'Notification'; - - @override - String get setupStepFolder => 'Folder'; - - @override - String get setupStepSpotify => 'Spotify'; - - @override - String get setupStepPermission => 'Permission'; - @override String get setupStorageGranted => 'Storage Permission Granted!'; @@ -730,13 +474,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get setupNotificationEnable => 'Enable Notifications'; - @override - String get setupNotificationDescription => - 'Get notified when downloads complete or require attention.'; - - @override - String get setupFolderSelected => 'Download Folder Selected!'; - @override String get setupFolderChoose => 'Choose Download Folder'; @@ -744,48 +481,12 @@ class AppLocalizationsNl extends AppLocalizations { String get setupFolderDescription => 'Select a folder where your downloaded music will be saved.'; - @override - String get setupChangeFolder => 'Change Folder'; - @override String get setupSelectFolder => 'Select Folder'; - @override - String get setupSpotifyApiOptional => 'Spotify API (Optional)'; - - @override - String get setupSpotifyApiDescription => - 'Add your Spotify API credentials for better search results and access to Spotify-exclusive content.'; - - @override - String get setupUseSpotifyApi => 'Use Spotify API'; - - @override - String get setupEnterCredentialsBelow => 'Enter your credentials below'; - - @override - String get setupUsingDeezer => 'Using Deezer (no account needed)'; - - @override - String get setupEnterClientId => 'Enter Spotify Client ID'; - - @override - String get setupEnterClientSecret => 'Enter Spotify Client Secret'; - - @override - String get setupGetFreeCredentials => - 'Get your free API credentials from the Spotify Developer Dashboard.'; - @override String get setupEnableNotifications => 'Enable Notifications'; - @override - String get setupProceedToNextStep => 'You can now proceed to the next step.'; - - @override - String get setupNotificationProgressDescription => - 'You will receive download progress notifications.'; - @override String get setupNotificationBackgroundDescription => 'Get notified about download progress and completion. This helps you track downloads when the app is in background.'; @@ -793,32 +494,19 @@ class AppLocalizationsNl extends AppLocalizations { @override String get setupSkipForNow => 'Skip for now'; - @override - String get setupBack => 'Back'; - @override String get setupNext => 'Next'; @override String get setupGetStarted => 'Get Started'; - @override - String get setupSkipAndStart => 'Skip & Start'; - @override String get setupAllowAccessToManageFiles => 'Please enable \"Allow access to manage all files\" in the next screen.'; - @override - String get setupGetCredentialsFromSpotify => - 'Get credentials from developer.spotify.com'; - @override String get dialogCancel => 'Cancel'; - @override - String get dialogOk => 'OK'; - @override String get dialogSave => 'Save'; @@ -828,21 +516,9 @@ class AppLocalizationsNl extends AppLocalizations { @override String get dialogRetry => 'Retry'; - @override - String get dialogClose => 'Close'; - - @override - String get dialogYes => 'Yes'; - - @override - String get dialogNo => 'No'; - @override String get dialogClear => 'Clear'; - @override - String get dialogConfirm => 'Confirm'; - @override String get dialogDone => 'Done'; @@ -865,28 +541,9 @@ class AppLocalizationsNl extends AppLocalizations { String get dialogUnsavedChanges => 'You have unsaved changes. Do you want to discard them?'; - @override - String get dialogDownloadFailed => 'Download Failed'; - - @override - String get dialogTrackLabel => 'Track:'; - - @override - String get dialogArtistLabel => 'Artist:'; - - @override - String get dialogErrorLabel => 'Error:'; - @override String get dialogClearAll => 'Clear All'; - @override - String get dialogClearAllDownloads => - 'Are you sure you want to clear all downloads?'; - - @override - String get dialogRemoveFromDevice => 'Remove from device?'; - @override String get dialogRemoveExtension => 'Remove Extension'; @@ -987,11 +644,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get snackbarViewQueue => 'View Queue'; - @override - String snackbarFailedToLoad(String error) { - return 'Failed to load: $error'; - } - @override String snackbarUrlCopied(String platform) { return '$platform URL copied to clipboard'; @@ -1033,11 +685,6 @@ class AppLocalizationsNl extends AppLocalizations { String get errorRateLimitedMessage => 'Too many requests. Please wait a moment before searching again.'; - @override - String errorFailedToLoad(String item) { - return 'Failed to load $item'; - } - @override String get errorNoTracksFound => 'No tracks found'; @@ -1046,27 +693,6 @@ class AppLocalizationsNl extends AppLocalizations { return 'Cannot load $item: missing extension source'; } - @override - String get statusQueued => 'Queued'; - - @override - String get statusDownloading => 'Downloading'; - - @override - String get statusFinalizing => 'Finalizing'; - - @override - String get statusCompleted => 'Completed'; - - @override - String get statusFailed => 'Failed'; - - @override - String get statusSkipped => 'Skipped'; - - @override - String get statusPaused => 'Paused'; - @override String get actionPause => 'Pause'; @@ -1076,24 +702,12 @@ class AppLocalizationsNl extends AppLocalizations { @override String get actionCancel => 'Cancel'; - @override - String get actionStop => 'Stop'; - - @override - String get actionSelect => 'Select'; - @override String get actionSelectAll => 'Select All'; @override String get actionDeselect => 'Deselect'; - @override - String get actionPaste => 'Paste'; - - @override - String get actionImportCsv => 'Import CSV'; - @override String get actionRemoveCredentials => 'Remove Credentials'; @@ -1108,20 +722,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get selectionAllSelected => 'All tracks selected'; - @override - String get selectionTapToSelect => 'Tap tracks to select'; - - @override - String selectionDeleteTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: 'tracks', - one: 'track', - ); - return 'Delete $count $_temp0'; - } - @override String get selectionSelectToDelete => 'Select tracks to delete'; @@ -1148,40 +748,9 @@ class AppLocalizationsNl extends AppLocalizations { @override String get tooltipPlay => 'Play'; - @override - String get tooltipCancel => 'Cancel'; - - @override - String get tooltipStop => 'Stop'; - - @override - String get tooltipRetry => 'Retry'; - - @override - String get tooltipRemove => 'Remove'; - - @override - String get tooltipClear => 'Clear'; - - @override - String get tooltipPaste => 'Paste'; - @override String get filenameFormat => 'Filename Format'; - @override - String filenameFormatPreview(String preview) { - return 'Preview: $preview'; - } - - @override - String get filenameAvailablePlaceholders => 'Available placeholders:'; - - @override - String filenameHint(Object artist, Object title) { - return '$artist - $title'; - } - @override String get filenameShowAdvancedTags => 'Show advanced tags'; @@ -1189,9 +758,6 @@ class AppLocalizationsNl extends AppLocalizations { String get filenameShowAdvancedTagsDescription => 'Enable formatted tags for track padding and date patterns'; - @override - String get folderOrganization => 'Folder Organization'; - @override String get folderOrganizationNone => 'No organization'; @@ -1226,20 +792,9 @@ class AppLocalizationsNl extends AppLocalizations { @override String get updateAvailable => 'Update Available'; - @override - String updateNewVersion(String version) { - return 'Version $version is available'; - } - - @override - String get updateDownload => 'Download'; - @override String get updateLater => 'Later'; - @override - String get updateChangelog => 'Changelog'; - @override String get updateStartingDownload => 'Starting download...'; @@ -1270,12 +825,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get updateDontRemind => 'Don\'t remind'; - @override - String get providerPriority => 'Provider Priority'; - - @override - String get providerPrioritySubtitle => 'Drag to reorder download providers'; - @override String get providerPriorityTitle => 'Provider Priority'; @@ -1293,13 +842,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get providerExtension => 'Extension'; - @override - String get metadataProviderPriority => 'Metadata Provider Priority'; - - @override - String get metadataProviderPrioritySubtitle => - 'Order used when fetching track metadata'; - @override String get metadataProviderPriorityTitle => 'Metadata Priority'; @@ -1320,18 +862,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get logTitle => 'Logs'; - @override - String get logCopy => 'Copy Logs'; - - @override - String get logClear => 'Clear Logs'; - - @override - String get logShare => 'Share Logs'; - - @override - String get logEmpty => 'No logs yet'; - @override String get logCopied => 'Logs copied to clipboard'; @@ -1356,18 +886,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get logClearLogsMessage => 'Are you sure you want to clear all logs?'; - @override - String get logIspBlocking => 'ISP BLOCKING DETECTED'; - - @override - String get logRateLimited => 'RATE LIMITED'; - - @override - String get logNetworkError => 'NETWORK ERROR'; - - @override - String get logTrackNotFound => 'TRACK NOT FOUND'; - @override String get logFilterBySeverity => 'Filter logs by severity'; @@ -1377,48 +895,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get logNoLogsYetSubtitle => 'Logs will appear here as you use the app'; - @override - String get logIssueSummary => 'Issue Summary'; - - @override - String get logIspBlockingDescription => - 'Your ISP may be blocking access to download services'; - - @override - String get logIspBlockingSuggestion => - 'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8'; - - @override - String get logRateLimitedDescription => 'Too many requests to the service'; - - @override - String get logRateLimitedSuggestion => - 'Wait a few minutes before trying again'; - - @override - String get logNetworkErrorDescription => 'Connection issues detected'; - - @override - String get logNetworkErrorSuggestion => 'Check your internet connection'; - - @override - String get logTrackNotFoundDescription => - 'Some tracks could not be found on download services'; - - @override - String get logTrackNotFoundSuggestion => - 'The track may not be available in lossless quality'; - - @override - String logTotalErrors(int count) { - return 'Total errors: $count'; - } - - @override - String logAffected(String domains) { - return 'Affected: $domains'; - } - @override String logEntriesFiltered(int count) { return 'Entries ($count filtered)'; @@ -1525,9 +1001,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get appearanceLanguage => 'App Language'; - @override - String get appearanceLanguageSubtitle => 'Choose your preferred language'; - @override String get settingsAppearanceSubtitle => 'Theme, colors, display'; @@ -1549,9 +1022,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get pressBackAgainToExit => 'Press back again to exit'; - @override - String get tracksHeader => 'Tracks'; - @override String downloadAllCount(int count) { return 'Download All ($count)'; @@ -1665,11 +1135,6 @@ class AppLocalizationsNl extends AppLocalizations { String get trackDeleteConfirmMessage => 'This will permanently delete the downloaded file and remove it from your history.'; - @override - String trackCannotOpen(String message) { - return 'Cannot open: $message'; - } - @override String get dateToday => 'Today'; @@ -1691,18 +1156,6 @@ class AppLocalizationsNl extends AppLocalizations { return '$count months ago'; } - @override - String get concurrentSequential => 'Sequential'; - - @override - String get concurrentParallel2 => '2 Parallel'; - - @override - String get concurrentParallel3 => '3 Parallel'; - - @override - String get tapToSeeError => 'Tap to see error details'; - @override String get storeFilterAll => 'All'; @@ -1724,15 +1177,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get storeClearFilters => 'Clear filters'; - @override - String get storeNoResults => 'No extensions found'; - - @override - String get extensionProviderPriority => 'Provider Priority'; - - @override - String get extensionInstallButton => 'Install Extension'; - @override String get extensionDefaultProvider => 'Default (Deezer/Spotify)'; @@ -1883,38 +1327,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; - @override - String get qualityLossy => 'Lossy'; - - @override - String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)'; - - @override - String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)'; - - @override - String get enableLossyOption => 'Enable Lossy Option'; - - @override - String get enableLossyOptionSubtitleOn => 'Lossy quality option is available'; - - @override - String get enableLossyOptionSubtitleOff => - 'Downloads FLAC then converts to lossy format'; - - @override - String get lossyFormat => 'Lossy Format'; - - @override - String get lossyFormatDescription => 'Choose the lossy format for conversion'; - - @override - String get lossyFormatMp3Subtitle => '320kbps, best compatibility'; - - @override - String get lossyFormatOpusSubtitle => - '128kbps, better quality at smaller size'; - @override String get qualityNote => 'Actual quality depends on track availability from the service'; @@ -1923,6 +1335,12 @@ class AppLocalizationsNl extends AppLocalizations { String get youtubeQualityNote => 'YouTube provides lossy audio only. Not part of lossless fallback.'; + @override + String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; + + @override + String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; + @override String get downloadAskBeforeDownload => 'Ask Before Download'; @@ -1938,14 +1356,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders'; - @override - String get downloadUseAlbumArtistForFoldersAlbumSubtitle => - 'Artist folders use Album Artist when available'; - - @override - String get downloadUseAlbumArtistForFoldersTrackSubtitle => - 'Artist folders use Track Artist only'; - @override String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders'; @@ -1957,78 +1367,18 @@ class AppLocalizationsNl extends AppLocalizations { String get downloadUsePrimaryArtistOnlyDisabled => 'Full artist string used for folder name'; - @override - String get downloadSaveFormat => 'Save Format'; - - @override - String get downloadSelectService => 'Select Service'; - @override String get downloadSelectQuality => 'Select Quality'; @override String get downloadFrom => 'Download From'; - @override - String get downloadDefaultQualityLabel => 'Default Quality'; - - @override - String get downloadBestAvailable => 'Best available'; - - @override - String get folderNone => 'None'; - - @override - String get folderNoneSubtitle => 'Save all files directly to download folder'; - - @override - String get folderArtist => 'Artist'; - - @override - String get folderArtistSubtitle => 'Artist Name/filename'; - - @override - String get folderAlbum => 'Album'; - - @override - String get folderAlbumSubtitle => 'Album Name/filename'; - - @override - String get folderArtistAlbum => 'Artist/Album'; - - @override - String get folderArtistAlbumSubtitle => 'Artist Name/Album Name/filename'; - - @override - String get serviceTidal => 'Tidal'; - - @override - String get serviceQobuz => 'Qobuz'; - - @override - String get serviceAmazon => 'Amazon'; - - @override - String get serviceDeezer => 'Deezer'; - - @override - String get serviceSpotify => 'Spotify'; - @override String get appearanceAmoledDark => 'AMOLED Dark'; @override String get appearanceAmoledDarkSubtitle => 'Pure black background'; - @override - String get appearanceChooseAccentColor => 'Choose Accent Color'; - - @override - String get appearanceChooseTheme => 'Theme Mode'; - - @override - String get queueTitle => 'Download Queue'; - @override String get queueClearAll => 'Clear All'; @@ -2036,19 +1386,6 @@ class AppLocalizationsNl extends AppLocalizations { String get queueClearAllMessage => 'Are you sure you want to clear all downloads?'; - @override - String get queueExportFailed => 'Export'; - - @override - String get queueExportFailedSuccess => - 'Failed downloads exported to TXT file'; - - @override - String get queueExportFailedClear => 'Clear Failed'; - - @override - String get queueExportFailedError => 'Failed to export downloads'; - @override String get settingsAutoExportFailed => 'Auto-export failed downloads'; @@ -2069,30 +1406,6 @@ class AppLocalizationsNl extends AppLocalizations { String get settingsDownloadNetworkSubtitle => 'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.'; - @override - String get queueEmpty => 'No downloads in queue'; - - @override - String get queueEmptySubtitle => 'Add tracks from the home screen'; - - @override - String get queueClearCompleted => 'Clear completed'; - - @override - String get queueDownloadFailed => 'Download Failed'; - - @override - String get queueTrackLabel => 'Track:'; - - @override - String get queueArtistLabel => 'Artist:'; - - @override - String get queueErrorLabel => 'Error:'; - - @override - String get queueUnknownError => 'Unknown error'; - @override String get albumFolderArtistAlbum => 'Artist / Album'; @@ -2139,14 +1452,6 @@ class AppLocalizationsNl extends AppLocalizations { return 'Delete $count $_temp0 from this album?\n\nThis will also delete the files from storage.'; } - @override - String get downloadedAlbumTracksHeader => 'Tracks'; - - @override - String downloadedAlbumDownloadedCount(int count) { - return '$count downloaded'; - } - @override String downloadedAlbumSelectedCount(int count) { return '$count selected'; @@ -2177,9 +1482,6 @@ class AppLocalizationsNl extends AppLocalizations { return 'Disc $discNumber'; } - @override - String get utilityFunctions => 'Utility Functions'; - @override String get recentTypeArtist => 'Artist'; @@ -2203,11 +1505,6 @@ class AppLocalizationsNl extends AppLocalizations { return 'Playlist: $name'; } - @override - String errorGeneric(String message) { - return 'Error: $message'; - } - @override String get discographyDownload => 'Download Discography'; @@ -2313,9 +1610,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get libraryTitle => 'Local Library'; - @override - String get libraryStatus => 'Library Status'; - @override String get libraryScanSettings => 'Scan Settings'; @@ -2379,8 +1673,14 @@ class AppLocalizationsNl extends AppLocalizations { 'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.'; @override - String libraryTracksCount(int count) { - return '$count tracks'; + String libraryTracksUnit(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return '$_temp0'; } @override @@ -2462,21 +1762,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get libraryFilterFormat => 'Format'; - @override - String get libraryFilterDate => 'Date Added'; - - @override - String get libraryFilterDateToday => 'Today'; - - @override - String get libraryFilterDateWeek => 'This Week'; - - @override - String get libraryFilterDateMonth => 'This Month'; - - @override - String get libraryFilterDateYear => 'This Year'; - @override String get libraryFilterSort => 'Sort'; @@ -2486,11 +1771,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get libraryFilterSortOldest => 'Oldest'; - @override - String libraryFilterActive(int count) { - return '$count filter(s) active'; - } - @override String get timeJustNow => 'Just now'; @@ -2516,72 +1796,6 @@ class AppLocalizationsNl extends AppLocalizations { return '$_temp0'; } - @override - String get storageSwitchTitle => 'Switch Storage Mode'; - - @override - String get storageSwitchToSafTitle => 'Switch to SAF Storage?'; - - @override - String get storageSwitchToAppTitle => 'Switch to App Storage?'; - - @override - String get storageSwitchToSafMessage => - 'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.'; - - @override - String get storageSwitchToAppMessage => - 'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.'; - - @override - String get storageSwitchExistingDownloads => 'Existing Downloads'; - - @override - String storageSwitchExistingDownloadsInfo(int count, String mode) { - return '$count tracks in $mode storage'; - } - - @override - String get storageSwitchNewDownloads => 'New Downloads'; - - @override - String storageSwitchNewDownloadsLocation(String location) { - return 'Will be saved to: $location'; - } - - @override - String get storageSwitchContinue => 'Continue'; - - @override - String get storageSwitchSelectFolder => 'Select SAF Folder'; - - @override - String get storageAppStorage => 'App Storage'; - - @override - String get storageSafStorage => 'SAF Storage'; - - @override - String storageModeBadge(String mode) { - return 'Storage: $mode'; - } - - @override - String get storageStatsTitle => 'Storage Statistics'; - - @override - String storageStatsAppCount(int count) { - return '$count tracks in App Storage'; - } - - @override - String storageStatsSafCount(int count) { - return '$count tracks in SAF Storage'; - } - - @override - String get storageModeInfo => 'Your files are stored in multiple locations'; - @override String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!'; @@ -2608,18 +1822,6 @@ class AppLocalizationsNl extends AppLocalizations { String get tutorialSearchDesc => 'There are two easy ways to find music you want to download.'; - @override - String get tutorialSearchTip1 => - 'Paste a Spotify or Deezer URL directly in the search box'; - - @override - String get tutorialSearchTip2 => - 'Or type the song name, artist, or album to search'; - - @override - String get tutorialSearchTip3 => - 'Supports tracks, albums, playlists, and artist pages'; - @override String get tutorialDownloadTitle => 'Downloading Music'; @@ -2627,18 +1829,6 @@ class AppLocalizationsNl extends AppLocalizations { String get tutorialDownloadDesc => 'Downloading music is simple and fast. Here\'s how it works.'; - @override - String get tutorialDownloadTip1 => - 'Tap the download button next to any track to start downloading'; - - @override - String get tutorialDownloadTip2 => - 'Choose your preferred quality (FLAC, Hi-Res, or MP3)'; - - @override - String get tutorialDownloadTip3 => - 'Download entire albums or playlists with one tap'; - @override String get tutorialLibraryTitle => 'Your Library'; @@ -2699,9 +1889,6 @@ class AppLocalizationsNl extends AppLocalizations { String get tutorialReadyMessage => 'You\'re all set! Start downloading your favorite music now.'; - @override - String get tutorialExample => 'EXAMPLE'; - @override String get libraryForceFullScan => 'Force Full Scan'; @@ -2862,11 +2049,7 @@ class AppLocalizationsNl extends AppLocalizations { String get trackSaveLyricsProgress => 'Saving lyrics...'; @override - String get trackReEnrich => 'Re-enrich Metadata'; - - @override - String get trackReEnrichSubtitle => - 'Re-embed metadata without re-downloading'; + String get trackReEnrich => 'Re-enrich'; @override String get trackReEnrichOnlineSubtitle => @@ -2942,4 +2125,221 @@ class AppLocalizationsNl extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + + @override + String get actionCreate => 'Create'; + + @override + String get collectionFoldersTitle => 'My folders'; + + @override + String get collectionWishlist => 'Wishlist'; + + @override + String get collectionLoved => 'Loved'; + + @override + String get collectionPlaylists => 'Playlists'; + + @override + String get collectionPlaylist => 'Playlist'; + + @override + String get collectionAddToPlaylist => 'Add to playlist'; + + @override + String get collectionCreatePlaylist => 'Create playlist'; + + @override + String get collectionNoPlaylistsYet => 'No playlists yet'; + + @override + String get collectionNoPlaylistsSubtitle => + 'Create a playlist to start categorizing tracks'; + + @override + String collectionPlaylistTracks(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String collectionAddedToPlaylist(String playlistName) { + return 'Added to \"$playlistName\"'; + } + + @override + String collectionAlreadyInPlaylist(String playlistName) { + return 'Already in \"$playlistName\"'; + } + + @override + String get collectionPlaylistCreated => 'Playlist created'; + + @override + String get collectionPlaylistNameHint => 'Playlist name'; + + @override + String get collectionPlaylistNameRequired => 'Playlist name is required'; + + @override + String get collectionRenamePlaylist => 'Rename playlist'; + + @override + String get collectionDeletePlaylist => 'Delete playlist'; + + @override + String collectionDeletePlaylistMessage(String playlistName) { + return 'Delete \"$playlistName\" and all tracks inside it?'; + } + + @override + String get collectionPlaylistDeleted => 'Playlist deleted'; + + @override + String get collectionPlaylistRenamed => 'Playlist renamed'; + + @override + String get collectionWishlistEmptyTitle => 'Wishlist is empty'; + + @override + String get collectionWishlistEmptySubtitle => + 'Tap + on tracks to save what you want to download later'; + + @override + String get collectionLovedEmptyTitle => 'Loved folder is empty'; + + @override + String get collectionLovedEmptySubtitle => + 'Tap love on tracks to keep your favorites'; + + @override + String get collectionPlaylistEmptyTitle => 'Playlist is empty'; + + @override + String get collectionPlaylistEmptySubtitle => + 'Long-press + on any track to add it here'; + + @override + String get collectionRemoveFromPlaylist => 'Remove from playlist'; + + @override + String get collectionRemoveFromFolder => 'Remove from folder'; + + @override + String collectionRemoved(String trackName) { + return '\"$trackName\" removed'; + } + + @override + String collectionAddedToLoved(String trackName) { + return '\"$trackName\" added to Loved'; + } + + @override + String collectionRemovedFromLoved(String trackName) { + return '\"$trackName\" removed from Loved'; + } + + @override + String collectionAddedToWishlist(String trackName) { + return '\"$trackName\" added to Wishlist'; + } + + @override + String collectionRemovedFromWishlist(String trackName) { + return '\"$trackName\" removed from Wishlist'; + } + + @override + String get trackOptionAddToLoved => 'Add to Loved'; + + @override + String get trackOptionRemoveFromLoved => 'Remove from Loved'; + + @override + String get trackOptionAddToWishlist => 'Add to Wishlist'; + + @override + String get trackOptionRemoveFromWishlist => 'Remove from Wishlist'; + + @override + String get collectionPlaylistChangeCover => 'Change cover image'; + + @override + String get collectionPlaylistRemoveCover => 'Remove cover image'; + + @override + String selectionShareCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Share $count $_temp0'; + } + + @override + String get selectionShareNoFiles => 'No shareable files found'; + + @override + String selectionConvertCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0'; + } + + @override + String get selectionConvertNoConvertible => 'No convertible tracks selected'; + + @override + String get selectionBatchConvertConfirmTitle => 'Batch Convert'; + + @override + String selectionBatchConvertConfirmMessage( + int count, + String format, + String bitrate, + ) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.'; + } + + @override + String selectionBatchConvertProgress(int current, int total) { + return 'Converting $current of $total...'; + } + + @override + String selectionBatchConvertSuccess(int success, int total, String format) { + return 'Converted $success of $total tracks to $format'; + } + + @override + String downloadedAlbumDownloadedCount(int count) { + return '$count downloaded'; + } + + @override + String get downloadUseAlbumArtistForFoldersAlbumSubtitle => + 'Artist folders use Album Artist when available'; + + @override + String get downloadUseAlbumArtistForFoldersTrackSubtitle => + 'Artist folders use Track Artist only'; } diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 3a946c0a..499adcbb 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -11,19 +11,12 @@ class AppLocalizationsPt extends AppLocalizations { @override String get appName => 'SpotiFLAC'; - @override - String get appDescription => - 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; - @override String get navHome => 'Home'; @override String get navLibrary => 'Library'; - @override - String get navHistory => 'History'; - @override String get navSettings => 'Settings'; @@ -33,14 +26,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get homeTitle => 'Home'; - @override - String get homeSearchHint => 'Paste Spotify URL or search...'; - - @override - String homeSearchHintExtension(String extensionName) { - return 'Search with $extensionName...'; - } - @override String get homeSubtitle => 'Paste a Spotify link or search by name'; @@ -50,17 +35,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get homeRecent => 'Recent'; - @override - String get historyTitle => 'History'; - - @override - String historyDownloading(int count) { - return 'Downloading ($count)'; - } - - @override - String get historyDownloaded => 'Downloaded'; - @override String get historyFilterAll => 'All'; @@ -70,48 +44,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get historyFilterSingles => 'Singles'; - @override - String historyTracksCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count tracks', - one: '1 track', - ); - return '$_temp0'; - } - - @override - String historyAlbumsCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count albums', - one: '1 album', - ); - return '$_temp0'; - } - - @override - String get historyNoDownloads => 'No download history'; - - @override - String get historyNoDownloadsSubtitle => 'Downloaded tracks will appear here'; - - @override - String get historyNoAlbums => 'No album downloads'; - - @override - String get historyNoAlbumsSubtitle => - 'Download multiple tracks from an album to see them here'; - - @override - String get historyNoSingles => 'No single downloads'; - - @override - String get historyNoSinglesSubtitle => - 'Single track downloads will appear here'; - @override String get historySearchHint => 'Search history...'; @@ -136,27 +68,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get downloadTitle => 'Download'; - @override - String get downloadLocation => 'Download Location'; - - @override - String get downloadLocationSubtitle => 'Choose where to save files'; - - @override - String get downloadLocationDefault => 'Default location'; - - @override - String get downloadDefaultService => 'Default Service'; - - @override - String get downloadDefaultServiceSubtitle => 'Service used for downloads'; - - @override - String get downloadDefaultQuality => 'Default Quality'; - - @override - String get downloadAskQuality => 'Ask Quality Before Download'; - @override String get downloadAskQualitySubtitle => 'Show quality picker for each download'; @@ -167,31 +78,9 @@ class AppLocalizationsPt extends AppLocalizations { @override String get downloadFolderOrganization => 'Folder Organization'; - @override - String get downloadSeparateSingles => 'Separate Singles'; - - @override - String get downloadSeparateSinglesSubtitle => - 'Put single tracks in a separate folder'; - - @override - String get qualityBest => 'Best Available'; - - @override - String get qualityFlac => 'FLAC'; - - @override - String get quality320 => '320 kbps'; - - @override - String get quality128 => '128 kbps'; - @override String get appearanceTitle => 'Appearance'; - @override - String get appearanceTheme => 'Theme'; - @override String get appearanceThemeSystem => 'System'; @@ -207,9 +96,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get appearanceDynamicColorSubtitle => 'Use colors from your wallpaper'; - @override - String get appearanceAccentColor => 'Accent Color'; - @override String get appearanceHistoryView => 'History View'; @@ -222,9 +108,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get optionsTitle => 'Options'; - @override - String get optionsSearchSource => 'Search Source'; - @override String get optionsPrimaryProvider => 'Primary Provider'; @@ -350,18 +233,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get extensionsTitle => 'Extensions'; - @override - String get extensionsInstalled => 'Installed Extensions'; - - @override - String get extensionsNone => 'No extensions installed'; - - @override - String get extensionsNoneSubtitle => 'Install extensions from the Store tab'; - - @override - String get extensionsEnabled => 'Enabled'; - @override String get extensionsDisabled => 'Disabled'; @@ -378,9 +249,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get extensionsUninstall => 'Uninstall'; - @override - String get extensionsSetAsSearch => 'Set as Search Provider'; - @override String get storeTitle => 'Extension Store'; @@ -454,9 +322,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get aboutSocial => 'Social'; - @override - String get aboutSupport => 'Support'; - @override String get aboutApp => 'App'; @@ -475,13 +340,6 @@ class AppLocalizationsPt extends AppLocalizations { String get aboutSjdonadoDesc => 'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!'; - @override - String get aboutDoubleDouble => 'DoubleDouble'; - - @override - String get aboutDoubleDoubleDesc => - 'Amazing API for Amazon Music downloads. Thank you for making it free!'; - @override String get aboutDabMusic => 'DAB Music'; @@ -500,32 +358,6 @@ class AppLocalizationsPt extends AppLocalizations { String get aboutAppDescription => 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; - @override - String get albumTitle => 'Album'; - - @override - String albumTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count tracks', - one: '1 track', - ); - return '$_temp0'; - } - - @override - String get albumDownloadAll => 'Download All'; - - @override - String get albumDownloadRemaining => 'Download Remaining'; - - @override - String get playlistTitle => 'Playlist'; - - @override - String get artistTitle => 'Artist'; - @override String get artistAlbums => 'Albums'; @@ -535,17 +367,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get artistCompilations => 'Compilations'; - @override - String artistReleases(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count releases', - one: '1 release', - ); - return '$_temp0'; - } - @override String get artistPopular => 'Popular'; @@ -554,27 +375,6 @@ class AppLocalizationsPt extends AppLocalizations { return '$count monthly listeners'; } - @override - String get trackMetadataTitle => 'Track Info'; - - @override - String get trackMetadataArtist => 'Artist'; - - @override - String get trackMetadataAlbum => 'Album'; - - @override - String get trackMetadataDuration => 'Duration'; - - @override - String get trackMetadataQuality => 'Quality'; - - @override - String get trackMetadataPath => 'File Path'; - - @override - String get trackMetadataDownloadedAt => 'Downloaded'; - @override String get trackMetadataService => 'Service'; @@ -587,53 +387,15 @@ class AppLocalizationsPt extends AppLocalizations { @override String get trackMetadataDelete => 'Delete'; - @override - String get trackMetadataRedownload => 'Re-download'; - - @override - String get trackMetadataOpenFolder => 'Open Folder'; - - @override - String get setupTitle => 'Welcome to SpotiFLAC'; - - @override - String get setupSubtitle => 'Let\'s get you started'; - - @override - String get setupStoragePermission => 'Storage Permission'; - - @override - String get setupStoragePermissionSubtitle => - 'Required to save downloaded files'; - - @override - String get setupStoragePermissionGranted => 'Permission granted'; - - @override - String get setupStoragePermissionDenied => 'Permission denied'; - @override String get setupGrantPermission => 'Grant Permission'; - @override - String get setupDownloadLocation => 'Download Location'; - - @override - String get setupChooseFolder => 'Choose Folder'; - - @override - String get setupContinue => 'Continue'; - @override String get setupSkip => 'Skip for now'; @override String get setupStorageAccessRequired => 'Storage Access Required'; - @override - String get setupStorageAccessMessage => - 'SpotiFLAC needs \"All files access\" permission to save music files to your chosen folder.'; - @override String get setupStorageAccessMessageAndroid11 => 'Android 11+ requires \"All files access\" permission to save files to your chosen download folder.'; @@ -655,9 +417,6 @@ class AppLocalizationsPt extends AppLocalizations { return '$permissionType permission is required for the best experience. You can change this later in Settings.'; } - @override - String get setupSelectDownloadFolder => 'Select Download Folder'; - @override String get setupUseDefaultFolder => 'Use Default Folder?'; @@ -699,21 +458,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get setupDownloadInFlac => 'Download Spotify tracks in FLAC'; - @override - String get setupStepStorage => 'Storage'; - - @override - String get setupStepNotification => 'Notification'; - - @override - String get setupStepFolder => 'Folder'; - - @override - String get setupStepSpotify => 'Spotify'; - - @override - String get setupStepPermission => 'Permission'; - @override String get setupStorageGranted => 'Storage Permission Granted!'; @@ -730,13 +474,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get setupNotificationEnable => 'Enable Notifications'; - @override - String get setupNotificationDescription => - 'Get notified when downloads complete or require attention.'; - - @override - String get setupFolderSelected => 'Download Folder Selected!'; - @override String get setupFolderChoose => 'Choose Download Folder'; @@ -744,48 +481,12 @@ class AppLocalizationsPt extends AppLocalizations { String get setupFolderDescription => 'Select a folder where your downloaded music will be saved.'; - @override - String get setupChangeFolder => 'Change Folder'; - @override String get setupSelectFolder => 'Select Folder'; - @override - String get setupSpotifyApiOptional => 'Spotify API (Optional)'; - - @override - String get setupSpotifyApiDescription => - 'Add your Spotify API credentials for better search results and access to Spotify-exclusive content.'; - - @override - String get setupUseSpotifyApi => 'Use Spotify API'; - - @override - String get setupEnterCredentialsBelow => 'Enter your credentials below'; - - @override - String get setupUsingDeezer => 'Using Deezer (no account needed)'; - - @override - String get setupEnterClientId => 'Enter Spotify Client ID'; - - @override - String get setupEnterClientSecret => 'Enter Spotify Client Secret'; - - @override - String get setupGetFreeCredentials => - 'Get your free API credentials from the Spotify Developer Dashboard.'; - @override String get setupEnableNotifications => 'Enable Notifications'; - @override - String get setupProceedToNextStep => 'You can now proceed to the next step.'; - - @override - String get setupNotificationProgressDescription => - 'You will receive download progress notifications.'; - @override String get setupNotificationBackgroundDescription => 'Get notified about download progress and completion. This helps you track downloads when the app is in background.'; @@ -793,32 +494,19 @@ class AppLocalizationsPt extends AppLocalizations { @override String get setupSkipForNow => 'Skip for now'; - @override - String get setupBack => 'Back'; - @override String get setupNext => 'Next'; @override String get setupGetStarted => 'Get Started'; - @override - String get setupSkipAndStart => 'Skip & Start'; - @override String get setupAllowAccessToManageFiles => 'Please enable \"Allow access to manage all files\" in the next screen.'; - @override - String get setupGetCredentialsFromSpotify => - 'Get credentials from developer.spotify.com'; - @override String get dialogCancel => 'Cancel'; - @override - String get dialogOk => 'OK'; - @override String get dialogSave => 'Save'; @@ -828,21 +516,9 @@ class AppLocalizationsPt extends AppLocalizations { @override String get dialogRetry => 'Retry'; - @override - String get dialogClose => 'Close'; - - @override - String get dialogYes => 'Yes'; - - @override - String get dialogNo => 'No'; - @override String get dialogClear => 'Clear'; - @override - String get dialogConfirm => 'Confirm'; - @override String get dialogDone => 'Done'; @@ -865,28 +541,9 @@ class AppLocalizationsPt extends AppLocalizations { String get dialogUnsavedChanges => 'You have unsaved changes. Do you want to discard them?'; - @override - String get dialogDownloadFailed => 'Download Failed'; - - @override - String get dialogTrackLabel => 'Track:'; - - @override - String get dialogArtistLabel => 'Artist:'; - - @override - String get dialogErrorLabel => 'Error:'; - @override String get dialogClearAll => 'Clear All'; - @override - String get dialogClearAllDownloads => - 'Are you sure you want to clear all downloads?'; - - @override - String get dialogRemoveFromDevice => 'Remove from device?'; - @override String get dialogRemoveExtension => 'Remove Extension'; @@ -987,11 +644,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get snackbarViewQueue => 'View Queue'; - @override - String snackbarFailedToLoad(String error) { - return 'Failed to load: $error'; - } - @override String snackbarUrlCopied(String platform) { return '$platform URL copied to clipboard'; @@ -1033,11 +685,6 @@ class AppLocalizationsPt extends AppLocalizations { String get errorRateLimitedMessage => 'Too many requests. Please wait a moment before searching again.'; - @override - String errorFailedToLoad(String item) { - return 'Failed to load $item'; - } - @override String get errorNoTracksFound => 'No tracks found'; @@ -1046,27 +693,6 @@ class AppLocalizationsPt extends AppLocalizations { return 'Cannot load $item: missing extension source'; } - @override - String get statusQueued => 'Queued'; - - @override - String get statusDownloading => 'Downloading'; - - @override - String get statusFinalizing => 'Finalizing'; - - @override - String get statusCompleted => 'Completed'; - - @override - String get statusFailed => 'Failed'; - - @override - String get statusSkipped => 'Skipped'; - - @override - String get statusPaused => 'Paused'; - @override String get actionPause => 'Pause'; @@ -1076,24 +702,12 @@ class AppLocalizationsPt extends AppLocalizations { @override String get actionCancel => 'Cancel'; - @override - String get actionStop => 'Stop'; - - @override - String get actionSelect => 'Select'; - @override String get actionSelectAll => 'Select All'; @override String get actionDeselect => 'Deselect'; - @override - String get actionPaste => 'Paste'; - - @override - String get actionImportCsv => 'Import CSV'; - @override String get actionRemoveCredentials => 'Remove Credentials'; @@ -1108,20 +722,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get selectionAllSelected => 'All tracks selected'; - @override - String get selectionTapToSelect => 'Tap tracks to select'; - - @override - String selectionDeleteTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: 'tracks', - one: 'track', - ); - return 'Delete $count $_temp0'; - } - @override String get selectionSelectToDelete => 'Select tracks to delete'; @@ -1148,40 +748,9 @@ class AppLocalizationsPt extends AppLocalizations { @override String get tooltipPlay => 'Play'; - @override - String get tooltipCancel => 'Cancel'; - - @override - String get tooltipStop => 'Stop'; - - @override - String get tooltipRetry => 'Retry'; - - @override - String get tooltipRemove => 'Remove'; - - @override - String get tooltipClear => 'Clear'; - - @override - String get tooltipPaste => 'Paste'; - @override String get filenameFormat => 'Filename Format'; - @override - String filenameFormatPreview(String preview) { - return 'Preview: $preview'; - } - - @override - String get filenameAvailablePlaceholders => 'Available placeholders:'; - - @override - String filenameHint(Object artist, Object title) { - return '$artist - $title'; - } - @override String get filenameShowAdvancedTags => 'Show advanced tags'; @@ -1189,9 +758,6 @@ class AppLocalizationsPt extends AppLocalizations { String get filenameShowAdvancedTagsDescription => 'Enable formatted tags for track padding and date patterns'; - @override - String get folderOrganization => 'Folder Organization'; - @override String get folderOrganizationNone => 'No organization'; @@ -1226,20 +792,9 @@ class AppLocalizationsPt extends AppLocalizations { @override String get updateAvailable => 'Update Available'; - @override - String updateNewVersion(String version) { - return 'Version $version is available'; - } - - @override - String get updateDownload => 'Download'; - @override String get updateLater => 'Later'; - @override - String get updateChangelog => 'Changelog'; - @override String get updateStartingDownload => 'Starting download...'; @@ -1270,12 +825,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get updateDontRemind => 'Don\'t remind'; - @override - String get providerPriority => 'Provider Priority'; - - @override - String get providerPrioritySubtitle => 'Drag to reorder download providers'; - @override String get providerPriorityTitle => 'Provider Priority'; @@ -1293,13 +842,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get providerExtension => 'Extension'; - @override - String get metadataProviderPriority => 'Metadata Provider Priority'; - - @override - String get metadataProviderPrioritySubtitle => - 'Order used when fetching track metadata'; - @override String get metadataProviderPriorityTitle => 'Metadata Priority'; @@ -1320,18 +862,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get logTitle => 'Logs'; - @override - String get logCopy => 'Copy Logs'; - - @override - String get logClear => 'Clear Logs'; - - @override - String get logShare => 'Share Logs'; - - @override - String get logEmpty => 'No logs yet'; - @override String get logCopied => 'Logs copied to clipboard'; @@ -1356,18 +886,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get logClearLogsMessage => 'Are you sure you want to clear all logs?'; - @override - String get logIspBlocking => 'ISP BLOCKING DETECTED'; - - @override - String get logRateLimited => 'RATE LIMITED'; - - @override - String get logNetworkError => 'NETWORK ERROR'; - - @override - String get logTrackNotFound => 'TRACK NOT FOUND'; - @override String get logFilterBySeverity => 'Filter logs by severity'; @@ -1377,48 +895,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get logNoLogsYetSubtitle => 'Logs will appear here as you use the app'; - @override - String get logIssueSummary => 'Issue Summary'; - - @override - String get logIspBlockingDescription => - 'Your ISP may be blocking access to download services'; - - @override - String get logIspBlockingSuggestion => - 'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8'; - - @override - String get logRateLimitedDescription => 'Too many requests to the service'; - - @override - String get logRateLimitedSuggestion => - 'Wait a few minutes before trying again'; - - @override - String get logNetworkErrorDescription => 'Connection issues detected'; - - @override - String get logNetworkErrorSuggestion => 'Check your internet connection'; - - @override - String get logTrackNotFoundDescription => - 'Some tracks could not be found on download services'; - - @override - String get logTrackNotFoundSuggestion => - 'The track may not be available in lossless quality'; - - @override - String logTotalErrors(int count) { - return 'Total errors: $count'; - } - - @override - String logAffected(String domains) { - return 'Affected: $domains'; - } - @override String logEntriesFiltered(int count) { return 'Entries ($count filtered)'; @@ -1525,9 +1001,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get appearanceLanguage => 'App Language'; - @override - String get appearanceLanguageSubtitle => 'Choose your preferred language'; - @override String get settingsAppearanceSubtitle => 'Theme, colors, display'; @@ -1549,9 +1022,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get pressBackAgainToExit => 'Press back again to exit'; - @override - String get tracksHeader => 'Tracks'; - @override String downloadAllCount(int count) { return 'Download All ($count)'; @@ -1665,11 +1135,6 @@ class AppLocalizationsPt extends AppLocalizations { String get trackDeleteConfirmMessage => 'This will permanently delete the downloaded file and remove it from your history.'; - @override - String trackCannotOpen(String message) { - return 'Cannot open: $message'; - } - @override String get dateToday => 'Today'; @@ -1691,18 +1156,6 @@ class AppLocalizationsPt extends AppLocalizations { return '$count months ago'; } - @override - String get concurrentSequential => 'Sequential'; - - @override - String get concurrentParallel2 => '2 Parallel'; - - @override - String get concurrentParallel3 => '3 Parallel'; - - @override - String get tapToSeeError => 'Tap to see error details'; - @override String get storeFilterAll => 'All'; @@ -1724,15 +1177,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get storeClearFilters => 'Clear filters'; - @override - String get storeNoResults => 'No extensions found'; - - @override - String get extensionProviderPriority => 'Provider Priority'; - - @override - String get extensionInstallButton => 'Install Extension'; - @override String get extensionDefaultProvider => 'Default (Deezer/Spotify)'; @@ -1883,38 +1327,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; - @override - String get qualityLossy => 'Lossy'; - - @override - String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)'; - - @override - String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)'; - - @override - String get enableLossyOption => 'Enable Lossy Option'; - - @override - String get enableLossyOptionSubtitleOn => 'Lossy quality option is available'; - - @override - String get enableLossyOptionSubtitleOff => - 'Downloads FLAC then converts to lossy format'; - - @override - String get lossyFormat => 'Lossy Format'; - - @override - String get lossyFormatDescription => 'Choose the lossy format for conversion'; - - @override - String get lossyFormatMp3Subtitle => '320kbps, best compatibility'; - - @override - String get lossyFormatOpusSubtitle => - '128kbps, better quality at smaller size'; - @override String get qualityNote => 'Actual quality depends on track availability from the service'; @@ -1923,6 +1335,12 @@ class AppLocalizationsPt extends AppLocalizations { String get youtubeQualityNote => 'YouTube provides lossy audio only. Not part of lossless fallback.'; + @override + String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; + + @override + String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; + @override String get downloadAskBeforeDownload => 'Ask Before Download'; @@ -1938,14 +1356,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders'; - @override - String get downloadUseAlbumArtistForFoldersAlbumSubtitle => - 'Artist folders use Album Artist when available'; - - @override - String get downloadUseAlbumArtistForFoldersTrackSubtitle => - 'Artist folders use Track Artist only'; - @override String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders'; @@ -1957,78 +1367,18 @@ class AppLocalizationsPt extends AppLocalizations { String get downloadUsePrimaryArtistOnlyDisabled => 'Full artist string used for folder name'; - @override - String get downloadSaveFormat => 'Save Format'; - - @override - String get downloadSelectService => 'Select Service'; - @override String get downloadSelectQuality => 'Select Quality'; @override String get downloadFrom => 'Download From'; - @override - String get downloadDefaultQualityLabel => 'Default Quality'; - - @override - String get downloadBestAvailable => 'Best available'; - - @override - String get folderNone => 'None'; - - @override - String get folderNoneSubtitle => 'Save all files directly to download folder'; - - @override - String get folderArtist => 'Artist'; - - @override - String get folderArtistSubtitle => 'Artist Name/filename'; - - @override - String get folderAlbum => 'Album'; - - @override - String get folderAlbumSubtitle => 'Album Name/filename'; - - @override - String get folderArtistAlbum => 'Artist/Album'; - - @override - String get folderArtistAlbumSubtitle => 'Artist Name/Album Name/filename'; - - @override - String get serviceTidal => 'Tidal'; - - @override - String get serviceQobuz => 'Qobuz'; - - @override - String get serviceAmazon => 'Amazon'; - - @override - String get serviceDeezer => 'Deezer'; - - @override - String get serviceSpotify => 'Spotify'; - @override String get appearanceAmoledDark => 'AMOLED Dark'; @override String get appearanceAmoledDarkSubtitle => 'Pure black background'; - @override - String get appearanceChooseAccentColor => 'Choose Accent Color'; - - @override - String get appearanceChooseTheme => 'Theme Mode'; - - @override - String get queueTitle => 'Download Queue'; - @override String get queueClearAll => 'Clear All'; @@ -2036,19 +1386,6 @@ class AppLocalizationsPt extends AppLocalizations { String get queueClearAllMessage => 'Are you sure you want to clear all downloads?'; - @override - String get queueExportFailed => 'Export'; - - @override - String get queueExportFailedSuccess => - 'Failed downloads exported to TXT file'; - - @override - String get queueExportFailedClear => 'Clear Failed'; - - @override - String get queueExportFailedError => 'Failed to export downloads'; - @override String get settingsAutoExportFailed => 'Auto-export failed downloads'; @@ -2069,30 +1406,6 @@ class AppLocalizationsPt extends AppLocalizations { String get settingsDownloadNetworkSubtitle => 'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.'; - @override - String get queueEmpty => 'No downloads in queue'; - - @override - String get queueEmptySubtitle => 'Add tracks from the home screen'; - - @override - String get queueClearCompleted => 'Clear completed'; - - @override - String get queueDownloadFailed => 'Download Failed'; - - @override - String get queueTrackLabel => 'Track:'; - - @override - String get queueArtistLabel => 'Artist:'; - - @override - String get queueErrorLabel => 'Error:'; - - @override - String get queueUnknownError => 'Unknown error'; - @override String get albumFolderArtistAlbum => 'Artist / Album'; @@ -2139,14 +1452,6 @@ class AppLocalizationsPt extends AppLocalizations { return 'Delete $count $_temp0 from this album?\n\nThis will also delete the files from storage.'; } - @override - String get downloadedAlbumTracksHeader => 'Tracks'; - - @override - String downloadedAlbumDownloadedCount(int count) { - return '$count downloaded'; - } - @override String downloadedAlbumSelectedCount(int count) { return '$count selected'; @@ -2177,9 +1482,6 @@ class AppLocalizationsPt extends AppLocalizations { return 'Disc $discNumber'; } - @override - String get utilityFunctions => 'Utility Functions'; - @override String get recentTypeArtist => 'Artist'; @@ -2203,11 +1505,6 @@ class AppLocalizationsPt extends AppLocalizations { return 'Playlist: $name'; } - @override - String errorGeneric(String message) { - return 'Error: $message'; - } - @override String get discographyDownload => 'Download Discography'; @@ -2313,9 +1610,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get libraryTitle => 'Local Library'; - @override - String get libraryStatus => 'Library Status'; - @override String get libraryScanSettings => 'Scan Settings'; @@ -2379,8 +1673,14 @@ class AppLocalizationsPt extends AppLocalizations { 'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.'; @override - String libraryTracksCount(int count) { - return '$count tracks'; + String libraryTracksUnit(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return '$_temp0'; } @override @@ -2462,21 +1762,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get libraryFilterFormat => 'Format'; - @override - String get libraryFilterDate => 'Date Added'; - - @override - String get libraryFilterDateToday => 'Today'; - - @override - String get libraryFilterDateWeek => 'This Week'; - - @override - String get libraryFilterDateMonth => 'This Month'; - - @override - String get libraryFilterDateYear => 'This Year'; - @override String get libraryFilterSort => 'Sort'; @@ -2486,11 +1771,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get libraryFilterSortOldest => 'Oldest'; - @override - String libraryFilterActive(int count) { - return '$count filter(s) active'; - } - @override String get timeJustNow => 'Just now'; @@ -2516,72 +1796,6 @@ class AppLocalizationsPt extends AppLocalizations { return '$_temp0'; } - @override - String get storageSwitchTitle => 'Switch Storage Mode'; - - @override - String get storageSwitchToSafTitle => 'Switch to SAF Storage?'; - - @override - String get storageSwitchToAppTitle => 'Switch to App Storage?'; - - @override - String get storageSwitchToSafMessage => - 'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.'; - - @override - String get storageSwitchToAppMessage => - 'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.'; - - @override - String get storageSwitchExistingDownloads => 'Existing Downloads'; - - @override - String storageSwitchExistingDownloadsInfo(int count, String mode) { - return '$count tracks in $mode storage'; - } - - @override - String get storageSwitchNewDownloads => 'New Downloads'; - - @override - String storageSwitchNewDownloadsLocation(String location) { - return 'Will be saved to: $location'; - } - - @override - String get storageSwitchContinue => 'Continue'; - - @override - String get storageSwitchSelectFolder => 'Select SAF Folder'; - - @override - String get storageAppStorage => 'App Storage'; - - @override - String get storageSafStorage => 'SAF Storage'; - - @override - String storageModeBadge(String mode) { - return 'Storage: $mode'; - } - - @override - String get storageStatsTitle => 'Storage Statistics'; - - @override - String storageStatsAppCount(int count) { - return '$count tracks in App Storage'; - } - - @override - String storageStatsSafCount(int count) { - return '$count tracks in SAF Storage'; - } - - @override - String get storageModeInfo => 'Your files are stored in multiple locations'; - @override String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!'; @@ -2608,18 +1822,6 @@ class AppLocalizationsPt extends AppLocalizations { String get tutorialSearchDesc => 'There are two easy ways to find music you want to download.'; - @override - String get tutorialSearchTip1 => - 'Paste a Spotify or Deezer URL directly in the search box'; - - @override - String get tutorialSearchTip2 => - 'Or type the song name, artist, or album to search'; - - @override - String get tutorialSearchTip3 => - 'Supports tracks, albums, playlists, and artist pages'; - @override String get tutorialDownloadTitle => 'Downloading Music'; @@ -2627,18 +1829,6 @@ class AppLocalizationsPt extends AppLocalizations { String get tutorialDownloadDesc => 'Downloading music is simple and fast. Here\'s how it works.'; - @override - String get tutorialDownloadTip1 => - 'Tap the download button next to any track to start downloading'; - - @override - String get tutorialDownloadTip2 => - 'Choose your preferred quality (FLAC, Hi-Res, or MP3)'; - - @override - String get tutorialDownloadTip3 => - 'Download entire albums or playlists with one tap'; - @override String get tutorialLibraryTitle => 'Your Library'; @@ -2699,9 +1889,6 @@ class AppLocalizationsPt extends AppLocalizations { String get tutorialReadyMessage => 'You\'re all set! Start downloading your favorite music now.'; - @override - String get tutorialExample => 'EXAMPLE'; - @override String get libraryForceFullScan => 'Force Full Scan'; @@ -2862,11 +2049,7 @@ class AppLocalizationsPt extends AppLocalizations { String get trackSaveLyricsProgress => 'Saving lyrics...'; @override - String get trackReEnrich => 'Re-enrich Metadata'; - - @override - String get trackReEnrichSubtitle => - 'Re-embed metadata without re-downloading'; + String get trackReEnrich => 'Re-enrich'; @override String get trackReEnrichOnlineSubtitle => @@ -2942,6 +2125,223 @@ class AppLocalizationsPt extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + + @override + String get actionCreate => 'Create'; + + @override + String get collectionFoldersTitle => 'My folders'; + + @override + String get collectionWishlist => 'Wishlist'; + + @override + String get collectionLoved => 'Loved'; + + @override + String get collectionPlaylists => 'Playlists'; + + @override + String get collectionPlaylist => 'Playlist'; + + @override + String get collectionAddToPlaylist => 'Add to playlist'; + + @override + String get collectionCreatePlaylist => 'Create playlist'; + + @override + String get collectionNoPlaylistsYet => 'No playlists yet'; + + @override + String get collectionNoPlaylistsSubtitle => + 'Create a playlist to start categorizing tracks'; + + @override + String collectionPlaylistTracks(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String collectionAddedToPlaylist(String playlistName) { + return 'Added to \"$playlistName\"'; + } + + @override + String collectionAlreadyInPlaylist(String playlistName) { + return 'Already in \"$playlistName\"'; + } + + @override + String get collectionPlaylistCreated => 'Playlist created'; + + @override + String get collectionPlaylistNameHint => 'Playlist name'; + + @override + String get collectionPlaylistNameRequired => 'Playlist name is required'; + + @override + String get collectionRenamePlaylist => 'Rename playlist'; + + @override + String get collectionDeletePlaylist => 'Delete playlist'; + + @override + String collectionDeletePlaylistMessage(String playlistName) { + return 'Delete \"$playlistName\" and all tracks inside it?'; + } + + @override + String get collectionPlaylistDeleted => 'Playlist deleted'; + + @override + String get collectionPlaylistRenamed => 'Playlist renamed'; + + @override + String get collectionWishlistEmptyTitle => 'Wishlist is empty'; + + @override + String get collectionWishlistEmptySubtitle => + 'Tap + on tracks to save what you want to download later'; + + @override + String get collectionLovedEmptyTitle => 'Loved folder is empty'; + + @override + String get collectionLovedEmptySubtitle => + 'Tap love on tracks to keep your favorites'; + + @override + String get collectionPlaylistEmptyTitle => 'Playlist is empty'; + + @override + String get collectionPlaylistEmptySubtitle => + 'Long-press + on any track to add it here'; + + @override + String get collectionRemoveFromPlaylist => 'Remove from playlist'; + + @override + String get collectionRemoveFromFolder => 'Remove from folder'; + + @override + String collectionRemoved(String trackName) { + return '\"$trackName\" removed'; + } + + @override + String collectionAddedToLoved(String trackName) { + return '\"$trackName\" added to Loved'; + } + + @override + String collectionRemovedFromLoved(String trackName) { + return '\"$trackName\" removed from Loved'; + } + + @override + String collectionAddedToWishlist(String trackName) { + return '\"$trackName\" added to Wishlist'; + } + + @override + String collectionRemovedFromWishlist(String trackName) { + return '\"$trackName\" removed from Wishlist'; + } + + @override + String get trackOptionAddToLoved => 'Add to Loved'; + + @override + String get trackOptionRemoveFromLoved => 'Remove from Loved'; + + @override + String get trackOptionAddToWishlist => 'Add to Wishlist'; + + @override + String get trackOptionRemoveFromWishlist => 'Remove from Wishlist'; + + @override + String get collectionPlaylistChangeCover => 'Change cover image'; + + @override + String get collectionPlaylistRemoveCover => 'Remove cover image'; + + @override + String selectionShareCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Share $count $_temp0'; + } + + @override + String get selectionShareNoFiles => 'No shareable files found'; + + @override + String selectionConvertCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0'; + } + + @override + String get selectionConvertNoConvertible => 'No convertible tracks selected'; + + @override + String get selectionBatchConvertConfirmTitle => 'Batch Convert'; + + @override + String selectionBatchConvertConfirmMessage( + int count, + String format, + String bitrate, + ) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.'; + } + + @override + String selectionBatchConvertProgress(int current, int total) { + return 'Converting $current of $total...'; + } + + @override + String selectionBatchConvertSuccess(int success, int total, String format) { + return 'Converted $success of $total tracks to $format'; + } + + @override + String downloadedAlbumDownloadedCount(int count) { + return '$count downloaded'; + } + + @override + String get downloadUseAlbumArtistForFoldersAlbumSubtitle => + 'Artist folders use Album Artist when available'; + + @override + String get downloadUseAlbumArtistForFoldersTrackSubtitle => + 'Artist folders use Track Artist only'; } /// The translations for Portuguese, as used in Portugal (`pt_PT`). @@ -2951,19 +2351,12 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get appName => 'SpotiFLAC'; - @override - String get appDescription => - 'Baixe faixas do Spotify em qualidade sem perdas de Tidal, Qobuz e Amazon Music.'; - @override String get navHome => 'Início'; @override String get navLibrary => 'Library'; - @override - String get navHistory => 'Histórico'; - @override String get navSettings => 'Configurações'; @@ -2973,14 +2366,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get homeTitle => 'Início'; - @override - String get homeSearchHint => 'Pesquise ou cole a URL do Spotify...'; - - @override - String homeSearchHintExtension(String extensionName) { - return 'Pesquisar com $extensionName...'; - } - @override String get homeSubtitle => 'Cole um link do Spotify ou procure por nome'; @@ -2991,17 +2376,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get homeRecent => 'Recentes'; - @override - String get historyTitle => 'Histórico'; - - @override - String historyDownloading(int count) { - return 'Baixando ($count)'; - } - - @override - String get historyDownloaded => 'Baixados'; - @override String get historyFilterAll => 'Tudo'; @@ -3011,48 +2385,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get historyFilterSingles => 'Singles'; - @override - String historyTracksCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count faixas', - one: '1 faixa', - ); - return '$_temp0'; - } - - @override - String historyAlbumsCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count álbuns', - one: '1 álbum', - ); - return '$_temp0'; - } - - @override - String get historyNoDownloads => 'Nenhum histórico de downloads'; - - @override - String get historyNoDownloadsSubtitle => 'As faixas baixadas aparecerão aqui'; - - @override - String get historyNoAlbums => 'Sem álbuns baixados'; - - @override - String get historyNoAlbumsSubtitle => - 'Baixe várias faixas de um álbum para vê-las aqui'; - - @override - String get historyNoSingles => 'Sem singles baixados'; - - @override - String get historyNoSinglesSubtitle => - 'Os downloads de faixa individuais aparecerão aqui'; - @override String get historySearchHint => 'Pesquisar histórico...'; @@ -3077,27 +2409,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get downloadTitle => 'Download'; - @override - String get downloadLocation => 'Local dos Downloads'; - - @override - String get downloadLocationSubtitle => 'Escolha onde salvar os arquivos'; - - @override - String get downloadLocationDefault => 'Local padrão'; - - @override - String get downloadDefaultService => 'Serviço Padrão'; - - @override - String get downloadDefaultServiceSubtitle => 'Serviço usado para downloads'; - - @override - String get downloadDefaultQuality => 'Qualidade Predefinida'; - - @override - String get downloadAskQuality => 'Perguntar qualidade antes de baixar'; - @override String get downloadAskQualitySubtitle => 'Mostrar seletor de qualidade para cada download'; @@ -3108,31 +2419,9 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get downloadFolderOrganization => 'Organização de Pastas'; - @override - String get downloadSeparateSingles => 'Separar Singles'; - - @override - String get downloadSeparateSinglesSubtitle => - 'Colocar singles numa pasta separada'; - - @override - String get qualityBest => 'Melhor Disponível'; - - @override - String get qualityFlac => 'FLAC'; - - @override - String get quality320 => '320 kbps'; - - @override - String get quality128 => '128 kbps'; - @override String get appearanceTitle => 'Aparência'; - @override - String get appearanceTheme => 'Tema'; - @override String get appearanceThemeSystem => 'Sistema'; @@ -3149,9 +2438,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { String get appearanceDynamicColorSubtitle => 'Usar cores do seu papel de parede'; - @override - String get appearanceAccentColor => 'Cor de Destaque'; - @override String get appearanceHistoryView => 'Visualização do Histórico'; @@ -3164,9 +2450,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get optionsTitle => 'Opções'; - @override - String get optionsSearchSource => 'Origem da Pesquisa'; - @override String get optionsPrimaryProvider => 'Provedor Primário'; @@ -3297,19 +2580,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get extensionsTitle => 'Extensões'; - @override - String get extensionsInstalled => 'Extensões Instaladas'; - - @override - String get extensionsNone => 'Nenhuma extensão instalada'; - - @override - String get extensionsNoneSubtitle => - 'Instalar extensões a partir da aba Loja'; - - @override - String get extensionsEnabled => 'Habilitado'; - @override String get extensionsDisabled => 'Desabilitado'; @@ -3326,9 +2596,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get extensionsUninstall => 'Desinstalar'; - @override - String get extensionsSetAsSearch => 'Definir como Provedor de Pesquisa'; - @override String get storeTitle => 'Loja de Extensões'; @@ -3404,9 +2671,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get aboutSocial => 'Social'; - @override - String get aboutSupport => 'Apoiar'; - @override String get aboutApp => 'Aplicativo'; @@ -3425,13 +2689,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { String get aboutSjdonadoDesc => 'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!'; - @override - String get aboutDoubleDouble => 'DoubleDouble'; - - @override - String get aboutDoubleDoubleDesc => - 'API incrível para downloads do Amazon Music. Obrigado por fazê-lo gratuitamente!'; - @override String get aboutDabMusic => 'DAB Music'; @@ -3450,32 +2707,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { String get aboutAppDescription => 'Baixe faixas do Spotify em qualidade sem perdas do Tidal, Qobuz e Amazon Music.'; - @override - String get albumTitle => 'Álbum'; - - @override - String albumTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count faixas', - one: '1 faixa', - ); - return '$_temp0'; - } - - @override - String get albumDownloadAll => 'Baixar Tudo'; - - @override - String get albumDownloadRemaining => 'Downloads Restantes'; - - @override - String get playlistTitle => 'Playlist'; - - @override - String get artistTitle => 'Artista'; - @override String get artistAlbums => 'Álbuns'; @@ -3485,17 +2716,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get artistCompilations => 'Compilações'; - @override - String artistReleases(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count lançamentos', - one: '1 lançamento', - ); - return '$_temp0'; - } - @override String get artistPopular => 'Populares'; @@ -3504,27 +2724,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { return '$count ouvintes mensais'; } - @override - String get trackMetadataTitle => 'Informações da Faixa'; - - @override - String get trackMetadataArtist => 'Artista'; - - @override - String get trackMetadataAlbum => 'Álbum'; - - @override - String get trackMetadataDuration => 'Duração'; - - @override - String get trackMetadataQuality => 'Qualidade'; - - @override - String get trackMetadataPath => 'Caminho do Arquivo'; - - @override - String get trackMetadataDownloadedAt => 'Baixado'; - @override String get trackMetadataService => 'Serviço'; @@ -3537,53 +2736,15 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get trackMetadataDelete => 'Apagar'; - @override - String get trackMetadataRedownload => 'Baixar Novamente'; - - @override - String get trackMetadataOpenFolder => 'Abrir Pasta'; - - @override - String get setupTitle => 'Bem-vindo ao SpotiFLAC'; - - @override - String get setupSubtitle => 'Vamos começar'; - - @override - String get setupStoragePermission => 'Permissão de Armazenamento'; - - @override - String get setupStoragePermissionSubtitle => - 'Necessária para salvar arquivos baixados'; - - @override - String get setupStoragePermissionGranted => 'Permissão concedida'; - - @override - String get setupStoragePermissionDenied => 'Permissão negada'; - @override String get setupGrantPermission => 'Conceder Permissão'; - @override - String get setupDownloadLocation => 'Local do Download'; - - @override - String get setupChooseFolder => 'Selecionar Pasta'; - - @override - String get setupContinue => 'Continuar'; - @override String get setupSkip => 'Ignorar por enquanto'; @override String get setupStorageAccessRequired => 'Acesso ao Armazenamento Necessário'; - @override - String get setupStorageAccessMessage => - 'O SpotiFLAC precisa da permissão \"Acesso a todos os arquivos\" para salvar arquivos de música na sua pasta escolhida.'; - @override String get setupStorageAccessMessageAndroid11 => 'O Android 11+ requer a permissão \"Acesso a Todos os Arquivos\" para salvar arquivos na pasta de download escolhida.'; @@ -3605,9 +2766,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { return 'A permissão $permissionType é necessária para a melhor experiência. Você pode alterar isso mais tarde em Configurações.'; } - @override - String get setupSelectDownloadFolder => 'Escolher Pasta de Download'; - @override String get setupUseDefaultFolder => 'Usar Pasta Padrão?'; @@ -3650,21 +2808,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get setupDownloadInFlac => 'Baixar faixas do Spotify em FLAC'; - @override - String get setupStepStorage => 'Armazenamento'; - - @override - String get setupStepNotification => 'Notificação'; - - @override - String get setupStepFolder => 'Pasta'; - - @override - String get setupStepSpotify => 'Spotify'; - - @override - String get setupStepPermission => 'Permissão'; - @override String get setupStorageGranted => 'Permissão de Armazenamento Concedida!'; @@ -3681,13 +2824,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get setupNotificationEnable => 'Habilitar Notificações'; - @override - String get setupNotificationDescription => - 'Seja notificado quando os downloads completarem ou exigirem atenção.'; - - @override - String get setupFolderSelected => 'Pasta para Download Selecionada!'; - @override String get setupFolderChoose => 'Escolher Pasta de Download'; @@ -3695,49 +2831,12 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { String get setupFolderDescription => 'Selecione uma pasta onde as suas músicas baixadas serão salvas.'; - @override - String get setupChangeFolder => 'Alterar Pasta'; - @override String get setupSelectFolder => 'Seleccionar Pasta'; - @override - String get setupSpotifyApiOptional => 'API do Spotify (opcional)'; - - @override - String get setupSpotifyApiDescription => - 'Adicione as suas credenciais da API do Spotify para obter melhores resultados de busca e acesso a conteúdo exclusivo do Spotify.'; - - @override - String get setupUseSpotifyApi => 'Usar API do Spotify'; - - @override - String get setupEnterCredentialsBelow => 'Insira as suas credenciais abaixo'; - - @override - String get setupUsingDeezer => 'Usando o Deezer (nenhuma conta necessária)'; - - @override - String get setupEnterClientId => 'Insira o Spotify Client ID'; - - @override - String get setupEnterClientSecret => 'Insira o Spotify Client Secret'; - - @override - String get setupGetFreeCredentials => - 'Receba as suas credenciais de API gratuitas na Spotify Developer Dashboard.'; - @override String get setupEnableNotifications => 'Habilitar Notificações'; - @override - String get setupProceedToNextStep => - 'Você já pode prosseguir para o próximo passo.'; - - @override - String get setupNotificationProgressDescription => - 'Você receberá notificações de progresso dos downloads.'; - @override String get setupNotificationBackgroundDescription => 'Seja notificado sobre o progresso e conclusão do download. Isso ajuda você a acompanhar os downloads quando o app estiver em segundo plano.'; @@ -3745,32 +2844,19 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get setupSkipForNow => 'Ignorar por enquanto'; - @override - String get setupBack => 'Voltar'; - @override String get setupNext => 'Próximo'; @override String get setupGetStarted => 'Começar'; - @override - String get setupSkipAndStart => 'Ignorar e Iniciar'; - @override String get setupAllowAccessToManageFiles => 'Por favor, habilite \"Permitir acesso para gerenciar todos os arquivos\" na próxima tela.'; - @override - String get setupGetCredentialsFromSpotify => - 'Obter credenciais do developer.spotify.com'; - @override String get dialogCancel => 'Cancelar'; - @override - String get dialogOk => 'OK'; - @override String get dialogSave => 'Salvar'; @@ -3780,21 +2866,9 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get dialogRetry => 'Tentar novamente'; - @override - String get dialogClose => 'Fechar'; - - @override - String get dialogYes => 'Sim'; - - @override - String get dialogNo => 'Não'; - @override String get dialogClear => 'Limpar'; - @override - String get dialogConfirm => 'Confirmar'; - @override String get dialogDone => 'Concluído'; @@ -3817,28 +2891,9 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { String get dialogUnsavedChanges => 'Você tem alterações não salvas. Deseja descartá-las?'; - @override - String get dialogDownloadFailed => 'Download Falhou'; - - @override - String get dialogTrackLabel => 'Faixa:'; - - @override - String get dialogArtistLabel => 'Artista:'; - - @override - String get dialogErrorLabel => 'Erro:'; - @override String get dialogClearAll => 'Limpar Tudo'; - @override - String get dialogClearAllDownloads => - 'Você tem certeza que deseja limpar todos os downloads?'; - - @override - String get dialogRemoveFromDevice => 'Remover do dispositivo?'; - @override String get dialogRemoveExtension => 'Remover Extensão'; @@ -3939,11 +2994,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get snackbarViewQueue => 'Ver Fila'; - @override - String snackbarFailedToLoad(String error) { - return 'Falha ao carregar: $error'; - } - @override String snackbarUrlCopied(String platform) { return 'URL do $platform copiado para a área de transferência'; @@ -3986,11 +3036,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { String get errorRateLimitedMessage => 'Muitas solicitações. Por favor, aguarde um momento antes de pesquisar novamente.'; - @override - String errorFailedToLoad(String item) { - return 'Falha ao carregar $item'; - } - @override String get errorNoTracksFound => 'Nenhuma faixa encontrada'; @@ -3999,27 +3044,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { return 'Não é possível carregar $item: faltando a fonte da extensão'; } - @override - String get statusQueued => 'Na Fila'; - - @override - String get statusDownloading => 'Baixando'; - - @override - String get statusFinalizing => 'Finalizando'; - - @override - String get statusCompleted => 'Concluído'; - - @override - String get statusFailed => 'Falhou'; - - @override - String get statusSkipped => 'Ignorado'; - - @override - String get statusPaused => 'Pausado'; - @override String get actionPause => 'Pausar'; @@ -4029,24 +3053,12 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get actionCancel => 'Cancelar'; - @override - String get actionStop => 'Parar'; - - @override - String get actionSelect => 'Selecionar'; - @override String get actionSelectAll => 'Selecionar Tudo'; @override String get actionDeselect => 'Desselecionar'; - @override - String get actionPaste => 'Colar'; - - @override - String get actionImportCsv => 'Importar CSV'; - @override String get actionRemoveCredentials => 'Remover Credenciais'; @@ -4061,20 +3073,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get selectionAllSelected => 'Todas as faixas selecionadas'; - @override - String get selectionTapToSelect => 'Toque nas faixas para selecionar'; - - @override - String selectionDeleteTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: 'faixas', - one: 'faixa', - ); - return 'Apagar $count $_temp0'; - } - @override String get selectionSelectToDelete => 'Selecione as faixas para apagar'; @@ -4101,43 +3099,9 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get tooltipPlay => 'Reproduzir'; - @override - String get tooltipCancel => 'Cancelar'; - - @override - String get tooltipStop => 'Parar'; - - @override - String get tooltipRetry => 'Tentar Novamente'; - - @override - String get tooltipRemove => 'Remover'; - - @override - String get tooltipClear => 'Limpar'; - - @override - String get tooltipPaste => 'Colar'; - @override String get filenameFormat => 'Formato do Nome do Arquivo'; - @override - String filenameFormatPreview(String preview) { - return 'Prévia: $preview'; - } - - @override - String get filenameAvailablePlaceholders => 'Substituições permitidas:'; - - @override - String filenameHint(Object artist, Object title) { - return '$artist - $title'; - } - - @override - String get folderOrganization => 'Organização de Pastas'; - @override String get folderOrganizationNone => 'Nenhuma organização'; @@ -4173,20 +3137,9 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get updateAvailable => 'Atualização Disponível'; - @override - String updateNewVersion(String version) { - return 'A versão $version está disponível'; - } - - @override - String get updateDownload => 'Baixar'; - @override String get updateLater => 'Depois'; - @override - String get updateChangelog => 'Lista de alterações'; - @override String get updateStartingDownload => 'Iniciando download...'; @@ -4217,13 +3170,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get updateDontRemind => 'Não lembrar'; - @override - String get providerPriority => 'Prioridade de Provedor'; - - @override - String get providerPrioritySubtitle => - 'Arraste para reordenar os provedores de download'; - @override String get providerPriorityTitle => 'Prioridade de Provedor'; @@ -4241,13 +3187,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get providerExtension => 'Extensão'; - @override - String get metadataProviderPriority => 'Prioridade de Provedor de Metadados'; - - @override - String get metadataProviderPrioritySubtitle => - 'Ordem usada para obter metadados de faixa'; - @override String get metadataProviderPriorityTitle => 'Prioridade de Metadados'; @@ -4268,18 +3207,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get logTitle => 'Registros'; - @override - String get logCopy => 'Copiar Registros'; - - @override - String get logClear => 'Limpar Registros'; - - @override - String get logShare => 'Compartilhar Registros'; - - @override - String get logEmpty => 'Ainda não há registros'; - @override String get logCopied => 'Registros copiados para área de transferência'; @@ -4305,18 +3232,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { String get logClearLogsMessage => 'Tem certeza de que deseja limpar todos os registros?'; - @override - String get logIspBlocking => 'BLOQUEIO DE ISP DETECTADO'; - - @override - String get logRateLimited => 'TAXA LIMITADA (RATELIMITED)'; - - @override - String get logNetworkError => 'ERRO DE REDE'; - - @override - String get logTrackNotFound => 'FAIXA NÃO ENCONTRADA'; - @override String get logFilterBySeverity => 'Filtrar registros por gravidade'; @@ -4327,48 +3242,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { String get logNoLogsYetSubtitle => 'Os registros aparecerão aqui enquanto você usa o aplicativo'; - @override - String get logIssueSummary => 'Resumo do Problemas'; - - @override - String get logIspBlockingDescription => - 'O seu provedor pode estar bloqueando o acesso aos serviços de download'; - - @override - String get logIspBlockingSuggestion => - 'Tente usar uma VPN ou altere o DNS para 1.1.1 ou 8.8.8.8'; - - @override - String get logRateLimitedDescription => 'Muitas solicitações ao serviço'; - - @override - String get logRateLimitedSuggestion => - 'Aguarde alguns minutos antes de tentar novamente'; - - @override - String get logNetworkErrorDescription => 'Problemas de conexão detectados'; - - @override - String get logNetworkErrorSuggestion => 'Verifique sua conexão de internet'; - - @override - String get logTrackNotFoundDescription => - 'Algumas faixas não foram encontradas nos serviços de download'; - - @override - String get logTrackNotFoundSuggestion => - 'A faixa pode não estar disponível em qualidade sem perdas'; - - @override - String logTotalErrors(int count) { - return 'Total de erros: $count'; - } - - @override - String logAffected(String domains) { - return 'Afetado(s): $domains'; - } - @override String logEntriesFiltered(int count) { return 'Entradas ($count filtradas)'; @@ -4476,9 +3349,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get appearanceLanguage => 'Idioma do aplicativo'; - @override - String get appearanceLanguageSubtitle => 'Escolha o seu idioma preferido'; - @override String get settingsAppearanceSubtitle => 'Tema, cores, exibição'; @@ -4502,9 +3372,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get pressBackAgainToExit => 'Pressione voltar novamente para sair'; - @override - String get tracksHeader => 'Faixas'; - @override String downloadAllCount(int count) { return 'Baixar Todos ($count)'; @@ -4619,11 +3486,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { String get trackDeleteConfirmMessage => 'Isto irá excluir o arquivo baixado permanentemente e removê-lo do seu histórico.'; - @override - String trackCannotOpen(String message) { - return 'Não foi possível abrir: $message'; - } - @override String get dateToday => 'Hoje'; @@ -4645,18 +3507,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { return '$count meses atrás'; } - @override - String get concurrentSequential => 'Sequencial'; - - @override - String get concurrentParallel2 => '2 Paralelos'; - - @override - String get concurrentParallel3 => '3 Paralelos'; - - @override - String get tapToSeeError => 'Toque para ver os detalhes do erro'; - @override String get storeFilterAll => 'Tudo'; @@ -4678,15 +3528,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get storeClearFilters => 'Limpar filtros'; - @override - String get storeNoResults => 'Nenhuma extensão encontrada'; - - @override - String get extensionProviderPriority => 'Prioridade de Provedor'; - - @override - String get extensionInstallButton => 'Instalar Extensão'; - @override String get extensionDefaultProvider => 'Padrão (Deezer/Spotify)'; @@ -4840,38 +3681,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get qualityHiResFlacMaxSubtitle => '24-bit / até 192kHz'; - @override - String get qualityLossy => 'Lossy'; - - @override - String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)'; - - @override - String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)'; - - @override - String get enableLossyOption => 'Enable Lossy Option'; - - @override - String get enableLossyOptionSubtitleOn => 'Lossy quality option is available'; - - @override - String get enableLossyOptionSubtitleOff => - 'Downloads FLAC then converts to lossy format'; - - @override - String get lossyFormat => 'Lossy Format'; - - @override - String get lossyFormatDescription => 'Choose the lossy format for conversion'; - - @override - String get lossyFormatMp3Subtitle => '320kbps, best compatibility'; - - @override - String get lossyFormatOpusSubtitle => - '128kbps, better quality at smaller size'; - @override String get qualityNote => 'A qualidade real depende da faixa que estiver disponível no serviço'; @@ -4895,14 +3704,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders'; - @override - String get downloadUseAlbumArtistForFoldersAlbumSubtitle => - 'Artist folders use Album Artist when available'; - - @override - String get downloadUseAlbumArtistForFoldersTrackSubtitle => - 'Artist folders use Track Artist only'; - @override String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders'; @@ -4914,80 +3715,18 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { String get downloadUsePrimaryArtistOnlyDisabled => 'Full artist string used for folder name'; - @override - String get downloadSaveFormat => 'Formato para Salvar'; - - @override - String get downloadSelectService => 'Selecionar Serviço'; - @override String get downloadSelectQuality => 'Selecionar Qualidade'; @override String get downloadFrom => 'Baixar De'; - @override - String get downloadDefaultQualityLabel => 'Qualidade Padrão'; - - @override - String get downloadBestAvailable => 'Melhor Disponível'; - - @override - String get folderNone => 'Nenhuma'; - - @override - String get folderNoneSubtitle => - 'Salvar todos os arquivos diretamente na pasta de download'; - - @override - String get folderArtist => 'Artista'; - - @override - String get folderArtistSubtitle => 'Nome do Artista/arquivo'; - - @override - String get folderAlbum => 'Álbum'; - - @override - String get folderAlbumSubtitle => 'Nome do Álbum/arquivo'; - - @override - String get folderArtistAlbum => 'Artista/Álbum'; - - @override - String get folderArtistAlbumSubtitle => - 'Nome do Artista/Nome do Álbum/arquivo'; - - @override - String get serviceTidal => 'Tidal'; - - @override - String get serviceQobuz => 'Qobuz'; - - @override - String get serviceAmazon => 'Amazon'; - - @override - String get serviceDeezer => 'Deezer'; - - @override - String get serviceSpotify => 'Spotify'; - @override String get appearanceAmoledDark => 'Escuro AMOLED'; @override String get appearanceAmoledDarkSubtitle => 'Fundo preto puro'; - @override - String get appearanceChooseAccentColor => 'Escolha a Cor de Destaque'; - - @override - String get appearanceChooseTheme => 'Modo do Tema'; - - @override - String get queueTitle => 'Fila de Download'; - @override String get queueClearAll => 'Limpar Tudo'; @@ -4995,19 +3734,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { String get queueClearAllMessage => 'Você tem certeza que deseja limpar todos os downloads?'; - @override - String get queueExportFailed => 'Export'; - - @override - String get queueExportFailedSuccess => - 'Failed downloads exported to TXT file'; - - @override - String get queueExportFailedClear => 'Clear Failed'; - - @override - String get queueExportFailedError => 'Failed to export downloads'; - @override String get settingsAutoExportFailed => 'Auto-export failed downloads'; @@ -5028,30 +3754,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { String get settingsDownloadNetworkSubtitle => 'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.'; - @override - String get queueEmpty => 'Nenhum download na fila'; - - @override - String get queueEmptySubtitle => 'Adicione faixas a partir da tela inicial'; - - @override - String get queueClearCompleted => 'Limpar concluídos'; - - @override - String get queueDownloadFailed => 'Download Falhou'; - - @override - String get queueTrackLabel => 'Faixa:'; - - @override - String get queueArtistLabel => 'Artista:'; - - @override - String get queueErrorLabel => 'Erro:'; - - @override - String get queueUnknownError => 'Erro desconhecido'; - @override String get albumFolderArtistAlbum => 'Artista / Álbum'; @@ -5099,14 +3801,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { return 'Excluir $count $_temp0 deste álbum?\n\nIsso também excluirá os arquivos do armazenamento.'; } - @override - String get downloadedAlbumTracksHeader => 'Faixas'; - - @override - String downloadedAlbumDownloadedCount(int count) { - return '$count baixado(s)'; - } - @override String downloadedAlbumSelectedCount(int count) { return '$count selecionado(s)'; @@ -5137,9 +3831,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { return 'Disco $discNumber'; } - @override - String get utilityFunctions => 'Funções Utilitárias'; - @override String get recentTypeArtist => 'Artista'; @@ -5163,11 +3854,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { return 'Playlist: $name'; } - @override - String errorGeneric(String message) { - return 'Erro: $message'; - } - @override String get discographyDownload => 'Baixar Discografia'; @@ -5273,9 +3959,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get libraryTitle => 'Local Library'; - @override - String get libraryStatus => 'Library Status'; - @override String get libraryScanSettings => 'Scan Settings'; @@ -5338,11 +4021,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { String get libraryAboutDescription => 'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.'; - @override - String libraryTracksCount(int count) { - return '$count tracks'; - } - @override String libraryLastScanned(String time) { return 'Last scanned: $time'; @@ -5422,21 +4100,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get libraryFilterFormat => 'Format'; - @override - String get libraryFilterDate => 'Date Added'; - - @override - String get libraryFilterDateToday => 'Today'; - - @override - String get libraryFilterDateWeek => 'This Week'; - - @override - String get libraryFilterDateMonth => 'This Month'; - - @override - String get libraryFilterDateYear => 'This Year'; - @override String get libraryFilterSort => 'Sort'; @@ -5446,11 +4109,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get libraryFilterSortOldest => 'Oldest'; - @override - String libraryFilterActive(int count) { - return '$count filter(s) active'; - } - @override String get timeJustNow => 'Just now'; @@ -5476,72 +4134,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { return '$_temp0'; } - @override - String get storageSwitchTitle => 'Switch Storage Mode'; - - @override - String get storageSwitchToSafTitle => 'Switch to SAF Storage?'; - - @override - String get storageSwitchToAppTitle => 'Switch to App Storage?'; - - @override - String get storageSwitchToSafMessage => - 'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.'; - - @override - String get storageSwitchToAppMessage => - 'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.'; - - @override - String get storageSwitchExistingDownloads => 'Existing Downloads'; - - @override - String storageSwitchExistingDownloadsInfo(int count, String mode) { - return '$count tracks in $mode storage'; - } - - @override - String get storageSwitchNewDownloads => 'New Downloads'; - - @override - String storageSwitchNewDownloadsLocation(String location) { - return 'Will be saved to: $location'; - } - - @override - String get storageSwitchContinue => 'Continue'; - - @override - String get storageSwitchSelectFolder => 'Select SAF Folder'; - - @override - String get storageAppStorage => 'App Storage'; - - @override - String get storageSafStorage => 'SAF Storage'; - - @override - String storageModeBadge(String mode) { - return 'Storage: $mode'; - } - - @override - String get storageStatsTitle => 'Storage Statistics'; - - @override - String storageStatsAppCount(int count) { - return '$count tracks in App Storage'; - } - - @override - String storageStatsSafCount(int count) { - return '$count tracks in SAF Storage'; - } - - @override - String get storageModeInfo => 'Your files are stored in multiple locations'; - @override String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!'; @@ -5568,18 +4160,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { String get tutorialSearchDesc => 'There are two easy ways to find music you want to download.'; - @override - String get tutorialSearchTip1 => - 'Paste a Spotify or Deezer URL directly in the search box'; - - @override - String get tutorialSearchTip2 => - 'Or type the song name, artist, or album to search'; - - @override - String get tutorialSearchTip3 => - 'Supports tracks, albums, playlists, and artist pages'; - @override String get tutorialDownloadTitle => 'Downloading Music'; @@ -5587,18 +4167,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { String get tutorialDownloadDesc => 'Downloading music is simple and fast. Here\'s how it works.'; - @override - String get tutorialDownloadTip1 => - 'Tap the download button next to any track to start downloading'; - - @override - String get tutorialDownloadTip2 => - 'Choose your preferred quality (FLAC, Hi-Res, or MP3)'; - - @override - String get tutorialDownloadTip3 => - 'Download entire albums or playlists with one tap'; - @override String get tutorialLibraryTitle => 'Your Library'; @@ -5659,9 +4227,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { String get tutorialReadyMessage => 'You\'re all set! Start downloading your favorite music now.'; - @override - String get tutorialExample => 'EXAMPLE'; - @override String get libraryForceFullScan => 'Force Full Scan'; @@ -5822,11 +4387,7 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { String get trackSaveLyricsProgress => 'Saving lyrics...'; @override - String get trackReEnrich => 'Re-enrich Metadata'; - - @override - String get trackReEnrichSubtitle => - 'Re-embed metadata without re-downloading'; + String get trackReEnrich => 'Re-enrich'; @override String get trackReEnrichOnlineSubtitle => @@ -5902,4 +4463,17 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get trackConvertFailed => 'Conversion failed'; + + @override + String downloadedAlbumDownloadedCount(int count) { + return '$count baixado(s)'; + } + + @override + String get downloadUseAlbumArtistForFoldersAlbumSubtitle => + 'Artist folders use Album Artist when available'; + + @override + String get downloadUseAlbumArtistForFoldersTrackSubtitle => + 'Artist folders use Track Artist only'; } diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 357cdddc..85af897d 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -11,19 +11,12 @@ class AppLocalizationsRu extends AppLocalizations { @override String get appName => 'SpotiFLAC'; - @override - String get appDescription => - 'Скачайте треки Spotify в Lossless качестве из Tidal, Qobuz и Amazon Music.'; - @override String get navHome => 'Главная'; @override String get navLibrary => 'Библиотека'; - @override - String get navHistory => 'История'; - @override String get navSettings => 'Настройки'; @@ -33,14 +26,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get homeTitle => 'Главная'; - @override - String get homeSearchHint => 'Вставьте URL Spotify или выполните поиск...'; - - @override - String homeSearchHintExtension(String extensionName) { - return 'Искать с помощью $extensionName...'; - } - @override String get homeSubtitle => 'Вставьте ссылку Spotify или ищите по названию'; @@ -51,17 +36,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get homeRecent => 'Недавние'; - @override - String get historyTitle => 'История'; - - @override - String historyDownloading(int count) { - return 'Скачивание ($count)'; - } - - @override - String get historyDownloaded => 'Скачано'; - @override String get historyFilterAll => 'Все'; @@ -71,52 +45,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get historyFilterSingles => 'Синглы'; - @override - String historyTracksCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count треков', - many: '$count треков', - few: '$count трека', - one: '$count трек', - ); - return '$_temp0'; - } - - @override - String historyAlbumsCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count альбомов', - many: '$count альбомов', - few: '$count альбома', - one: '$count альбом', - ); - return '$_temp0'; - } - - @override - String get historyNoDownloads => 'Нет истории скачиваний'; - - @override - String get historyNoDownloadsSubtitle => 'Скачанные треки появятся здесь'; - - @override - String get historyNoAlbums => 'Нет скачанных альбомов'; - - @override - String get historyNoAlbumsSubtitle => - 'Скачайте несколько треков из альбома, чтобы увидеть их здесь'; - - @override - String get historyNoSingles => 'Нет скачанных синглов'; - - @override - String get historyNoSinglesSubtitle => - 'Здесь будут отображаться загрузки синглов'; - @override String get historySearchHint => 'Поиск в истории...'; @@ -141,28 +69,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get downloadTitle => 'Скачивание'; - @override - String get downloadLocation => 'Папка для скачивания'; - - @override - String get downloadLocationSubtitle => 'Выберите, куда сохранить файлы'; - - @override - String get downloadLocationDefault => 'Расположение по умолчанию'; - - @override - String get downloadDefaultService => 'Сервис по умолчанию'; - - @override - String get downloadDefaultServiceSubtitle => - 'Сервис, используемый для скачивания'; - - @override - String get downloadDefaultQuality => 'Качество по умолчанию'; - - @override - String get downloadAskQuality => 'Спрашивать качество перед скачиванием'; - @override String get downloadAskQualitySubtitle => 'Показывать выбор качества для каждого скачивания'; @@ -173,31 +79,9 @@ class AppLocalizationsRu extends AppLocalizations { @override String get downloadFolderOrganization => 'Организация папок'; - @override - String get downloadSeparateSingles => 'Разделять синглы'; - - @override - String get downloadSeparateSinglesSubtitle => - 'Помещать синглы в отдельную папку'; - - @override - String get qualityBest => 'Лучшее из доступных'; - - @override - String get qualityFlac => 'FLAC'; - - @override - String get quality320 => '320 кбит/с'; - - @override - String get quality128 => '128 кбит/с'; - @override String get appearanceTitle => 'Внешний вид'; - @override - String get appearanceTheme => 'Тема'; - @override String get appearanceThemeSystem => 'Системная'; @@ -214,9 +98,6 @@ class AppLocalizationsRu extends AppLocalizations { String get appearanceDynamicColorSubtitle => 'Использовать цвета из ваших обоев'; - @override - String get appearanceAccentColor => 'Акцентный цвет'; - @override String get appearanceHistoryView => 'Отображение истории'; @@ -229,9 +110,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get optionsTitle => 'Опции'; - @override - String get optionsSearchSource => 'Поиск источника'; - @override String get optionsPrimaryProvider => 'Основной провайдер'; @@ -361,19 +239,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get extensionsTitle => 'Расширения'; - @override - String get extensionsInstalled => 'Установленные расширения'; - - @override - String get extensionsNone => 'Нет установленных расширений'; - - @override - String get extensionsNoneSubtitle => - 'Установка расширений из вкладки Магазин'; - - @override - String get extensionsEnabled => 'Включено'; - @override String get extensionsDisabled => 'Выключено'; @@ -390,9 +255,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get extensionsUninstall => 'Удалить'; - @override - String get extensionsSetAsSearch => 'Установить в качестве поисковой системы'; - @override String get storeTitle => 'Магазин расширений'; @@ -467,9 +329,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get aboutSocial => 'Соцсети'; - @override - String get aboutSupport => 'Поддержка'; - @override String get aboutApp => 'Приложение'; @@ -488,13 +347,6 @@ class AppLocalizationsRu extends AppLocalizations { String get aboutSjdonadoDesc => 'Создатель I Don\'t Have Spotify (IDHS). Резервный резолвер ссылки'; - @override - String get aboutDoubleDouble => 'DoubleDouble'; - - @override - String get aboutDoubleDoubleDesc => - 'Удивительный API для загрузок Amazon Music. Спасибо за то, что сделали это бесплатно!'; - @override String get aboutDabMusic => 'DAB Music'; @@ -513,34 +365,6 @@ class AppLocalizationsRu extends AppLocalizations { String get aboutAppDescription => 'Скачайте треки Spotify в Lossless качестве из Tidal, Qobuz и Amazon Music.'; - @override - String get albumTitle => 'Альбом'; - - @override - String albumTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count треков', - many: '$count треков', - few: '$count трека', - one: '$count трек', - ); - return '$_temp0'; - } - - @override - String get albumDownloadAll => 'Скачать всё'; - - @override - String get albumDownloadRemaining => 'Скачать оставшиеся'; - - @override - String get playlistTitle => 'Плейлист'; - - @override - String get artistTitle => 'Исполнитель'; - @override String get artistAlbums => 'Альбомы'; @@ -550,19 +374,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get artistCompilations => 'Сборники'; - @override - String artistReleases(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count релизов', - many: '$count релизов', - few: '$count релиза', - one: '$count релиз', - ); - return '$_temp0'; - } - @override String get artistPopular => 'Популярное'; @@ -571,27 +382,6 @@ class AppLocalizationsRu extends AppLocalizations { return '$count слушателей в месяц'; } - @override - String get trackMetadataTitle => 'Информация о треке'; - - @override - String get trackMetadataArtist => 'Исполнитель'; - - @override - String get trackMetadataAlbum => 'Альбом'; - - @override - String get trackMetadataDuration => 'Продолжительность'; - - @override - String get trackMetadataQuality => 'Качество'; - - @override - String get trackMetadataPath => 'Путь к файлу'; - - @override - String get trackMetadataDownloadedAt => 'Скачано'; - @override String get trackMetadataService => 'Сервис'; @@ -604,53 +394,15 @@ class AppLocalizationsRu extends AppLocalizations { @override String get trackMetadataDelete => 'Удалить'; - @override - String get trackMetadataRedownload => 'Скачать снова'; - - @override - String get trackMetadataOpenFolder => 'Открыть папку'; - - @override - String get setupTitle => 'Добро пожаловать в SpotiFLAC'; - - @override - String get setupSubtitle => 'Давайте начнем'; - - @override - String get setupStoragePermission => 'Доступ к хранилищу'; - - @override - String get setupStoragePermissionSubtitle => - 'Необходимо для сохранения загруженных файлов'; - - @override - String get setupStoragePermissionGranted => 'Разрешение предоставлено'; - - @override - String get setupStoragePermissionDenied => 'Разрешение не предоставлено'; - @override String get setupGrantPermission => 'Предоставить разрешение'; - @override - String get setupDownloadLocation => 'Папка для скачивания'; - - @override - String get setupChooseFolder => 'Выбрать папку'; - - @override - String get setupContinue => 'Продолжить'; - @override String get setupSkip => 'Пропустить'; @override String get setupStorageAccessRequired => 'Требуется доступ к хранилищу'; - @override - String get setupStorageAccessMessage => - 'SpotiFLAC требуется разрешение \"Доступ ко всем файлам\" для сохранения музыкальных файлов в выбранную папку.'; - @override String get setupStorageAccessMessageAndroid11 => 'Для Android 11+ требуется разрешение \"Доступ ко всем файлам\" для сохранения файлов в выбранную вами папку загрузки.'; @@ -672,9 +424,6 @@ class AppLocalizationsRu extends AppLocalizations { return 'Для оптимальной работы требуется разрешение $permissionType. Вы можете изменить это позже в настройках.'; } - @override - String get setupSelectDownloadFolder => 'Выбрать папку для скачивания'; - @override String get setupUseDefaultFolder => 'Использовать папку по умолчанию?'; @@ -717,21 +466,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get setupDownloadInFlac => 'Скачать Spotify треки во FLAC'; - @override - String get setupStepStorage => 'Хранилище'; - - @override - String get setupStepNotification => 'Уведомления'; - - @override - String get setupStepFolder => 'Папка'; - - @override - String get setupStepSpotify => 'Spotify'; - - @override - String get setupStepPermission => 'Разрешение'; - @override String get setupStorageGranted => 'Доступ к хранилищу предоставлен!'; @@ -749,13 +483,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get setupNotificationEnable => 'Включить уведомления'; - @override - String get setupNotificationDescription => - 'Получайте уведомления о завершении загрузки или о необходимости привлечения внимания.'; - - @override - String get setupFolderSelected => 'Папка для загрузки выбрана!'; - @override String get setupFolderChoose => 'Выбрать папку для скачивания'; @@ -763,49 +490,12 @@ class AppLocalizationsRu extends AppLocalizations { String get setupFolderDescription => 'Выберите папку, в которой будет сохраняться скачанная музыка.'; - @override - String get setupChangeFolder => 'Сменить папку'; - @override String get setupSelectFolder => 'Выбрать папку'; - @override - String get setupSpotifyApiOptional => 'Spotify API (необязательно)'; - - @override - String get setupSpotifyApiDescription => - 'Добавьте свои учётные данные Spotify для улучшения результатов поиска и доступа к эксклюзивному контенту Spotify.'; - - @override - String get setupUseSpotifyApi => 'Использовать Spotify API'; - - @override - String get setupEnterCredentialsBelow => 'Введите ваши учётные данные ниже'; - - @override - String get setupUsingDeezer => 'Использование Deezer (аккаунт не требуется)'; - - @override - String get setupEnterClientId => 'Введите Client ID Spotify'; - - @override - String get setupEnterClientSecret => 'Введите Spotify Client Secret'; - - @override - String get setupGetFreeCredentials => - 'Получите бесплатный API учётной записи на панели разработчика Spotify.'; - @override String get setupEnableNotifications => 'Включить уведомления'; - @override - String get setupProceedToNextStep => - 'Теперь вы можете перейти к следующему шагу.'; - - @override - String get setupNotificationProgressDescription => - 'Вы будете получать уведомления о ходе загрузки.'; - @override String get setupNotificationBackgroundDescription => 'Получайте уведомления о ходе и завершении загрузки. Это поможет вам отслеживать загрузки, когда приложение находится в фоновом режиме.'; @@ -813,32 +503,19 @@ class AppLocalizationsRu extends AppLocalizations { @override String get setupSkipForNow => 'Пропустить'; - @override - String get setupBack => 'Назад'; - @override String get setupNext => 'Далее'; @override String get setupGetStarted => 'Приступить к работе'; - @override - String get setupSkipAndStart => 'Пропустить и начать'; - @override String get setupAllowAccessToManageFiles => 'Пожалуйста, включите \"Разрешить доступ для управления всеми файлами\" на следующем экране.'; - @override - String get setupGetCredentialsFromSpotify => - 'Получить учётные данные с developer.spotify.com'; - @override String get dialogCancel => 'Отмена'; - @override - String get dialogOk => 'ОК'; - @override String get dialogSave => 'Сохранить'; @@ -848,21 +525,9 @@ class AppLocalizationsRu extends AppLocalizations { @override String get dialogRetry => 'Повторить'; - @override - String get dialogClose => 'Закрыть'; - - @override - String get dialogYes => 'Да'; - - @override - String get dialogNo => 'Нет'; - @override String get dialogClear => 'Очистить'; - @override - String get dialogConfirm => 'Подтвердить'; - @override String get dialogDone => 'Готово'; @@ -885,28 +550,9 @@ class AppLocalizationsRu extends AppLocalizations { String get dialogUnsavedChanges => 'Есть несохраненные изменения. Отменить их?'; - @override - String get dialogDownloadFailed => 'Ошибка скачивания'; - - @override - String get dialogTrackLabel => 'Трек:'; - - @override - String get dialogArtistLabel => 'Исполнитель:'; - - @override - String get dialogErrorLabel => 'Ошибка:'; - @override String get dialogClearAll => 'Очистить всё'; - @override - String get dialogClearAllDownloads => - 'Вы уверены, что хотите очистить все загрузки?'; - - @override - String get dialogRemoveFromDevice => 'Удалить с устройства?'; - @override String get dialogRemoveExtension => 'Удалить расширение'; @@ -1011,11 +657,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get snackbarViewQueue => 'Просмотр очереди'; - @override - String snackbarFailedToLoad(String error) { - return 'Ошибка загрузки: $error'; - } - @override String snackbarUrlCopied(String platform) { return '$platform ссылка скопирована в буфер обмена'; @@ -1058,11 +699,6 @@ class AppLocalizationsRu extends AppLocalizations { String get errorRateLimitedMessage => 'Слишком много запросов. Пожалуйста, подождите минуту перед повторным поиском.'; - @override - String errorFailedToLoad(String item) { - return 'Ошибка загрузки $item'; - } - @override String get errorNoTracksFound => 'Треки не найдены'; @@ -1071,27 +707,6 @@ class AppLocalizationsRu extends AppLocalizations { return 'Невозможно загрузить $item: отсутствует источник расширения'; } - @override - String get statusQueued => 'В очереди'; - - @override - String get statusDownloading => 'Скачивание'; - - @override - String get statusFinalizing => 'Завершение'; - - @override - String get statusCompleted => 'Завершено'; - - @override - String get statusFailed => 'Неудачно'; - - @override - String get statusSkipped => 'Пропущено'; - - @override - String get statusPaused => 'Приостановлено'; - @override String get actionPause => 'Пауза'; @@ -1101,24 +716,12 @@ class AppLocalizationsRu extends AppLocalizations { @override String get actionCancel => 'Отмена'; - @override - String get actionStop => 'Стоп'; - - @override - String get actionSelect => 'Выбрать'; - @override String get actionSelectAll => 'Выбрать все'; @override String get actionDeselect => 'Снять выделение'; - @override - String get actionPaste => 'Вставить'; - - @override - String get actionImportCsv => 'Импорт CSV'; - @override String get actionRemoveCredentials => 'Удалить учётные данные'; @@ -1133,22 +736,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get selectionAllSelected => 'Все треки выбраны'; - @override - String get selectionTapToSelect => 'Нажмите на треки для выбора'; - - @override - String selectionDeleteTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: 'треков', - many: 'треков', - few: 'трека', - one: 'трек', - ); - return 'Удалить $count $_temp0'; - } - @override String get selectionSelectToDelete => 'Выберите треки для удаления'; @@ -1175,40 +762,9 @@ class AppLocalizationsRu extends AppLocalizations { @override String get tooltipPlay => 'Воспроизвести'; - @override - String get tooltipCancel => 'Отмена'; - - @override - String get tooltipStop => 'Стоп'; - - @override - String get tooltipRetry => 'Повторить'; - - @override - String get tooltipRemove => 'Убрать'; - - @override - String get tooltipClear => 'Очистить'; - - @override - String get tooltipPaste => 'Вставить'; - @override String get filenameFormat => 'Формат имени файла'; - @override - String filenameFormatPreview(String preview) { - return 'Предпросмотр: $preview'; - } - - @override - String get filenameAvailablePlaceholders => 'Доступные заполнители:'; - - @override - String filenameHint(Object artist, Object title) { - return '$artist - $title'; - } - @override String get filenameShowAdvancedTags => 'Show advanced tags'; @@ -1216,9 +772,6 @@ class AppLocalizationsRu extends AppLocalizations { String get filenameShowAdvancedTagsDescription => 'Enable formatted tags for track padding and date patterns'; - @override - String get folderOrganization => 'Организация папок'; - @override String get folderOrganizationNone => 'Без организации'; @@ -1253,20 +806,9 @@ class AppLocalizationsRu extends AppLocalizations { @override String get updateAvailable => 'Доступно обновление'; - @override - String updateNewVersion(String version) { - return 'Версия $version доступна'; - } - - @override - String get updateDownload => 'Скачать'; - @override String get updateLater => 'Позже'; - @override - String get updateChangelog => 'Список изменений'; - @override String get updateStartingDownload => 'Загрузка началась...'; @@ -1297,12 +839,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get updateDontRemind => 'Не напоминать'; - @override - String get providerPriority => 'Приоритет провайдера'; - - @override - String get providerPrioritySubtitle => 'Перетащите для изменения порядка'; - @override String get providerPriorityTitle => 'Приоритет провайдера'; @@ -1320,13 +856,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get providerExtension => 'Расширение'; - @override - String get metadataProviderPriority => 'Приоритет провайдера метаданных'; - - @override - String get metadataProviderPrioritySubtitle => - 'Порядок, используемый при получении метаданных'; - @override String get metadataProviderPriorityTitle => 'Приоритет метаданных'; @@ -1347,18 +876,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get logTitle => 'Логи'; - @override - String get logCopy => 'Скопировать логи'; - - @override - String get logClear => 'Очистить логи'; - - @override - String get logShare => 'Поделиться логами'; - - @override - String get logEmpty => 'Логов нет'; - @override String get logCopied => 'Логи скопированы в буфер обмена'; @@ -1383,18 +900,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get logClearLogsMessage => 'Вы уверены, что хотите очистить все логи?'; - @override - String get logIspBlocking => 'ОБНАРУЖЕНА БЛОКИРОВКА ИНТЕРНЕТ ПРОВАЙДЕРОМ'; - - @override - String get logRateLimited => 'ОГРАНИЧЕННАЯ СКОРОСТЬ'; - - @override - String get logNetworkError => 'ОШИБКА СЕТИ'; - - @override - String get logTrackNotFound => 'ТРЕК НЕ НАЙДЕН'; - @override String get logFilterBySeverity => 'Фильтровать логи по серьезности'; @@ -1405,48 +910,6 @@ class AppLocalizationsRu extends AppLocalizations { String get logNoLogsYetSubtitle => 'Логи появятся здесь по мере использования приложения'; - @override - String get logIssueSummary => 'Краткое описание проблемы'; - - @override - String get logIspBlockingDescription => - 'Ваш провайдер может блокировать доступ к сервисам скачивания'; - - @override - String get logIspBlockingSuggestion => - 'Попробуйте использовать VPN или измените DNS на 1.1.1.1 или 8.8.8.8'; - - @override - String get logRateLimitedDescription => 'Слишком много запросов к сервису'; - - @override - String get logRateLimitedSuggestion => - 'Подождите несколько минут, прежде чем повторить попытку'; - - @override - String get logNetworkErrorDescription => 'Обнаружены проблемы с подключением'; - - @override - String get logNetworkErrorSuggestion => 'Проверьте подключение к Интернету'; - - @override - String get logTrackNotFoundDescription => - 'Некоторые треки не найдены в сервисах загрузки'; - - @override - String get logTrackNotFoundSuggestion => - 'Трек может быть недоступен в lossless формате'; - - @override - String logTotalErrors(int count) { - return 'Всего ошибок: $count'; - } - - @override - String logAffected(String domains) { - return 'Затронуто: $domains'; - } - @override String logEntriesFiltered(int count) { return 'Записи ($count фильтровано)'; @@ -1553,9 +1016,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get appearanceLanguage => 'Язык приложения'; - @override - String get appearanceLanguageSubtitle => 'Выберите предпочитаемый язык'; - @override String get settingsAppearanceSubtitle => 'Тема, цвета, дисплей'; @@ -1579,9 +1039,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get pressBackAgainToExit => 'Нажмите «Назад» ещё раз, чтобы выйти'; - @override - String get tracksHeader => 'Треки'; - @override String downloadAllCount(int count) { return 'Скачать все ($count)'; @@ -1699,11 +1156,6 @@ class AppLocalizationsRu extends AppLocalizations { String get trackDeleteConfirmMessage => 'Это приведет к окончательному удалению загруженного файла и его удалению из истории.'; - @override - String trackCannotOpen(String message) { - return 'Невозможно открыть: $message'; - } - @override String get dateToday => 'Сегодня'; @@ -1725,18 +1177,6 @@ class AppLocalizationsRu extends AppLocalizations { return '$count месяцев назад'; } - @override - String get concurrentSequential => 'Последовательно'; - - @override - String get concurrentParallel2 => '2 параллельно'; - - @override - String get concurrentParallel3 => '3 параллельно'; - - @override - String get tapToSeeError => 'Нажмите, чтобы увидеть подробности ошибки'; - @override String get storeFilterAll => 'Все'; @@ -1758,15 +1198,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get storeClearFilters => 'Очистить фильтры'; - @override - String get storeNoResults => 'Расширения не найдены'; - - @override - String get extensionProviderPriority => 'Приоритет провайдера'; - - @override - String get extensionInstallButton => 'Установить расширение'; - @override String get extensionDefaultProvider => 'По умолчанию (Deezer/Spotify)'; @@ -1921,40 +1352,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get qualityHiResFlacMaxSubtitle => '24-бит / до 192кГц'; - @override - String get qualityLossy => 'Lossy'; - - @override - String get qualityLossyMp3Subtitle => - 'Opus 320 кбит/с (конвертировать из FLAC)'; - - @override - String get qualityLossyOpusSubtitle => - 'Opus 128 кбит/с (конвертировать из FLAC)'; - - @override - String get enableLossyOption => 'Включить опцию Lossy'; - - @override - String get enableLossyOptionSubtitleOn => 'Доступно качество с потерями'; - - @override - String get enableLossyOptionSubtitleOff => - 'Скачивать FLAC и конвертировать в MP3 320 кбит/с'; - - @override - String get lossyFormat => 'Формат с потерями'; - - @override - String get lossyFormatDescription => 'Выберите Lossy формат для конвертации'; - - @override - String get lossyFormatMp3Subtitle => '320Кбит/с, лучшая совместимость'; - - @override - String get lossyFormatOpusSubtitle => - '128кбит/с, лучшее качество при меньших размерах'; - @override String get qualityNote => 'Фактическое качество зависит от доступности треков в сервисе'; @@ -1963,6 +1360,12 @@ class AppLocalizationsRu extends AppLocalizations { String get youtubeQualityNote => 'YouTube обеспечивает только звук с потерями(Lossy).'; + @override + String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; + + @override + String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; + @override String get downloadAskBeforeDownload => 'Спрашивать перед скачиванием'; @@ -1979,14 +1382,6 @@ class AppLocalizationsRu extends AppLocalizations { String get downloadUseAlbumArtistForFolders => 'Использовать исполнителя альбома для папок'; - @override - String get downloadUseAlbumArtistForFoldersAlbumSubtitle => - 'Artist folders use Album Artist when available'; - - @override - String get downloadUseAlbumArtistForFoldersTrackSubtitle => - 'Папки исполнителя используют только трек исполнителя'; - @override String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders'; @@ -1998,90 +1393,18 @@ class AppLocalizationsRu extends AppLocalizations { String get downloadUsePrimaryArtistOnlyDisabled => 'Full artist string used for folder name'; - @override - String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders'; - - @override - String get downloadUsePrimaryArtistOnlyEnabled => - 'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)'; - - @override - String get downloadUsePrimaryArtistOnlyDisabled => - 'Full artist string used for folder name'; - - @override - String get downloadSaveFormat => 'Формат сохранения'; - - @override - String get downloadSelectService => 'Выбор сервиса'; - @override String get downloadSelectQuality => 'Выбор качества'; @override String get downloadFrom => 'Скачивать из'; - @override - String get downloadDefaultQualityLabel => 'Качество по умолчанию'; - - @override - String get downloadBestAvailable => 'Лучшее из доступных'; - - @override - String get folderNone => 'Отсутствует'; - - @override - String get folderNoneSubtitle => - 'Сохранить все файлы непосредственно в папку загрузки'; - - @override - String get folderArtist => 'Исполнитель'; - - @override - String get folderArtistSubtitle => 'Исполнитель/имя файла'; - - @override - String get folderAlbum => 'Альбом'; - - @override - String get folderAlbumSubtitle => 'Альбом/имя файла'; - - @override - String get folderArtistAlbum => 'Исполнитель/Альбом'; - - @override - String get folderArtistAlbumSubtitle => 'Исполнитель/ Альбом/имя файла'; - - @override - String get serviceTidal => 'Tidal'; - - @override - String get serviceQobuz => 'Qobuz'; - - @override - String get serviceAmazon => 'Amazon'; - - @override - String get serviceDeezer => 'Deezer'; - - @override - String get serviceSpotify => 'Spotify'; - @override String get appearanceAmoledDark => 'AMOLED'; @override String get appearanceAmoledDarkSubtitle => 'Глубокий чёрный фон'; - @override - String get appearanceChooseAccentColor => 'Выберите акцентный цвет'; - - @override - String get appearanceChooseTheme => 'Режим темы'; - - @override - String get queueTitle => 'Очередь скачиваний'; - @override String get queueClearAll => 'Очистить всё'; @@ -2089,19 +1412,6 @@ class AppLocalizationsRu extends AppLocalizations { String get queueClearAllMessage => 'Вы уверены, что хотите очистить все загрузки?'; - @override - String get queueExportFailed => 'Экспорт'; - - @override - String get queueExportFailedSuccess => - 'Сбой при экспорте загрузок в файл TXT'; - - @override - String get queueExportFailedClear => 'Не удалось очистить'; - - @override - String get queueExportFailedError => 'Не удалось экспортировать загрузки'; - @override String get settingsAutoExportFailed => 'Автоэкспорт неудачных загрузок'; @@ -2122,30 +1432,6 @@ class AppLocalizationsRu extends AppLocalizations { String get settingsDownloadNetworkSubtitle => 'Выберите, какую сеть использовать для скачивания. Когда установлено значение только WiFi — скачивания через мобильную сеть будут приостановлены.'; - @override - String get queueEmpty => 'Нет загрузок в очереди'; - - @override - String get queueEmptySubtitle => 'Добавить треки с главного экрана'; - - @override - String get queueClearCompleted => 'Очистка завершена'; - - @override - String get queueDownloadFailed => 'Ошибка скачивания'; - - @override - String get queueTrackLabel => 'Трек:'; - - @override - String get queueArtistLabel => 'Исполнитель:'; - - @override - String get queueErrorLabel => 'Ошибка:'; - - @override - String get queueUnknownError => 'Неизвестная ошибка'; - @override String get albumFolderArtistAlbum => 'Исполнитель / Альбом'; @@ -2196,14 +1482,6 @@ class AppLocalizationsRu extends AppLocalizations { return 'Удалить $count $_temp0 из этого альбома?\n\nЭто также удалит файлы из хранилища.'; } - @override - String get downloadedAlbumTracksHeader => 'Треки'; - - @override - String downloadedAlbumDownloadedCount(int count) { - return '$count скачано'; - } - @override String downloadedAlbumSelectedCount(int count) { return '$count выбрано'; @@ -2236,9 +1514,6 @@ class AppLocalizationsRu extends AppLocalizations { return 'Диск $discNumber'; } - @override - String get utilityFunctions => 'Функции утилиты'; - @override String get recentTypeArtist => 'Исполнитель'; @@ -2262,11 +1537,6 @@ class AppLocalizationsRu extends AppLocalizations { return 'Плейлист: $name'; } - @override - String errorGeneric(String message) { - return 'Ошибка: $message'; - } - @override String get discographyDownload => 'Скачать дискографию'; @@ -2375,9 +1645,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get libraryTitle => 'Локальная библиотека'; - @override - String get libraryStatus => 'Статус Библиотеки'; - @override String get libraryScanSettings => 'Настройки сканирования'; @@ -2441,16 +1708,14 @@ class AppLocalizationsRu extends AppLocalizations { 'Сканирует существующую коллекцию музыки для обнаружения дубликатов при загрузке. Поддерживает форматы FLAC, M4A, MP3, Opus и OGG. Метаданные читаются из тегов файлов, если доступны.'; @override - String libraryTracksCount(int count) { + String libraryTracksUnit(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'треков', - many: 'треков', - few: 'трека', - one: 'трек', + other: 'tracks', + one: 'track', ); - return '$count $_temp0'; + return '$_temp0'; } @override @@ -2540,21 +1805,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get libraryFilterFormat => 'Формат'; - @override - String get libraryFilterDate => 'Дата добавления'; - - @override - String get libraryFilterDateToday => 'Сегодня'; - - @override - String get libraryFilterDateWeek => 'На этой неделе'; - - @override - String get libraryFilterDateMonth => 'В этом месяце'; - - @override - String get libraryFilterDateYear => 'В этом году'; - @override String get libraryFilterSort => 'Сортировка'; @@ -2564,11 +1814,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get libraryFilterSortOldest => 'Старые'; - @override - String libraryFilterActive(int count) { - return '$count фильтр(-ов) активно'; - } - @override String get timeJustNow => 'Только что'; @@ -2598,96 +1843,6 @@ class AppLocalizationsRu extends AppLocalizations { return '$_temp0 назад'; } - @override - String get storageSwitchTitle => 'Сменить режим хранения'; - - @override - String get storageSwitchToSafTitle => 'Переключиться на SAF хранилище?'; - - @override - String get storageSwitchToAppTitle => 'Переключиться хранилище приложения?'; - - @override - String get storageSwitchToSafMessage => - 'Ваши скачанные файлы останутся в текущем расположении и будут доступны.\n\nНовые файлы будут сохранены в выбранной вами папке SAF.'; - - @override - String get storageSwitchToAppMessage => - 'Ваши скачанные файлы останутся в текущем выбранной вами папке SAF.\n\nНовые файлы будут сохранены в папке Music/SpotiFLAC.'; - - @override - String get storageSwitchExistingDownloads => 'Существующие загрузки'; - - @override - String storageSwitchExistingDownloadsInfo(int count, String mode) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count треков', - many: '$count треков', - few: '$count трека', - one: '$count трек', - ); - return '$_temp0 в $mode хранилище'; - } - - @override - String get storageSwitchNewDownloads => 'Новые загрузки'; - - @override - String storageSwitchNewDownloadsLocation(String location) { - return 'Будет сохранено в: $location'; - } - - @override - String get storageSwitchContinue => 'Продолжить'; - - @override - String get storageSwitchSelectFolder => 'Выберите папку SAF'; - - @override - String get storageAppStorage => 'Хранилище приложения'; - - @override - String get storageSafStorage => 'Хранилище SAF'; - - @override - String storageModeBadge(String mode) { - return 'Хранилище: $mode'; - } - - @override - String get storageStatsTitle => 'Статистика хранилища'; - - @override - String storageStatsAppCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count треков', - many: '$count треков', - few: '$count трека', - one: '$count трек', - ); - return '$_temp0 в хранилище приложения'; - } - - @override - String storageStatsSafCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count треков', - many: '$count треков', - few: '$count трека', - one: '$count трек', - ); - return '$_temp0 в вашей папке в SAF'; - } - - @override - String get storageModeInfo => 'Ваши файлы хранятся в нескольких местах'; - @override String get tutorialWelcomeTitle => 'Добро пожаловать в SpotiFLAC!'; @@ -2714,18 +1869,6 @@ class AppLocalizationsRu extends AppLocalizations { String get tutorialSearchDesc => 'Есть два простых способа найти музыку, которую вы хотите скачать.'; - @override - String get tutorialSearchTip1 => - 'Вставьте ссылку Spotify или Deezer прямо в поле поиска'; - - @override - String get tutorialSearchTip2 => - 'Или введите название песни, исполнителя или альбом для поиска'; - - @override - String get tutorialSearchTip3 => - 'Поддержка треков, альбомов, плейлистов и страниц исполнителей'; - @override String get tutorialDownloadTitle => 'Скачивание музыки'; @@ -2733,18 +1876,6 @@ class AppLocalizationsRu extends AppLocalizations { String get tutorialDownloadDesc => 'Скачивание музыки просто и быстро. Вот как это работает.'; - @override - String get tutorialDownloadTip1 => - 'Нажмите кнопку скачать рядом с любым треком, чтобы начать скачивание'; - - @override - String get tutorialDownloadTip2 => - 'Выберите предпочитаемое качество (FLAC, Hi-Res или MP3)'; - - @override - String get tutorialDownloadTip3 => - 'Скачать все альбомы или плейлисты одним нажатием'; - @override String get tutorialLibraryTitle => 'Ваша библиотека'; @@ -2805,9 +1936,6 @@ class AppLocalizationsRu extends AppLocalizations { String get tutorialReadyMessage => 'Всё готово! Начните загружать любимую музыку прямо сейчас.'; - @override - String get tutorialExample => 'EXAMPLE'; - @override String get libraryForceFullScan => 'Полное сканирование'; @@ -2970,14 +2098,7 @@ class AppLocalizationsRu extends AppLocalizations { String get trackSaveLyricsProgress => 'Saving lyrics...'; @override - String get trackSaveLyricsProgress => 'Saving lyrics...'; - - @override - String get trackReEnrich => 'Re-enrich Metadata'; - - @override - String get trackReEnrichSubtitle => - 'Re-embed metadata without re-downloading'; + String get trackReEnrich => 'Re-enrich'; @override String get trackReEnrichOnlineSubtitle => @@ -3054,4 +2175,221 @@ class AppLocalizationsRu extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + + @override + String get actionCreate => 'Create'; + + @override + String get collectionFoldersTitle => 'My folders'; + + @override + String get collectionWishlist => 'Wishlist'; + + @override + String get collectionLoved => 'Loved'; + + @override + String get collectionPlaylists => 'Playlists'; + + @override + String get collectionPlaylist => 'Playlist'; + + @override + String get collectionAddToPlaylist => 'Add to playlist'; + + @override + String get collectionCreatePlaylist => 'Create playlist'; + + @override + String get collectionNoPlaylistsYet => 'No playlists yet'; + + @override + String get collectionNoPlaylistsSubtitle => + 'Create a playlist to start categorizing tracks'; + + @override + String collectionPlaylistTracks(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String collectionAddedToPlaylist(String playlistName) { + return 'Added to \"$playlistName\"'; + } + + @override + String collectionAlreadyInPlaylist(String playlistName) { + return 'Already in \"$playlistName\"'; + } + + @override + String get collectionPlaylistCreated => 'Playlist created'; + + @override + String get collectionPlaylistNameHint => 'Playlist name'; + + @override + String get collectionPlaylistNameRequired => 'Playlist name is required'; + + @override + String get collectionRenamePlaylist => 'Rename playlist'; + + @override + String get collectionDeletePlaylist => 'Delete playlist'; + + @override + String collectionDeletePlaylistMessage(String playlistName) { + return 'Delete \"$playlistName\" and all tracks inside it?'; + } + + @override + String get collectionPlaylistDeleted => 'Playlist deleted'; + + @override + String get collectionPlaylistRenamed => 'Playlist renamed'; + + @override + String get collectionWishlistEmptyTitle => 'Wishlist is empty'; + + @override + String get collectionWishlistEmptySubtitle => + 'Tap + on tracks to save what you want to download later'; + + @override + String get collectionLovedEmptyTitle => 'Loved folder is empty'; + + @override + String get collectionLovedEmptySubtitle => + 'Tap love on tracks to keep your favorites'; + + @override + String get collectionPlaylistEmptyTitle => 'Playlist is empty'; + + @override + String get collectionPlaylistEmptySubtitle => + 'Long-press + on any track to add it here'; + + @override + String get collectionRemoveFromPlaylist => 'Remove from playlist'; + + @override + String get collectionRemoveFromFolder => 'Remove from folder'; + + @override + String collectionRemoved(String trackName) { + return '\"$trackName\" removed'; + } + + @override + String collectionAddedToLoved(String trackName) { + return '\"$trackName\" added to Loved'; + } + + @override + String collectionRemovedFromLoved(String trackName) { + return '\"$trackName\" removed from Loved'; + } + + @override + String collectionAddedToWishlist(String trackName) { + return '\"$trackName\" added to Wishlist'; + } + + @override + String collectionRemovedFromWishlist(String trackName) { + return '\"$trackName\" removed from Wishlist'; + } + + @override + String get trackOptionAddToLoved => 'Add to Loved'; + + @override + String get trackOptionRemoveFromLoved => 'Remove from Loved'; + + @override + String get trackOptionAddToWishlist => 'Add to Wishlist'; + + @override + String get trackOptionRemoveFromWishlist => 'Remove from Wishlist'; + + @override + String get collectionPlaylistChangeCover => 'Change cover image'; + + @override + String get collectionPlaylistRemoveCover => 'Remove cover image'; + + @override + String selectionShareCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Share $count $_temp0'; + } + + @override + String get selectionShareNoFiles => 'No shareable files found'; + + @override + String selectionConvertCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0'; + } + + @override + String get selectionConvertNoConvertible => 'No convertible tracks selected'; + + @override + String get selectionBatchConvertConfirmTitle => 'Batch Convert'; + + @override + String selectionBatchConvertConfirmMessage( + int count, + String format, + String bitrate, + ) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.'; + } + + @override + String selectionBatchConvertProgress(int current, int total) { + return 'Converting $current of $total...'; + } + + @override + String selectionBatchConvertSuccess(int success, int total, String format) { + return 'Converted $success of $total tracks to $format'; + } + + @override + String downloadedAlbumDownloadedCount(int count) { + return '$count скачано'; + } + + @override + String get downloadUseAlbumArtistForFoldersAlbumSubtitle => + 'Artist folders use Album Artist when available'; + + @override + String get downloadUseAlbumArtistForFoldersTrackSubtitle => + 'Папки исполнителя используют только трек исполнителя'; } diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index fc66ca2b..bcc77961 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -11,19 +11,12 @@ class AppLocalizationsTr extends AppLocalizations { @override String get appName => 'SpotiFLAC'; - @override - String get appDescription => - 'Spotify şarkılarını Tidal, Qobuz ve Amazon Music\'den yüksek kalitede indir.'; - @override String get navHome => 'Ara'; @override String get navLibrary => 'Library'; - @override - String get navHistory => 'Geçmiş'; - @override String get navSettings => 'Ayarlar'; @@ -33,14 +26,6 @@ class AppLocalizationsTr extends AppLocalizations { @override String get homeTitle => 'Ara'; - @override - String get homeSearchHint => 'Spotify URL\'i yapıştır veya ara...'; - - @override - String homeSearchHintExtension(String extensionName) { - return '$extensionName ile arat...'; - } - @override String get homeSubtitle => 'Spotify linki yapıştır veya isimle arat'; @@ -51,17 +36,6 @@ class AppLocalizationsTr extends AppLocalizations { @override String get homeRecent => 'En son'; - @override - String get historyTitle => 'Geçmiş'; - - @override - String historyDownloading(int count) { - return '($count) tane indiriliyor'; - } - - @override - String get historyDownloaded => 'İndirildi'; - @override String get historyFilterAll => 'Tümü'; @@ -71,48 +45,6 @@ class AppLocalizationsTr extends AppLocalizations { @override String get historyFilterSingles => 'Single\'lar'; - @override - String historyTracksCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count şarkı', - one: '1 şarkı', - ); - return '$_temp0'; - } - - @override - String historyAlbumsCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count albüm', - one: '1 albüm', - ); - return '$_temp0'; - } - - @override - String get historyNoDownloads => 'İndirme geçmişi yok'; - - @override - String get historyNoDownloadsSubtitle => - 'İndirilen şarkılar burada gözükecek'; - - @override - String get historyNoAlbums => 'İndirilen albüm yok'; - - @override - String get historyNoAlbumsSubtitle => - 'Albümleri burada görmek için bir albümden birden fazla şarkı indir'; - - @override - String get historyNoSingles => 'Single indirilmemiş'; - - @override - String get historyNoSinglesSubtitle => 'Single şarkılar burada gözükecek'; - @override String get historySearchHint => 'Arama geçmişi...'; @@ -137,29 +69,6 @@ class AppLocalizationsTr extends AppLocalizations { @override String get downloadTitle => 'İndirme'; - @override - String get downloadLocation => 'İndirme Konumu'; - - @override - String get downloadLocationSubtitle => - 'Dosyaları nereye kaydedeceğinizi seçin'; - - @override - String get downloadLocationDefault => 'Varsayılan dizin'; - - @override - String get downloadDefaultService => 'Varsayılan Hizmet'; - - @override - String get downloadDefaultServiceSubtitle => - 'İndirmeler için kullanılan hizmet'; - - @override - String get downloadDefaultQuality => 'Varsayılan Kalite'; - - @override - String get downloadAskQuality => 'İndirmeden Önce Kaliteyi Sor'; - @override String get downloadAskQualitySubtitle => 'Her indirmeden önce kalite seçim ekranını göster'; @@ -170,31 +79,9 @@ class AppLocalizationsTr extends AppLocalizations { @override String get downloadFolderOrganization => 'Dosya Organizasyonu'; - @override - String get downloadSeparateSingles => 'Single\'ları Ayır'; - - @override - String get downloadSeparateSinglesSubtitle => - 'Single şarkıları ayrı dosyaya koy'; - - @override - String get qualityBest => 'Mevcut en iyi'; - - @override - String get qualityFlac => 'FLAC'; - - @override - String get quality320 => '320 kbps'; - - @override - String get quality128 => '128 kbps'; - @override String get appearanceTitle => 'Görünüm'; - @override - String get appearanceTheme => 'Tema'; - @override String get appearanceThemeSystem => 'Sistem'; @@ -211,9 +98,6 @@ class AppLocalizationsTr extends AppLocalizations { String get appearanceDynamicColorSubtitle => 'Duvar kağıdının renklerini kullan'; - @override - String get appearanceAccentColor => 'Vurgu Rengi'; - @override String get appearanceHistoryView => 'Geçmiş Düzeni'; @@ -226,9 +110,6 @@ class AppLocalizationsTr extends AppLocalizations { @override String get optionsTitle => 'Seçenekler'; - @override - String get optionsSearchSource => 'Arama Kaynağı'; - @override String get optionsPrimaryProvider => 'Ana Kaynek'; @@ -355,18 +236,6 @@ class AppLocalizationsTr extends AppLocalizations { @override String get extensionsTitle => 'Eklentiler'; - @override - String get extensionsInstalled => 'Kurulu Eklentiler'; - - @override - String get extensionsNone => 'Hiçbir eklenti kurulmamış'; - - @override - String get extensionsNoneSubtitle => 'Dükkan sekmesinden eklenti indir'; - - @override - String get extensionsEnabled => 'Etkin'; - @override String get extensionsDisabled => 'Devre Dışı'; @@ -383,9 +252,6 @@ class AppLocalizationsTr extends AppLocalizations { @override String get extensionsUninstall => 'Kaldır'; - @override - String get extensionsSetAsSearch => 'Arama Sağlayıcı olarak Ayarla'; - @override String get storeTitle => 'Eklenti Dükkanı'; @@ -461,9 +327,6 @@ class AppLocalizationsTr extends AppLocalizations { @override String get aboutSocial => 'Sosyal ağlar'; - @override - String get aboutSupport => 'Destek'; - @override String get aboutApp => 'Uygulama'; @@ -482,13 +345,6 @@ class AppLocalizationsTr extends AppLocalizations { String get aboutSjdonadoDesc => 'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!'; - @override - String get aboutDoubleDouble => 'DoubleDouble'; - - @override - String get aboutDoubleDoubleDesc => - 'Amazom Music indirmeleri için harika bir API. Ücretsiz yaptığın için teşekkürler!'; - @override String get aboutDabMusic => 'DAB Music'; @@ -507,32 +363,6 @@ class AppLocalizationsTr extends AppLocalizations { String get aboutAppDescription => 'Spotify şarkılarını Tidal, Qobuz ve Amazon Music\'den yüksek kalitede indir.'; - @override - String get albumTitle => 'Albüm'; - - @override - String albumTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count şarkı', - one: '1 şarkı', - ); - return '$_temp0'; - } - - @override - String get albumDownloadAll => 'Tümünü İndir'; - - @override - String get albumDownloadRemaining => 'Kalanını İndir'; - - @override - String get playlistTitle => 'Çalma Listesi'; - - @override - String get artistTitle => 'Sanatçı'; - @override String get artistAlbums => 'Albümler'; @@ -542,17 +372,6 @@ class AppLocalizationsTr extends AppLocalizations { @override String get artistCompilations => 'Derlemeler'; - @override - String artistReleases(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count yayın', - one: '1 yayın', - ); - return '$_temp0'; - } - @override String get artistPopular => 'Popüler'; @@ -561,27 +380,6 @@ class AppLocalizationsTr extends AppLocalizations { return 'Aylık $count dinleyici'; } - @override - String get trackMetadataTitle => 'Şarkı Bilgisi'; - - @override - String get trackMetadataArtist => 'Sanatçı'; - - @override - String get trackMetadataAlbum => 'Albüm'; - - @override - String get trackMetadataDuration => 'Süre'; - - @override - String get trackMetadataQuality => 'Kalite'; - - @override - String get trackMetadataPath => 'Dosya Yolu'; - - @override - String get trackMetadataDownloadedAt => 'İndirme tarihi'; - @override String get trackMetadataService => 'Hizmet'; @@ -594,53 +392,15 @@ class AppLocalizationsTr extends AppLocalizations { @override String get trackMetadataDelete => 'Sil'; - @override - String get trackMetadataRedownload => 'Yeniden İndir'; - - @override - String get trackMetadataOpenFolder => 'Klasörü Aç'; - - @override - String get setupTitle => 'SpotiFLAC\'e Hoşgeldiniz'; - - @override - String get setupSubtitle => 'Hadi başlayalım'; - - @override - String get setupStoragePermission => 'Depolama İzni'; - - @override - String get setupStoragePermissionSubtitle => - 'İndirilen dosyaları kaydetmek için gerekli'; - - @override - String get setupStoragePermissionGranted => 'İzin verildi'; - - @override - String get setupStoragePermissionDenied => 'İzin reddedildi'; - @override String get setupGrantPermission => 'İzin Ver'; - @override - String get setupDownloadLocation => 'İndirme Konumu'; - - @override - String get setupChooseFolder => 'Klasör Seç'; - - @override - String get setupContinue => 'Devam'; - @override String get setupSkip => 'Şimdilik atla'; @override String get setupStorageAccessRequired => 'Depolama Erişimi Gerekli'; - @override - String get setupStorageAccessMessage => - 'SpotiFLAC\'ın şarkıları seçili klasörünüze kaydetmek için \"Bütün dosyalara eriş\" iznine ihtiyacı var.'; - @override String get setupStorageAccessMessageAndroid11 => 'Android 11 ve sonrasında şarkıların seçili klasörünüze kaydedilebilmesi için \"Bütün dosyalara eriş\" iznine ihtiyaç var.'; @@ -662,9 +422,6 @@ class AppLocalizationsTr extends AppLocalizations { return 'En iyi deneyim için $permissionType izni zorunludur. Bunu ayarlardan daha sonra değiştirebilirsiniz.'; } - @override - String get setupSelectDownloadFolder => 'İndirilecek Klasörü Seç'; - @override String get setupUseDefaultFolder => 'Varsayılan Klasörü Kullan?'; @@ -706,21 +463,6 @@ class AppLocalizationsTr extends AppLocalizations { @override String get setupDownloadInFlac => 'Spotify şarkılarını FLAC olarak indirin'; - @override - String get setupStepStorage => 'Depolama'; - - @override - String get setupStepNotification => 'Bildirim'; - - @override - String get setupStepFolder => 'Klasör'; - - @override - String get setupStepSpotify => 'Spotify'; - - @override - String get setupStepPermission => 'İzin'; - @override String get setupStorageGranted => 'Depolama İzni Verildi!'; @@ -737,13 +479,6 @@ class AppLocalizationsTr extends AppLocalizations { @override String get setupNotificationEnable => 'Bildirimleri Etkinleştir'; - @override - String get setupNotificationDescription => - 'İndirmeler bittiğinde veya bakılması gereken bir şey olduğunda haberdar olun.'; - - @override - String get setupFolderSelected => 'İndirilecek Klasör Seçildi!'; - @override String get setupFolderChoose => 'İndirilecek Klasörü Seç'; @@ -751,48 +486,12 @@ class AppLocalizationsTr extends AppLocalizations { String get setupFolderDescription => 'İndirdiğin şarkıların kaydedileceği klasörü seç.'; - @override - String get setupChangeFolder => 'Klasörü Değiştir'; - @override String get setupSelectFolder => 'Klasör Seç'; - @override - String get setupSpotifyApiOptional => 'Spotify API (İsteğe Bağlı)'; - - @override - String get setupSpotifyApiDescription => - 'Daha iyi arama sonuçları ve Spotify\'a özel içeriklere erişmek için Spotify API kimlik bilgilerini gir.'; - - @override - String get setupUseSpotifyApi => 'Spotify API\'ı kullan'; - - @override - String get setupEnterCredentialsBelow => 'Kimlik bilgilerini aşağıya gir'; - - @override - String get setupUsingDeezer => 'Deezer kullanılıyor (hesap gerekli değil)'; - - @override - String get setupEnterClientId => 'Spotify Client ID gir'; - - @override - String get setupEnterClientSecret => 'Spotify Client Secret gir'; - - @override - String get setupGetFreeCredentials => - 'Spotify Developer Dashboard\'tan API kimlik bilgilerini ücretsiz alın.'; - @override String get setupEnableNotifications => 'Bildirimleri Etkinleştir'; - @override - String get setupProceedToNextStep => 'Bir sonraki adıma geçebilirsin.'; - - @override - String get setupNotificationProgressDescription => - 'İndirme ilerlemelerinin bildirimlerini alacaksın.'; - @override String get setupNotificationBackgroundDescription => 'İndirmelerin durumu hakkında bildirim al. Bunu açmak uygulama arka plandayken indirmelerinizi takip etmenizi sağlar.'; @@ -800,32 +499,19 @@ class AppLocalizationsTr extends AppLocalizations { @override String get setupSkipForNow => 'Şimdilik atla'; - @override - String get setupBack => 'Geri'; - @override String get setupNext => 'Sıradaki'; @override String get setupGetStarted => 'Başla'; - @override - String get setupSkipAndStart => 'Kurulumu atla'; - @override String get setupAllowAccessToManageFiles => 'Lütfen bir sonraki ekranda \"Bütün dosyalara eriş\" iznini sağlayın.'; - @override - String get setupGetCredentialsFromSpotify => - 'Kimlik bilgilerini developer.spotify.com\'dan alın'; - @override String get dialogCancel => 'İptal'; - @override - String get dialogOk => 'Tamam'; - @override String get dialogSave => 'Kaydet'; @@ -835,21 +521,9 @@ class AppLocalizationsTr extends AppLocalizations { @override String get dialogRetry => 'Yeniden dene'; - @override - String get dialogClose => 'Kapat'; - - @override - String get dialogYes => 'Evet'; - - @override - String get dialogNo => 'Hayır'; - @override String get dialogClear => 'Temizle'; - @override - String get dialogConfirm => 'Onayla'; - @override String get dialogDone => 'Tamamlandı'; @@ -872,28 +546,9 @@ class AppLocalizationsTr extends AppLocalizations { String get dialogUnsavedChanges => 'Kaydedilmeyen değişiklikler mevcut. Bu değişiklikleri iptal etmek istiyor musunuz?'; - @override - String get dialogDownloadFailed => 'İndirme Başarısız'; - - @override - String get dialogTrackLabel => 'Şarkı:'; - - @override - String get dialogArtistLabel => 'Sanatçı:'; - - @override - String get dialogErrorLabel => 'Hata:'; - @override String get dialogClearAll => 'Tümünü Temizle'; - @override - String get dialogClearAllDownloads => - 'Bütün indirmeleri temizlemek istediğinize emin misiniz?'; - - @override - String get dialogRemoveFromDevice => 'Cihazdan kaldır?'; - @override String get dialogRemoveExtension => 'Eklentiyi Kaldır'; @@ -994,11 +649,6 @@ class AppLocalizationsTr extends AppLocalizations { @override String get snackbarViewQueue => 'Kuyruğu Görüntüle'; - @override - String snackbarFailedToLoad(String error) { - return 'Yüklenemedi: $error'; - } - @override String snackbarUrlCopied(String platform) { return '$platform Bağlantı panoya kopyalandı'; @@ -1040,11 +690,6 @@ class AppLocalizationsTr extends AppLocalizations { String get errorRateLimitedMessage => 'Çok fazla istek. Lütfen arama yapmadan önce biraz bekleyin.'; - @override - String errorFailedToLoad(String item) { - return '$item yüklenirken hata oluştu'; - } - @override String get errorNoTracksFound => 'Parça bulunamadı'; @@ -1053,27 +698,6 @@ class AppLocalizationsTr extends AppLocalizations { return '$item yüklenemedi: Eksik eklenti kaynağı'; } - @override - String get statusQueued => 'Sıraya alındı'; - - @override - String get statusDownloading => 'İndiriliyor'; - - @override - String get statusFinalizing => 'Tamamlanıyor'; - - @override - String get statusCompleted => 'Tamamlandı'; - - @override - String get statusFailed => 'Başarısız'; - - @override - String get statusSkipped => 'Atlandı'; - - @override - String get statusPaused => 'Durduruldu'; - @override String get actionPause => 'Duraklat'; @@ -1083,24 +707,12 @@ class AppLocalizationsTr extends AppLocalizations { @override String get actionCancel => 'Vazgeç'; - @override - String get actionStop => 'Durdur'; - - @override - String get actionSelect => 'Seç'; - @override String get actionSelectAll => 'Tümünü Seç'; @override String get actionDeselect => 'Seçimi kaldır'; - @override - String get actionPaste => 'Yapıştır'; - - @override - String get actionImportCsv => 'CSV İçe Aktar'; - @override String get actionRemoveCredentials => 'Özellikleri kaldır'; @@ -1115,20 +727,6 @@ class AppLocalizationsTr extends AppLocalizations { @override String get selectionAllSelected => 'Tüm parçalar seçildi'; - @override - String get selectionTapToSelect => 'Seçmek için parçalara dokunun'; - - @override - String selectionDeleteTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: 'şarkıyı', - one: 'şarkıyı', - ); - return '$count $_temp0 sil'; - } - @override String get selectionSelectToDelete => 'Silinecek parçaları seçin'; @@ -1155,40 +753,9 @@ class AppLocalizationsTr extends AppLocalizations { @override String get tooltipPlay => 'Oynat'; - @override - String get tooltipCancel => 'Vazgeç'; - - @override - String get tooltipStop => 'Durdur'; - - @override - String get tooltipRetry => 'Yeniden dene'; - - @override - String get tooltipRemove => 'Kaldır'; - - @override - String get tooltipClear => 'Temizle'; - - @override - String get tooltipPaste => 'Yapıştır'; - @override String get filenameFormat => 'Dosya adı formatı'; - @override - String filenameFormatPreview(String preview) { - return 'Önizleme: $preview'; - } - - @override - String get filenameAvailablePlaceholders => 'Kullanılabilir yer tutucular:'; - - @override - String filenameHint(Object artist, Object title) { - return '$artist - $title'; - } - @override String get filenameShowAdvancedTags => 'Show advanced tags'; @@ -1196,9 +763,6 @@ class AppLocalizationsTr extends AppLocalizations { String get filenameShowAdvancedTagsDescription => 'Enable formatted tags for track padding and date patterns'; - @override - String get folderOrganization => 'Klasör Organizasyonu'; - @override String get folderOrganizationNone => 'Organizasyon yok'; @@ -1233,20 +797,9 @@ class AppLocalizationsTr extends AppLocalizations { @override String get updateAvailable => 'Güncelleme Mevcut'; - @override - String updateNewVersion(String version) { - return '$version sürümü mevcut'; - } - - @override - String get updateDownload => 'İndir'; - @override String get updateLater => 'Daha Sonra'; - @override - String get updateChangelog => 'Değişiklikler'; - @override String get updateStartingDownload => 'İndirme başlıyor...'; @@ -1277,13 +830,6 @@ class AppLocalizationsTr extends AppLocalizations { @override String get updateDontRemind => 'Bir daha sorma'; - @override - String get providerPriority => 'İndirme hizmetleri öncelik sırası'; - - @override - String get providerPrioritySubtitle => - 'İndirme hizmetlerini sıralamak için kaydır'; - @override String get providerPriorityTitle => 'İndirme hizmetleri öncelik sırası'; @@ -1301,13 +847,6 @@ class AppLocalizationsTr extends AppLocalizations { @override String get providerExtension => 'Eklenti'; - @override - String get metadataProviderPriority => 'Metadata Sağlayıcı Önceliği'; - - @override - String get metadataProviderPrioritySubtitle => - 'Şarkı metadata\'sı alınırken kullanılan sıra'; - @override String get metadataProviderPriorityTitle => 'Metadata Önceliği'; @@ -1328,18 +867,6 @@ class AppLocalizationsTr extends AppLocalizations { @override String get logTitle => 'Kayıtlar'; - @override - String get logCopy => 'Kayıtları Kopyala'; - - @override - String get logClear => 'Kayıtları temizle'; - - @override - String get logShare => 'Kayıtları Paylaş'; - - @override - String get logEmpty => 'Henüz kayıt yok'; - @override String get logCopied => 'Kayıtlar panoya kopyalandı'; @@ -1365,18 +892,6 @@ class AppLocalizationsTr extends AppLocalizations { String get logClearLogsMessage => 'Tüm kayıtları temizlemek istediğinize emin misiniz?'; - @override - String get logIspBlocking => 'ISP BLOCKING DETECTED'; - - @override - String get logRateLimited => 'RATE LIMITED'; - - @override - String get logNetworkError => 'NETWORK ERROR'; - - @override - String get logTrackNotFound => 'TRACK NOT FOUND'; - @override String get logFilterBySeverity => 'Kayıtları önem derecesine göre filtrele'; @@ -1387,48 +902,6 @@ class AppLocalizationsTr extends AppLocalizations { String get logNoLogsYetSubtitle => 'Uygulamayı kullandıkça kayıtlar burada görünecek'; - @override - String get logIssueSummary => 'Sorun Özeti'; - - @override - String get logIspBlockingDescription => - 'İnternet sağlayıcınız indirme hizmetlerine erişimi engelliyor olabilir'; - - @override - String get logIspBlockingSuggestion => - 'VPN kullanmayı veya DNS\'i 1.1.1.1 ya da 8.8.8.8 olarak değiştirmeyi deneyin'; - - @override - String get logRateLimitedDescription => 'Hizmete çok fazla istek gönderildi'; - - @override - String get logRateLimitedSuggestion => - 'Tekrar denemeden önce birkaç dakika bekleyin'; - - @override - String get logNetworkErrorDescription => 'Bağlantı sorunları tespit edildi'; - - @override - String get logNetworkErrorSuggestion => 'İnternet bağlantınızı kontrol edin'; - - @override - String get logTrackNotFoundDescription => - 'Bazı şarkılar indirme hizmetlerinde bulunamadı'; - - @override - String get logTrackNotFoundSuggestion => - 'Şarkı kayıpsız kalitede mevcut olmayabilir'; - - @override - String logTotalErrors(int count) { - return 'Total errors: $count'; - } - - @override - String logAffected(String domains) { - return 'Affected: $domains'; - } - @override String logEntriesFiltered(int count) { return 'Entries ($count filtered)'; @@ -1536,9 +1009,6 @@ class AppLocalizationsTr extends AppLocalizations { @override String get appearanceLanguage => 'Uygulama Dili'; - @override - String get appearanceLanguageSubtitle => 'Tercih ettiğiniz dili seçin'; - @override String get settingsAppearanceSubtitle => 'Tema, renkler, görünüm'; @@ -1562,9 +1032,6 @@ class AppLocalizationsTr extends AppLocalizations { @override String get pressBackAgainToExit => 'Çıkmak için tekrar geri basın'; - @override - String get tracksHeader => 'Şarkılar'; - @override String downloadAllCount(int count) { return 'Tümünü İndir ($count)'; @@ -1679,11 +1146,6 @@ class AppLocalizationsTr extends AppLocalizations { String get trackDeleteConfirmMessage => 'Bu işlem indirilen dosyayı kalıcı olarak silecek ve geçmişten kaldıracaktır.'; - @override - String trackCannotOpen(String message) { - return 'Cannot open: $message'; - } - @override String get dateToday => 'Bugün'; @@ -1705,18 +1167,6 @@ class AppLocalizationsTr extends AppLocalizations { return '$count ay önce'; } - @override - String get concurrentSequential => 'Sıralı'; - - @override - String get concurrentParallel2 => '2 Paralel'; - - @override - String get concurrentParallel3 => '3 Paralel'; - - @override - String get tapToSeeError => 'Hata detaylarını görmek için dokun'; - @override String get storeFilterAll => 'Tümü'; @@ -1738,15 +1188,6 @@ class AppLocalizationsTr extends AppLocalizations { @override String get storeClearFilters => 'Filtreleri temizle'; - @override - String get storeNoResults => 'Eklenti bulunamadı'; - - @override - String get extensionProviderPriority => 'Sağlayıcı Önceliği'; - - @override - String get extensionInstallButton => 'Eklenti Yükle'; - @override String get extensionDefaultProvider => 'Varsayılan (Deezer/Spotify)'; @@ -1898,38 +1339,6 @@ class AppLocalizationsTr extends AppLocalizations { @override String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; - @override - String get qualityLossy => 'Lossy'; - - @override - String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)'; - - @override - String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)'; - - @override - String get enableLossyOption => 'Enable Lossy Option'; - - @override - String get enableLossyOptionSubtitleOn => 'Lossy quality option is available'; - - @override - String get enableLossyOptionSubtitleOff => - 'Downloads FLAC then converts to lossy format'; - - @override - String get lossyFormat => 'Lossy Format'; - - @override - String get lossyFormatDescription => 'Choose the lossy format for conversion'; - - @override - String get lossyFormatMp3Subtitle => '320kbps, best compatibility'; - - @override - String get lossyFormatOpusSubtitle => - '128kbps, better quality at smaller size'; - @override String get qualityNote => 'Actual quality depends on track availability from the service'; @@ -1938,6 +1347,12 @@ class AppLocalizationsTr extends AppLocalizations { String get youtubeQualityNote => 'YouTube provides lossy audio only. Not part of lossless fallback.'; + @override + String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; + + @override + String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; + @override String get downloadAskBeforeDownload => 'Ask Before Download'; @@ -1953,14 +1368,6 @@ class AppLocalizationsTr extends AppLocalizations { @override String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders'; - @override - String get downloadUseAlbumArtistForFoldersAlbumSubtitle => - 'Artist folders use Album Artist when available'; - - @override - String get downloadUseAlbumArtistForFoldersTrackSubtitle => - 'Artist folders use Track Artist only'; - @override String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders'; @@ -1972,78 +1379,18 @@ class AppLocalizationsTr extends AppLocalizations { String get downloadUsePrimaryArtistOnlyDisabled => 'Full artist string used for folder name'; - @override - String get downloadSaveFormat => 'Save Format'; - - @override - String get downloadSelectService => 'Select Service'; - @override String get downloadSelectQuality => 'Select Quality'; @override String get downloadFrom => 'Download From'; - @override - String get downloadDefaultQualityLabel => 'Default Quality'; - - @override - String get downloadBestAvailable => 'Best available'; - - @override - String get folderNone => 'None'; - - @override - String get folderNoneSubtitle => 'Save all files directly to download folder'; - - @override - String get folderArtist => 'Artist'; - - @override - String get folderArtistSubtitle => 'Artist Name/filename'; - - @override - String get folderAlbum => 'Album'; - - @override - String get folderAlbumSubtitle => 'Album Name/filename'; - - @override - String get folderArtistAlbum => 'Artist/Album'; - - @override - String get folderArtistAlbumSubtitle => 'Artist Name/Album Name/filename'; - - @override - String get serviceTidal => 'Tidal'; - - @override - String get serviceQobuz => 'Qobuz'; - - @override - String get serviceAmazon => 'Amazon'; - - @override - String get serviceDeezer => 'Deezer'; - - @override - String get serviceSpotify => 'Spotify'; - @override String get appearanceAmoledDark => 'AMOLED Dark'; @override String get appearanceAmoledDarkSubtitle => 'Pure black background'; - @override - String get appearanceChooseAccentColor => 'Choose Accent Color'; - - @override - String get appearanceChooseTheme => 'Theme Mode'; - - @override - String get queueTitle => 'Download Queue'; - @override String get queueClearAll => 'Clear All'; @@ -2051,19 +1398,6 @@ class AppLocalizationsTr extends AppLocalizations { String get queueClearAllMessage => 'Are you sure you want to clear all downloads?'; - @override - String get queueExportFailed => 'Export'; - - @override - String get queueExportFailedSuccess => - 'Failed downloads exported to TXT file'; - - @override - String get queueExportFailedClear => 'Clear Failed'; - - @override - String get queueExportFailedError => 'Failed to export downloads'; - @override String get settingsAutoExportFailed => 'Auto-export failed downloads'; @@ -2084,30 +1418,6 @@ class AppLocalizationsTr extends AppLocalizations { String get settingsDownloadNetworkSubtitle => 'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.'; - @override - String get queueEmpty => 'No downloads in queue'; - - @override - String get queueEmptySubtitle => 'Add tracks from the home screen'; - - @override - String get queueClearCompleted => 'Clear completed'; - - @override - String get queueDownloadFailed => 'Download Failed'; - - @override - String get queueTrackLabel => 'Track:'; - - @override - String get queueArtistLabel => 'Artist:'; - - @override - String get queueErrorLabel => 'Error:'; - - @override - String get queueUnknownError => 'Unknown error'; - @override String get albumFolderArtistAlbum => 'Artist / Album'; @@ -2154,14 +1464,6 @@ class AppLocalizationsTr extends AppLocalizations { return 'Delete $count $_temp0 from this album?\n\nThis will also delete the files from storage.'; } - @override - String get downloadedAlbumTracksHeader => 'Tracks'; - - @override - String downloadedAlbumDownloadedCount(int count) { - return '$count downloaded'; - } - @override String downloadedAlbumSelectedCount(int count) { return '$count selected'; @@ -2192,9 +1494,6 @@ class AppLocalizationsTr extends AppLocalizations { return 'Disc $discNumber'; } - @override - String get utilityFunctions => 'Utility Functions'; - @override String get recentTypeArtist => 'Artist'; @@ -2218,11 +1517,6 @@ class AppLocalizationsTr extends AppLocalizations { return 'Playlist: $name'; } - @override - String errorGeneric(String message) { - return 'Error: $message'; - } - @override String get discographyDownload => 'Download Discography'; @@ -2328,9 +1622,6 @@ class AppLocalizationsTr extends AppLocalizations { @override String get libraryTitle => 'Local Library'; - @override - String get libraryStatus => 'Library Status'; - @override String get libraryScanSettings => 'Scan Settings'; @@ -2394,8 +1685,14 @@ class AppLocalizationsTr extends AppLocalizations { 'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.'; @override - String libraryTracksCount(int count) { - return '$count tracks'; + String libraryTracksUnit(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return '$_temp0'; } @override @@ -2477,21 +1774,6 @@ class AppLocalizationsTr extends AppLocalizations { @override String get libraryFilterFormat => 'Format'; - @override - String get libraryFilterDate => 'Date Added'; - - @override - String get libraryFilterDateToday => 'Today'; - - @override - String get libraryFilterDateWeek => 'This Week'; - - @override - String get libraryFilterDateMonth => 'This Month'; - - @override - String get libraryFilterDateYear => 'This Year'; - @override String get libraryFilterSort => 'Sort'; @@ -2501,11 +1783,6 @@ class AppLocalizationsTr extends AppLocalizations { @override String get libraryFilterSortOldest => 'Oldest'; - @override - String libraryFilterActive(int count) { - return '$count filter(s) active'; - } - @override String get timeJustNow => 'Just now'; @@ -2531,72 +1808,6 @@ class AppLocalizationsTr extends AppLocalizations { return '$_temp0'; } - @override - String get storageSwitchTitle => 'Switch Storage Mode'; - - @override - String get storageSwitchToSafTitle => 'Switch to SAF Storage?'; - - @override - String get storageSwitchToAppTitle => 'Switch to App Storage?'; - - @override - String get storageSwitchToSafMessage => - 'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.'; - - @override - String get storageSwitchToAppMessage => - 'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.'; - - @override - String get storageSwitchExistingDownloads => 'Existing Downloads'; - - @override - String storageSwitchExistingDownloadsInfo(int count, String mode) { - return '$count tracks in $mode storage'; - } - - @override - String get storageSwitchNewDownloads => 'New Downloads'; - - @override - String storageSwitchNewDownloadsLocation(String location) { - return 'Will be saved to: $location'; - } - - @override - String get storageSwitchContinue => 'Continue'; - - @override - String get storageSwitchSelectFolder => 'Select SAF Folder'; - - @override - String get storageAppStorage => 'App Storage'; - - @override - String get storageSafStorage => 'SAF Storage'; - - @override - String storageModeBadge(String mode) { - return 'Storage: $mode'; - } - - @override - String get storageStatsTitle => 'Storage Statistics'; - - @override - String storageStatsAppCount(int count) { - return '$count tracks in App Storage'; - } - - @override - String storageStatsSafCount(int count) { - return '$count tracks in SAF Storage'; - } - - @override - String get storageModeInfo => 'Your files are stored in multiple locations'; - @override String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!'; @@ -2623,18 +1834,6 @@ class AppLocalizationsTr extends AppLocalizations { String get tutorialSearchDesc => 'There are two easy ways to find music you want to download.'; - @override - String get tutorialSearchTip1 => - 'Paste a Spotify or Deezer URL directly in the search box'; - - @override - String get tutorialSearchTip2 => - 'Or type the song name, artist, or album to search'; - - @override - String get tutorialSearchTip3 => - 'Supports tracks, albums, playlists, and artist pages'; - @override String get tutorialDownloadTitle => 'Downloading Music'; @@ -2642,18 +1841,6 @@ class AppLocalizationsTr extends AppLocalizations { String get tutorialDownloadDesc => 'Downloading music is simple and fast. Here\'s how it works.'; - @override - String get tutorialDownloadTip1 => - 'Tap the download button next to any track to start downloading'; - - @override - String get tutorialDownloadTip2 => - 'Choose your preferred quality (FLAC, Hi-Res, or MP3)'; - - @override - String get tutorialDownloadTip3 => - 'Download entire albums or playlists with one tap'; - @override String get tutorialLibraryTitle => 'Your Library'; @@ -2714,9 +1901,6 @@ class AppLocalizationsTr extends AppLocalizations { String get tutorialReadyMessage => 'You\'re all set! Start downloading your favorite music now.'; - @override - String get tutorialExample => 'EXAMPLE'; - @override String get libraryForceFullScan => 'Force Full Scan'; @@ -2877,11 +2061,7 @@ class AppLocalizationsTr extends AppLocalizations { String get trackSaveLyricsProgress => 'Saving lyrics...'; @override - String get trackReEnrich => 'Re-enrich Metadata'; - - @override - String get trackReEnrichSubtitle => - 'Re-embed metadata without re-downloading'; + String get trackReEnrich => 'Re-enrich'; @override String get trackReEnrichOnlineSubtitle => @@ -2957,4 +2137,221 @@ class AppLocalizationsTr extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + + @override + String get actionCreate => 'Create'; + + @override + String get collectionFoldersTitle => 'My folders'; + + @override + String get collectionWishlist => 'Wishlist'; + + @override + String get collectionLoved => 'Loved'; + + @override + String get collectionPlaylists => 'Playlists'; + + @override + String get collectionPlaylist => 'Playlist'; + + @override + String get collectionAddToPlaylist => 'Add to playlist'; + + @override + String get collectionCreatePlaylist => 'Create playlist'; + + @override + String get collectionNoPlaylistsYet => 'No playlists yet'; + + @override + String get collectionNoPlaylistsSubtitle => + 'Create a playlist to start categorizing tracks'; + + @override + String collectionPlaylistTracks(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String collectionAddedToPlaylist(String playlistName) { + return 'Added to \"$playlistName\"'; + } + + @override + String collectionAlreadyInPlaylist(String playlistName) { + return 'Already in \"$playlistName\"'; + } + + @override + String get collectionPlaylistCreated => 'Playlist created'; + + @override + String get collectionPlaylistNameHint => 'Playlist name'; + + @override + String get collectionPlaylistNameRequired => 'Playlist name is required'; + + @override + String get collectionRenamePlaylist => 'Rename playlist'; + + @override + String get collectionDeletePlaylist => 'Delete playlist'; + + @override + String collectionDeletePlaylistMessage(String playlistName) { + return 'Delete \"$playlistName\" and all tracks inside it?'; + } + + @override + String get collectionPlaylistDeleted => 'Playlist deleted'; + + @override + String get collectionPlaylistRenamed => 'Playlist renamed'; + + @override + String get collectionWishlistEmptyTitle => 'Wishlist is empty'; + + @override + String get collectionWishlistEmptySubtitle => + 'Tap + on tracks to save what you want to download later'; + + @override + String get collectionLovedEmptyTitle => 'Loved folder is empty'; + + @override + String get collectionLovedEmptySubtitle => + 'Tap love on tracks to keep your favorites'; + + @override + String get collectionPlaylistEmptyTitle => 'Playlist is empty'; + + @override + String get collectionPlaylistEmptySubtitle => + 'Long-press + on any track to add it here'; + + @override + String get collectionRemoveFromPlaylist => 'Remove from playlist'; + + @override + String get collectionRemoveFromFolder => 'Remove from folder'; + + @override + String collectionRemoved(String trackName) { + return '\"$trackName\" removed'; + } + + @override + String collectionAddedToLoved(String trackName) { + return '\"$trackName\" added to Loved'; + } + + @override + String collectionRemovedFromLoved(String trackName) { + return '\"$trackName\" removed from Loved'; + } + + @override + String collectionAddedToWishlist(String trackName) { + return '\"$trackName\" added to Wishlist'; + } + + @override + String collectionRemovedFromWishlist(String trackName) { + return '\"$trackName\" removed from Wishlist'; + } + + @override + String get trackOptionAddToLoved => 'Add to Loved'; + + @override + String get trackOptionRemoveFromLoved => 'Remove from Loved'; + + @override + String get trackOptionAddToWishlist => 'Add to Wishlist'; + + @override + String get trackOptionRemoveFromWishlist => 'Remove from Wishlist'; + + @override + String get collectionPlaylistChangeCover => 'Change cover image'; + + @override + String get collectionPlaylistRemoveCover => 'Remove cover image'; + + @override + String selectionShareCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Share $count $_temp0'; + } + + @override + String get selectionShareNoFiles => 'No shareable files found'; + + @override + String selectionConvertCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0'; + } + + @override + String get selectionConvertNoConvertible => 'No convertible tracks selected'; + + @override + String get selectionBatchConvertConfirmTitle => 'Batch Convert'; + + @override + String selectionBatchConvertConfirmMessage( + int count, + String format, + String bitrate, + ) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.'; + } + + @override + String selectionBatchConvertProgress(int current, int total) { + return 'Converting $current of $total...'; + } + + @override + String selectionBatchConvertSuccess(int success, int total, String format) { + return 'Converted $success of $total tracks to $format'; + } + + @override + String downloadedAlbumDownloadedCount(int count) { + return '$count downloaded'; + } + + @override + String get downloadUseAlbumArtistForFoldersAlbumSubtitle => + 'Artist folders use Album Artist when available'; + + @override + String get downloadUseAlbumArtistForFoldersTrackSubtitle => + 'Artist folders use Track Artist only'; } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index e1d656eb..433e771b 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -11,19 +11,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String get appName => 'SpotiFLAC'; - @override - String get appDescription => - 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; - @override String get navHome => 'Home'; @override String get navLibrary => 'Library'; - @override - String get navHistory => 'History'; - @override String get navSettings => 'Settings'; @@ -33,14 +26,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get homeTitle => 'Home'; - @override - String get homeSearchHint => 'Paste Spotify URL or search...'; - - @override - String homeSearchHintExtension(String extensionName) { - return 'Search with $extensionName...'; - } - @override String get homeSubtitle => 'Paste a Spotify link or search by name'; @@ -50,17 +35,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get homeRecent => 'Recent'; - @override - String get historyTitle => 'History'; - - @override - String historyDownloading(int count) { - return 'Downloading ($count)'; - } - - @override - String get historyDownloaded => 'Downloaded'; - @override String get historyFilterAll => 'All'; @@ -70,48 +44,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get historyFilterSingles => 'Singles'; - @override - String historyTracksCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count tracks', - one: '1 track', - ); - return '$_temp0'; - } - - @override - String historyAlbumsCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count albums', - one: '1 album', - ); - return '$_temp0'; - } - - @override - String get historyNoDownloads => 'No download history'; - - @override - String get historyNoDownloadsSubtitle => 'Downloaded tracks will appear here'; - - @override - String get historyNoAlbums => 'No album downloads'; - - @override - String get historyNoAlbumsSubtitle => - 'Download multiple tracks from an album to see them here'; - - @override - String get historyNoSingles => 'No single downloads'; - - @override - String get historyNoSinglesSubtitle => - 'Single track downloads will appear here'; - @override String get historySearchHint => 'Search history...'; @@ -136,27 +68,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get downloadTitle => 'Download'; - @override - String get downloadLocation => 'Download Location'; - - @override - String get downloadLocationSubtitle => 'Choose where to save files'; - - @override - String get downloadLocationDefault => 'Default location'; - - @override - String get downloadDefaultService => 'Default Service'; - - @override - String get downloadDefaultServiceSubtitle => 'Service used for downloads'; - - @override - String get downloadDefaultQuality => 'Default Quality'; - - @override - String get downloadAskQuality => 'Ask Quality Before Download'; - @override String get downloadAskQualitySubtitle => 'Show quality picker for each download'; @@ -167,31 +78,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get downloadFolderOrganization => 'Folder Organization'; - @override - String get downloadSeparateSingles => 'Separate Singles'; - - @override - String get downloadSeparateSinglesSubtitle => - 'Put single tracks in a separate folder'; - - @override - String get qualityBest => 'Best Available'; - - @override - String get qualityFlac => 'FLAC'; - - @override - String get quality320 => '320 kbps'; - - @override - String get quality128 => '128 kbps'; - @override String get appearanceTitle => 'Appearance'; - @override - String get appearanceTheme => 'Theme'; - @override String get appearanceThemeSystem => 'System'; @@ -207,9 +96,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get appearanceDynamicColorSubtitle => 'Use colors from your wallpaper'; - @override - String get appearanceAccentColor => 'Accent Color'; - @override String get appearanceHistoryView => 'History View'; @@ -222,9 +108,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get optionsTitle => 'Options'; - @override - String get optionsSearchSource => 'Search Source'; - @override String get optionsPrimaryProvider => 'Primary Provider'; @@ -350,18 +233,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get extensionsTitle => 'Extensions'; - @override - String get extensionsInstalled => 'Installed Extensions'; - - @override - String get extensionsNone => 'No extensions installed'; - - @override - String get extensionsNoneSubtitle => 'Install extensions from the Store tab'; - - @override - String get extensionsEnabled => 'Enabled'; - @override String get extensionsDisabled => 'Disabled'; @@ -378,9 +249,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get extensionsUninstall => 'Uninstall'; - @override - String get extensionsSetAsSearch => 'Set as Search Provider'; - @override String get storeTitle => 'Extension Store'; @@ -454,9 +322,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get aboutSocial => 'Social'; - @override - String get aboutSupport => 'Support'; - @override String get aboutApp => 'App'; @@ -475,13 +340,6 @@ class AppLocalizationsZh extends AppLocalizations { String get aboutSjdonadoDesc => 'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!'; - @override - String get aboutDoubleDouble => 'DoubleDouble'; - - @override - String get aboutDoubleDoubleDesc => - 'Amazing API for Amazon Music downloads. Thank you for making it free!'; - @override String get aboutDabMusic => 'DAB Music'; @@ -500,32 +358,6 @@ class AppLocalizationsZh extends AppLocalizations { String get aboutAppDescription => 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; - @override - String get albumTitle => 'Album'; - - @override - String albumTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count tracks', - one: '1 track', - ); - return '$_temp0'; - } - - @override - String get albumDownloadAll => 'Download All'; - - @override - String get albumDownloadRemaining => 'Download Remaining'; - - @override - String get playlistTitle => 'Playlist'; - - @override - String get artistTitle => 'Artist'; - @override String get artistAlbums => 'Albums'; @@ -535,17 +367,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get artistCompilations => 'Compilations'; - @override - String artistReleases(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count releases', - one: '1 release', - ); - return '$_temp0'; - } - @override String get artistPopular => 'Popular'; @@ -554,27 +375,6 @@ class AppLocalizationsZh extends AppLocalizations { return '$count monthly listeners'; } - @override - String get trackMetadataTitle => 'Track Info'; - - @override - String get trackMetadataArtist => 'Artist'; - - @override - String get trackMetadataAlbum => 'Album'; - - @override - String get trackMetadataDuration => 'Duration'; - - @override - String get trackMetadataQuality => 'Quality'; - - @override - String get trackMetadataPath => 'File Path'; - - @override - String get trackMetadataDownloadedAt => 'Downloaded'; - @override String get trackMetadataService => 'Service'; @@ -587,53 +387,15 @@ class AppLocalizationsZh extends AppLocalizations { @override String get trackMetadataDelete => 'Delete'; - @override - String get trackMetadataRedownload => 'Re-download'; - - @override - String get trackMetadataOpenFolder => 'Open Folder'; - - @override - String get setupTitle => 'Welcome to SpotiFLAC'; - - @override - String get setupSubtitle => 'Let\'s get you started'; - - @override - String get setupStoragePermission => 'Storage Permission'; - - @override - String get setupStoragePermissionSubtitle => - 'Required to save downloaded files'; - - @override - String get setupStoragePermissionGranted => 'Permission granted'; - - @override - String get setupStoragePermissionDenied => 'Permission denied'; - @override String get setupGrantPermission => 'Grant Permission'; - @override - String get setupDownloadLocation => 'Download Location'; - - @override - String get setupChooseFolder => 'Choose Folder'; - - @override - String get setupContinue => 'Continue'; - @override String get setupSkip => 'Skip for now'; @override String get setupStorageAccessRequired => 'Storage Access Required'; - @override - String get setupStorageAccessMessage => - 'SpotiFLAC needs \"All files access\" permission to save music files to your chosen folder.'; - @override String get setupStorageAccessMessageAndroid11 => 'Android 11+ requires \"All files access\" permission to save files to your chosen download folder.'; @@ -655,9 +417,6 @@ class AppLocalizationsZh extends AppLocalizations { return '$permissionType permission is required for the best experience. You can change this later in Settings.'; } - @override - String get setupSelectDownloadFolder => 'Select Download Folder'; - @override String get setupUseDefaultFolder => 'Use Default Folder?'; @@ -699,21 +458,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get setupDownloadInFlac => 'Download Spotify tracks in FLAC'; - @override - String get setupStepStorage => 'Storage'; - - @override - String get setupStepNotification => 'Notification'; - - @override - String get setupStepFolder => 'Folder'; - - @override - String get setupStepSpotify => 'Spotify'; - - @override - String get setupStepPermission => 'Permission'; - @override String get setupStorageGranted => 'Storage Permission Granted!'; @@ -730,13 +474,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get setupNotificationEnable => 'Enable Notifications'; - @override - String get setupNotificationDescription => - 'Get notified when downloads complete or require attention.'; - - @override - String get setupFolderSelected => 'Download Folder Selected!'; - @override String get setupFolderChoose => 'Choose Download Folder'; @@ -744,48 +481,12 @@ class AppLocalizationsZh extends AppLocalizations { String get setupFolderDescription => 'Select a folder where your downloaded music will be saved.'; - @override - String get setupChangeFolder => 'Change Folder'; - @override String get setupSelectFolder => 'Select Folder'; - @override - String get setupSpotifyApiOptional => 'Spotify API (Optional)'; - - @override - String get setupSpotifyApiDescription => - 'Add your Spotify API credentials for better search results and access to Spotify-exclusive content.'; - - @override - String get setupUseSpotifyApi => 'Use Spotify API'; - - @override - String get setupEnterCredentialsBelow => 'Enter your credentials below'; - - @override - String get setupUsingDeezer => 'Using Deezer (no account needed)'; - - @override - String get setupEnterClientId => 'Enter Spotify Client ID'; - - @override - String get setupEnterClientSecret => 'Enter Spotify Client Secret'; - - @override - String get setupGetFreeCredentials => - 'Get your free API credentials from the Spotify Developer Dashboard.'; - @override String get setupEnableNotifications => 'Enable Notifications'; - @override - String get setupProceedToNextStep => 'You can now proceed to the next step.'; - - @override - String get setupNotificationProgressDescription => - 'You will receive download progress notifications.'; - @override String get setupNotificationBackgroundDescription => 'Get notified about download progress and completion. This helps you track downloads when the app is in background.'; @@ -793,32 +494,19 @@ class AppLocalizationsZh extends AppLocalizations { @override String get setupSkipForNow => 'Skip for now'; - @override - String get setupBack => 'Back'; - @override String get setupNext => 'Next'; @override String get setupGetStarted => 'Get Started'; - @override - String get setupSkipAndStart => 'Skip & Start'; - @override String get setupAllowAccessToManageFiles => 'Please enable \"Allow access to manage all files\" in the next screen.'; - @override - String get setupGetCredentialsFromSpotify => - 'Get credentials from developer.spotify.com'; - @override String get dialogCancel => 'Cancel'; - @override - String get dialogOk => 'OK'; - @override String get dialogSave => 'Save'; @@ -828,21 +516,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get dialogRetry => 'Retry'; - @override - String get dialogClose => 'Close'; - - @override - String get dialogYes => 'Yes'; - - @override - String get dialogNo => 'No'; - @override String get dialogClear => 'Clear'; - @override - String get dialogConfirm => 'Confirm'; - @override String get dialogDone => 'Done'; @@ -865,28 +541,9 @@ class AppLocalizationsZh extends AppLocalizations { String get dialogUnsavedChanges => 'You have unsaved changes. Do you want to discard them?'; - @override - String get dialogDownloadFailed => 'Download Failed'; - - @override - String get dialogTrackLabel => 'Track:'; - - @override - String get dialogArtistLabel => 'Artist:'; - - @override - String get dialogErrorLabel => 'Error:'; - @override String get dialogClearAll => 'Clear All'; - @override - String get dialogClearAllDownloads => - 'Are you sure you want to clear all downloads?'; - - @override - String get dialogRemoveFromDevice => 'Remove from device?'; - @override String get dialogRemoveExtension => 'Remove Extension'; @@ -987,11 +644,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get snackbarViewQueue => 'View Queue'; - @override - String snackbarFailedToLoad(String error) { - return 'Failed to load: $error'; - } - @override String snackbarUrlCopied(String platform) { return '$platform URL copied to clipboard'; @@ -1033,11 +685,6 @@ class AppLocalizationsZh extends AppLocalizations { String get errorRateLimitedMessage => 'Too many requests. Please wait a moment before searching again.'; - @override - String errorFailedToLoad(String item) { - return 'Failed to load $item'; - } - @override String get errorNoTracksFound => 'No tracks found'; @@ -1046,27 +693,6 @@ class AppLocalizationsZh extends AppLocalizations { return 'Cannot load $item: missing extension source'; } - @override - String get statusQueued => 'Queued'; - - @override - String get statusDownloading => 'Downloading'; - - @override - String get statusFinalizing => 'Finalizing'; - - @override - String get statusCompleted => 'Completed'; - - @override - String get statusFailed => 'Failed'; - - @override - String get statusSkipped => 'Skipped'; - - @override - String get statusPaused => 'Paused'; - @override String get actionPause => 'Pause'; @@ -1076,24 +702,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String get actionCancel => 'Cancel'; - @override - String get actionStop => 'Stop'; - - @override - String get actionSelect => 'Select'; - @override String get actionSelectAll => 'Select All'; @override String get actionDeselect => 'Deselect'; - @override - String get actionPaste => 'Paste'; - - @override - String get actionImportCsv => 'Import CSV'; - @override String get actionRemoveCredentials => 'Remove Credentials'; @@ -1108,20 +722,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get selectionAllSelected => 'All tracks selected'; - @override - String get selectionTapToSelect => 'Tap tracks to select'; - - @override - String selectionDeleteTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: 'tracks', - one: 'track', - ); - return 'Delete $count $_temp0'; - } - @override String get selectionSelectToDelete => 'Select tracks to delete'; @@ -1148,40 +748,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get tooltipPlay => 'Play'; - @override - String get tooltipCancel => 'Cancel'; - - @override - String get tooltipStop => 'Stop'; - - @override - String get tooltipRetry => 'Retry'; - - @override - String get tooltipRemove => 'Remove'; - - @override - String get tooltipClear => 'Clear'; - - @override - String get tooltipPaste => 'Paste'; - @override String get filenameFormat => 'Filename Format'; - @override - String filenameFormatPreview(String preview) { - return 'Preview: $preview'; - } - - @override - String get filenameAvailablePlaceholders => 'Available placeholders:'; - - @override - String filenameHint(Object artist, Object title) { - return '$artist - $title'; - } - @override String get filenameShowAdvancedTags => 'Show advanced tags'; @@ -1189,9 +758,6 @@ class AppLocalizationsZh extends AppLocalizations { String get filenameShowAdvancedTagsDescription => 'Enable formatted tags for track padding and date patterns'; - @override - String get folderOrganization => 'Folder Organization'; - @override String get folderOrganizationNone => 'No organization'; @@ -1226,20 +792,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get updateAvailable => 'Update Available'; - @override - String updateNewVersion(String version) { - return 'Version $version is available'; - } - - @override - String get updateDownload => 'Download'; - @override String get updateLater => 'Later'; - @override - String get updateChangelog => 'Changelog'; - @override String get updateStartingDownload => 'Starting download...'; @@ -1270,12 +825,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get updateDontRemind => 'Don\'t remind'; - @override - String get providerPriority => 'Provider Priority'; - - @override - String get providerPrioritySubtitle => 'Drag to reorder download providers'; - @override String get providerPriorityTitle => 'Provider Priority'; @@ -1293,13 +842,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get providerExtension => 'Extension'; - @override - String get metadataProviderPriority => 'Metadata Provider Priority'; - - @override - String get metadataProviderPrioritySubtitle => - 'Order used when fetching track metadata'; - @override String get metadataProviderPriorityTitle => 'Metadata Priority'; @@ -1320,18 +862,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get logTitle => 'Logs'; - @override - String get logCopy => 'Copy Logs'; - - @override - String get logClear => 'Clear Logs'; - - @override - String get logShare => 'Share Logs'; - - @override - String get logEmpty => 'No logs yet'; - @override String get logCopied => 'Logs copied to clipboard'; @@ -1356,18 +886,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get logClearLogsMessage => 'Are you sure you want to clear all logs?'; - @override - String get logIspBlocking => 'ISP BLOCKING DETECTED'; - - @override - String get logRateLimited => 'RATE LIMITED'; - - @override - String get logNetworkError => 'NETWORK ERROR'; - - @override - String get logTrackNotFound => 'TRACK NOT FOUND'; - @override String get logFilterBySeverity => 'Filter logs by severity'; @@ -1377,48 +895,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get logNoLogsYetSubtitle => 'Logs will appear here as you use the app'; - @override - String get logIssueSummary => 'Issue Summary'; - - @override - String get logIspBlockingDescription => - 'Your ISP may be blocking access to download services'; - - @override - String get logIspBlockingSuggestion => - 'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8'; - - @override - String get logRateLimitedDescription => 'Too many requests to the service'; - - @override - String get logRateLimitedSuggestion => - 'Wait a few minutes before trying again'; - - @override - String get logNetworkErrorDescription => 'Connection issues detected'; - - @override - String get logNetworkErrorSuggestion => 'Check your internet connection'; - - @override - String get logTrackNotFoundDescription => - 'Some tracks could not be found on download services'; - - @override - String get logTrackNotFoundSuggestion => - 'The track may not be available in lossless quality'; - - @override - String logTotalErrors(int count) { - return 'Total errors: $count'; - } - - @override - String logAffected(String domains) { - return 'Affected: $domains'; - } - @override String logEntriesFiltered(int count) { return 'Entries ($count filtered)'; @@ -1525,9 +1001,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get appearanceLanguage => 'App Language'; - @override - String get appearanceLanguageSubtitle => 'Choose your preferred language'; - @override String get settingsAppearanceSubtitle => 'Theme, colors, display'; @@ -1549,9 +1022,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get pressBackAgainToExit => 'Press back again to exit'; - @override - String get tracksHeader => 'Tracks'; - @override String downloadAllCount(int count) { return 'Download All ($count)'; @@ -1665,11 +1135,6 @@ class AppLocalizationsZh extends AppLocalizations { String get trackDeleteConfirmMessage => 'This will permanently delete the downloaded file and remove it from your history.'; - @override - String trackCannotOpen(String message) { - return 'Cannot open: $message'; - } - @override String get dateToday => 'Today'; @@ -1691,18 +1156,6 @@ class AppLocalizationsZh extends AppLocalizations { return '$count months ago'; } - @override - String get concurrentSequential => 'Sequential'; - - @override - String get concurrentParallel2 => '2 Parallel'; - - @override - String get concurrentParallel3 => '3 Parallel'; - - @override - String get tapToSeeError => 'Tap to see error details'; - @override String get storeFilterAll => 'All'; @@ -1724,15 +1177,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get storeClearFilters => 'Clear filters'; - @override - String get storeNoResults => 'No extensions found'; - - @override - String get extensionProviderPriority => 'Provider Priority'; - - @override - String get extensionInstallButton => 'Install Extension'; - @override String get extensionDefaultProvider => 'Default (Deezer/Spotify)'; @@ -1883,38 +1327,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; - @override - String get qualityLossy => 'Lossy'; - - @override - String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)'; - - @override - String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)'; - - @override - String get enableLossyOption => 'Enable Lossy Option'; - - @override - String get enableLossyOptionSubtitleOn => 'Lossy quality option is available'; - - @override - String get enableLossyOptionSubtitleOff => - 'Downloads FLAC then converts to lossy format'; - - @override - String get lossyFormat => 'Lossy Format'; - - @override - String get lossyFormatDescription => 'Choose the lossy format for conversion'; - - @override - String get lossyFormatMp3Subtitle => '320kbps, best compatibility'; - - @override - String get lossyFormatOpusSubtitle => - '128kbps, better quality at smaller size'; - @override String get qualityNote => 'Actual quality depends on track availability from the service'; @@ -1923,6 +1335,12 @@ class AppLocalizationsZh extends AppLocalizations { String get youtubeQualityNote => 'YouTube provides lossy audio only. Not part of lossless fallback.'; + @override + String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; + + @override + String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; + @override String get downloadAskBeforeDownload => 'Ask Before Download'; @@ -1938,14 +1356,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders'; - @override - String get downloadUseAlbumArtistForFoldersAlbumSubtitle => - 'Artist folders use Album Artist when available'; - - @override - String get downloadUseAlbumArtistForFoldersTrackSubtitle => - 'Artist folders use Track Artist only'; - @override String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders'; @@ -1957,78 +1367,18 @@ class AppLocalizationsZh extends AppLocalizations { String get downloadUsePrimaryArtistOnlyDisabled => 'Full artist string used for folder name'; - @override - String get downloadSaveFormat => 'Save Format'; - - @override - String get downloadSelectService => 'Select Service'; - @override String get downloadSelectQuality => 'Select Quality'; @override String get downloadFrom => 'Download From'; - @override - String get downloadDefaultQualityLabel => 'Default Quality'; - - @override - String get downloadBestAvailable => 'Best available'; - - @override - String get folderNone => 'None'; - - @override - String get folderNoneSubtitle => 'Save all files directly to download folder'; - - @override - String get folderArtist => 'Artist'; - - @override - String get folderArtistSubtitle => 'Artist Name/filename'; - - @override - String get folderAlbum => 'Album'; - - @override - String get folderAlbumSubtitle => 'Album Name/filename'; - - @override - String get folderArtistAlbum => 'Artist/Album'; - - @override - String get folderArtistAlbumSubtitle => 'Artist Name/Album Name/filename'; - - @override - String get serviceTidal => 'Tidal'; - - @override - String get serviceQobuz => 'Qobuz'; - - @override - String get serviceAmazon => 'Amazon'; - - @override - String get serviceDeezer => 'Deezer'; - - @override - String get serviceSpotify => 'Spotify'; - @override String get appearanceAmoledDark => 'AMOLED Dark'; @override String get appearanceAmoledDarkSubtitle => 'Pure black background'; - @override - String get appearanceChooseAccentColor => 'Choose Accent Color'; - - @override - String get appearanceChooseTheme => 'Theme Mode'; - - @override - String get queueTitle => 'Download Queue'; - @override String get queueClearAll => 'Clear All'; @@ -2036,19 +1386,6 @@ class AppLocalizationsZh extends AppLocalizations { String get queueClearAllMessage => 'Are you sure you want to clear all downloads?'; - @override - String get queueExportFailed => 'Export'; - - @override - String get queueExportFailedSuccess => - 'Failed downloads exported to TXT file'; - - @override - String get queueExportFailedClear => 'Clear Failed'; - - @override - String get queueExportFailedError => 'Failed to export downloads'; - @override String get settingsAutoExportFailed => 'Auto-export failed downloads'; @@ -2069,30 +1406,6 @@ class AppLocalizationsZh extends AppLocalizations { String get settingsDownloadNetworkSubtitle => 'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.'; - @override - String get queueEmpty => 'No downloads in queue'; - - @override - String get queueEmptySubtitle => 'Add tracks from the home screen'; - - @override - String get queueClearCompleted => 'Clear completed'; - - @override - String get queueDownloadFailed => 'Download Failed'; - - @override - String get queueTrackLabel => 'Track:'; - - @override - String get queueArtistLabel => 'Artist:'; - - @override - String get queueErrorLabel => 'Error:'; - - @override - String get queueUnknownError => 'Unknown error'; - @override String get albumFolderArtistAlbum => 'Artist / Album'; @@ -2139,14 +1452,6 @@ class AppLocalizationsZh extends AppLocalizations { return 'Delete $count $_temp0 from this album?\n\nThis will also delete the files from storage.'; } - @override - String get downloadedAlbumTracksHeader => 'Tracks'; - - @override - String downloadedAlbumDownloadedCount(int count) { - return '$count downloaded'; - } - @override String downloadedAlbumSelectedCount(int count) { return '$count selected'; @@ -2177,9 +1482,6 @@ class AppLocalizationsZh extends AppLocalizations { return 'Disc $discNumber'; } - @override - String get utilityFunctions => 'Utility Functions'; - @override String get recentTypeArtist => 'Artist'; @@ -2203,11 +1505,6 @@ class AppLocalizationsZh extends AppLocalizations { return 'Playlist: $name'; } - @override - String errorGeneric(String message) { - return 'Error: $message'; - } - @override String get discographyDownload => 'Download Discography'; @@ -2313,9 +1610,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get libraryTitle => 'Local Library'; - @override - String get libraryStatus => 'Library Status'; - @override String get libraryScanSettings => 'Scan Settings'; @@ -2379,8 +1673,14 @@ class AppLocalizationsZh extends AppLocalizations { 'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.'; @override - String libraryTracksCount(int count) { - return '$count tracks'; + String libraryTracksUnit(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return '$_temp0'; } @override @@ -2462,21 +1762,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get libraryFilterFormat => 'Format'; - @override - String get libraryFilterDate => 'Date Added'; - - @override - String get libraryFilterDateToday => 'Today'; - - @override - String get libraryFilterDateWeek => 'This Week'; - - @override - String get libraryFilterDateMonth => 'This Month'; - - @override - String get libraryFilterDateYear => 'This Year'; - @override String get libraryFilterSort => 'Sort'; @@ -2486,11 +1771,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get libraryFilterSortOldest => 'Oldest'; - @override - String libraryFilterActive(int count) { - return '$count filter(s) active'; - } - @override String get timeJustNow => 'Just now'; @@ -2516,72 +1796,6 @@ class AppLocalizationsZh extends AppLocalizations { return '$_temp0'; } - @override - String get storageSwitchTitle => 'Switch Storage Mode'; - - @override - String get storageSwitchToSafTitle => 'Switch to SAF Storage?'; - - @override - String get storageSwitchToAppTitle => 'Switch to App Storage?'; - - @override - String get storageSwitchToSafMessage => - 'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.'; - - @override - String get storageSwitchToAppMessage => - 'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.'; - - @override - String get storageSwitchExistingDownloads => 'Existing Downloads'; - - @override - String storageSwitchExistingDownloadsInfo(int count, String mode) { - return '$count tracks in $mode storage'; - } - - @override - String get storageSwitchNewDownloads => 'New Downloads'; - - @override - String storageSwitchNewDownloadsLocation(String location) { - return 'Will be saved to: $location'; - } - - @override - String get storageSwitchContinue => 'Continue'; - - @override - String get storageSwitchSelectFolder => 'Select SAF Folder'; - - @override - String get storageAppStorage => 'App Storage'; - - @override - String get storageSafStorage => 'SAF Storage'; - - @override - String storageModeBadge(String mode) { - return 'Storage: $mode'; - } - - @override - String get storageStatsTitle => 'Storage Statistics'; - - @override - String storageStatsAppCount(int count) { - return '$count tracks in App Storage'; - } - - @override - String storageStatsSafCount(int count) { - return '$count tracks in SAF Storage'; - } - - @override - String get storageModeInfo => 'Your files are stored in multiple locations'; - @override String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!'; @@ -2608,18 +1822,6 @@ class AppLocalizationsZh extends AppLocalizations { String get tutorialSearchDesc => 'There are two easy ways to find music you want to download.'; - @override - String get tutorialSearchTip1 => - 'Paste a Spotify or Deezer URL directly in the search box'; - - @override - String get tutorialSearchTip2 => - 'Or type the song name, artist, or album to search'; - - @override - String get tutorialSearchTip3 => - 'Supports tracks, albums, playlists, and artist pages'; - @override String get tutorialDownloadTitle => 'Downloading Music'; @@ -2627,18 +1829,6 @@ class AppLocalizationsZh extends AppLocalizations { String get tutorialDownloadDesc => 'Downloading music is simple and fast. Here\'s how it works.'; - @override - String get tutorialDownloadTip1 => - 'Tap the download button next to any track to start downloading'; - - @override - String get tutorialDownloadTip2 => - 'Choose your preferred quality (FLAC, Hi-Res, or MP3)'; - - @override - String get tutorialDownloadTip3 => - 'Download entire albums or playlists with one tap'; - @override String get tutorialLibraryTitle => 'Your Library'; @@ -2699,9 +1889,6 @@ class AppLocalizationsZh extends AppLocalizations { String get tutorialReadyMessage => 'You\'re all set! Start downloading your favorite music now.'; - @override - String get tutorialExample => 'EXAMPLE'; - @override String get libraryForceFullScan => 'Force Full Scan'; @@ -2862,11 +2049,7 @@ class AppLocalizationsZh extends AppLocalizations { String get trackSaveLyricsProgress => 'Saving lyrics...'; @override - String get trackReEnrich => 'Re-enrich Metadata'; - - @override - String get trackReEnrichSubtitle => - 'Re-embed metadata without re-downloading'; + String get trackReEnrich => 'Re-enrich'; @override String get trackReEnrichOnlineSubtitle => @@ -2942,6 +2125,223 @@ class AppLocalizationsZh extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + + @override + String get actionCreate => 'Create'; + + @override + String get collectionFoldersTitle => 'My folders'; + + @override + String get collectionWishlist => 'Wishlist'; + + @override + String get collectionLoved => 'Loved'; + + @override + String get collectionPlaylists => 'Playlists'; + + @override + String get collectionPlaylist => 'Playlist'; + + @override + String get collectionAddToPlaylist => 'Add to playlist'; + + @override + String get collectionCreatePlaylist => 'Create playlist'; + + @override + String get collectionNoPlaylistsYet => 'No playlists yet'; + + @override + String get collectionNoPlaylistsSubtitle => + 'Create a playlist to start categorizing tracks'; + + @override + String collectionPlaylistTracks(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String collectionAddedToPlaylist(String playlistName) { + return 'Added to \"$playlistName\"'; + } + + @override + String collectionAlreadyInPlaylist(String playlistName) { + return 'Already in \"$playlistName\"'; + } + + @override + String get collectionPlaylistCreated => 'Playlist created'; + + @override + String get collectionPlaylistNameHint => 'Playlist name'; + + @override + String get collectionPlaylistNameRequired => 'Playlist name is required'; + + @override + String get collectionRenamePlaylist => 'Rename playlist'; + + @override + String get collectionDeletePlaylist => 'Delete playlist'; + + @override + String collectionDeletePlaylistMessage(String playlistName) { + return 'Delete \"$playlistName\" and all tracks inside it?'; + } + + @override + String get collectionPlaylistDeleted => 'Playlist deleted'; + + @override + String get collectionPlaylistRenamed => 'Playlist renamed'; + + @override + String get collectionWishlistEmptyTitle => 'Wishlist is empty'; + + @override + String get collectionWishlistEmptySubtitle => + 'Tap + on tracks to save what you want to download later'; + + @override + String get collectionLovedEmptyTitle => 'Loved folder is empty'; + + @override + String get collectionLovedEmptySubtitle => + 'Tap love on tracks to keep your favorites'; + + @override + String get collectionPlaylistEmptyTitle => 'Playlist is empty'; + + @override + String get collectionPlaylistEmptySubtitle => + 'Long-press + on any track to add it here'; + + @override + String get collectionRemoveFromPlaylist => 'Remove from playlist'; + + @override + String get collectionRemoveFromFolder => 'Remove from folder'; + + @override + String collectionRemoved(String trackName) { + return '\"$trackName\" removed'; + } + + @override + String collectionAddedToLoved(String trackName) { + return '\"$trackName\" added to Loved'; + } + + @override + String collectionRemovedFromLoved(String trackName) { + return '\"$trackName\" removed from Loved'; + } + + @override + String collectionAddedToWishlist(String trackName) { + return '\"$trackName\" added to Wishlist'; + } + + @override + String collectionRemovedFromWishlist(String trackName) { + return '\"$trackName\" removed from Wishlist'; + } + + @override + String get trackOptionAddToLoved => 'Add to Loved'; + + @override + String get trackOptionRemoveFromLoved => 'Remove from Loved'; + + @override + String get trackOptionAddToWishlist => 'Add to Wishlist'; + + @override + String get trackOptionRemoveFromWishlist => 'Remove from Wishlist'; + + @override + String get collectionPlaylistChangeCover => 'Change cover image'; + + @override + String get collectionPlaylistRemoveCover => 'Remove cover image'; + + @override + String selectionShareCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Share $count $_temp0'; + } + + @override + String get selectionShareNoFiles => 'No shareable files found'; + + @override + String selectionConvertCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0'; + } + + @override + String get selectionConvertNoConvertible => 'No convertible tracks selected'; + + @override + String get selectionBatchConvertConfirmTitle => 'Batch Convert'; + + @override + String selectionBatchConvertConfirmMessage( + int count, + String format, + String bitrate, + ) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.'; + } + + @override + String selectionBatchConvertProgress(int current, int total) { + return 'Converting $current of $total...'; + } + + @override + String selectionBatchConvertSuccess(int success, int total, String format) { + return 'Converted $success of $total tracks to $format'; + } + + @override + String downloadedAlbumDownloadedCount(int count) { + return '$count downloaded'; + } + + @override + String get downloadUseAlbumArtistForFoldersAlbumSubtitle => + 'Artist folders use Album Artist when available'; + + @override + String get downloadUseAlbumArtistForFoldersTrackSubtitle => + 'Artist folders use Track Artist only'; } /// The translations for Chinese, as used in China (`zh_CN`). @@ -2951,19 +2351,12 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get appName => 'SpotiFLAC'; - @override - String get appDescription => - 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; - @override String get navHome => 'Home'; @override String get navLibrary => 'Library'; - @override - String get navHistory => 'History'; - @override String get navSettings => 'Settings'; @@ -2973,14 +2366,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get homeTitle => 'Home'; - @override - String get homeSearchHint => 'Paste Spotify URL or search...'; - - @override - String homeSearchHintExtension(String extensionName) { - return 'Search with $extensionName...'; - } - @override String get homeSubtitle => 'Paste a Spotify link or search by name'; @@ -2990,17 +2375,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get homeRecent => 'Recent'; - @override - String get historyTitle => 'History'; - - @override - String historyDownloading(int count) { - return 'Downloading ($count)'; - } - - @override - String get historyDownloaded => 'Downloaded'; - @override String get historyFilterAll => 'All'; @@ -3010,48 +2384,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get historyFilterSingles => 'Singles'; - @override - String historyTracksCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count tracks', - one: '1 track', - ); - return '$_temp0'; - } - - @override - String historyAlbumsCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count albums', - one: '1 album', - ); - return '$_temp0'; - } - - @override - String get historyNoDownloads => 'No download history'; - - @override - String get historyNoDownloadsSubtitle => 'Downloaded tracks will appear here'; - - @override - String get historyNoAlbums => 'No album downloads'; - - @override - String get historyNoAlbumsSubtitle => - 'Download multiple tracks from an album to see them here'; - - @override - String get historyNoSingles => 'No single downloads'; - - @override - String get historyNoSinglesSubtitle => - 'Single track downloads will appear here'; - @override String get historySearchHint => 'Search history...'; @@ -3076,27 +2408,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get downloadTitle => 'Download'; - @override - String get downloadLocation => 'Download Location'; - - @override - String get downloadLocationSubtitle => 'Choose where to save files'; - - @override - String get downloadLocationDefault => 'Default location'; - - @override - String get downloadDefaultService => 'Default Service'; - - @override - String get downloadDefaultServiceSubtitle => 'Service used for downloads'; - - @override - String get downloadDefaultQuality => 'Default Quality'; - - @override - String get downloadAskQuality => 'Ask Quality Before Download'; - @override String get downloadAskQualitySubtitle => 'Show quality picker for each download'; @@ -3107,31 +2418,9 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get downloadFolderOrganization => 'Folder Organization'; - @override - String get downloadSeparateSingles => 'Separate Singles'; - - @override - String get downloadSeparateSinglesSubtitle => - 'Put single tracks in a separate folder'; - - @override - String get qualityBest => 'Best Available'; - - @override - String get qualityFlac => 'FLAC'; - - @override - String get quality320 => '320 kbps'; - - @override - String get quality128 => '128 kbps'; - @override String get appearanceTitle => 'Appearance'; - @override - String get appearanceTheme => 'Theme'; - @override String get appearanceThemeSystem => 'System'; @@ -3147,9 +2436,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get appearanceDynamicColorSubtitle => 'Use colors from your wallpaper'; - @override - String get appearanceAccentColor => 'Accent Color'; - @override String get appearanceHistoryView => 'History View'; @@ -3162,9 +2448,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get optionsTitle => 'Options'; - @override - String get optionsSearchSource => 'Search Source'; - @override String get optionsPrimaryProvider => 'Primary Provider'; @@ -3290,18 +2573,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get extensionsTitle => 'Extensions'; - @override - String get extensionsInstalled => 'Installed Extensions'; - - @override - String get extensionsNone => 'No extensions installed'; - - @override - String get extensionsNoneSubtitle => 'Install extensions from the Store tab'; - - @override - String get extensionsEnabled => 'Enabled'; - @override String get extensionsDisabled => 'Disabled'; @@ -3318,9 +2589,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get extensionsUninstall => 'Uninstall'; - @override - String get extensionsSetAsSearch => 'Set as Search Provider'; - @override String get storeTitle => 'Extension Store'; @@ -3394,9 +2662,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get aboutSocial => 'Social'; - @override - String get aboutSupport => 'Support'; - @override String get aboutApp => 'App'; @@ -3415,13 +2680,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { String get aboutSjdonadoDesc => 'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!'; - @override - String get aboutDoubleDouble => 'DoubleDouble'; - - @override - String get aboutDoubleDoubleDesc => - 'Amazing API for Amazon Music downloads. Thank you for making it free!'; - @override String get aboutDabMusic => 'DAB Music'; @@ -3440,32 +2698,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { String get aboutAppDescription => 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; - @override - String get albumTitle => 'Album'; - - @override - String albumTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count tracks', - one: '1 track', - ); - return '$_temp0'; - } - - @override - String get albumDownloadAll => 'Download All'; - - @override - String get albumDownloadRemaining => 'Download Remaining'; - - @override - String get playlistTitle => 'Playlist'; - - @override - String get artistTitle => 'Artist'; - @override String get artistAlbums => 'Albums'; @@ -3475,17 +2707,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get artistCompilations => 'Compilations'; - @override - String artistReleases(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count releases', - one: '1 release', - ); - return '$_temp0'; - } - @override String get artistPopular => 'Popular'; @@ -3494,27 +2715,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { return '$count monthly listeners'; } - @override - String get trackMetadataTitle => 'Track Info'; - - @override - String get trackMetadataArtist => 'Artist'; - - @override - String get trackMetadataAlbum => 'Album'; - - @override - String get trackMetadataDuration => 'Duration'; - - @override - String get trackMetadataQuality => 'Quality'; - - @override - String get trackMetadataPath => 'File Path'; - - @override - String get trackMetadataDownloadedAt => 'Downloaded'; - @override String get trackMetadataService => 'Service'; @@ -3527,53 +2727,15 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get trackMetadataDelete => 'Delete'; - @override - String get trackMetadataRedownload => 'Re-download'; - - @override - String get trackMetadataOpenFolder => 'Open Folder'; - - @override - String get setupTitle => 'Welcome to SpotiFLAC'; - - @override - String get setupSubtitle => 'Let\'s get you started'; - - @override - String get setupStoragePermission => 'Storage Permission'; - - @override - String get setupStoragePermissionSubtitle => - 'Required to save downloaded files'; - - @override - String get setupStoragePermissionGranted => 'Permission granted'; - - @override - String get setupStoragePermissionDenied => 'Permission denied'; - @override String get setupGrantPermission => 'Grant Permission'; - @override - String get setupDownloadLocation => 'Download Location'; - - @override - String get setupChooseFolder => 'Choose Folder'; - - @override - String get setupContinue => 'Continue'; - @override String get setupSkip => 'Skip for now'; @override String get setupStorageAccessRequired => 'Storage Access Required'; - @override - String get setupStorageAccessMessage => - 'SpotiFLAC needs \"All files access\" permission to save music files to your chosen folder.'; - @override String get setupStorageAccessMessageAndroid11 => 'Android 11+ requires \"All files access\" permission to save files to your chosen download folder.'; @@ -3595,9 +2757,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { return '$permissionType permission is required for the best experience. You can change this later in Settings.'; } - @override - String get setupSelectDownloadFolder => 'Select Download Folder'; - @override String get setupUseDefaultFolder => 'Use Default Folder?'; @@ -3639,21 +2798,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get setupDownloadInFlac => 'Download Spotify tracks in FLAC'; - @override - String get setupStepStorage => 'Storage'; - - @override - String get setupStepNotification => 'Notification'; - - @override - String get setupStepFolder => 'Folder'; - - @override - String get setupStepSpotify => 'Spotify'; - - @override - String get setupStepPermission => 'Permission'; - @override String get setupStorageGranted => 'Storage Permission Granted!'; @@ -3670,13 +2814,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get setupNotificationEnable => 'Enable Notifications'; - @override - String get setupNotificationDescription => - 'Get notified when downloads complete or require attention.'; - - @override - String get setupFolderSelected => 'Download Folder Selected!'; - @override String get setupFolderChoose => 'Choose Download Folder'; @@ -3684,48 +2821,12 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { String get setupFolderDescription => 'Select a folder where your downloaded music will be saved.'; - @override - String get setupChangeFolder => 'Change Folder'; - @override String get setupSelectFolder => 'Select Folder'; - @override - String get setupSpotifyApiOptional => 'Spotify API (Optional)'; - - @override - String get setupSpotifyApiDescription => - 'Add your Spotify API credentials for better search results and access to Spotify-exclusive content.'; - - @override - String get setupUseSpotifyApi => 'Use Spotify API'; - - @override - String get setupEnterCredentialsBelow => 'Enter your credentials below'; - - @override - String get setupUsingDeezer => 'Using Deezer (no account needed)'; - - @override - String get setupEnterClientId => 'Enter Spotify Client ID'; - - @override - String get setupEnterClientSecret => 'Enter Spotify Client Secret'; - - @override - String get setupGetFreeCredentials => - 'Get your free API credentials from the Spotify Developer Dashboard.'; - @override String get setupEnableNotifications => 'Enable Notifications'; - @override - String get setupProceedToNextStep => 'You can now proceed to the next step.'; - - @override - String get setupNotificationProgressDescription => - 'You will receive download progress notifications.'; - @override String get setupNotificationBackgroundDescription => 'Get notified about download progress and completion. This helps you track downloads when the app is in background.'; @@ -3733,32 +2834,19 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get setupSkipForNow => 'Skip for now'; - @override - String get setupBack => 'Back'; - @override String get setupNext => 'Next'; @override String get setupGetStarted => 'Get Started'; - @override - String get setupSkipAndStart => 'Skip & Start'; - @override String get setupAllowAccessToManageFiles => 'Please enable \"Allow access to manage all files\" in the next screen.'; - @override - String get setupGetCredentialsFromSpotify => - 'Get credentials from developer.spotify.com'; - @override String get dialogCancel => 'Cancel'; - @override - String get dialogOk => 'OK'; - @override String get dialogSave => 'Save'; @@ -3768,21 +2856,9 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get dialogRetry => 'Retry'; - @override - String get dialogClose => 'Close'; - - @override - String get dialogYes => 'Yes'; - - @override - String get dialogNo => 'No'; - @override String get dialogClear => 'Clear'; - @override - String get dialogConfirm => 'Confirm'; - @override String get dialogDone => 'Done'; @@ -3805,28 +2881,9 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { String get dialogUnsavedChanges => 'You have unsaved changes. Do you want to discard them?'; - @override - String get dialogDownloadFailed => 'Download Failed'; - - @override - String get dialogTrackLabel => 'Track:'; - - @override - String get dialogArtistLabel => 'Artist:'; - - @override - String get dialogErrorLabel => 'Error:'; - @override String get dialogClearAll => 'Clear All'; - @override - String get dialogClearAllDownloads => - 'Are you sure you want to clear all downloads?'; - - @override - String get dialogRemoveFromDevice => 'Remove from device?'; - @override String get dialogRemoveExtension => 'Remove Extension'; @@ -3927,11 +2984,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get snackbarViewQueue => 'View Queue'; - @override - String snackbarFailedToLoad(String error) { - return 'Failed to load: $error'; - } - @override String snackbarUrlCopied(String platform) { return '$platform URL copied to clipboard'; @@ -3973,11 +3025,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { String get errorRateLimitedMessage => 'Too many requests. Please wait a moment before searching again.'; - @override - String errorFailedToLoad(String item) { - return 'Failed to load $item'; - } - @override String get errorNoTracksFound => 'No tracks found'; @@ -3986,27 +3033,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { return 'Cannot load $item: missing extension source'; } - @override - String get statusQueued => 'Queued'; - - @override - String get statusDownloading => 'Downloading'; - - @override - String get statusFinalizing => 'Finalizing'; - - @override - String get statusCompleted => 'Completed'; - - @override - String get statusFailed => 'Failed'; - - @override - String get statusSkipped => 'Skipped'; - - @override - String get statusPaused => 'Paused'; - @override String get actionPause => 'Pause'; @@ -4016,24 +3042,12 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get actionCancel => 'Cancel'; - @override - String get actionStop => 'Stop'; - - @override - String get actionSelect => 'Select'; - @override String get actionSelectAll => 'Select All'; @override String get actionDeselect => 'Deselect'; - @override - String get actionPaste => 'Paste'; - - @override - String get actionImportCsv => 'Import CSV'; - @override String get actionRemoveCredentials => 'Remove Credentials'; @@ -4048,20 +3062,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get selectionAllSelected => 'All tracks selected'; - @override - String get selectionTapToSelect => 'Tap tracks to select'; - - @override - String selectionDeleteTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: 'tracks', - one: 'track', - ); - return 'Delete $count $_temp0'; - } - @override String get selectionSelectToDelete => 'Select tracks to delete'; @@ -4088,43 +3088,9 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get tooltipPlay => 'Play'; - @override - String get tooltipCancel => 'Cancel'; - - @override - String get tooltipStop => 'Stop'; - - @override - String get tooltipRetry => 'Retry'; - - @override - String get tooltipRemove => 'Remove'; - - @override - String get tooltipClear => 'Clear'; - - @override - String get tooltipPaste => 'Paste'; - @override String get filenameFormat => 'Filename Format'; - @override - String filenameFormatPreview(String preview) { - return 'Preview: $preview'; - } - - @override - String get filenameAvailablePlaceholders => 'Available placeholders:'; - - @override - String filenameHint(Object artist, Object title) { - return '$artist - $title'; - } - - @override - String get folderOrganization => 'Folder Organization'; - @override String get folderOrganizationNone => 'No organization'; @@ -4159,20 +3125,9 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get updateAvailable => 'Update Available'; - @override - String updateNewVersion(String version) { - return 'Version $version is available'; - } - - @override - String get updateDownload => 'Download'; - @override String get updateLater => 'Later'; - @override - String get updateChangelog => 'Changelog'; - @override String get updateStartingDownload => 'Starting download...'; @@ -4203,12 +3158,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get updateDontRemind => 'Don\'t remind'; - @override - String get providerPriority => 'Provider Priority'; - - @override - String get providerPrioritySubtitle => 'Drag to reorder download providers'; - @override String get providerPriorityTitle => 'Provider Priority'; @@ -4226,13 +3175,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get providerExtension => 'Extension'; - @override - String get metadataProviderPriority => 'Metadata Provider Priority'; - - @override - String get metadataProviderPrioritySubtitle => - 'Order used when fetching track metadata'; - @override String get metadataProviderPriorityTitle => 'Metadata Priority'; @@ -4253,18 +3195,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get logTitle => 'Logs'; - @override - String get logCopy => 'Copy Logs'; - - @override - String get logClear => 'Clear Logs'; - - @override - String get logShare => 'Share Logs'; - - @override - String get logEmpty => 'No logs yet'; - @override String get logCopied => 'Logs copied to clipboard'; @@ -4289,18 +3219,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get logClearLogsMessage => 'Are you sure you want to clear all logs?'; - @override - String get logIspBlocking => 'ISP BLOCKING DETECTED'; - - @override - String get logRateLimited => 'RATE LIMITED'; - - @override - String get logNetworkError => 'NETWORK ERROR'; - - @override - String get logTrackNotFound => 'TRACK NOT FOUND'; - @override String get logFilterBySeverity => 'Filter logs by severity'; @@ -4310,48 +3228,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get logNoLogsYetSubtitle => 'Logs will appear here as you use the app'; - @override - String get logIssueSummary => 'Issue Summary'; - - @override - String get logIspBlockingDescription => - 'Your ISP may be blocking access to download services'; - - @override - String get logIspBlockingSuggestion => - 'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8'; - - @override - String get logRateLimitedDescription => 'Too many requests to the service'; - - @override - String get logRateLimitedSuggestion => - 'Wait a few minutes before trying again'; - - @override - String get logNetworkErrorDescription => 'Connection issues detected'; - - @override - String get logNetworkErrorSuggestion => 'Check your internet connection'; - - @override - String get logTrackNotFoundDescription => - 'Some tracks could not be found on download services'; - - @override - String get logTrackNotFoundSuggestion => - 'The track may not be available in lossless quality'; - - @override - String logTotalErrors(int count) { - return 'Total errors: $count'; - } - - @override - String logAffected(String domains) { - return 'Affected: $domains'; - } - @override String logEntriesFiltered(int count) { return 'Entries ($count filtered)'; @@ -4458,9 +3334,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get appearanceLanguage => 'App Language'; - @override - String get appearanceLanguageSubtitle => 'Choose your preferred language'; - @override String get settingsAppearanceSubtitle => 'Theme, colors, display'; @@ -4482,9 +3355,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get pressBackAgainToExit => 'Press back again to exit'; - @override - String get tracksHeader => 'Tracks'; - @override String downloadAllCount(int count) { return 'Download All ($count)'; @@ -4598,11 +3468,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { String get trackDeleteConfirmMessage => 'This will permanently delete the downloaded file and remove it from your history.'; - @override - String trackCannotOpen(String message) { - return 'Cannot open: $message'; - } - @override String get dateToday => 'Today'; @@ -4624,18 +3489,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { return '$count months ago'; } - @override - String get concurrentSequential => 'Sequential'; - - @override - String get concurrentParallel2 => '2 Parallel'; - - @override - String get concurrentParallel3 => '3 Parallel'; - - @override - String get tapToSeeError => 'Tap to see error details'; - @override String get storeFilterAll => 'All'; @@ -4657,15 +3510,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get storeClearFilters => 'Clear filters'; - @override - String get storeNoResults => 'No extensions found'; - - @override - String get extensionProviderPriority => 'Provider Priority'; - - @override - String get extensionInstallButton => 'Install Extension'; - @override String get extensionDefaultProvider => 'Default (Deezer/Spotify)'; @@ -4816,38 +3660,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; - @override - String get qualityLossy => 'Lossy'; - - @override - String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)'; - - @override - String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)'; - - @override - String get enableLossyOption => 'Enable Lossy Option'; - - @override - String get enableLossyOptionSubtitleOn => 'Lossy quality option is available'; - - @override - String get enableLossyOptionSubtitleOff => - 'Downloads FLAC then converts to lossy format'; - - @override - String get lossyFormat => 'Lossy Format'; - - @override - String get lossyFormatDescription => 'Choose the lossy format for conversion'; - - @override - String get lossyFormatMp3Subtitle => '320kbps, best compatibility'; - - @override - String get lossyFormatOpusSubtitle => - '128kbps, better quality at smaller size'; - @override String get qualityNote => 'Actual quality depends on track availability from the service'; @@ -4871,14 +3683,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders'; - @override - String get downloadUseAlbumArtistForFoldersAlbumSubtitle => - 'Artist folders use Album Artist when available'; - - @override - String get downloadUseAlbumArtistForFoldersTrackSubtitle => - 'Artist folders use Track Artist only'; - @override String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders'; @@ -4890,78 +3694,18 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { String get downloadUsePrimaryArtistOnlyDisabled => 'Full artist string used for folder name'; - @override - String get downloadSaveFormat => 'Save Format'; - - @override - String get downloadSelectService => 'Select Service'; - @override String get downloadSelectQuality => 'Select Quality'; @override String get downloadFrom => 'Download From'; - @override - String get downloadDefaultQualityLabel => 'Default Quality'; - - @override - String get downloadBestAvailable => 'Best available'; - - @override - String get folderNone => 'None'; - - @override - String get folderNoneSubtitle => 'Save all files directly to download folder'; - - @override - String get folderArtist => 'Artist'; - - @override - String get folderArtistSubtitle => 'Artist Name/filename'; - - @override - String get folderAlbum => 'Album'; - - @override - String get folderAlbumSubtitle => 'Album Name/filename'; - - @override - String get folderArtistAlbum => 'Artist/Album'; - - @override - String get folderArtistAlbumSubtitle => 'Artist Name/Album Name/filename'; - - @override - String get serviceTidal => 'Tidal'; - - @override - String get serviceQobuz => 'Qobuz'; - - @override - String get serviceAmazon => 'Amazon'; - - @override - String get serviceDeezer => 'Deezer'; - - @override - String get serviceSpotify => 'Spotify'; - @override String get appearanceAmoledDark => 'AMOLED Dark'; @override String get appearanceAmoledDarkSubtitle => 'Pure black background'; - @override - String get appearanceChooseAccentColor => 'Choose Accent Color'; - - @override - String get appearanceChooseTheme => 'Theme Mode'; - - @override - String get queueTitle => 'Download Queue'; - @override String get queueClearAll => 'Clear All'; @@ -4969,19 +3713,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { String get queueClearAllMessage => 'Are you sure you want to clear all downloads?'; - @override - String get queueExportFailed => 'Export'; - - @override - String get queueExportFailedSuccess => - 'Failed downloads exported to TXT file'; - - @override - String get queueExportFailedClear => 'Clear Failed'; - - @override - String get queueExportFailedError => 'Failed to export downloads'; - @override String get settingsAutoExportFailed => 'Auto-export failed downloads'; @@ -5002,30 +3733,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { String get settingsDownloadNetworkSubtitle => 'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.'; - @override - String get queueEmpty => 'No downloads in queue'; - - @override - String get queueEmptySubtitle => 'Add tracks from the home screen'; - - @override - String get queueClearCompleted => 'Clear completed'; - - @override - String get queueDownloadFailed => 'Download Failed'; - - @override - String get queueTrackLabel => 'Track:'; - - @override - String get queueArtistLabel => 'Artist:'; - - @override - String get queueErrorLabel => 'Error:'; - - @override - String get queueUnknownError => 'Unknown error'; - @override String get albumFolderArtistAlbum => 'Artist / Album'; @@ -5072,14 +3779,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { return 'Delete $count $_temp0 from this album?\n\nThis will also delete the files from storage.'; } - @override - String get downloadedAlbumTracksHeader => 'Tracks'; - - @override - String downloadedAlbumDownloadedCount(int count) { - return '$count downloaded'; - } - @override String downloadedAlbumSelectedCount(int count) { return '$count selected'; @@ -5110,9 +3809,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { return 'Disc $discNumber'; } - @override - String get utilityFunctions => 'Utility Functions'; - @override String get recentTypeArtist => 'Artist'; @@ -5136,11 +3832,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { return 'Playlist: $name'; } - @override - String errorGeneric(String message) { - return 'Error: $message'; - } - @override String get discographyDownload => 'Download Discography'; @@ -5246,9 +3937,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get libraryTitle => 'Local Library'; - @override - String get libraryStatus => 'Library Status'; - @override String get libraryScanSettings => 'Scan Settings'; @@ -5311,11 +3999,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { String get libraryAboutDescription => 'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.'; - @override - String libraryTracksCount(int count) { - return '$count tracks'; - } - @override String libraryLastScanned(String time) { return 'Last scanned: $time'; @@ -5395,21 +4078,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get libraryFilterFormat => 'Format'; - @override - String get libraryFilterDate => 'Date Added'; - - @override - String get libraryFilterDateToday => 'Today'; - - @override - String get libraryFilterDateWeek => 'This Week'; - - @override - String get libraryFilterDateMonth => 'This Month'; - - @override - String get libraryFilterDateYear => 'This Year'; - @override String get libraryFilterSort => 'Sort'; @@ -5419,11 +4087,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get libraryFilterSortOldest => 'Oldest'; - @override - String libraryFilterActive(int count) { - return '$count filter(s) active'; - } - @override String get timeJustNow => 'Just now'; @@ -5449,72 +4112,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { return '$_temp0'; } - @override - String get storageSwitchTitle => 'Switch Storage Mode'; - - @override - String get storageSwitchToSafTitle => 'Switch to SAF Storage?'; - - @override - String get storageSwitchToAppTitle => 'Switch to App Storage?'; - - @override - String get storageSwitchToSafMessage => - 'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.'; - - @override - String get storageSwitchToAppMessage => - 'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.'; - - @override - String get storageSwitchExistingDownloads => 'Existing Downloads'; - - @override - String storageSwitchExistingDownloadsInfo(int count, String mode) { - return '$count tracks in $mode storage'; - } - - @override - String get storageSwitchNewDownloads => 'New Downloads'; - - @override - String storageSwitchNewDownloadsLocation(String location) { - return 'Will be saved to: $location'; - } - - @override - String get storageSwitchContinue => 'Continue'; - - @override - String get storageSwitchSelectFolder => 'Select SAF Folder'; - - @override - String get storageAppStorage => 'App Storage'; - - @override - String get storageSafStorage => 'SAF Storage'; - - @override - String storageModeBadge(String mode) { - return 'Storage: $mode'; - } - - @override - String get storageStatsTitle => 'Storage Statistics'; - - @override - String storageStatsAppCount(int count) { - return '$count tracks in App Storage'; - } - - @override - String storageStatsSafCount(int count) { - return '$count tracks in SAF Storage'; - } - - @override - String get storageModeInfo => 'Your files are stored in multiple locations'; - @override String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!'; @@ -5541,18 +4138,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { String get tutorialSearchDesc => 'There are two easy ways to find music you want to download.'; - @override - String get tutorialSearchTip1 => - 'Paste a Spotify or Deezer URL directly in the search box'; - - @override - String get tutorialSearchTip2 => - 'Or type the song name, artist, or album to search'; - - @override - String get tutorialSearchTip3 => - 'Supports tracks, albums, playlists, and artist pages'; - @override String get tutorialDownloadTitle => 'Downloading Music'; @@ -5560,18 +4145,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { String get tutorialDownloadDesc => 'Downloading music is simple and fast. Here\'s how it works.'; - @override - String get tutorialDownloadTip1 => - 'Tap the download button next to any track to start downloading'; - - @override - String get tutorialDownloadTip2 => - 'Choose your preferred quality (FLAC, Hi-Res, or MP3)'; - - @override - String get tutorialDownloadTip3 => - 'Download entire albums or playlists with one tap'; - @override String get tutorialLibraryTitle => 'Your Library'; @@ -5632,9 +4205,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { String get tutorialReadyMessage => 'You\'re all set! Start downloading your favorite music now.'; - @override - String get tutorialExample => 'EXAMPLE'; - @override String get libraryForceFullScan => 'Force Full Scan'; @@ -5795,11 +4365,7 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { String get trackSaveLyricsProgress => 'Saving lyrics...'; @override - String get trackReEnrich => 'Re-enrich Metadata'; - - @override - String get trackReEnrichSubtitle => - 'Re-embed metadata without re-downloading'; + String get trackReEnrich => 'Re-enrich'; @override String get trackReEnrichOnlineSubtitle => @@ -5875,6 +4441,19 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get trackConvertFailed => 'Conversion failed'; + + @override + String downloadedAlbumDownloadedCount(int count) { + return '$count downloaded'; + } + + @override + String get downloadUseAlbumArtistForFoldersAlbumSubtitle => + 'Artist folders use Album Artist when available'; + + @override + String get downloadUseAlbumArtistForFoldersTrackSubtitle => + 'Artist folders use Track Artist only'; } /// The translations for Chinese, as used in Taiwan (`zh_TW`). @@ -5884,19 +4463,12 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get appName => 'SpotiFLAC'; - @override - String get appDescription => - 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; - @override String get navHome => 'Home'; @override String get navLibrary => 'Library'; - @override - String get navHistory => 'History'; - @override String get navSettings => 'Settings'; @@ -5906,14 +4478,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get homeTitle => 'Home'; - @override - String get homeSearchHint => 'Paste Spotify URL or search...'; - - @override - String homeSearchHintExtension(String extensionName) { - return 'Search with $extensionName...'; - } - @override String get homeSubtitle => 'Paste a Spotify link or search by name'; @@ -5923,17 +4487,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get homeRecent => '最新的'; - @override - String get historyTitle => 'History'; - - @override - String historyDownloading(int count) { - return 'Downloading ($count)'; - } - - @override - String get historyDownloaded => 'Downloaded'; - @override String get historyFilterAll => 'All'; @@ -5943,48 +4496,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get historyFilterSingles => 'Singles'; - @override - String historyTracksCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count tracks', - one: '1 track', - ); - return '$_temp0'; - } - - @override - String historyAlbumsCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count albums', - one: '1 album', - ); - return '$_temp0'; - } - - @override - String get historyNoDownloads => 'No download history'; - - @override - String get historyNoDownloadsSubtitle => 'Downloaded tracks will appear here'; - - @override - String get historyNoAlbums => 'No album downloads'; - - @override - String get historyNoAlbumsSubtitle => - 'Download multiple tracks from an album to see them here'; - - @override - String get historyNoSingles => 'No single downloads'; - - @override - String get historyNoSinglesSubtitle => - 'Single track downloads will appear here'; - @override String get historySearchHint => 'Search history...'; @@ -6009,27 +4520,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get downloadTitle => 'Download'; - @override - String get downloadLocation => 'Download Location'; - - @override - String get downloadLocationSubtitle => 'Choose where to save files'; - - @override - String get downloadLocationDefault => 'Default location'; - - @override - String get downloadDefaultService => 'Default Service'; - - @override - String get downloadDefaultServiceSubtitle => 'Service used for downloads'; - - @override - String get downloadDefaultQuality => 'Default Quality'; - - @override - String get downloadAskQuality => 'Ask Quality Before Download'; - @override String get downloadAskQualitySubtitle => 'Show quality picker for each download'; @@ -6040,31 +4530,9 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get downloadFolderOrganization => 'Folder Organization'; - @override - String get downloadSeparateSingles => 'Separate Singles'; - - @override - String get downloadSeparateSinglesSubtitle => - 'Put single tracks in a separate folder'; - - @override - String get qualityBest => 'Best Available'; - - @override - String get qualityFlac => 'FLAC'; - - @override - String get quality320 => '320 kbps'; - - @override - String get quality128 => '128 kbps'; - @override String get appearanceTitle => 'Appearance'; - @override - String get appearanceTheme => 'Theme'; - @override String get appearanceThemeSystem => 'System'; @@ -6080,9 +4548,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get appearanceDynamicColorSubtitle => 'Use colors from your wallpaper'; - @override - String get appearanceAccentColor => 'Accent Color'; - @override String get appearanceHistoryView => 'History View'; @@ -6095,9 +4560,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get optionsTitle => 'Options'; - @override - String get optionsSearchSource => 'Search Source'; - @override String get optionsPrimaryProvider => 'Primary Provider'; @@ -6223,18 +4685,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get extensionsTitle => 'Extensions'; - @override - String get extensionsInstalled => 'Installed Extensions'; - - @override - String get extensionsNone => 'No extensions installed'; - - @override - String get extensionsNoneSubtitle => 'Install extensions from the Store tab'; - - @override - String get extensionsEnabled => 'Enabled'; - @override String get extensionsDisabled => 'Disabled'; @@ -6251,9 +4701,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get extensionsUninstall => 'Uninstall'; - @override - String get extensionsSetAsSearch => 'Set as Search Provider'; - @override String get storeTitle => 'Extension Store'; @@ -6327,9 +4774,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get aboutSocial => 'Social'; - @override - String get aboutSupport => 'Support'; - @override String get aboutApp => 'App'; @@ -6348,13 +4792,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { String get aboutSjdonadoDesc => 'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!'; - @override - String get aboutDoubleDouble => 'DoubleDouble'; - - @override - String get aboutDoubleDoubleDesc => - 'Amazing API for Amazon Music downloads. Thank you for making it free!'; - @override String get aboutDabMusic => 'DAB Music'; @@ -6373,32 +4810,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { String get aboutAppDescription => 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; - @override - String get albumTitle => 'Album'; - - @override - String albumTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count tracks', - one: '1 track', - ); - return '$_temp0'; - } - - @override - String get albumDownloadAll => 'Download All'; - - @override - String get albumDownloadRemaining => 'Download Remaining'; - - @override - String get playlistTitle => 'Playlist'; - - @override - String get artistTitle => 'Artist'; - @override String get artistAlbums => 'Albums'; @@ -6408,17 +4819,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get artistCompilations => 'Compilations'; - @override - String artistReleases(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count releases', - one: '1 release', - ); - return '$_temp0'; - } - @override String get artistPopular => 'Popular'; @@ -6427,27 +4827,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { return '$count monthly listeners'; } - @override - String get trackMetadataTitle => 'Track Info'; - - @override - String get trackMetadataArtist => 'Artist'; - - @override - String get trackMetadataAlbum => 'Album'; - - @override - String get trackMetadataDuration => 'Duration'; - - @override - String get trackMetadataQuality => 'Quality'; - - @override - String get trackMetadataPath => 'File Path'; - - @override - String get trackMetadataDownloadedAt => 'Downloaded'; - @override String get trackMetadataService => 'Service'; @@ -6460,53 +4839,15 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get trackMetadataDelete => 'Delete'; - @override - String get trackMetadataRedownload => 'Re-download'; - - @override - String get trackMetadataOpenFolder => 'Open Folder'; - - @override - String get setupTitle => 'Welcome to SpotiFLAC'; - - @override - String get setupSubtitle => 'Let\'s get you started'; - - @override - String get setupStoragePermission => 'Storage Permission'; - - @override - String get setupStoragePermissionSubtitle => - 'Required to save downloaded files'; - - @override - String get setupStoragePermissionGranted => 'Permission granted'; - - @override - String get setupStoragePermissionDenied => 'Permission denied'; - @override String get setupGrantPermission => 'Grant Permission'; - @override - String get setupDownloadLocation => 'Download Location'; - - @override - String get setupChooseFolder => 'Choose Folder'; - - @override - String get setupContinue => 'Continue'; - @override String get setupSkip => 'Skip for now'; @override String get setupStorageAccessRequired => 'Storage Access Required'; - @override - String get setupStorageAccessMessage => - 'SpotiFLAC needs \"All files access\" permission to save music files to your chosen folder.'; - @override String get setupStorageAccessMessageAndroid11 => 'Android 11+ requires \"All files access\" permission to save files to your chosen download folder.'; @@ -6528,9 +4869,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { return '$permissionType permission is required for the best experience. You can change this later in Settings.'; } - @override - String get setupSelectDownloadFolder => 'Select Download Folder'; - @override String get setupUseDefaultFolder => 'Use Default Folder?'; @@ -6572,21 +4910,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get setupDownloadInFlac => 'Download Spotify tracks in FLAC'; - @override - String get setupStepStorage => 'Storage'; - - @override - String get setupStepNotification => 'Notification'; - - @override - String get setupStepFolder => 'Folder'; - - @override - String get setupStepSpotify => 'Spotify'; - - @override - String get setupStepPermission => 'Permission'; - @override String get setupStorageGranted => 'Storage Permission Granted!'; @@ -6603,13 +4926,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get setupNotificationEnable => 'Enable Notifications'; - @override - String get setupNotificationDescription => - 'Get notified when downloads complete or require attention.'; - - @override - String get setupFolderSelected => 'Download Folder Selected!'; - @override String get setupFolderChoose => 'Choose Download Folder'; @@ -6617,48 +4933,12 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { String get setupFolderDescription => 'Select a folder where your downloaded music will be saved.'; - @override - String get setupChangeFolder => 'Change Folder'; - @override String get setupSelectFolder => 'Select Folder'; - @override - String get setupSpotifyApiOptional => 'Spotify API (Optional)'; - - @override - String get setupSpotifyApiDescription => - 'Add your Spotify API credentials for better search results and access to Spotify-exclusive content.'; - - @override - String get setupUseSpotifyApi => 'Use Spotify API'; - - @override - String get setupEnterCredentialsBelow => 'Enter your credentials below'; - - @override - String get setupUsingDeezer => 'Using Deezer (no account needed)'; - - @override - String get setupEnterClientId => 'Enter Spotify Client ID'; - - @override - String get setupEnterClientSecret => 'Enter Spotify Client Secret'; - - @override - String get setupGetFreeCredentials => - 'Get your free API credentials from the Spotify Developer Dashboard.'; - @override String get setupEnableNotifications => 'Enable Notifications'; - @override - String get setupProceedToNextStep => 'You can now proceed to the next step.'; - - @override - String get setupNotificationProgressDescription => - 'You will receive download progress notifications.'; - @override String get setupNotificationBackgroundDescription => 'Get notified about download progress and completion. This helps you track downloads when the app is in background.'; @@ -6666,32 +4946,19 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get setupSkipForNow => 'Skip for now'; - @override - String get setupBack => 'Back'; - @override String get setupNext => 'Next'; @override String get setupGetStarted => 'Get Started'; - @override - String get setupSkipAndStart => 'Skip & Start'; - @override String get setupAllowAccessToManageFiles => 'Please enable \"Allow access to manage all files\" in the next screen.'; - @override - String get setupGetCredentialsFromSpotify => - 'Get credentials from developer.spotify.com'; - @override String get dialogCancel => 'Cancel'; - @override - String get dialogOk => 'OK'; - @override String get dialogSave => 'Save'; @@ -6701,21 +4968,9 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get dialogRetry => 'Retry'; - @override - String get dialogClose => 'Close'; - - @override - String get dialogYes => 'Yes'; - - @override - String get dialogNo => 'No'; - @override String get dialogClear => 'Clear'; - @override - String get dialogConfirm => 'Confirm'; - @override String get dialogDone => 'Done'; @@ -6738,28 +4993,9 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { String get dialogUnsavedChanges => 'You have unsaved changes. Do you want to discard them?'; - @override - String get dialogDownloadFailed => 'Download Failed'; - - @override - String get dialogTrackLabel => 'Track:'; - - @override - String get dialogArtistLabel => 'Artist:'; - - @override - String get dialogErrorLabel => 'Error:'; - @override String get dialogClearAll => 'Clear All'; - @override - String get dialogClearAllDownloads => - 'Are you sure you want to clear all downloads?'; - - @override - String get dialogRemoveFromDevice => 'Remove from device?'; - @override String get dialogRemoveExtension => 'Remove Extension'; @@ -6860,11 +5096,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get snackbarViewQueue => 'View Queue'; - @override - String snackbarFailedToLoad(String error) { - return 'Failed to load: $error'; - } - @override String snackbarUrlCopied(String platform) { return '$platform URL copied to clipboard'; @@ -6906,11 +5137,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { String get errorRateLimitedMessage => 'Too many requests. Please wait a moment before searching again.'; - @override - String errorFailedToLoad(String item) { - return 'Failed to load $item'; - } - @override String get errorNoTracksFound => 'No tracks found'; @@ -6919,27 +5145,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { return 'Cannot load $item: missing extension source'; } - @override - String get statusQueued => 'Queued'; - - @override - String get statusDownloading => 'Downloading'; - - @override - String get statusFinalizing => 'Finalizing'; - - @override - String get statusCompleted => 'Completed'; - - @override - String get statusFailed => 'Failed'; - - @override - String get statusSkipped => 'Skipped'; - - @override - String get statusPaused => 'Paused'; - @override String get actionPause => 'Pause'; @@ -6949,24 +5154,12 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get actionCancel => 'Cancel'; - @override - String get actionStop => 'Stop'; - - @override - String get actionSelect => 'Select'; - @override String get actionSelectAll => 'Select All'; @override String get actionDeselect => 'Deselect'; - @override - String get actionPaste => 'Paste'; - - @override - String get actionImportCsv => 'Import CSV'; - @override String get actionRemoveCredentials => 'Remove Credentials'; @@ -6981,20 +5174,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get selectionAllSelected => 'All tracks selected'; - @override - String get selectionTapToSelect => 'Tap tracks to select'; - - @override - String selectionDeleteTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: 'tracks', - one: 'track', - ); - return 'Delete $count $_temp0'; - } - @override String get selectionSelectToDelete => 'Select tracks to delete'; @@ -7021,43 +5200,9 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get tooltipPlay => 'Play'; - @override - String get tooltipCancel => 'Cancel'; - - @override - String get tooltipStop => 'Stop'; - - @override - String get tooltipRetry => 'Retry'; - - @override - String get tooltipRemove => 'Remove'; - - @override - String get tooltipClear => 'Clear'; - - @override - String get tooltipPaste => 'Paste'; - @override String get filenameFormat => 'Filename Format'; - @override - String filenameFormatPreview(String preview) { - return 'Preview: $preview'; - } - - @override - String get filenameAvailablePlaceholders => 'Available placeholders:'; - - @override - String filenameHint(Object artist, Object title) { - return '$artist - $title'; - } - - @override - String get folderOrganization => 'Folder Organization'; - @override String get folderOrganizationNone => 'No organization'; @@ -7092,20 +5237,9 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get updateAvailable => 'Update Available'; - @override - String updateNewVersion(String version) { - return 'Version $version is available'; - } - - @override - String get updateDownload => 'Download'; - @override String get updateLater => 'Later'; - @override - String get updateChangelog => 'Changelog'; - @override String get updateStartingDownload => 'Starting download...'; @@ -7136,12 +5270,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get updateDontRemind => 'Don\'t remind'; - @override - String get providerPriority => 'Provider Priority'; - - @override - String get providerPrioritySubtitle => 'Drag to reorder download providers'; - @override String get providerPriorityTitle => 'Provider Priority'; @@ -7159,13 +5287,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get providerExtension => 'Extension'; - @override - String get metadataProviderPriority => 'Metadata Provider Priority'; - - @override - String get metadataProviderPrioritySubtitle => - 'Order used when fetching track metadata'; - @override String get metadataProviderPriorityTitle => 'Metadata Priority'; @@ -7186,18 +5307,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get logTitle => 'Logs'; - @override - String get logCopy => 'Copy Logs'; - - @override - String get logClear => 'Clear Logs'; - - @override - String get logShare => 'Share Logs'; - - @override - String get logEmpty => 'No logs yet'; - @override String get logCopied => 'Logs copied to clipboard'; @@ -7222,18 +5331,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get logClearLogsMessage => 'Are you sure you want to clear all logs?'; - @override - String get logIspBlocking => 'ISP BLOCKING DETECTED'; - - @override - String get logRateLimited => 'RATE LIMITED'; - - @override - String get logNetworkError => 'NETWORK ERROR'; - - @override - String get logTrackNotFound => 'TRACK NOT FOUND'; - @override String get logFilterBySeverity => 'Filter logs by severity'; @@ -7243,48 +5340,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get logNoLogsYetSubtitle => 'Logs will appear here as you use the app'; - @override - String get logIssueSummary => 'Issue Summary'; - - @override - String get logIspBlockingDescription => - 'Your ISP may be blocking access to download services'; - - @override - String get logIspBlockingSuggestion => - 'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8'; - - @override - String get logRateLimitedDescription => 'Too many requests to the service'; - - @override - String get logRateLimitedSuggestion => - 'Wait a few minutes before trying again'; - - @override - String get logNetworkErrorDescription => 'Connection issues detected'; - - @override - String get logNetworkErrorSuggestion => 'Check your internet connection'; - - @override - String get logTrackNotFoundDescription => - 'Some tracks could not be found on download services'; - - @override - String get logTrackNotFoundSuggestion => - 'The track may not be available in lossless quality'; - - @override - String logTotalErrors(int count) { - return 'Total errors: $count'; - } - - @override - String logAffected(String domains) { - return 'Affected: $domains'; - } - @override String logEntriesFiltered(int count) { return 'Entries ($count filtered)'; @@ -7391,9 +5446,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get appearanceLanguage => 'App Language'; - @override - String get appearanceLanguageSubtitle => 'Choose your preferred language'; - @override String get settingsAppearanceSubtitle => 'Theme, colors, display'; @@ -7415,9 +5467,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get pressBackAgainToExit => 'Press back again to exit'; - @override - String get tracksHeader => 'Tracks'; - @override String downloadAllCount(int count) { return 'Download All ($count)'; @@ -7531,11 +5580,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { String get trackDeleteConfirmMessage => 'This will permanently delete the downloaded file and remove it from your history.'; - @override - String trackCannotOpen(String message) { - return 'Cannot open: $message'; - } - @override String get dateToday => 'Today'; @@ -7557,18 +5601,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { return '$count months ago'; } - @override - String get concurrentSequential => 'Sequential'; - - @override - String get concurrentParallel2 => '2 Parallel'; - - @override - String get concurrentParallel3 => '3 Parallel'; - - @override - String get tapToSeeError => 'Tap to see error details'; - @override String get storeFilterAll => 'All'; @@ -7590,15 +5622,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get storeClearFilters => 'Clear filters'; - @override - String get storeNoResults => 'No extensions found'; - - @override - String get extensionProviderPriority => 'Provider Priority'; - - @override - String get extensionInstallButton => 'Install Extension'; - @override String get extensionDefaultProvider => 'Default (Deezer/Spotify)'; @@ -7749,38 +5772,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; - @override - String get qualityLossy => 'Lossy'; - - @override - String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)'; - - @override - String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)'; - - @override - String get enableLossyOption => 'Enable Lossy Option'; - - @override - String get enableLossyOptionSubtitleOn => 'Lossy quality option is available'; - - @override - String get enableLossyOptionSubtitleOff => - 'Downloads FLAC then converts to lossy format'; - - @override - String get lossyFormat => 'Lossy Format'; - - @override - String get lossyFormatDescription => 'Choose the lossy format for conversion'; - - @override - String get lossyFormatMp3Subtitle => '320kbps, best compatibility'; - - @override - String get lossyFormatOpusSubtitle => - '128kbps, better quality at smaller size'; - @override String get qualityNote => 'Actual quality depends on track availability from the service'; @@ -7804,14 +5795,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders'; - @override - String get downloadUseAlbumArtistForFoldersAlbumSubtitle => - 'Artist folders use Album Artist when available'; - - @override - String get downloadUseAlbumArtistForFoldersTrackSubtitle => - 'Artist folders use Track Artist only'; - @override String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders'; @@ -7823,78 +5806,18 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { String get downloadUsePrimaryArtistOnlyDisabled => 'Full artist string used for folder name'; - @override - String get downloadSaveFormat => 'Save Format'; - - @override - String get downloadSelectService => 'Select Service'; - @override String get downloadSelectQuality => 'Select Quality'; @override String get downloadFrom => 'Download From'; - @override - String get downloadDefaultQualityLabel => 'Default Quality'; - - @override - String get downloadBestAvailable => 'Best available'; - - @override - String get folderNone => 'None'; - - @override - String get folderNoneSubtitle => 'Save all files directly to download folder'; - - @override - String get folderArtist => 'Artist'; - - @override - String get folderArtistSubtitle => 'Artist Name/filename'; - - @override - String get folderAlbum => 'Album'; - - @override - String get folderAlbumSubtitle => 'Album Name/filename'; - - @override - String get folderArtistAlbum => 'Artist/Album'; - - @override - String get folderArtistAlbumSubtitle => 'Artist Name/Album Name/filename'; - - @override - String get serviceTidal => 'Tidal'; - - @override - String get serviceQobuz => 'Qobuz'; - - @override - String get serviceAmazon => 'Amazon'; - - @override - String get serviceDeezer => 'Deezer'; - - @override - String get serviceSpotify => 'Spotify'; - @override String get appearanceAmoledDark => 'AMOLED Dark'; @override String get appearanceAmoledDarkSubtitle => 'Pure black background'; - @override - String get appearanceChooseAccentColor => 'Choose Accent Color'; - - @override - String get appearanceChooseTheme => 'Theme Mode'; - - @override - String get queueTitle => 'Download Queue'; - @override String get queueClearAll => 'Clear All'; @@ -7902,19 +5825,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { String get queueClearAllMessage => 'Are you sure you want to clear all downloads?'; - @override - String get queueExportFailed => 'Export'; - - @override - String get queueExportFailedSuccess => - 'Failed downloads exported to TXT file'; - - @override - String get queueExportFailedClear => 'Clear Failed'; - - @override - String get queueExportFailedError => 'Failed to export downloads'; - @override String get settingsAutoExportFailed => 'Auto-export failed downloads'; @@ -7935,30 +5845,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { String get settingsDownloadNetworkSubtitle => 'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.'; - @override - String get queueEmpty => 'No downloads in queue'; - - @override - String get queueEmptySubtitle => 'Add tracks from the home screen'; - - @override - String get queueClearCompleted => 'Clear completed'; - - @override - String get queueDownloadFailed => 'Download Failed'; - - @override - String get queueTrackLabel => 'Track:'; - - @override - String get queueArtistLabel => 'Artist:'; - - @override - String get queueErrorLabel => 'Error:'; - - @override - String get queueUnknownError => 'Unknown error'; - @override String get albumFolderArtistAlbum => 'Artist / Album'; @@ -8005,14 +5891,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { return 'Delete $count $_temp0 from this album?\n\nThis will also delete the files from storage.'; } - @override - String get downloadedAlbumTracksHeader => 'Tracks'; - - @override - String downloadedAlbumDownloadedCount(int count) { - return '$count downloaded'; - } - @override String downloadedAlbumSelectedCount(int count) { return '$count selected'; @@ -8043,9 +5921,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { return 'Disc $discNumber'; } - @override - String get utilityFunctions => 'Utility Functions'; - @override String get recentTypeArtist => 'Artist'; @@ -8069,11 +5944,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { return 'Playlist: $name'; } - @override - String errorGeneric(String message) { - return 'Error: $message'; - } - @override String get discographyDownload => 'Download Discography'; @@ -8179,9 +6049,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get libraryTitle => 'Local Library'; - @override - String get libraryStatus => 'Library Status'; - @override String get libraryScanSettings => 'Scan Settings'; @@ -8244,11 +6111,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { String get libraryAboutDescription => 'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.'; - @override - String libraryTracksCount(int count) { - return '$count tracks'; - } - @override String libraryLastScanned(String time) { return 'Last scanned: $time'; @@ -8328,21 +6190,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get libraryFilterFormat => 'Format'; - @override - String get libraryFilterDate => 'Date Added'; - - @override - String get libraryFilterDateToday => 'Today'; - - @override - String get libraryFilterDateWeek => 'This Week'; - - @override - String get libraryFilterDateMonth => 'This Month'; - - @override - String get libraryFilterDateYear => 'This Year'; - @override String get libraryFilterSort => 'Sort'; @@ -8352,11 +6199,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get libraryFilterSortOldest => 'Oldest'; - @override - String libraryFilterActive(int count) { - return '$count filter(s) active'; - } - @override String get timeJustNow => 'Just now'; @@ -8382,72 +6224,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { return '$_temp0'; } - @override - String get storageSwitchTitle => 'Switch Storage Mode'; - - @override - String get storageSwitchToSafTitle => 'Switch to SAF Storage?'; - - @override - String get storageSwitchToAppTitle => 'Switch to App Storage?'; - - @override - String get storageSwitchToSafMessage => - 'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.'; - - @override - String get storageSwitchToAppMessage => - 'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.'; - - @override - String get storageSwitchExistingDownloads => 'Existing Downloads'; - - @override - String storageSwitchExistingDownloadsInfo(int count, String mode) { - return '$count tracks in $mode storage'; - } - - @override - String get storageSwitchNewDownloads => 'New Downloads'; - - @override - String storageSwitchNewDownloadsLocation(String location) { - return 'Will be saved to: $location'; - } - - @override - String get storageSwitchContinue => 'Continue'; - - @override - String get storageSwitchSelectFolder => 'Select SAF Folder'; - - @override - String get storageAppStorage => 'App Storage'; - - @override - String get storageSafStorage => 'SAF Storage'; - - @override - String storageModeBadge(String mode) { - return 'Storage: $mode'; - } - - @override - String get storageStatsTitle => 'Storage Statistics'; - - @override - String storageStatsAppCount(int count) { - return '$count tracks in App Storage'; - } - - @override - String storageStatsSafCount(int count) { - return '$count tracks in SAF Storage'; - } - - @override - String get storageModeInfo => 'Your files are stored in multiple locations'; - @override String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!'; @@ -8474,18 +6250,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { String get tutorialSearchDesc => 'There are two easy ways to find music you want to download.'; - @override - String get tutorialSearchTip1 => - 'Paste a Spotify or Deezer URL directly in the search box'; - - @override - String get tutorialSearchTip2 => - 'Or type the song name, artist, or album to search'; - - @override - String get tutorialSearchTip3 => - 'Supports tracks, albums, playlists, and artist pages'; - @override String get tutorialDownloadTitle => 'Downloading Music'; @@ -8493,18 +6257,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { String get tutorialDownloadDesc => 'Downloading music is simple and fast. Here\'s how it works.'; - @override - String get tutorialDownloadTip1 => - 'Tap the download button next to any track to start downloading'; - - @override - String get tutorialDownloadTip2 => - 'Choose your preferred quality (FLAC, Hi-Res, or MP3)'; - - @override - String get tutorialDownloadTip3 => - 'Download entire albums or playlists with one tap'; - @override String get tutorialLibraryTitle => 'Your Library'; @@ -8565,9 +6317,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { String get tutorialReadyMessage => 'You\'re all set! Start downloading your favorite music now.'; - @override - String get tutorialExample => 'EXAMPLE'; - @override String get libraryForceFullScan => 'Force Full Scan'; @@ -8728,11 +6477,7 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { String get trackSaveLyricsProgress => 'Saving lyrics...'; @override - String get trackReEnrich => 'Re-enrich Metadata'; - - @override - String get trackReEnrichSubtitle => - 'Re-embed metadata without re-downloading'; + String get trackReEnrich => 'Re-enrich'; @override String get trackReEnrichOnlineSubtitle => @@ -8808,4 +6553,17 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get trackConvertFailed => 'Conversion failed'; + + @override + String downloadedAlbumDownloadedCount(int count) { + return '$count downloaded'; + } + + @override + String get downloadUseAlbumArtistForFoldersAlbumSubtitle => + 'Artist folders use Album Artist when available'; + + @override + String get downloadUseAlbumArtistForFoldersTrackSubtitle => + 'Artist folders use Track Artist only'; } diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index 9f356f1b..b40c1f71 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -5,10 +5,6 @@ "@appName": { "description": "App name - DO NOT TRANSLATE" }, - "appDescription": "Lade Spotify-Titel in verlustfreier Qualität von Tidal, Qobuz und Amazon Music herunter.", - "@appDescription": { - "description": "App description shown in about page" - }, "navHome": "Startseite", "@navHome": { "description": "Bottom navigation - Home tab" @@ -17,10 +13,6 @@ "@navLibrary": { "description": "Bottom navigation - Library tab" }, - "navHistory": "Verlauf", - "@navHistory": { - "description": "Bottom navigation - History tab (legacy)" - }, "navSettings": "Einstellungen", "@navSettings": { "description": "Bottom navigation - Settings tab" @@ -33,20 +25,6 @@ "@homeTitle": { "description": "Home screen title" }, - "homeSearchHint": "Spotify-URL einfügen oder suchen...", - "@homeSearchHint": { - "description": "Placeholder text in search box" - }, - "homeSearchHintExtension": "Mit {extensionName} suchen...", - "@homeSearchHintExtension": { - "description": "Placeholder when extension search is active", - "placeholders": { - "extensionName": { - "type": "String", - "description": "Name of the active extension" - } - } - }, "homeSubtitle": "Spotify-Link einfügen oder nach Namen suchen", "@homeSubtitle": { "description": "Subtitle shown below search box" @@ -59,24 +37,6 @@ "@homeRecent": { "description": "Section header for recent searches" }, - "historyTitle": "Verlauf", - "@historyTitle": { - "description": "History screen title" - }, - "historyDownloading": "Wird heruntergeladen ({count})", - "@historyDownloading": { - "description": "Tab showing active downloads count", - "placeholders": { - "count": { - "type": "int", - "description": "Number of active downloads" - } - } - }, - "historyDownloaded": "Heruntergeladen", - "@historyDownloaded": { - "description": "Tab showing completed downloads" - }, "historyFilterAll": "Alle", "@historyFilterAll": { "description": "Filter chip - show all items" @@ -89,48 +49,6 @@ "@historyFilterSingles": { "description": "Filter chip - show singles only" }, - "historyTracksCount": "{count, plural, =1{1 Titel} other{{count} Titel}}", - "@historyTracksCount": { - "description": "Track count with plural form", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "historyAlbumsCount": "{count, plural, =1{1 Album} other{{count} Alben}}", - "@historyAlbumsCount": { - "description": "Album count with plural form", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "historyNoDownloads": "Kein Download-Verlauf", - "@historyNoDownloads": { - "description": "Empty state title" - }, - "historyNoDownloadsSubtitle": "Heruntergeladene Titel werden hier angezeigt", - "@historyNoDownloadsSubtitle": { - "description": "Empty state subtitle" - }, - "historyNoAlbums": "Keine Album-Downloads", - "@historyNoAlbums": { - "description": "Empty state when filtering albums" - }, - "historyNoAlbumsSubtitle": "Lade mehrere Titel eines Albums herunter, um sie hier zu sehen", - "@historyNoAlbumsSubtitle": { - "description": "Empty state subtitle for albums filter" - }, - "historyNoSingles": "Keine Einzel-Downloads", - "@historyNoSingles": { - "description": "Empty state when filtering singles" - }, - "historyNoSinglesSubtitle": "Einzelne Titel-Downloads werden hier angezeigt", - "@historyNoSinglesSubtitle": { - "description": "Empty state subtitle for singles filter" - }, "historySearchHint": "Suchverlauf...", "@historySearchHint": { "description": "Search bar placeholder in history" @@ -163,34 +81,6 @@ "@downloadTitle": { "description": "Download settings page title" }, - "downloadLocation": "Download-Speicherort", - "@downloadLocation": { - "description": "Setting for download folder" - }, - "downloadLocationSubtitle": "Wähle den Speicherort der Dateien", - "@downloadLocationSubtitle": { - "description": "Subtitle for download location" - }, - "downloadLocationDefault": "Standard-Speicherort", - "@downloadLocationDefault": { - "description": "Shown when using default folder" - }, - "downloadDefaultService": "Standard-Dienst", - "@downloadDefaultService": { - "description": "Setting for preferred download service (Tidal/Qobuz/Amazon)" - }, - "downloadDefaultServiceSubtitle": "Dienst für Downloads", - "@downloadDefaultServiceSubtitle": { - "description": "Subtitle for default service" - }, - "downloadDefaultQuality": "Standard-Qualität", - "@downloadDefaultQuality": { - "description": "Setting for audio quality" - }, - "downloadAskQuality": "Qualität vor Download abfragen", - "@downloadAskQuality": { - "description": "Toggle to show quality picker" - }, "downloadAskQualitySubtitle": "Qualitätsauswahl für jeden Download anzeigen", "@downloadAskQualitySubtitle": { "description": "Subtitle for ask quality toggle" @@ -203,38 +93,10 @@ "@downloadFolderOrganization": { "description": "Setting for folder structure" }, - "downloadSeparateSingles": "Singles trennen", - "@downloadSeparateSingles": { - "description": "Toggle to separate single tracks" - }, - "downloadSeparateSinglesSubtitle": "Einzelne Titel in separatem Ordner speichern", - "@downloadSeparateSinglesSubtitle": { - "description": "Subtitle for separate singles toggle" - }, - "qualityBest": "Beste Qualität", - "@qualityBest": { - "description": "Audio quality option - highest available" - }, - "qualityFlac": "FLAC", - "@qualityFlac": { - "description": "Audio quality option - FLAC lossless" - }, - "quality320": "320 kbps", - "@quality320": { - "description": "Audio quality option - 320kbps MP3" - }, - "quality128": "128 kbps", - "@quality128": { - "description": "Audio quality option - 128kbps MP3" - }, "appearanceTitle": "Erscheinungsbild", "@appearanceTitle": { "description": "Appearance settings page title" }, - "appearanceTheme": "Design", - "@appearanceTheme": { - "description": "Theme mode setting" - }, "appearanceThemeSystem": "System", "@appearanceThemeSystem": { "description": "Follow system theme" @@ -255,10 +117,6 @@ "@appearanceDynamicColorSubtitle": { "description": "Subtitle for dynamic color" }, - "appearanceAccentColor": "Akzentfarbe", - "@appearanceAccentColor": { - "description": "Custom accent color picker" - }, "appearanceHistoryView": "Verlaufsansicht", "@appearanceHistoryView": { "description": "Layout style for history" @@ -275,10 +133,6 @@ "@optionsTitle": { "description": "Options settings page title" }, - "optionsSearchSource": "Suchquelle", - "@optionsSearchSource": { - "description": "Section for search provider settings" - }, "optionsPrimaryProvider": "Primärer Anbieter", "@optionsPrimaryProvider": { "description": "Main search provider setting" @@ -438,22 +292,6 @@ "@extensionsTitle": { "description": "Extensions page title" }, - "extensionsInstalled": "Installierte Erweiterungen", - "@extensionsInstalled": { - "description": "Section header for installed extensions" - }, - "extensionsNone": "Keine Erweiterungen installiert", - "@extensionsNone": { - "description": "Empty state title" - }, - "extensionsNoneSubtitle": "Erweiterungen aus dem Store-Tab installieren", - "@extensionsNoneSubtitle": { - "description": "Empty state subtitle" - }, - "extensionsEnabled": "Aktiviert", - "@extensionsEnabled": { - "description": "Extension status - active" - }, "extensionsDisabled": "Deaktiviert", "@extensionsDisabled": { "description": "Extension status - inactive" @@ -480,10 +318,6 @@ "@extensionsUninstall": { "description": "Uninstall extension button" }, - "extensionsSetAsSearch": "Als Suchanbieter festlegen", - "@extensionsSetAsSearch": { - "description": "Use extension for search" - }, "storeTitle": "Erweiterungs-Store", "@storeTitle": { "description": "Store screen title" @@ -580,10 +414,6 @@ "@aboutSocial": { "description": "Section for social links" }, - "aboutSupport": "Support", - "@aboutSupport": { - "description": "Section for support/donation links" - }, "aboutApp": "App", "@aboutApp": { "description": "Section for app info" @@ -604,14 +434,6 @@ "@aboutSjdonadoDesc": { "description": "Credit description for sjdonado" }, - "aboutDoubleDouble": "DoubleDouble", - "@aboutDoubleDouble": { - "description": "Name of Amazon API service - DO NOT TRANSLATE" - }, - "aboutDoubleDoubleDesc": "Wundervolle API für Amazon Musik-Downloads.", - "@aboutDoubleDoubleDesc": { - "description": "Credit for DoubleDouble API" - }, "aboutDabMusic": "DAB Music", "@aboutDabMusic": { "description": "Name of Qobuz API service - DO NOT TRANSLATE" @@ -632,35 +454,6 @@ "@aboutAppDescription": { "description": "App description in header card" }, - "albumTitle": "Album", - "@albumTitle": { - "description": "Album screen title" - }, - "albumTracks": "{count, plural,=1{1 Song} other{{count} Songs}}", - "@albumTracks": { - "description": "Album track count", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "albumDownloadAll": "Alle Herunterladen", - "@albumDownloadAll": { - "description": "Button to download all tracks" - }, - "albumDownloadRemaining": "Downloads verbleibend", - "@albumDownloadRemaining": { - "description": "Button to download remaining tracks" - }, - "playlistTitle": "Playlist", - "@playlistTitle": { - "description": "Playlist screen title" - }, - "artistTitle": "Künstler", - "@artistTitle": { - "description": "Artist screen title" - }, "artistAlbums": "Alben", "@artistAlbums": { "description": "Section header for artist albums" @@ -673,15 +466,6 @@ "@artistCompilations": { "description": "Section header for compilations" }, - "artistReleases": "{count, plural,=1{1 Veröffentlichung} other{{count} Veröffentlichungen}}", - "@artistReleases": { - "description": "Artist release count", - "placeholders": { - "count": { - "type": "int" - } - } - }, "artistPopular": "Beliebt", "@artistPopular": { "description": "Section header for popular/top tracks" @@ -696,34 +480,6 @@ } } }, - "trackMetadataTitle": "Titel Info", - "@trackMetadataTitle": { - "description": "Track metadata screen title" - }, - "trackMetadataArtist": "Künstler", - "@trackMetadataArtist": { - "description": "Metadata field - artist name" - }, - "trackMetadataAlbum": "Album", - "@trackMetadataAlbum": { - "description": "Metadata field - album name" - }, - "trackMetadataDuration": "Länge", - "@trackMetadataDuration": { - "description": "Metadata field - track length" - }, - "trackMetadataQuality": "Qualität", - "@trackMetadataQuality": { - "description": "Metadata field - audio quality" - }, - "trackMetadataPath": "Dateipfad", - "@trackMetadataPath": { - "description": "Metadata field - file location" - }, - "trackMetadataDownloadedAt": "Heruntergeladen", - "@trackMetadataDownloadedAt": { - "description": "Metadata field - download date" - }, "trackMetadataService": "Anbieter", "@trackMetadataService": { "description": "Metadata field - download service used" @@ -740,54 +496,10 @@ "@trackMetadataDelete": { "description": "Action button - delete track" }, - "trackMetadataRedownload": "Erneut herunterladen", - "@trackMetadataRedownload": { - "description": "Action button - download again" - }, - "trackMetadataOpenFolder": "Ordner öffnen", - "@trackMetadataOpenFolder": { - "description": "Action button - open containing folder" - }, - "setupTitle": "Willkommen bei SpotiFLAC", - "@setupTitle": { - "description": "Setup wizard title" - }, - "setupSubtitle": "Los geht's", - "@setupSubtitle": { - "description": "Setup wizard subtitle" - }, - "setupStoragePermission": "Speicherberechtigung", - "@setupStoragePermission": { - "description": "Storage permission step title" - }, - "setupStoragePermissionSubtitle": "Benötigt um heruntergeladene Dateien zu Speichern", - "@setupStoragePermissionSubtitle": { - "description": "Explanation for storage permission" - }, - "setupStoragePermissionGranted": "Berechtigung erteilt", - "@setupStoragePermissionGranted": { - "description": "Status when permission granted" - }, - "setupStoragePermissionDenied": "Berechtigung verweigert", - "@setupStoragePermissionDenied": { - "description": "Status when permission denied" - }, "setupGrantPermission": "Berechtigung erlauben", "@setupGrantPermission": { "description": "Button to request permission" }, - "setupDownloadLocation": "Speicherort", - "@setupDownloadLocation": { - "description": "Download folder step title" - }, - "setupChooseFolder": "Ordner wählen", - "@setupChooseFolder": { - "description": "Button to pick folder" - }, - "setupContinue": "Fortfahren", - "@setupContinue": { - "description": "Continue to next step button" - }, "setupSkip": "Vorerst überspringen", "@setupSkip": { "description": "Skip current step button" @@ -796,10 +508,6 @@ "@setupStorageAccessRequired": { "description": "Title when storage access needed" }, - "setupStorageAccessMessage": "SpotiFLAC benötigt die Berechtigung \"Auf alle Dateien zugreifen\", um Musikdateien in deinen gewählten Ordner zu speichern.", - "@setupStorageAccessMessage": { - "description": "Explanation for storage access" - }, "setupStorageAccessMessageAndroid11": "Android 11+ benötigt die Berechtigung „Auf alle Dateien“, um Dateien im ausgewählten Download-Ordner zu speichern.", "@setupStorageAccessMessageAndroid11": { "description": "Android 11+ specific explanation" @@ -831,10 +539,6 @@ } } }, - "setupSelectDownloadFolder": "Wähle Download-Ordner aus", - "@setupSelectDownloadFolder": { - "description": "Folder selection step title" - }, "setupUseDefaultFolder": "Als Standardordner verwenden?", "@setupUseDefaultFolder": { "description": "Dialog title for default folder" @@ -883,26 +587,6 @@ "@setupDownloadInFlac": { "description": "App tagline in setup" }, - "setupStepStorage": "Speicherort", - "@setupStepStorage": { - "description": "Setup step indicator - storage" - }, - "setupStepNotification": "Benachrichtigung", - "@setupStepNotification": { - "description": "Setup step indicator - notification" - }, - "setupStepFolder": "Ordner", - "@setupStepFolder": { - "description": "Setup step indicator - folder" - }, - "setupStepSpotify": "Spotify", - "@setupStepSpotify": { - "description": "Setup step indicator - Spotify API" - }, - "setupStepPermission": "Berechtigung", - "@setupStepPermission": { - "description": "Setup step indicator - permission" - }, "setupStorageGranted": "Speicherberechtigung erlaubt!", "@setupStorageGranted": { "description": "Success message for storage permission" @@ -923,14 +607,6 @@ "@setupNotificationEnable": { "description": "Button to enable notifications" }, - "setupNotificationDescription": "Benachrichtigt werden, wenn Downloads abgeschlossen sind.", - "@setupNotificationDescription": { - "description": "Explanation for notifications" - }, - "setupFolderSelected": "Download Ordner ausgewählt!", - "@setupFolderSelected": { - "description": "Success message for folder selection" - }, "setupFolderChoose": "Speicherort auwählen", "@setupFolderChoose": { "description": "Button to choose folder" @@ -939,58 +615,14 @@ "@setupFolderDescription": { "description": "Explanation for folder selection" }, - "setupChangeFolder": "Ordner ändern", - "@setupChangeFolder": { - "description": "Button to change selected folder" - }, "setupSelectFolder": "Ordner wählen", "@setupSelectFolder": { "description": "Button to select folder" }, - "setupSpotifyApiOptional": "Spotify-API (optional)", - "@setupSpotifyApiOptional": { - "description": "Spotify API step title" - }, - "setupSpotifyApiDescription": "Füge deine Spotify-API-Zugangsdaten für bessere Suchergebnisse und den Zugriff auf Spotify-exklusive Inhalte hinzu.", - "@setupSpotifyApiDescription": { - "description": "Explanation for Spotify API" - }, - "setupUseSpotifyApi": "Spotify-API verwenden", - "@setupUseSpotifyApi": { - "description": "Toggle to enable Spotify API" - }, - "setupEnterCredentialsBelow": "Gib deine Anmeldedaten unten ein", - "@setupEnterCredentialsBelow": { - "description": "Prompt to enter credentials" - }, - "setupUsingDeezer": "Deezer verwenden (kein Konto erforderlich)", - "@setupUsingDeezer": { - "description": "Status when using Deezer" - }, - "setupEnterClientId": "Spotify-Client-ID eingeben", - "@setupEnterClientId": { - "description": "Placeholder for client ID field" - }, - "setupEnterClientSecret": "Spotify Client-Secret eingeben", - "@setupEnterClientSecret": { - "description": "Placeholder for client secret field" - }, - "setupGetFreeCredentials": "Hole dir kostenlose API-Anmeldeinformationen aus dem Spotify-Entwickler-Dashboard.", - "@setupGetFreeCredentials": { - "description": "Info about getting Spotify credentials" - }, "setupEnableNotifications": "Benachrichtigungen aktivieren", "@setupEnableNotifications": { "description": "Button to enable notifications" }, - "setupProceedToNextStep": "Du kannst mit dem nächsten Schritt fortfahren.", - "@setupProceedToNextStep": { - "description": "Message after completing a step" - }, - "setupNotificationProgressDescription": "Du erhältst Benachrichtigungen über den Download-Fortschritt.", - "@setupNotificationProgressDescription": { - "description": "Info about notification usage" - }, "setupNotificationBackgroundDescription": "Werde benachrichtigt über Download-Fortschritt und -Fertigstellung. Dies hilft Ihnen, Downloads zu verfolgen, wenn die App im Hintergrund ist.", "@setupNotificationBackgroundDescription": { "description": "Detailed notification explanation" @@ -999,10 +631,6 @@ "@setupSkipForNow": { "description": "Skip button text" }, - "setupBack": "Zurück", - "@setupBack": { - "description": "Back button text" - }, "setupNext": "Weiter", "@setupNext": { "description": "Next button text" @@ -1011,26 +639,14 @@ "@setupGetStarted": { "description": "Final setup button" }, - "setupSkipAndStart": "Überspringen & Starten", - "@setupSkipAndStart": { - "description": "Skip setup and start app" - }, "setupAllowAccessToManageFiles": "Bitte aktiviere \"Zugriff auf alle Dateien erlauben\" auf dem nächsten Bildschirm.", "@setupAllowAccessToManageFiles": { "description": "Instruction for file access permission" }, - "setupGetCredentialsFromSpotify": "Zugangsdaten von developer.spotify.com erhalten", - "@setupGetCredentialsFromSpotify": { - "description": "Link text for Spotify developer portal" - }, "dialogCancel": "Abbrechen", "@dialogCancel": { "description": "Dialog button - cancel action" }, - "dialogOk": "OK", - "@dialogOk": { - "description": "Dialog button - confirm/acknowledge" - }, "dialogSave": "Speichern", "@dialogSave": { "description": "Dialog button - save changes" @@ -1043,26 +659,10 @@ "@dialogRetry": { "description": "Dialog button - retry action" }, - "dialogClose": "Schließen", - "@dialogClose": { - "description": "Dialog button - close dialog" - }, - "dialogYes": "Ja", - "@dialogYes": { - "description": "Dialog button - confirm yes" - }, - "dialogNo": "Nein", - "@dialogNo": { - "description": "Dialog button - confirm no" - }, "dialogClear": "Leeren", "@dialogClear": { "description": "Dialog button - clear items" }, - "dialogConfirm": "Bestätigen", - "@dialogConfirm": { - "description": "Dialog button - confirm action" - }, "dialogDone": "Fertig", "@dialogDone": { "description": "Dialog button - action completed" @@ -1091,34 +691,10 @@ "@dialogUnsavedChanges": { "description": "Dialog message - unsaved changes" }, - "dialogDownloadFailed": "Download fehlgeschlagen", - "@dialogDownloadFailed": { - "description": "Dialog title - download error" - }, - "dialogTrackLabel": "Titel:", - "@dialogTrackLabel": { - "description": "Label for track name in error dialog" - }, - "dialogArtistLabel": "Künstler:", - "@dialogArtistLabel": { - "description": "Label for artist name in error dialog" - }, - "dialogErrorLabel": "Fehler:", - "@dialogErrorLabel": { - "description": "Label for error message" - }, "dialogClearAll": "Alles löschen", "@dialogClearAll": { "description": "Dialog title - clear all items" }, - "dialogClearAllDownloads": "Bist du dir sicher, dass du alle Downloads löschen möchten?", - "@dialogClearAllDownloads": { - "description": "Dialog message - clear downloads confirmation" - }, - "dialogRemoveFromDevice": "Vom Gerät entfernen?", - "@dialogRemoveFromDevice": { - "description": "Dialog title - delete file confirmation" - }, "dialogRemoveExtension": "Erweiterung entfernen", "@dialogRemoveExtension": { "description": "Dialog title - uninstall extension" @@ -1257,15 +833,6 @@ "@snackbarViewQueue": { "description": "Snackbar action - view download queue" }, - "snackbarFailedToLoad": "Fehler beim Laden: {error}", - "@snackbarFailedToLoad": { - "description": "Snackbar - loading error", - "placeholders": { - "error": { - "type": "String" - } - } - }, "snackbarUrlCopied": "{platform} URL in die Zwischenablage kopiert", "@snackbarUrlCopied": { "description": "Snackbar - URL copied", @@ -1326,16 +893,6 @@ "@errorRateLimitedMessage": { "description": "Error message - rate limit explanation" }, - "errorFailedToLoad": "Fehler beim Laden von: {item}", - "@errorFailedToLoad": { - "description": "Error message - loading failed", - "placeholders": { - "item": { - "type": "String", - "description": "Item that failed to load (album/playlist/etc)" - } - } - }, "errorNoTracksFound": "Keine Titel gefunden", "@errorNoTracksFound": { "description": "Error - search returned no results" @@ -1349,34 +906,6 @@ } } }, - "statusQueued": "In der Warteschlange", - "@statusQueued": { - "description": "Download status - waiting in queue" - }, - "statusDownloading": "Wird heruntergeladen", - "@statusDownloading": { - "description": "Download status - in progress" - }, - "statusFinalizing": "Wird fertiggestellt", - "@statusFinalizing": { - "description": "Download status - writing metadata" - }, - "statusCompleted": "Beendet", - "@statusCompleted": { - "description": "Download status - finished" - }, - "statusFailed": "Fehlgeschlagen", - "@statusFailed": { - "description": "Download status - error occurred" - }, - "statusSkipped": "Übersprungen", - "@statusSkipped": { - "description": "Download status - already exists" - }, - "statusPaused": "Pausiert", - "@statusPaused": { - "description": "Download status - paused" - }, "actionPause": "Pause", "@actionPause": { "description": "Action button - pause download" @@ -1389,14 +918,6 @@ "@actionCancel": { "description": "Action button - cancel operation" }, - "actionStop": "Beenden", - "@actionStop": { - "description": "Action button - stop operation" - }, - "actionSelect": "Wähle", - "@actionSelect": { - "description": "Action button - enter selection mode" - }, "actionSelectAll": "Alles Auswählen", "@actionSelectAll": { "description": "Action button - select all items" @@ -1405,14 +926,6 @@ "@actionDeselect": { "description": "Action button - deselect all" }, - "actionPaste": "Einfügen", - "@actionPaste": { - "description": "Action button - paste from clipboard" - }, - "actionImportCsv": "CSV-Datei importieren", - "@actionImportCsv": { - "description": "Action button - import CSV file" - }, "actionRemoveCredentials": "Anmeldedaten entfernen", "@actionRemoveCredentials": { "description": "Action button - delete Spotify credentials" @@ -1434,19 +947,6 @@ "@selectionAllSelected": { "description": "Status - all items selected" }, - "selectionTapToSelect": "Tippe auf Titel zum Auswählen", - "@selectionTapToSelect": { - "description": "Hint - how to select items" - }, - "selectionDeleteTracks": "Lösche {count} {count, plural, one {Titel}other{Titel}}", - "@selectionDeleteTracks": { - "description": "Delete button with count", - "placeholders": { - "count": { - "type": "int" - } - } - }, "selectionSelectToDelete": "Titel zum Löschen auswählen", "@selectionSelectToDelete": { "description": "Placeholder when nothing selected" @@ -1487,55 +987,10 @@ "@tooltipPlay": { "description": "Tooltip - play button" }, - "tooltipCancel": "Abbrechen", - "@tooltipCancel": { - "description": "Tooltip - cancel button" - }, - "tooltipStop": "Beenden", - "@tooltipStop": { - "description": "Tooltip - stop button" - }, - "tooltipRetry": "Wiederholen", - "@tooltipRetry": { - "description": "Tooltip - retry button" - }, - "tooltipRemove": "Entfernen", - "@tooltipRemove": { - "description": "Tooltip - remove button" - }, - "tooltipClear": "Leeren", - "@tooltipClear": { - "description": "Tooltip - clear button" - }, - "tooltipPaste": "Einfügen", - "@tooltipPaste": { - "description": "Tooltip - paste button" - }, "filenameFormat": "Dateinamenformat", "@filenameFormat": { "description": "Setting title - filename pattern" }, - "filenameFormatPreview": "Vorschau: {preview}", - "@filenameFormatPreview": { - "description": "Preview of filename pattern", - "placeholders": { - "preview": { - "type": "String" - } - } - }, - "filenameAvailablePlaceholders": "Verfügbare Platzhalter:", - "@filenameAvailablePlaceholders": { - "description": "Label for placeholder list" - }, - "filenameHint": "{artist} - {title}", - "@filenameHint": { - "description": "Default filename format hint" - }, - "folderOrganization": "Ordnerstruktur", - "@folderOrganization": { - "description": "Setting title - folder structure" - }, "folderOrganizationNone": "Keine Organisation", "@folderOrganizationNone": { "description": "Folder option - flat structure" @@ -1576,27 +1031,10 @@ "@updateAvailable": { "description": "Update dialog title" }, - "updateNewVersion": "Version {version} ist verfügbar", - "@updateNewVersion": { - "description": "Update available message", - "placeholders": { - "version": { - "type": "String" - } - } - }, - "updateDownload": "Herunterladen", - "@updateDownload": { - "description": "Update button - download update" - }, "updateLater": "Später", "@updateLater": { "description": "Update button - dismiss" }, - "updateChangelog": "Änderungsverlauf", - "@updateChangelog": { - "description": "Link to changelog" - }, "updateStartingDownload": "Download wird gestartet...", "@updateStartingDownload": { "description": "Update status - initializing" @@ -1637,14 +1075,6 @@ "@updateDontRemind": { "description": "Update button - skip this version" }, - "providerPriority": "Anbieterpriorität", - "@providerPriority": { - "description": "Setting title - download provider order" - }, - "providerPrioritySubtitle": "Ziehen, um Download-Anbieter neu anzuordnen", - "@providerPrioritySubtitle": { - "description": "Subtitle for provider priority" - }, "providerPriorityTitle": "Anbieterpriorität", "@providerPriorityTitle": { "description": "Provider priority page title" @@ -1665,14 +1095,6 @@ "@providerExtension": { "description": "Label for extension-provided providers" }, - "metadataProviderPriority": "Priorität des Metadaten-Anbieters", - "@metadataProviderPriority": { - "description": "Setting title - metadata provider order" - }, - "metadataProviderPrioritySubtitle": "Reihenfolge beim Abrufen von Titelmetadaten", - "@metadataProviderPrioritySubtitle": { - "description": "Subtitle for metadata priority" - }, "metadataProviderPriorityTitle": "Metadaten Priorität", "@metadataProviderPriorityTitle": { "description": "Metadata priority page title" @@ -1697,22 +1119,6 @@ "@logTitle": { "description": "Logs screen title" }, - "logCopy": "Protokolle kopieren", - "@logCopy": { - "description": "Action - copy logs to clipboard" - }, - "logClear": "Protokolle löschen", - "@logClear": { - "description": "Action - delete all logs" - }, - "logShare": "Protokolle teilen", - "@logShare": { - "description": "Action - share logs file" - }, - "logEmpty": "Keine Protokolle bisher", - "@logEmpty": { - "description": "Empty state title" - }, "logCopied": "Protokolle in Zwischenablage kopiert", "@logCopied": { "description": "Snackbar - logs copied" @@ -1745,22 +1151,6 @@ "@logClearLogsMessage": { "description": "Clear logs confirmation message" }, - "logIspBlocking": "ISP BLOCKIERUNG ERKANNT", - "@logIspBlocking": { - "description": "Error category - ISP blocking" - }, - "logRateLimited": "LIMIT ERKANNT", - "@logRateLimited": { - "description": "Error category - rate limiting" - }, - "logNetworkError": "NETZWERKFEHLER", - "@logNetworkError": { - "description": "Error category - network issues" - }, - "logTrackNotFound": "TITEL NICHT GEFUNDEN", - "@logTrackNotFound": { - "description": "Error category - missing tracks" - }, "logFilterBySeverity": "Protokolle nach Schweregrad filtern", "@logFilterBySeverity": { "description": "Filter dialog title" @@ -1773,60 +1163,6 @@ "@logNoLogsYetSubtitle": { "description": "Empty state subtitle" }, - "logIssueSummary": "Problemübersicht", - "@logIssueSummary": { - "description": "Section header for error summary" - }, - "logIspBlockingDescription": "Ihr ISP blockiert möglicherweise den Zugriff auf den Download Dienst", - "@logIspBlockingDescription": { - "description": "ISP blocking explanation" - }, - "logIspBlockingSuggestion": "Versuche es einem VPN oder ändere DNS auf 1.1.1.1 oder 8.8.8.8", - "@logIspBlockingSuggestion": { - "description": "ISP blocking fix suggestion" - }, - "logRateLimitedDescription": "Zu viele Anfragen an den Dienst", - "@logRateLimitedDescription": { - "description": "Rate limit explanation" - }, - "logRateLimitedSuggestion": "Warte ein paar Minuten, bevor du es erneut versuchst", - "@logRateLimitedSuggestion": { - "description": "Rate limit fix suggestion" - }, - "logNetworkErrorDescription": "Verbindungsprobleme erkannt", - "@logNetworkErrorDescription": { - "description": "Network error explanation" - }, - "logNetworkErrorSuggestion": "Überprüfe deine Internetverbindung", - "@logNetworkErrorSuggestion": { - "description": "Network error fix suggestion" - }, - "logTrackNotFoundDescription": "Einige Titel konnten auf Download-Diensten nicht gefunden werden", - "@logTrackNotFoundDescription": { - "description": "Track not found explanation" - }, - "logTrackNotFoundSuggestion": "Der Titel ist möglicherweise nicht in verlustfreier Qualität verfügbar", - "@logTrackNotFoundSuggestion": { - "description": "Track not found explanation" - }, - "logTotalErrors": "Gesamte Fehler: {count}", - "@logTotalErrors": { - "description": "Error count display", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "logAffected": "Betroffen: {domains}", - "@logAffected": { - "description": "Affected domains display", - "placeholders": { - "domains": { - "type": "String" - } - } - }, "logEntriesFiltered": "Einträge ({count} gefiltert)", "@logEntriesFiltered": { "description": "Log count with filter active", @@ -1969,10 +1305,6 @@ "@appearanceLanguage": { "description": "Language setting title" }, - "appearanceLanguageSubtitle": "Choose your preferred language", - "@appearanceLanguageSubtitle": { - "description": "Language setting subtitle" - }, "settingsAppearanceSubtitle": "Theme, colors, display", "@settingsAppearanceSubtitle": { "description": "Appearance settings description" @@ -2001,10 +1333,6 @@ "@pressBackAgainToExit": { "description": "Exit confirmation message" }, - "tracksHeader": "Tracks", - "@tracksHeader": { - "description": "Section header for track list" - }, "downloadAllCount": "Download All ({count})", "@downloadAllCount": { "description": "Download all button with count", @@ -2151,15 +1479,6 @@ "@trackDeleteConfirmMessage": { "description": "Delete confirmation message" }, - "trackCannotOpen": "Cannot open: {message}", - "@trackCannotOpen": { - "description": "Error opening file", - "placeholders": { - "message": { - "type": "String" - } - } - }, "dateToday": "Today", "@dateToday": { "description": "Relative date - today" @@ -2195,22 +1514,6 @@ } } }, - "concurrentSequential": "Sequential", - "@concurrentSequential": { - "description": "Download mode - one at a time" - }, - "concurrentParallel2": "2 Parallel", - "@concurrentParallel2": { - "description": "Download mode - 2 simultaneous" - }, - "concurrentParallel3": "3 Parallel", - "@concurrentParallel3": { - "description": "Download mode - 3 simultaneous" - }, - "tapToSeeError": "Tap to see error details", - "@tapToSeeError": { - "description": "Tooltip for failed download" - }, "storeFilterAll": "All", "@storeFilterAll": { "description": "Store filter - all extensions" @@ -2239,18 +1542,6 @@ "@storeClearFilters": { "description": "Button to clear all filters" }, - "storeNoResults": "No extensions found", - "@storeNoResults": { - "description": "Empty state when no extensions match filters" - }, - "extensionProviderPriority": "Provider Priority", - "@extensionProviderPriority": { - "description": "Extension capability - provider priority" - }, - "extensionInstallButton": "Install Extension", - "@extensionInstallButton": { - "description": "Button to install extension" - }, "extensionDefaultProvider": "Default (Deezer/Spotify)", "@extensionDefaultProvider": { "description": "Default search provider option" @@ -2450,46 +1741,6 @@ "@qualityHiResFlacMaxSubtitle": { "description": "Technical spec for hi-res max" }, - "qualityLossy": "Lossy", - "@qualityLossy": { - "description": "Quality option - lossy format (MP3/Opus)" - }, - "qualityLossyMp3Subtitle": "MP3 320kbps (converted from FLAC)", - "@qualityLossyMp3Subtitle": { - "description": "Technical spec for lossy MP3" - }, - "qualityLossyOpusSubtitle": "Opus 128kbps (converted from FLAC)", - "@qualityLossyOpusSubtitle": { - "description": "Technical spec for lossy Opus" - }, - "enableLossyOption": "Enable Lossy Option", - "@enableLossyOption": { - "description": "Setting - enable lossy quality option" - }, - "enableLossyOptionSubtitleOn": "Lossy quality option is available", - "@enableLossyOptionSubtitleOn": { - "description": "Subtitle when lossy is enabled" - }, - "enableLossyOptionSubtitleOff": "Downloads FLAC then converts to lossy format", - "@enableLossyOptionSubtitleOff": { - "description": "Subtitle when lossy is disabled" - }, - "lossyFormat": "Lossy Format", - "@lossyFormat": { - "description": "Setting - choose lossy format" - }, - "lossyFormatDescription": "Choose the lossy format for conversion", - "@lossyFormatDescription": { - "description": "Description for lossy format picker" - }, - "lossyFormatMp3Subtitle": "320kbps, best compatibility", - "@lossyFormatMp3Subtitle": { - "description": "MP3 format description" - }, - "lossyFormatOpusSubtitle": "128kbps, better quality at smaller size", - "@lossyFormatOpusSubtitle": { - "description": "Opus format description" - }, "qualityNote": "Actual quality depends on track availability from the service", "@qualityNote": { "description": "Note about quality availability" @@ -2518,14 +1769,6 @@ "@downloadUseAlbumArtistForFolders": { "description": "Setting - choose whether artist folders use Album Artist or Track Artist" }, - "downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available", - "@downloadUseAlbumArtistForFoldersAlbumSubtitle": { - "description": "Subtitle when Album Artist is used for folder naming" - }, - "downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only", - "@downloadUseAlbumArtistForFoldersTrackSubtitle": { - "description": "Subtitle when Track Artist is used for folder naming" - }, "downloadUsePrimaryArtistOnly": "Primary artist only for folders", "@downloadUsePrimaryArtistOnly": { "description": "Setting - strip featured artists from folder name" @@ -2538,14 +1781,6 @@ "@downloadUsePrimaryArtistOnlyDisabled": { "description": "Subtitle when primary artist only is disabled" }, - "downloadSaveFormat": "Save Format", - "@downloadSaveFormat": { - "description": "Setting - output file format" - }, - "downloadSelectService": "Select Service", - "@downloadSelectService": { - "description": "Dialog title - choose download service" - }, "downloadSelectQuality": "Select Quality", "@downloadSelectQuality": { "description": "Dialog title - choose audio quality" @@ -2554,66 +1789,6 @@ "@downloadFrom": { "description": "Label - download source" }, - "downloadDefaultQualityLabel": "Default Quality", - "@downloadDefaultQualityLabel": { - "description": "Label - default quality setting" - }, - "downloadBestAvailable": "Best available", - "@downloadBestAvailable": { - "description": "Quality option - highest available" - }, - "folderNone": "None", - "@folderNone": { - "description": "Folder option - no organization" - }, - "folderNoneSubtitle": "Save all files directly to download folder", - "@folderNoneSubtitle": { - "description": "Subtitle for no folder organization" - }, - "folderArtist": "Artist", - "@folderArtist": { - "description": "Folder option - by artist" - }, - "folderArtistSubtitle": "Artist Name/filename", - "@folderArtistSubtitle": { - "description": "Folder structure example" - }, - "folderAlbum": "Album", - "@folderAlbum": { - "description": "Folder option - by album" - }, - "folderAlbumSubtitle": "Album Name/filename", - "@folderAlbumSubtitle": { - "description": "Folder structure example" - }, - "folderArtistAlbum": "Artist/Album", - "@folderArtistAlbum": { - "description": "Folder option - nested" - }, - "folderArtistAlbumSubtitle": "Artist Name/Album Name/filename", - "@folderArtistAlbumSubtitle": { - "description": "Folder structure example" - }, - "serviceTidal": "Tidal", - "@serviceTidal": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceQobuz": "Qobuz", - "@serviceQobuz": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceAmazon": "Amazon", - "@serviceAmazon": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceDeezer": "Deezer", - "@serviceDeezer": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceSpotify": "Spotify", - "@serviceSpotify": { - "description": "Service name - DO NOT TRANSLATE" - }, "appearanceAmoledDark": "AMOLED Dark", "@appearanceAmoledDark": { "description": "Theme option - pure black" @@ -2622,18 +1797,6 @@ "@appearanceAmoledDarkSubtitle": { "description": "Subtitle for AMOLED dark" }, - "appearanceChooseAccentColor": "Choose Accent Color", - "@appearanceChooseAccentColor": { - "description": "Color picker dialog title" - }, - "appearanceChooseTheme": "Theme Mode", - "@appearanceChooseTheme": { - "description": "Theme picker dialog title" - }, - "queueTitle": "Download Queue", - "@queueTitle": { - "description": "Queue screen title" - }, "queueClearAll": "Clear All", "@queueClearAll": { "description": "Button - clear all queue items" @@ -2642,22 +1805,6 @@ "@queueClearAllMessage": { "description": "Clear queue confirmation" }, - "queueExportFailed": "Export", - "@queueExportFailed": { - "description": "Button - export failed downloads to TXT" - }, - "queueExportFailedSuccess": "Failed downloads exported to TXT file", - "@queueExportFailedSuccess": { - "description": "Success message after exporting failed downloads" - }, - "queueExportFailedClear": "Clear Failed", - "@queueExportFailedClear": { - "description": "Action to clear failed downloads after export" - }, - "queueExportFailedError": "Failed to export downloads", - "@queueExportFailedError": { - "description": "Error message when export fails" - }, "settingsAutoExportFailed": "Auto-export failed downloads", "@settingsAutoExportFailed": { "description": "Setting toggle for auto-export" @@ -2682,38 +1829,6 @@ "@settingsDownloadNetworkSubtitle": { "description": "Subtitle explaining network preference" }, - "queueEmpty": "No downloads in queue", - "@queueEmpty": { - "description": "Empty queue state title" - }, - "queueEmptySubtitle": "Add tracks from the home screen", - "@queueEmptySubtitle": { - "description": "Empty queue state subtitle" - }, - "queueClearCompleted": "Clear completed", - "@queueClearCompleted": { - "description": "Button - clear finished downloads" - }, - "queueDownloadFailed": "Download Failed", - "@queueDownloadFailed": { - "description": "Error dialog title" - }, - "queueTrackLabel": "Track:", - "@queueTrackLabel": { - "description": "Label in error dialog" - }, - "queueArtistLabel": "Artist:", - "@queueArtistLabel": { - "description": "Label in error dialog" - }, - "queueErrorLabel": "Error:", - "@queueErrorLabel": { - "description": "Label in error dialog" - }, - "queueUnknownError": "Unknown error", - "@queueUnknownError": { - "description": "Fallback error message" - }, "albumFolderArtistAlbum": "Artist / Album", "@albumFolderArtistAlbum": { "description": "Album folder option" @@ -2767,19 +1882,6 @@ } } }, - "downloadedAlbumTracksHeader": "Tracks", - "@downloadedAlbumTracksHeader": { - "description": "Section header for tracks" - }, - "downloadedAlbumDownloadedCount": "{count} downloaded", - "@downloadedAlbumDownloadedCount": { - "description": "Downloaded tracks count badge", - "placeholders": { - "count": { - "type": "int" - } - } - }, "downloadedAlbumSelectedCount": "{count} selected", "@downloadedAlbumSelectedCount": { "description": "Selection count indicator", @@ -2820,10 +1922,6 @@ } } }, - "utilityFunctions": "Utility Functions", - "@utilityFunctions": { - "description": "Extension capability - utility functions" - }, "recentTypeArtist": "Artist", "@recentTypeArtist": { "description": "Recent access item type - artist" @@ -2858,16 +1956,6 @@ } } }, - "errorGeneric": "Error: {message}", - "@errorGeneric": { - "description": "Generic error message format", - "placeholders": { - "message": { - "type": "String", - "description": "Error message" - } - } - }, "discographyDownload": "Download Discography", "@discographyDownload": { "description": "Button - download artist discography" @@ -3034,10 +2122,6 @@ "@libraryTitle": { "description": "Library settings page title" }, - "libraryStatus": "Library Status", - "@libraryStatus": { - "description": "Section header for library status" - }, "libraryScanSettings": "Scan Settings", "@libraryScanSettings": { "description": "Section header for scan settings" @@ -3114,15 +2198,6 @@ "@libraryAboutDescription": { "description": "Description of local library feature" }, - "libraryTracksCount": "{count} tracks", - "@libraryTracksCount": { - "description": "Track count in library", - "placeholders": { - "count": { - "type": "int" - } - } - }, "libraryLastScanned": "Last scanned: {time}", "@libraryLastScanned": { "description": "Last scan time display", @@ -3237,26 +2312,6 @@ "@libraryFilterFormat": { "description": "Filter section - file format" }, - "libraryFilterDate": "Date Added", - "@libraryFilterDate": { - "description": "Filter section - date range" - }, - "libraryFilterDateToday": "Today", - "@libraryFilterDateToday": { - "description": "Filter option - today only" - }, - "libraryFilterDateWeek": "This Week", - "@libraryFilterDateWeek": { - "description": "Filter option - this week" - }, - "libraryFilterDateMonth": "This Month", - "@libraryFilterDateMonth": { - "description": "Filter option - this month" - }, - "libraryFilterDateYear": "This Year", - "@libraryFilterDateYear": { - "description": "Filter option - this year" - }, "libraryFilterSort": "Sort", "@libraryFilterSort": { "description": "Filter section - sort order" @@ -3269,15 +2324,6 @@ "@libraryFilterSortOldest": { "description": "Sort option - oldest first" }, - "libraryFilterActive": "{count} filter(s) active", - "@libraryFilterActive": { - "description": "Badge showing number of active filters", - "placeholders": { - "count": { - "type": "int" - } - } - }, "timeJustNow": "Just now", "@timeJustNow": { "description": "Relative time - less than a minute ago" @@ -3300,106 +2346,6 @@ } } }, - "storageSwitchTitle": "Switch Storage Mode", - "@storageSwitchTitle": { - "description": "Dialog title when switching storage mode" - }, - "storageSwitchToSafTitle": "Switch to SAF Storage?", - "@storageSwitchToSafTitle": { - "description": "Dialog title when switching to SAF" - }, - "storageSwitchToAppTitle": "Switch to App Storage?", - "@storageSwitchToAppTitle": { - "description": "Dialog title when switching to app storage" - }, - "storageSwitchToSafMessage": "Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.", - "@storageSwitchToSafMessage": { - "description": "Explanation when switching to SAF" - }, - "storageSwitchToAppMessage": "Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.", - "@storageSwitchToAppMessage": { - "description": "Explanation when switching to app storage" - }, - "storageSwitchExistingDownloads": "Existing Downloads", - "@storageSwitchExistingDownloads": { - "description": "Section header for existing downloads info" - }, - "storageSwitchExistingDownloadsInfo": "{count} tracks in {mode} storage", - "@storageSwitchExistingDownloadsInfo": { - "description": "Info about existing downloads count", - "placeholders": { - "count": { - "type": "int" - }, - "mode": { - "type": "String" - } - } - }, - "storageSwitchNewDownloads": "New Downloads", - "@storageSwitchNewDownloads": { - "description": "Section header for new downloads info" - }, - "storageSwitchNewDownloadsLocation": "Will be saved to: {location}", - "@storageSwitchNewDownloadsLocation": { - "description": "Shows where new downloads will go", - "placeholders": { - "location": { - "type": "String" - } - } - }, - "storageSwitchContinue": "Continue", - "@storageSwitchContinue": { - "description": "Button to proceed with storage switch" - }, - "storageSwitchSelectFolder": "Select SAF Folder", - "@storageSwitchSelectFolder": { - "description": "Button to select SAF folder" - }, - "storageAppStorage": "App Storage", - "@storageAppStorage": { - "description": "Label for app storage mode" - }, - "storageSafStorage": "SAF Storage", - "@storageSafStorage": { - "description": "Label for SAF storage mode" - }, - "storageModeBadge": "Storage: {mode}", - "@storageModeBadge": { - "description": "Badge showing storage mode for a track", - "placeholders": { - "mode": { - "type": "String" - } - } - }, - "storageStatsTitle": "Storage Statistics", - "@storageStatsTitle": { - "description": "Section title for storage stats" - }, - "storageStatsAppCount": "{count} tracks in App Storage", - "@storageStatsAppCount": { - "description": "Count of tracks in app storage", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "storageStatsSafCount": "{count} tracks in SAF Storage", - "@storageStatsSafCount": { - "description": "Count of tracks in SAF storage", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "storageModeInfo": "Your files are stored in multiple locations", - "@storageModeInfo": { - "description": "Info when user has files in both storage modes" - }, "tutorialWelcomeTitle": "Welcome to SpotiFLAC!", "@tutorialWelcomeTitle": { "description": "Tutorial welcome page title" @@ -3428,18 +2374,6 @@ "@tutorialSearchDesc": { "description": "Tutorial search page description" }, - "tutorialSearchTip1": "Paste a Spotify or Deezer URL directly in the search box", - "@tutorialSearchTip1": { - "description": "Tutorial search tip 1" - }, - "tutorialSearchTip2": "Or type the song name, artist, or album to search", - "@tutorialSearchTip2": { - "description": "Tutorial search tip 2" - }, - "tutorialSearchTip3": "Supports tracks, albums, playlists, and artist pages", - "@tutorialSearchTip3": { - "description": "Tutorial search tip 3" - }, "tutorialDownloadTitle": "Downloading Music", "@tutorialDownloadTitle": { "description": "Tutorial download page title" @@ -3448,18 +2382,6 @@ "@tutorialDownloadDesc": { "description": "Tutorial download page description" }, - "tutorialDownloadTip1": "Tap the download button next to any track to start downloading", - "@tutorialDownloadTip1": { - "description": "Tutorial download tip 1" - }, - "tutorialDownloadTip2": "Choose your preferred quality (FLAC, Hi-Res, or MP3)", - "@tutorialDownloadTip2": { - "description": "Tutorial download tip 2" - }, - "tutorialDownloadTip3": "Download entire albums or playlists with one tap", - "@tutorialDownloadTip3": { - "description": "Tutorial download tip 3" - }, "tutorialLibraryTitle": "Your Library", "@tutorialLibraryTitle": { "description": "Tutorial library page title" @@ -3524,10 +2446,6 @@ "@tutorialReadyMessage": { "description": "Tutorial completion message" }, - "tutorialExample": "EXAMPLE", - "@tutorialExample": { - "description": "Example label in tutorial" - }, "libraryForceFullScan": "Force Full Scan", "@libraryForceFullScan": { "description": "Button to force a complete rescan of library" @@ -3750,14 +2668,10 @@ "@trackSaveLyricsProgress": { "description": "Snackbar while saving lyrics to file" }, - "trackReEnrich": "Re-enrich Metadata", + "trackReEnrich": "Re-enrich", "@trackReEnrich": { "description": "Menu action - re-embed metadata into audio file" }, - "trackReEnrichSubtitle": "Re-embed metadata without re-downloading", - "@trackReEnrichSubtitle": { - "description": "Subtitle for re-enrich metadata action" - }, "trackReEnrichOnlineSubtitle": "Search metadata online and embed into file", "@trackReEnrichOnlineSubtitle": { "description": "Subtitle for re-enrich metadata action for local items" @@ -3868,5 +2782,22 @@ "trackConvertFailed": "Conversion failed", "@trackConvertFailed": { "description": "Snackbar when conversion fails" + }, + "downloadedAlbumDownloadedCount": "{count} downloaded", + "@downloadedAlbumDownloadedCount": { + "description": "Downloaded tracks count badge", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available", + "@downloadUseAlbumArtistForFoldersAlbumSubtitle": { + "description": "Subtitle when Album Artist is used for folder naming" + }, + "downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only", + "@downloadUseAlbumArtistForFoldersTrackSubtitle": { + "description": "Subtitle when Track Artist is used for folder naming" } -} \ No newline at end of file +} diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 2ff691cb..ca416fa0 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -1,879 +1,996 @@ { "@@locale": "en", "@@last_modified": "2026-01-16", - "appName": "SpotiFLAC", - "@appName": {"description": "App name - DO NOT TRANSLATE"}, - "appDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.", - "@appDescription": {"description": "App description shown in about page"}, - + "@appName": { + "description": "App name - DO NOT TRANSLATE" + }, "navHome": "Home", - "@navHome": {"description": "Bottom navigation - Home tab"}, + "@navHome": { + "description": "Bottom navigation - Home tab" + }, "navLibrary": "Library", - "@navLibrary": {"description": "Bottom navigation - Library tab"}, - "navHistory": "History", - "@navHistory": {"description": "Bottom navigation - History tab (legacy)"}, + "@navLibrary": { + "description": "Bottom navigation - Library tab" + }, "navSettings": "Settings", - "@navSettings": {"description": "Bottom navigation - Settings tab"}, + "@navSettings": { + "description": "Bottom navigation - Settings tab" + }, "navStore": "Store", - "@navStore": {"description": "Bottom navigation - Extension store tab"}, - + "@navStore": { + "description": "Bottom navigation - Extension store tab" + }, "homeTitle": "Home", - "@homeTitle": {"description": "Home screen title"}, - "homeSearchHint": "Paste Spotify URL or search...", - "@homeSearchHint": {"description": "Placeholder text in search box"}, - "homeSearchHintExtension": "Search with {extensionName}...", - "@homeSearchHintExtension": { - "description": "Placeholder when extension search is active", - "placeholders": { - "extensionName": {"type": "String", "description": "Name of the active extension"} - } + "@homeTitle": { + "description": "Home screen title" }, "homeSubtitle": "Paste a Spotify link or search by name", - "@homeSubtitle": {"description": "Subtitle shown below search box"}, + "@homeSubtitle": { + "description": "Subtitle shown below search box" + }, "homeSupports": "Supports: Track, Album, Playlist, Artist URLs", - "@homeSupports": {"description": "Info text about supported URL types"}, + "@homeSupports": { + "description": "Info text about supported URL types" + }, "homeRecent": "Recent", - "@homeRecent": {"description": "Section header for recent searches"}, - - "historyTitle": "History", - "@historyTitle": {"description": "History screen title"}, - "historyDownloading": "Downloading ({count})", - "@historyDownloading": { - "description": "Tab showing active downloads count", - "placeholders": { - "count": {"type": "int", "description": "Number of active downloads"} - } + "@homeRecent": { + "description": "Section header for recent searches" }, - "historyDownloaded": "Downloaded", - "@historyDownloaded": {"description": "Tab showing completed downloads"}, "historyFilterAll": "All", - "@historyFilterAll": {"description": "Filter chip - show all items"}, + "@historyFilterAll": { + "description": "Filter chip - show all items" + }, "historyFilterAlbums": "Albums", - "@historyFilterAlbums": {"description": "Filter chip - show albums only"}, + "@historyFilterAlbums": { + "description": "Filter chip - show albums only" + }, "historyFilterSingles": "Singles", - "@historyFilterSingles": {"description": "Filter chip - show singles only"}, - "historyTracksCount": "{count, plural, =1{1 track} other{{count} tracks}}", - "@historyTracksCount": { - "description": "Track count with plural form", - "placeholders": { - "count": {"type": "int"} - } + "@historyFilterSingles": { + "description": "Filter chip - show singles only" }, - "historyAlbumsCount": "{count, plural, =1{1 album} other{{count} albums}}", - "@historyAlbumsCount": { - "description": "Album count with plural form", - "placeholders": { - "count": {"type": "int"} - } - }, - "historyNoDownloads": "No download history", - "@historyNoDownloads": {"description": "Empty state title"}, - "historyNoDownloadsSubtitle": "Downloaded tracks will appear here", - "@historyNoDownloadsSubtitle": {"description": "Empty state subtitle"}, - "historyNoAlbums": "No album downloads", - "@historyNoAlbums": {"description": "Empty state when filtering albums"}, - "historyNoAlbumsSubtitle": "Download multiple tracks from an album to see them here", - "@historyNoAlbumsSubtitle": {"description": "Empty state subtitle for albums filter"}, - "historyNoSingles": "No single downloads", - "@historyNoSingles": {"description": "Empty state when filtering singles"}, -"historyNoSinglesSubtitle": "Single track downloads will appear here", - "@historyNoSinglesSubtitle": {"description": "Empty state subtitle for singles filter"}, "historySearchHint": "Search history...", - "@historySearchHint": {"description": "Search bar placeholder in history"}, - + "@historySearchHint": { + "description": "Search bar placeholder in history" + }, "settingsTitle": "Settings", - "@settingsTitle": {"description": "Settings screen title"}, + "@settingsTitle": { + "description": "Settings screen title" + }, "settingsDownload": "Download", - "@settingsDownload": {"description": "Settings section - download options"}, + "@settingsDownload": { + "description": "Settings section - download options" + }, "settingsAppearance": "Appearance", - "@settingsAppearance": {"description": "Settings section - visual customization"}, + "@settingsAppearance": { + "description": "Settings section - visual customization" + }, "settingsOptions": "Options", - "@settingsOptions": {"description": "Settings section - app options"}, + "@settingsOptions": { + "description": "Settings section - app options" + }, "settingsExtensions": "Extensions", - "@settingsExtensions": {"description": "Settings section - extension management"}, + "@settingsExtensions": { + "description": "Settings section - extension management" + }, "settingsAbout": "About", - "@settingsAbout": {"description": "Settings section - app info"}, - + "@settingsAbout": { + "description": "Settings section - app info" + }, "downloadTitle": "Download", - "@downloadTitle": {"description": "Download settings page title"}, - "downloadLocation": "Download Location", - "@downloadLocation": {"description": "Setting for download folder"}, - "downloadLocationSubtitle": "Choose where to save files", - "@downloadLocationSubtitle": {"description": "Subtitle for download location"}, - "downloadLocationDefault": "Default location", - "@downloadLocationDefault": {"description": "Shown when using default folder"}, - "downloadDefaultService": "Default Service", - "@downloadDefaultService": {"description": "Setting for preferred download service (Tidal/Qobuz/Amazon)"}, - "downloadDefaultServiceSubtitle": "Service used for downloads", - "@downloadDefaultServiceSubtitle": {"description": "Subtitle for default service"}, - "downloadDefaultQuality": "Default Quality", - "@downloadDefaultQuality": {"description": "Setting for audio quality"}, - "downloadAskQuality": "Ask Quality Before Download", - "@downloadAskQuality": {"description": "Toggle to show quality picker"}, + "@downloadTitle": { + "description": "Download settings page title" + }, "downloadAskQualitySubtitle": "Show quality picker for each download", - "@downloadAskQualitySubtitle": {"description": "Subtitle for ask quality toggle"}, + "@downloadAskQualitySubtitle": { + "description": "Subtitle for ask quality toggle" + }, "downloadFilenameFormat": "Filename Format", - "@downloadFilenameFormat": {"description": "Setting for output filename pattern"}, + "@downloadFilenameFormat": { + "description": "Setting for output filename pattern" + }, "downloadFolderOrganization": "Folder Organization", - "@downloadFolderOrganization": {"description": "Setting for folder structure"}, - "downloadSeparateSingles": "Separate Singles", - "@downloadSeparateSingles": {"description": "Toggle to separate single tracks"}, - "downloadSeparateSinglesSubtitle": "Put single tracks in a separate folder", - "@downloadSeparateSinglesSubtitle": {"description": "Subtitle for separate singles toggle"}, - - "qualityBest": "Best Available", - "@qualityBest": {"description": "Audio quality option - highest available"}, - "qualityFlac": "FLAC", - "@qualityFlac": {"description": "Audio quality option - FLAC lossless"}, - "quality320": "320 kbps", - "@quality320": {"description": "Audio quality option - 320kbps MP3"}, - "quality128": "128 kbps", - "@quality128": {"description": "Audio quality option - 128kbps MP3"}, - + "@downloadFolderOrganization": { + "description": "Setting for folder structure" + }, "appearanceTitle": "Appearance", - "@appearanceTitle": {"description": "Appearance settings page title"}, - "appearanceTheme": "Theme", - "@appearanceTheme": {"description": "Theme mode setting"}, + "@appearanceTitle": { + "description": "Appearance settings page title" + }, "appearanceThemeSystem": "System", - "@appearanceThemeSystem": {"description": "Follow system theme"}, + "@appearanceThemeSystem": { + "description": "Follow system theme" + }, "appearanceThemeLight": "Light", - "@appearanceThemeLight": {"description": "Light theme"}, + "@appearanceThemeLight": { + "description": "Light theme" + }, "appearanceThemeDark": "Dark", - "@appearanceThemeDark": {"description": "Dark theme"}, + "@appearanceThemeDark": { + "description": "Dark theme" + }, "appearanceDynamicColor": "Dynamic Color", - "@appearanceDynamicColor": {"description": "Material You dynamic colors"}, + "@appearanceDynamicColor": { + "description": "Material You dynamic colors" + }, "appearanceDynamicColorSubtitle": "Use colors from your wallpaper", - "@appearanceDynamicColorSubtitle": {"description": "Subtitle for dynamic color"}, - "appearanceAccentColor": "Accent Color", - "@appearanceAccentColor": {"description": "Custom accent color picker"}, + "@appearanceDynamicColorSubtitle": { + "description": "Subtitle for dynamic color" + }, "appearanceHistoryView": "History View", - "@appearanceHistoryView": {"description": "Layout style for history"}, + "@appearanceHistoryView": { + "description": "Layout style for history" + }, "appearanceHistoryViewList": "List", - "@appearanceHistoryViewList": {"description": "List layout option"}, + "@appearanceHistoryViewList": { + "description": "List layout option" + }, "appearanceHistoryViewGrid": "Grid", - "@appearanceHistoryViewGrid": {"description": "Grid layout option"}, - + "@appearanceHistoryViewGrid": { + "description": "Grid layout option" + }, "optionsTitle": "Options", - "@optionsTitle": {"description": "Options settings page title"}, - "optionsSearchSource": "Search Source", - "@optionsSearchSource": {"description": "Section for search provider settings"}, + "@optionsTitle": { + "description": "Options settings page title" + }, "optionsPrimaryProvider": "Primary Provider", - "@optionsPrimaryProvider": {"description": "Main search provider setting"}, + "@optionsPrimaryProvider": { + "description": "Main search provider setting" + }, "optionsPrimaryProviderSubtitle": "Service used when searching by track name.", - "@optionsPrimaryProviderSubtitle": {"description": "Subtitle for primary provider"}, + "@optionsPrimaryProviderSubtitle": { + "description": "Subtitle for primary provider" + }, "optionsUsingExtension": "Using extension: {extensionName}", "@optionsUsingExtension": { "description": "Shows active extension name", "placeholders": { - "extensionName": {"type": "String"} + "extensionName": { + "type": "String" + } } }, "optionsSwitchBack": "Tap Deezer or Spotify to switch back from extension", - "@optionsSwitchBack": {"description": "Hint to switch back to built-in providers"}, + "@optionsSwitchBack": { + "description": "Hint to switch back to built-in providers" + }, "optionsAutoFallback": "Auto Fallback", - "@optionsAutoFallback": {"description": "Auto-retry with other services"}, + "@optionsAutoFallback": { + "description": "Auto-retry with other services" + }, "optionsAutoFallbackSubtitle": "Try other services if download fails", - "@optionsAutoFallbackSubtitle": {"description": "Subtitle for auto fallback"}, + "@optionsAutoFallbackSubtitle": { + "description": "Subtitle for auto fallback" + }, "optionsUseExtensionProviders": "Use Extension Providers", - "@optionsUseExtensionProviders": {"description": "Enable extension download providers"}, + "@optionsUseExtensionProviders": { + "description": "Enable extension download providers" + }, "optionsUseExtensionProvidersOn": "Extensions will be tried first", - "@optionsUseExtensionProvidersOn": {"description": "Status when extension providers enabled"}, + "@optionsUseExtensionProvidersOn": { + "description": "Status when extension providers enabled" + }, "optionsUseExtensionProvidersOff": "Using built-in providers only", - "@optionsUseExtensionProvidersOff": {"description": "Status when extension providers disabled"}, + "@optionsUseExtensionProvidersOff": { + "description": "Status when extension providers disabled" + }, "optionsEmbedLyrics": "Embed Lyrics", - "@optionsEmbedLyrics": {"description": "Embed lyrics in audio files"}, + "@optionsEmbedLyrics": { + "description": "Embed lyrics in audio files" + }, "optionsEmbedLyricsSubtitle": "Embed synced lyrics into FLAC files", - "@optionsEmbedLyricsSubtitle": {"description": "Subtitle for embed lyrics"}, + "@optionsEmbedLyricsSubtitle": { + "description": "Subtitle for embed lyrics" + }, "optionsMaxQualityCover": "Max Quality Cover", - "@optionsMaxQualityCover": {"description": "Download highest quality album art"}, + "@optionsMaxQualityCover": { + "description": "Download highest quality album art" + }, "optionsMaxQualityCoverSubtitle": "Download highest resolution cover art", - "@optionsMaxQualityCoverSubtitle": {"description": "Subtitle for max quality cover"}, + "@optionsMaxQualityCoverSubtitle": { + "description": "Subtitle for max quality cover" + }, "optionsConcurrentDownloads": "Concurrent Downloads", - "@optionsConcurrentDownloads": {"description": "Number of parallel downloads"}, + "@optionsConcurrentDownloads": { + "description": "Number of parallel downloads" + }, "optionsConcurrentSequential": "Sequential (1 at a time)", - "@optionsConcurrentSequential": {"description": "Download one at a time"}, + "@optionsConcurrentSequential": { + "description": "Download one at a time" + }, "optionsConcurrentParallel": "{count} parallel downloads", "@optionsConcurrentParallel": { "description": "Multiple parallel downloads", "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "optionsConcurrentWarning": "Parallel downloads may trigger rate limiting", - "@optionsConcurrentWarning": {"description": "Warning about rate limits"}, + "@optionsConcurrentWarning": { + "description": "Warning about rate limits" + }, "optionsExtensionStore": "Extension Store", - "@optionsExtensionStore": {"description": "Show/hide store tab"}, + "@optionsExtensionStore": { + "description": "Show/hide store tab" + }, "optionsExtensionStoreSubtitle": "Show Store tab in navigation", - "@optionsExtensionStoreSubtitle": {"description": "Subtitle for extension store toggle"}, + "@optionsExtensionStoreSubtitle": { + "description": "Subtitle for extension store toggle" + }, "optionsCheckUpdates": "Check for Updates", - "@optionsCheckUpdates": {"description": "Auto update check toggle"}, + "@optionsCheckUpdates": { + "description": "Auto update check toggle" + }, "optionsCheckUpdatesSubtitle": "Notify when new version is available", - "@optionsCheckUpdatesSubtitle": {"description": "Subtitle for update check"}, + "@optionsCheckUpdatesSubtitle": { + "description": "Subtitle for update check" + }, "optionsUpdateChannel": "Update Channel", - "@optionsUpdateChannel": {"description": "Stable vs preview releases"}, + "@optionsUpdateChannel": { + "description": "Stable vs preview releases" + }, "optionsUpdateChannelStable": "Stable releases only", - "@optionsUpdateChannelStable": {"description": "Only stable updates"}, + "@optionsUpdateChannelStable": { + "description": "Only stable updates" + }, "optionsUpdateChannelPreview": "Get preview releases", - "@optionsUpdateChannelPreview": {"description": "Include beta/preview updates"}, + "@optionsUpdateChannelPreview": { + "description": "Include beta/preview updates" + }, "optionsUpdateChannelWarning": "Preview may contain bugs or incomplete features", - "@optionsUpdateChannelWarning": {"description": "Warning about preview channel"}, + "@optionsUpdateChannelWarning": { + "description": "Warning about preview channel" + }, "optionsClearHistory": "Clear Download History", - "@optionsClearHistory": {"description": "Delete all download history"}, + "@optionsClearHistory": { + "description": "Delete all download history" + }, "optionsClearHistorySubtitle": "Remove all downloaded tracks from history", - "@optionsClearHistorySubtitle": {"description": "Subtitle for clear history"}, + "@optionsClearHistorySubtitle": { + "description": "Subtitle for clear history" + }, "optionsDetailedLogging": "Detailed Logging", - "@optionsDetailedLogging": {"description": "Enable verbose logs for debugging"}, + "@optionsDetailedLogging": { + "description": "Enable verbose logs for debugging" + }, "optionsDetailedLoggingOn": "Detailed logs are being recorded", - "@optionsDetailedLoggingOn": {"description": "Status when logging enabled"}, + "@optionsDetailedLoggingOn": { + "description": "Status when logging enabled" + }, "optionsDetailedLoggingOff": "Enable for bug reports", - "@optionsDetailedLoggingOff": {"description": "Status when logging disabled"}, + "@optionsDetailedLoggingOff": { + "description": "Status when logging disabled" + }, "optionsSpotifyCredentials": "Spotify Credentials", - "@optionsSpotifyCredentials": {"description": "Spotify API credentials setting"}, + "@optionsSpotifyCredentials": { + "description": "Spotify API credentials setting" + }, "optionsSpotifyCredentialsConfigured": "Client ID: {clientId}...", "@optionsSpotifyCredentialsConfigured": { "description": "Shows configured client ID preview", "placeholders": { - "clientId": {"type": "String"} + "clientId": { + "type": "String" + } } }, "optionsSpotifyCredentialsRequired": "Required - tap to configure", - "@optionsSpotifyCredentialsRequired": {"description": "Prompt to set up credentials"}, + "@optionsSpotifyCredentialsRequired": { + "description": "Prompt to set up credentials" + }, "optionsSpotifyWarning": "Spotify requires your own API credentials. Get them free from developer.spotify.com", - "@optionsSpotifyWarning": {"description": "Info about Spotify API requirement"}, + "@optionsSpotifyWarning": { + "description": "Info about Spotify API requirement" + }, "optionsSpotifyDeprecationWarning": "Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.", - "@optionsSpotifyDeprecationWarning": {"description": "Warning about Spotify API deprecation"}, - + "@optionsSpotifyDeprecationWarning": { + "description": "Warning about Spotify API deprecation" + }, "extensionsTitle": "Extensions", - "@extensionsTitle": {"description": "Extensions page title"}, - "extensionsInstalled": "Installed Extensions", - "@extensionsInstalled": {"description": "Section header for installed extensions"}, - "extensionsNone": "No extensions installed", - "@extensionsNone": {"description": "Empty state title"}, - "extensionsNoneSubtitle": "Install extensions from the Store tab", - "@extensionsNoneSubtitle": {"description": "Empty state subtitle"}, - "extensionsEnabled": "Enabled", - "@extensionsEnabled": {"description": "Extension status - active"}, + "@extensionsTitle": { + "description": "Extensions page title" + }, "extensionsDisabled": "Disabled", - "@extensionsDisabled": {"description": "Extension status - inactive"}, + "@extensionsDisabled": { + "description": "Extension status - inactive" + }, "extensionsVersion": "Version {version}", "@extensionsVersion": { "description": "Extension version display", "placeholders": { - "version": {"type": "String"} + "version": { + "type": "String" + } } }, "extensionsAuthor": "by {author}", "@extensionsAuthor": { "description": "Extension author credit", "placeholders": { - "author": {"type": "String"} + "author": { + "type": "String" + } } }, "extensionsUninstall": "Uninstall", - "@extensionsUninstall": {"description": "Uninstall extension button"}, - "extensionsSetAsSearch": "Set as Search Provider", - "@extensionsSetAsSearch": {"description": "Use extension for search"}, - - "storeTitle": "Extension Store", - "@storeTitle": {"description": "Store screen title"}, - "storeSearch": "Search extensions...", - "@storeSearch": {"description": "Store search placeholder"}, - "storeInstall": "Install", - "@storeInstall": {"description": "Install extension button"}, - "storeInstalled": "Installed", - "@storeInstalled": {"description": "Already installed badge"}, - "storeUpdate": "Update", - "@storeUpdate": {"description": "Update available button"}, - - "aboutTitle": "About", - "@aboutTitle": {"description": "About page title"}, - "aboutContributors": "Contributors", - "@aboutContributors": {"description": "Section for contributors"}, - "aboutMobileDeveloper": "Mobile version developer", - "@aboutMobileDeveloper": {"description": "Role description for mobile dev"}, - "aboutOriginalCreator": "Creator of the original SpotiFLAC", - "@aboutOriginalCreator": {"description": "Role description for original creator"}, - "aboutLogoArtist": "The talented artist who created our beautiful app logo!", - "@aboutLogoArtist": {"description": "Role description for logo artist"}, - "aboutTranslators": "Translators", - "@aboutTranslators": {"description": "Section for translators"}, - "aboutSpecialThanks": "Special Thanks", - "@aboutSpecialThanks": {"description": "Section for special thanks"}, - "aboutLinks": "Links", - "@aboutLinks": {"description": "Section for external links"}, - "aboutMobileSource": "Mobile source code", - "@aboutMobileSource": {"description": "Link to mobile GitHub repo"}, - "aboutPCSource": "PC source code", - "@aboutPCSource": {"description": "Link to PC GitHub repo"}, - "aboutReportIssue": "Report an issue", - "@aboutReportIssue": {"description": "Link to report bugs"}, - "aboutReportIssueSubtitle": "Report any problems you encounter", - "@aboutReportIssueSubtitle": {"description": "Subtitle for report issue"}, -"aboutFeatureRequest": "Feature request", - "@aboutFeatureRequest": {"description": "Link to suggest features"}, - "aboutFeatureRequestSubtitle": "Suggest new features for the app", - "@aboutFeatureRequestSubtitle": {"description": "Subtitle for feature request"}, - "aboutTelegramChannel": "Telegram Channel", - "@aboutTelegramChannel": {"description": "Link to Telegram channel"}, - "aboutTelegramChannelSubtitle": "Announcements and updates", - "@aboutTelegramChannelSubtitle": {"description": "Subtitle for Telegram channel"}, - "aboutTelegramChat": "Telegram Community", - "@aboutTelegramChat": {"description": "Link to Telegram chat group"}, - "aboutTelegramChatSubtitle": "Chat with other users", - "@aboutTelegramChatSubtitle": {"description": "Subtitle for Telegram chat"}, - "aboutSocial": "Social", - "@aboutSocial": {"description": "Section for social links"}, - "aboutSupport": "Support", - "@aboutSupport": {"description": "Section for support/donation links"}, - "aboutApp": "App", - "@aboutApp": {"description": "Section for app info"}, - "aboutVersion": "Version", - "@aboutVersion": {"description": "Version info label"}, - "aboutBinimumDesc": "The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn't exist!", - "@aboutBinimumDesc": {"description": "Credit description for binimum"}, - "aboutSachinsenalDesc": "The original HiFi project creator. The foundation of Tidal integration!", - "@aboutSachinsenalDesc": {"description": "Credit description for sachinsenal0x64"}, - "aboutSjdonadoDesc": "Creator of I Don't Have Spotify (IDHS). The fallback link resolver that saves the day!", - "@aboutSjdonadoDesc": {"description": "Credit description for sjdonado"}, - "aboutDoubleDouble": "DoubleDouble", - "@aboutDoubleDouble": {"description": "Name of Amazon API service - DO NOT TRANSLATE"}, - "aboutDoubleDoubleDesc": "Amazing API for Amazon Music downloads. Thank you for making it free!", - "@aboutDoubleDoubleDesc": {"description": "Credit for DoubleDouble API"}, - "aboutDabMusic": "DAB Music", - "@aboutDabMusic": {"description": "Name of Qobuz API service - DO NOT TRANSLATE"}, - "aboutDabMusicDesc": "The best Qobuz streaming API. Hi-Res downloads wouldn't be possible without this!", - "@aboutDabMusicDesc": {"description": "Credit for DAB Music API"}, - "aboutSpotiSaver": "SpotiSaver", - "@aboutSpotiSaver": {"description": "Name of SpotiSaver API service - DO NOT TRANSLATE"}, - "aboutSpotiSaverDesc": "Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!", - "@aboutSpotiSaverDesc": {"description": "Credit for SpotiSaver API"}, - "aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.", - "@aboutAppDescription": {"description": "App description in header card"}, - - "albumTitle": "Album", - "@albumTitle": {"description": "Album screen title"}, - "albumTracks": "{count, plural, =1{1 track} other{{count} tracks}}", - "@albumTracks": { - "description": "Album track count", - "placeholders": { - "count": {"type": "int"} - } + "@extensionsUninstall": { + "description": "Uninstall extension button" + }, + "storeTitle": "Extension Store", + "@storeTitle": { + "description": "Store screen title" + }, + "storeSearch": "Search extensions...", + "@storeSearch": { + "description": "Store search placeholder" + }, + "storeInstall": "Install", + "@storeInstall": { + "description": "Install extension button" + }, + "storeInstalled": "Installed", + "@storeInstalled": { + "description": "Already installed badge" + }, + "storeUpdate": "Update", + "@storeUpdate": { + "description": "Update available button" + }, + "aboutTitle": "About", + "@aboutTitle": { + "description": "About page title" + }, + "aboutContributors": "Contributors", + "@aboutContributors": { + "description": "Section for contributors" + }, + "aboutMobileDeveloper": "Mobile version developer", + "@aboutMobileDeveloper": { + "description": "Role description for mobile dev" + }, + "aboutOriginalCreator": "Creator of the original SpotiFLAC", + "@aboutOriginalCreator": { + "description": "Role description for original creator" + }, + "aboutLogoArtist": "The talented artist who created our beautiful app logo!", + "@aboutLogoArtist": { + "description": "Role description for logo artist" + }, + "aboutTranslators": "Translators", + "@aboutTranslators": { + "description": "Section for translators" + }, + "aboutSpecialThanks": "Special Thanks", + "@aboutSpecialThanks": { + "description": "Section for special thanks" + }, + "aboutLinks": "Links", + "@aboutLinks": { + "description": "Section for external links" + }, + "aboutMobileSource": "Mobile source code", + "@aboutMobileSource": { + "description": "Link to mobile GitHub repo" + }, + "aboutPCSource": "PC source code", + "@aboutPCSource": { + "description": "Link to PC GitHub repo" + }, + "aboutReportIssue": "Report an issue", + "@aboutReportIssue": { + "description": "Link to report bugs" + }, + "aboutReportIssueSubtitle": "Report any problems you encounter", + "@aboutReportIssueSubtitle": { + "description": "Subtitle for report issue" + }, + "aboutFeatureRequest": "Feature request", + "@aboutFeatureRequest": { + "description": "Link to suggest features" + }, + "aboutFeatureRequestSubtitle": "Suggest new features for the app", + "@aboutFeatureRequestSubtitle": { + "description": "Subtitle for feature request" + }, + "aboutTelegramChannel": "Telegram Channel", + "@aboutTelegramChannel": { + "description": "Link to Telegram channel" + }, + "aboutTelegramChannelSubtitle": "Announcements and updates", + "@aboutTelegramChannelSubtitle": { + "description": "Subtitle for Telegram channel" + }, + "aboutTelegramChat": "Telegram Community", + "@aboutTelegramChat": { + "description": "Link to Telegram chat group" + }, + "aboutTelegramChatSubtitle": "Chat with other users", + "@aboutTelegramChatSubtitle": { + "description": "Subtitle for Telegram chat" + }, + "aboutSocial": "Social", + "@aboutSocial": { + "description": "Section for social links" + }, + "aboutApp": "App", + "@aboutApp": { + "description": "Section for app info" + }, + "aboutVersion": "Version", + "@aboutVersion": { + "description": "Version info label" + }, + "aboutBinimumDesc": "The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn't exist!", + "@aboutBinimumDesc": { + "description": "Credit description for binimum" + }, + "aboutSachinsenalDesc": "The original HiFi project creator. The foundation of Tidal integration!", + "@aboutSachinsenalDesc": { + "description": "Credit description for sachinsenal0x64" + }, + "aboutSjdonadoDesc": "Creator of I Don't Have Spotify (IDHS). The fallback link resolver that saves the day!", + "@aboutSjdonadoDesc": { + "description": "Credit description for sjdonado" + }, + "aboutDabMusic": "DAB Music", + "@aboutDabMusic": { + "description": "Name of Qobuz API service - DO NOT TRANSLATE" + }, + "aboutDabMusicDesc": "The best Qobuz streaming API. Hi-Res downloads wouldn't be possible without this!", + "@aboutDabMusicDesc": { + "description": "Credit for DAB Music API" + }, + "aboutSpotiSaver": "SpotiSaver", + "@aboutSpotiSaver": { + "description": "Name of SpotiSaver API service - DO NOT TRANSLATE" + }, + "aboutSpotiSaverDesc": "Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!", + "@aboutSpotiSaverDesc": { + "description": "Credit for SpotiSaver API" + }, + "aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.", + "@aboutAppDescription": { + "description": "App description in header card" }, - "albumDownloadAll": "Download All", - "@albumDownloadAll": {"description": "Button to download all tracks"}, - "albumDownloadRemaining": "Download Remaining", - "@albumDownloadRemaining": {"description": "Button to download remaining tracks"}, - - "playlistTitle": "Playlist", - "@playlistTitle": {"description": "Playlist screen title"}, - "artistTitle": "Artist", - "@artistTitle": {"description": "Artist screen title"}, "artistAlbums": "Albums", - "@artistAlbums": {"description": "Section header for artist albums"}, + "@artistAlbums": { + "description": "Section header for artist albums" + }, "artistSingles": "Singles & EPs", - "@artistSingles": {"description": "Section header for singles/EPs"}, + "@artistSingles": { + "description": "Section header for singles/EPs" + }, "artistCompilations": "Compilations", - "@artistCompilations": {"description": "Section header for compilations"}, - "artistReleases": "{count, plural, =1{1 release} other{{count} releases}}", - "@artistReleases": { - "description": "Artist release count", - "placeholders": { - "count": {"type": "int"} - } + "@artistCompilations": { + "description": "Section header for compilations" }, "artistPopular": "Popular", - "@artistPopular": {"description": "Section header for popular/top tracks"}, + "@artistPopular": { + "description": "Section header for popular/top tracks" + }, "artistMonthlyListeners": "{count} monthly listeners", "@artistMonthlyListeners": { "description": "Monthly listener count display", "placeholders": { - "count": {"type": "String", "description": "Formatted listener count"} + "count": { + "type": "String", + "description": "Formatted listener count" + } } }, - - "trackMetadataTitle": "Track Info", - "@trackMetadataTitle": {"description": "Track metadata screen title"}, - "trackMetadataArtist": "Artist", - "@trackMetadataArtist": {"description": "Metadata field - artist name"}, - "trackMetadataAlbum": "Album", - "@trackMetadataAlbum": {"description": "Metadata field - album name"}, - "trackMetadataDuration": "Duration", - "@trackMetadataDuration": {"description": "Metadata field - track length"}, - "trackMetadataQuality": "Quality", - "@trackMetadataQuality": {"description": "Metadata field - audio quality"}, - "trackMetadataPath": "File Path", - "@trackMetadataPath": {"description": "Metadata field - file location"}, - "trackMetadataDownloadedAt": "Downloaded", - "@trackMetadataDownloadedAt": {"description": "Metadata field - download date"}, "trackMetadataService": "Service", - "@trackMetadataService": {"description": "Metadata field - download service used"}, + "@trackMetadataService": { + "description": "Metadata field - download service used" + }, "trackMetadataPlay": "Play", - "@trackMetadataPlay": {"description": "Action button - play track"}, + "@trackMetadataPlay": { + "description": "Action button - play track" + }, "trackMetadataShare": "Share", - "@trackMetadataShare": {"description": "Action button - share track"}, + "@trackMetadataShare": { + "description": "Action button - share track" + }, "trackMetadataDelete": "Delete", - "@trackMetadataDelete": {"description": "Action button - delete track"}, - "trackMetadataRedownload": "Re-download", - "@trackMetadataRedownload": {"description": "Action button - download again"}, - "trackMetadataOpenFolder": "Open Folder", - "@trackMetadataOpenFolder": {"description": "Action button - open containing folder"}, - - "setupTitle": "Welcome to SpotiFLAC", - "@setupTitle": {"description": "Setup wizard title"}, - "setupSubtitle": "Let's get you started", - "@setupSubtitle": {"description": "Setup wizard subtitle"}, - "setupStoragePermission": "Storage Permission", - "@setupStoragePermission": {"description": "Storage permission step title"}, - "setupStoragePermissionSubtitle": "Required to save downloaded files", - "@setupStoragePermissionSubtitle": {"description": "Explanation for storage permission"}, - "setupStoragePermissionGranted": "Permission granted", - "@setupStoragePermissionGranted": {"description": "Status when permission granted"}, - "setupStoragePermissionDenied": "Permission denied", - "@setupStoragePermissionDenied": {"description": "Status when permission denied"}, + "@trackMetadataDelete": { + "description": "Action button - delete track" + }, "setupGrantPermission": "Grant Permission", - "@setupGrantPermission": {"description": "Button to request permission"}, - "setupDownloadLocation": "Download Location", - "@setupDownloadLocation": {"description": "Download folder step title"}, - "setupChooseFolder": "Choose Folder", - "@setupChooseFolder": {"description": "Button to pick folder"}, - "setupContinue": "Continue", - "@setupContinue": {"description": "Continue to next step button"}, + "@setupGrantPermission": { + "description": "Button to request permission" + }, "setupSkip": "Skip for now", - "@setupSkip": {"description": "Skip current step button"}, + "@setupSkip": { + "description": "Skip current step button" + }, "setupStorageAccessRequired": "Storage Access Required", - "@setupStorageAccessRequired": {"description": "Title when storage access needed"}, - "setupStorageAccessMessage": "SpotiFLAC needs \"All files access\" permission to save music files to your chosen folder.", - "@setupStorageAccessMessage": {"description": "Explanation for storage access"}, + "@setupStorageAccessRequired": { + "description": "Title when storage access needed" + }, "setupStorageAccessMessageAndroid11": "Android 11+ requires \"All files access\" permission to save files to your chosen download folder.", - "@setupStorageAccessMessageAndroid11": {"description": "Android 11+ specific explanation"}, + "@setupStorageAccessMessageAndroid11": { + "description": "Android 11+ specific explanation" + }, "setupOpenSettings": "Open Settings", - "@setupOpenSettings": {"description": "Button to open system settings"}, + "@setupOpenSettings": { + "description": "Button to open system settings" + }, "setupPermissionDeniedMessage": "Permission denied. Please grant all permissions to continue.", - "@setupPermissionDeniedMessage": {"description": "Error when permission denied"}, + "@setupPermissionDeniedMessage": { + "description": "Error when permission denied" + }, "setupPermissionRequired": "{permissionType} Permission Required", "@setupPermissionRequired": { "description": "Generic permission required title", "placeholders": { - "permissionType": {"type": "String", "description": "Type of permission (Storage/Notification)"} + "permissionType": { + "type": "String", + "description": "Type of permission (Storage/Notification)" + } } }, "setupPermissionRequiredMessage": "{permissionType} permission is required for the best experience. You can change this later in Settings.", "@setupPermissionRequiredMessage": { "description": "Generic permission required message", "placeholders": { - "permissionType": {"type": "String"} + "permissionType": { + "type": "String" + } } }, - "setupSelectDownloadFolder": "Select Download Folder", - "@setupSelectDownloadFolder": {"description": "Folder selection step title"}, "setupUseDefaultFolder": "Use Default Folder?", - "@setupUseDefaultFolder": {"description": "Dialog title for default folder"}, + "@setupUseDefaultFolder": { + "description": "Dialog title for default folder" + }, "setupNoFolderSelected": "No folder selected. Would you like to use the default Music folder?", - "@setupNoFolderSelected": {"description": "Prompt when no folder selected"}, + "@setupNoFolderSelected": { + "description": "Prompt when no folder selected" + }, "setupUseDefault": "Use Default", - "@setupUseDefault": {"description": "Button to use default folder"}, + "@setupUseDefault": { + "description": "Button to use default folder" + }, "setupDownloadLocationTitle": "Download Location", - "@setupDownloadLocationTitle": {"description": "Download location dialog title"}, + "@setupDownloadLocationTitle": { + "description": "Download location dialog title" + }, "setupDownloadLocationIosMessage": "On iOS, downloads are saved to the app's Documents folder. You can access them via the Files app.", - "@setupDownloadLocationIosMessage": {"description": "iOS-specific folder info"}, + "@setupDownloadLocationIosMessage": { + "description": "iOS-specific folder info" + }, "setupAppDocumentsFolder": "App Documents Folder", - "@setupAppDocumentsFolder": {"description": "iOS documents folder option"}, + "@setupAppDocumentsFolder": { + "description": "iOS documents folder option" + }, "setupAppDocumentsFolderSubtitle": "Recommended - accessible via Files app", - "@setupAppDocumentsFolderSubtitle": {"description": "Subtitle for documents folder"}, + "@setupAppDocumentsFolderSubtitle": { + "description": "Subtitle for documents folder" + }, "setupChooseFromFiles": "Choose from Files", - "@setupChooseFromFiles": {"description": "iOS file picker option"}, + "@setupChooseFromFiles": { + "description": "iOS file picker option" + }, "setupChooseFromFilesSubtitle": "Select iCloud or other location", - "@setupChooseFromFilesSubtitle": {"description": "Subtitle for file picker"}, -"setupIosEmptyFolderWarning": "iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.", - "@setupIosEmptyFolderWarning": {"description": "iOS folder selection warning"}, + "@setupChooseFromFilesSubtitle": { + "description": "Subtitle for file picker" + }, + "setupIosEmptyFolderWarning": "iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.", + "@setupIosEmptyFolderWarning": { + "description": "iOS folder selection warning" + }, "setupIcloudNotSupported": "iCloud Drive is not supported. Please use the app Documents folder.", - "@setupIcloudNotSupported": {"description": "Error when user selects iCloud Drive on iOS"}, + "@setupIcloudNotSupported": { + "description": "Error when user selects iCloud Drive on iOS" + }, "setupDownloadInFlac": "Download Spotify tracks in FLAC", - "@setupDownloadInFlac": {"description": "App tagline in setup"}, - "setupStepStorage": "Storage", - "@setupStepStorage": {"description": "Setup step indicator - storage"}, - "setupStepNotification": "Notification", - "@setupStepNotification": {"description": "Setup step indicator - notification"}, - "setupStepFolder": "Folder", - "@setupStepFolder": {"description": "Setup step indicator - folder"}, - "setupStepSpotify": "Spotify", - "@setupStepSpotify": {"description": "Setup step indicator - Spotify API"}, - "setupStepPermission": "Permission", - "@setupStepPermission": {"description": "Setup step indicator - permission"}, + "@setupDownloadInFlac": { + "description": "App tagline in setup" + }, "setupStorageGranted": "Storage Permission Granted!", - "@setupStorageGranted": {"description": "Success message for storage permission"}, + "@setupStorageGranted": { + "description": "Success message for storage permission" + }, "setupStorageRequired": "Storage Permission Required", - "@setupStorageRequired": {"description": "Title when storage permission needed"}, + "@setupStorageRequired": { + "description": "Title when storage permission needed" + }, "setupStorageDescription": "SpotiFLAC needs storage permission to save your downloaded music files.", - "@setupStorageDescription": {"description": "Explanation for storage permission"}, + "@setupStorageDescription": { + "description": "Explanation for storage permission" + }, "setupNotificationGranted": "Notification Permission Granted!", - "@setupNotificationGranted": {"description": "Success message for notification permission"}, + "@setupNotificationGranted": { + "description": "Success message for notification permission" + }, "setupNotificationEnable": "Enable Notifications", - "@setupNotificationEnable": {"description": "Button to enable notifications"}, - "setupNotificationDescription": "Get notified when downloads complete or require attention.", - "@setupNotificationDescription": {"description": "Explanation for notifications"}, - "setupFolderSelected": "Download Folder Selected!", - "@setupFolderSelected": {"description": "Success message for folder selection"}, + "@setupNotificationEnable": { + "description": "Button to enable notifications" + }, "setupFolderChoose": "Choose Download Folder", - "@setupFolderChoose": {"description": "Button to choose folder"}, + "@setupFolderChoose": { + "description": "Button to choose folder" + }, "setupFolderDescription": "Select a folder where your downloaded music will be saved.", - "@setupFolderDescription": {"description": "Explanation for folder selection"}, - "setupChangeFolder": "Change Folder", - "@setupChangeFolder": {"description": "Button to change selected folder"}, + "@setupFolderDescription": { + "description": "Explanation for folder selection" + }, "setupSelectFolder": "Select Folder", - "@setupSelectFolder": {"description": "Button to select folder"}, - "setupSpotifyApiOptional": "Spotify API (Optional)", - "@setupSpotifyApiOptional": {"description": "Spotify API step title"}, - "setupSpotifyApiDescription": "Add your Spotify API credentials for better search results and access to Spotify-exclusive content.", - "@setupSpotifyApiDescription": {"description": "Explanation for Spotify API"}, - "setupUseSpotifyApi": "Use Spotify API", - "@setupUseSpotifyApi": {"description": "Toggle to enable Spotify API"}, - "setupEnterCredentialsBelow": "Enter your credentials below", - "@setupEnterCredentialsBelow": {"description": "Prompt to enter credentials"}, - "setupUsingDeezer": "Using Deezer (no account needed)", - "@setupUsingDeezer": {"description": "Status when using Deezer"}, - "setupEnterClientId": "Enter Spotify Client ID", - "@setupEnterClientId": {"description": "Placeholder for client ID field"}, - "setupEnterClientSecret": "Enter Spotify Client Secret", - "@setupEnterClientSecret": {"description": "Placeholder for client secret field"}, - "setupGetFreeCredentials": "Get your free API credentials from the Spotify Developer Dashboard.", - "@setupGetFreeCredentials": {"description": "Info about getting Spotify credentials"}, + "@setupSelectFolder": { + "description": "Button to select folder" + }, "setupEnableNotifications": "Enable Notifications", - "@setupEnableNotifications": {"description": "Button to enable notifications"}, - "setupProceedToNextStep": "You can now proceed to the next step.", - "@setupProceedToNextStep": {"description": "Message after completing a step"}, - "setupNotificationProgressDescription": "You will receive download progress notifications.", - "@setupNotificationProgressDescription": {"description": "Info about notification usage"}, + "@setupEnableNotifications": { + "description": "Button to enable notifications" + }, "setupNotificationBackgroundDescription": "Get notified about download progress and completion. This helps you track downloads when the app is in background.", - "@setupNotificationBackgroundDescription": {"description": "Detailed notification explanation"}, + "@setupNotificationBackgroundDescription": { + "description": "Detailed notification explanation" + }, "setupSkipForNow": "Skip for now", - "@setupSkipForNow": {"description": "Skip button text"}, - "setupBack": "Back", - "@setupBack": {"description": "Back button text"}, + "@setupSkipForNow": { + "description": "Skip button text" + }, "setupNext": "Next", - "@setupNext": {"description": "Next button text"}, + "@setupNext": { + "description": "Next button text" + }, "setupGetStarted": "Get Started", - "@setupGetStarted": {"description": "Final setup button"}, - "setupSkipAndStart": "Skip & Start", - "@setupSkipAndStart": {"description": "Skip setup and start app"}, + "@setupGetStarted": { + "description": "Final setup button" + }, "setupAllowAccessToManageFiles": "Please enable \"Allow access to manage all files\" in the next screen.", - "@setupAllowAccessToManageFiles": {"description": "Instruction for file access permission"}, - "setupGetCredentialsFromSpotify": "Get credentials from developer.spotify.com", - "@setupGetCredentialsFromSpotify": {"description": "Link text for Spotify developer portal"}, - + "@setupAllowAccessToManageFiles": { + "description": "Instruction for file access permission" + }, "dialogCancel": "Cancel", - "@dialogCancel": {"description": "Dialog button - cancel action"}, - "dialogOk": "OK", - "@dialogOk": {"description": "Dialog button - confirm/acknowledge"}, + "@dialogCancel": { + "description": "Dialog button - cancel action" + }, "dialogSave": "Save", - "@dialogSave": {"description": "Dialog button - save changes"}, + "@dialogSave": { + "description": "Dialog button - save changes" + }, "dialogDelete": "Delete", - "@dialogDelete": {"description": "Dialog button - delete item"}, + "@dialogDelete": { + "description": "Dialog button - delete item" + }, "dialogRetry": "Retry", - "@dialogRetry": {"description": "Dialog button - retry action"}, - "dialogClose": "Close", - "@dialogClose": {"description": "Dialog button - close dialog"}, - "dialogYes": "Yes", - "@dialogYes": {"description": "Dialog button - confirm yes"}, - "dialogNo": "No", - "@dialogNo": {"description": "Dialog button - confirm no"}, + "@dialogRetry": { + "description": "Dialog button - retry action" + }, "dialogClear": "Clear", - "@dialogClear": {"description": "Dialog button - clear items"}, - "dialogConfirm": "Confirm", - "@dialogConfirm": {"description": "Dialog button - confirm action"}, + "@dialogClear": { + "description": "Dialog button - clear items" + }, "dialogDone": "Done", - "@dialogDone": {"description": "Dialog button - action completed"}, + "@dialogDone": { + "description": "Dialog button - action completed" + }, "dialogImport": "Import", - "@dialogImport": {"description": "Dialog button - import data"}, + "@dialogImport": { + "description": "Dialog button - import data" + }, "dialogDiscard": "Discard", - "@dialogDiscard": {"description": "Dialog button - discard changes"}, + "@dialogDiscard": { + "description": "Dialog button - discard changes" + }, "dialogRemove": "Remove", - "@dialogRemove": {"description": "Dialog button - remove item"}, + "@dialogRemove": { + "description": "Dialog button - remove item" + }, "dialogUninstall": "Uninstall", - "@dialogUninstall": {"description": "Dialog button - uninstall extension"}, + "@dialogUninstall": { + "description": "Dialog button - uninstall extension" + }, "dialogDiscardChanges": "Discard Changes?", - "@dialogDiscardChanges": {"description": "Dialog title - unsaved changes warning"}, + "@dialogDiscardChanges": { + "description": "Dialog title - unsaved changes warning" + }, "dialogUnsavedChanges": "You have unsaved changes. Do you want to discard them?", - "@dialogUnsavedChanges": {"description": "Dialog message - unsaved changes"}, - "dialogDownloadFailed": "Download Failed", - "@dialogDownloadFailed": {"description": "Dialog title - download error"}, - "dialogTrackLabel": "Track:", - "@dialogTrackLabel": {"description": "Label for track name in error dialog"}, - "dialogArtistLabel": "Artist:", - "@dialogArtistLabel": {"description": "Label for artist name in error dialog"}, - "dialogErrorLabel": "Error:", - "@dialogErrorLabel": {"description": "Label for error message"}, + "@dialogUnsavedChanges": { + "description": "Dialog message - unsaved changes" + }, "dialogClearAll": "Clear All", - "@dialogClearAll": {"description": "Dialog title - clear all items"}, - "dialogClearAllDownloads": "Are you sure you want to clear all downloads?", - "@dialogClearAllDownloads": {"description": "Dialog message - clear downloads confirmation"}, - "dialogRemoveFromDevice": "Remove from device?", - "@dialogRemoveFromDevice": {"description": "Dialog title - delete file confirmation"}, + "@dialogClearAll": { + "description": "Dialog title - clear all items" + }, "dialogRemoveExtension": "Remove Extension", - "@dialogRemoveExtension": {"description": "Dialog title - uninstall extension"}, + "@dialogRemoveExtension": { + "description": "Dialog title - uninstall extension" + }, "dialogRemoveExtensionMessage": "Are you sure you want to remove this extension? This cannot be undone.", - "@dialogRemoveExtensionMessage": {"description": "Dialog message - uninstall confirmation"}, + "@dialogRemoveExtensionMessage": { + "description": "Dialog message - uninstall confirmation" + }, "dialogUninstallExtension": "Uninstall Extension?", - "@dialogUninstallExtension": {"description": "Dialog title - uninstall extension"}, + "@dialogUninstallExtension": { + "description": "Dialog title - uninstall extension" + }, "dialogUninstallExtensionMessage": "Are you sure you want to remove {extensionName}?", "@dialogUninstallExtensionMessage": { "description": "Dialog message - uninstall specific extension", "placeholders": { - "extensionName": {"type": "String"} + "extensionName": { + "type": "String" + } } }, "dialogClearHistoryTitle": "Clear History", - "@dialogClearHistoryTitle": {"description": "Dialog title - clear download history"}, + "@dialogClearHistoryTitle": { + "description": "Dialog title - clear download history" + }, "dialogClearHistoryMessage": "Are you sure you want to clear all download history? This cannot be undone.", - "@dialogClearHistoryMessage": {"description": "Dialog message - clear history confirmation"}, + "@dialogClearHistoryMessage": { + "description": "Dialog message - clear history confirmation" + }, "dialogDeleteSelectedTitle": "Delete Selected", - "@dialogDeleteSelectedTitle": {"description": "Dialog title - delete selected items"}, + "@dialogDeleteSelectedTitle": { + "description": "Dialog title - delete selected items" + }, "dialogDeleteSelectedMessage": "Delete {count} {count, plural, =1{track} other{tracks}} from history?\n\nThis will also delete the files from storage.", "@dialogDeleteSelectedMessage": { "description": "Dialog message - delete selected tracks", "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "dialogImportPlaylistTitle": "Import Playlist", - "@dialogImportPlaylistTitle": {"description": "Dialog title - import CSV playlist"}, + "@dialogImportPlaylistTitle": { + "description": "Dialog title - import CSV playlist" + }, "dialogImportPlaylistMessage": "Found {count} tracks in CSV. Add them to download queue?", "csvImportTracks": "{count} tracks from CSV", "@csvImportTracks": { "description": "Label shown in quality picker for CSV import", "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "@dialogImportPlaylistMessage": { "description": "Dialog message - import playlist confirmation", "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, - "snackbarAddedToQueue": "Added \"{trackName}\" to queue", "@snackbarAddedToQueue": { "description": "Snackbar - track added to download queue", "placeholders": { - "trackName": {"type": "String"} + "trackName": { + "type": "String" + } } }, "snackbarAddedTracksToQueue": "Added {count} tracks to queue", "@snackbarAddedTracksToQueue": { "description": "Snackbar - multiple tracks added to queue", "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "snackbarAlreadyDownloaded": "\"{trackName}\" already downloaded", "@snackbarAlreadyDownloaded": { "description": "Snackbar - track already exists", "placeholders": { - "trackName": {"type": "String"} + "trackName": { + "type": "String" + } } }, "snackbarAlreadyInLibrary": "\"{trackName}\" already exists in your library", "@snackbarAlreadyInLibrary": { "description": "Snackbar - track already exists in local library", "placeholders": { - "trackName": {"type": "String"} + "trackName": { + "type": "String" + } } }, "snackbarHistoryCleared": "History cleared", - "@snackbarHistoryCleared": {"description": "Snackbar - history deleted"}, + "@snackbarHistoryCleared": { + "description": "Snackbar - history deleted" + }, "snackbarCredentialsSaved": "Credentials saved", - "@snackbarCredentialsSaved": {"description": "Snackbar - Spotify credentials saved"}, + "@snackbarCredentialsSaved": { + "description": "Snackbar - Spotify credentials saved" + }, "snackbarCredentialsCleared": "Credentials cleared", - "@snackbarCredentialsCleared": {"description": "Snackbar - Spotify credentials removed"}, + "@snackbarCredentialsCleared": { + "description": "Snackbar - Spotify credentials removed" + }, "snackbarDeletedTracks": "Deleted {count} {count, plural, =1{track} other{tracks}}", "@snackbarDeletedTracks": { "description": "Snackbar - tracks deleted", "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "snackbarCannotOpenFile": "Cannot open file: {error}", "@snackbarCannotOpenFile": { "description": "Snackbar - file open error", "placeholders": { - "error": {"type": "String"} + "error": { + "type": "String" + } } }, "snackbarFillAllFields": "Please fill all fields", - "@snackbarFillAllFields": {"description": "Snackbar - validation error"}, + "@snackbarFillAllFields": { + "description": "Snackbar - validation error" + }, "snackbarViewQueue": "View Queue", - "@snackbarViewQueue": {"description": "Snackbar action - view download queue"}, - "snackbarFailedToLoad": "Failed to load: {error}", - "@snackbarFailedToLoad": { - "description": "Snackbar - loading error", - "placeholders": { - "error": {"type": "String"} - } + "@snackbarViewQueue": { + "description": "Snackbar action - view download queue" }, "snackbarUrlCopied": "{platform} URL copied to clipboard", "@snackbarUrlCopied": { "description": "Snackbar - URL copied", "placeholders": { - "platform": {"type": "String", "description": "Platform name (Spotify/Deezer)"} + "platform": { + "type": "String", + "description": "Platform name (Spotify/Deezer)" + } } }, "snackbarFileNotFound": "File not found", - "@snackbarFileNotFound": {"description": "Snackbar - file doesn't exist"}, + "@snackbarFileNotFound": { + "description": "Snackbar - file doesn't exist" + }, "snackbarSelectExtFile": "Please select a .spotiflac-ext file", - "@snackbarSelectExtFile": {"description": "Snackbar - wrong file type selected"}, + "@snackbarSelectExtFile": { + "description": "Snackbar - wrong file type selected" + }, "snackbarProviderPrioritySaved": "Provider priority saved", - "@snackbarProviderPrioritySaved": {"description": "Snackbar - provider order saved"}, + "@snackbarProviderPrioritySaved": { + "description": "Snackbar - provider order saved" + }, "snackbarMetadataProviderSaved": "Metadata provider priority saved", - "@snackbarMetadataProviderSaved": {"description": "Snackbar - metadata provider order saved"}, + "@snackbarMetadataProviderSaved": { + "description": "Snackbar - metadata provider order saved" + }, "snackbarExtensionInstalled": "{extensionName} installed.", "@snackbarExtensionInstalled": { "description": "Snackbar - extension installed successfully", "placeholders": { - "extensionName": {"type": "String"} + "extensionName": { + "type": "String" + } } }, "snackbarExtensionUpdated": "{extensionName} updated.", "@snackbarExtensionUpdated": { "description": "Snackbar - extension updated successfully", "placeholders": { - "extensionName": {"type": "String"} + "extensionName": { + "type": "String" + } } }, "snackbarFailedToInstall": "Failed to install extension", - "@snackbarFailedToInstall": {"description": "Snackbar - extension install error"}, + "@snackbarFailedToInstall": { + "description": "Snackbar - extension install error" + }, "snackbarFailedToUpdate": "Failed to update extension", - "@snackbarFailedToUpdate": {"description": "Snackbar - extension update error"}, - + "@snackbarFailedToUpdate": { + "description": "Snackbar - extension update error" + }, "errorRateLimited": "Rate Limited", - "@errorRateLimited": {"description": "Error title - too many requests"}, + "@errorRateLimited": { + "description": "Error title - too many requests" + }, "errorRateLimitedMessage": "Too many requests. Please wait a moment before searching again.", - "@errorRateLimitedMessage": {"description": "Error message - rate limit explanation"}, - "errorFailedToLoad": "Failed to load {item}", - "@errorFailedToLoad": { - "description": "Error message - loading failed", - "placeholders": { - "item": {"type": "String", "description": "Item that failed to load (album/playlist/etc)"} - } + "@errorRateLimitedMessage": { + "description": "Error message - rate limit explanation" }, "errorNoTracksFound": "No tracks found", - "@errorNoTracksFound": {"description": "Error - search returned no results"}, + "@errorNoTracksFound": { + "description": "Error - search returned no results" + }, "errorMissingExtensionSource": "Cannot load {item}: missing extension source", "@errorMissingExtensionSource": { "description": "Error - extension source not available", "placeholders": { - "item": {"type": "String"} + "item": { + "type": "String" + } } }, - - "statusQueued": "Queued", - "@statusQueued": {"description": "Download status - waiting in queue"}, - "statusDownloading": "Downloading", - "@statusDownloading": {"description": "Download status - in progress"}, - "statusFinalizing": "Finalizing", - "@statusFinalizing": {"description": "Download status - writing metadata"}, - "statusCompleted": "Completed", - "@statusCompleted": {"description": "Download status - finished"}, - "statusFailed": "Failed", - "@statusFailed": {"description": "Download status - error occurred"}, - "statusSkipped": "Skipped", - "@statusSkipped": {"description": "Download status - already exists"}, - "statusPaused": "Paused", - "@statusPaused": {"description": "Download status - paused"}, - "actionPause": "Pause", - "@actionPause": {"description": "Action button - pause download"}, + "@actionPause": { + "description": "Action button - pause download" + }, "actionResume": "Resume", - "@actionResume": {"description": "Action button - resume download"}, + "@actionResume": { + "description": "Action button - resume download" + }, "actionCancel": "Cancel", - "@actionCancel": {"description": "Action button - cancel operation"}, - "actionStop": "Stop", - "@actionStop": {"description": "Action button - stop operation"}, - "actionSelect": "Select", - "@actionSelect": {"description": "Action button - enter selection mode"}, + "@actionCancel": { + "description": "Action button - cancel operation" + }, "actionSelectAll": "Select All", - "@actionSelectAll": {"description": "Action button - select all items"}, + "@actionSelectAll": { + "description": "Action button - select all items" + }, "actionDeselect": "Deselect", - "@actionDeselect": {"description": "Action button - deselect all"}, - "actionPaste": "Paste", - "@actionPaste": {"description": "Action button - paste from clipboard"}, - "actionImportCsv": "Import CSV", - "@actionImportCsv": {"description": "Action button - import CSV file"}, + "@actionDeselect": { + "description": "Action button - deselect all" + }, "actionRemoveCredentials": "Remove Credentials", - "@actionRemoveCredentials": {"description": "Action button - delete Spotify credentials"}, + "@actionRemoveCredentials": { + "description": "Action button - delete Spotify credentials" + }, "actionSaveCredentials": "Save Credentials", - "@actionSaveCredentials": {"description": "Action button - save Spotify credentials"}, - + "@actionSaveCredentials": { + "description": "Action button - save Spotify credentials" + }, "selectionSelected": "{count} selected", "@selectionSelected": { "description": "Selection count indicator", "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "selectionAllSelected": "All tracks selected", - "@selectionAllSelected": {"description": "Status - all items selected"}, - "selectionTapToSelect": "Tap tracks to select", - "@selectionTapToSelect": {"description": "Hint - how to select items"}, - "selectionDeleteTracks": "Delete {count} {count, plural, =1{track} other{tracks}}", - "@selectionDeleteTracks": { - "description": "Delete button with count", - "placeholders": { - "count": {"type": "int"} - } + "@selectionAllSelected": { + "description": "Status - all items selected" }, "selectionSelectToDelete": "Select tracks to delete", - "@selectionSelectToDelete": {"description": "Placeholder when nothing selected"}, - + "@selectionSelectToDelete": { + "description": "Placeholder when nothing selected" + }, "progressFetchingMetadata": "Fetching metadata... {current}/{total}", "@progressFetchingMetadata": { "description": "Progress indicator - loading track info", "placeholders": { - "current": {"type": "int"}, - "total": {"type": "int"} + "current": { + "type": "int" + }, + "total": { + "type": "int" + } } }, "progressReadingCsv": "Reading CSV...", - "@progressReadingCsv": {"description": "Progress indicator - parsing CSV file"}, - - "searchSongs": "Songs", - "@searchSongs": {"description": "Search result category - songs"}, - "searchArtists": "Artists", - "@searchArtists": {"description": "Search result category - artists"}, - "searchAlbums": "Albums", - "@searchAlbums": {"description": "Search result category - albums"}, - "searchPlaylists": "Playlists", - "@searchPlaylists": {"description": "Search result category - playlists"}, - - "tooltipPlay": "Play", - "@tooltipPlay": {"description": "Tooltip - play button"}, - "tooltipCancel": "Cancel", - "@tooltipCancel": {"description": "Tooltip - cancel button"}, - "tooltipStop": "Stop", - "@tooltipStop": {"description": "Tooltip - stop button"}, - "tooltipRetry": "Retry", - "@tooltipRetry": {"description": "Tooltip - retry button"}, - "tooltipRemove": "Remove", - "@tooltipRemove": {"description": "Tooltip - remove button"}, - "tooltipClear": "Clear", - "@tooltipClear": {"description": "Tooltip - clear button"}, - "tooltipPaste": "Paste", - "@tooltipPaste": {"description": "Tooltip - paste button"}, - - "filenameFormat": "Filename Format", - "@filenameFormat": {"description": "Setting title - filename pattern"}, - "filenameFormatPreview": "Preview: {preview}", - "@filenameFormatPreview": { - "description": "Preview of filename pattern", - "placeholders": { - "preview": {"type": "String"} - } + "@progressReadingCsv": { + "description": "Progress indicator - parsing CSV file" + }, + "searchSongs": "Songs", + "@searchSongs": { + "description": "Search result category - songs" + }, + "searchArtists": "Artists", + "@searchArtists": { + "description": "Search result category - artists" + }, + "searchAlbums": "Albums", + "@searchAlbums": { + "description": "Search result category - albums" + }, + "searchPlaylists": "Playlists", + "@searchPlaylists": { + "description": "Search result category - playlists" + }, + "tooltipPlay": "Play", + "@tooltipPlay": { + "description": "Tooltip - play button" + }, + "filenameFormat": "Filename Format", + "@filenameFormat": { + "description": "Setting title - filename pattern" }, - "filenameAvailablePlaceholders": "Available placeholders:", - "@filenameAvailablePlaceholders": {"description": "Label for placeholder list"}, - "filenameHint": "{artist} - {title}", - "@filenameHint": {"description": "Default filename format hint"}, "filenameShowAdvancedTags": "Show advanced tags", "@filenameShowAdvancedTags": { "description": "Toggle label for showing advanced filename tags" @@ -882,1350 +999,2107 @@ "@filenameShowAdvancedTagsDescription": { "description": "Description for advanced filename tag toggle" }, - - "folderOrganization": "Folder Organization", - "@folderOrganization": {"description": "Setting title - folder structure"}, "folderOrganizationNone": "No organization", - "@folderOrganizationNone": {"description": "Folder option - flat structure"}, + "@folderOrganizationNone": { + "description": "Folder option - flat structure" + }, "folderOrganizationByArtist": "By Artist", - "@folderOrganizationByArtist": {"description": "Folder option - artist folders"}, + "@folderOrganizationByArtist": { + "description": "Folder option - artist folders" + }, "folderOrganizationByAlbum": "By Album", - "@folderOrganizationByAlbum": {"description": "Folder option - album folders"}, + "@folderOrganizationByAlbum": { + "description": "Folder option - album folders" + }, "folderOrganizationByArtistAlbum": "Artist/Album", - "@folderOrganizationByArtistAlbum": {"description": "Folder option - nested folders"}, + "@folderOrganizationByArtistAlbum": { + "description": "Folder option - nested folders" + }, "folderOrganizationDescription": "Organize downloaded files into folders", - "@folderOrganizationDescription": {"description": "Folder organization sheet description"}, + "@folderOrganizationDescription": { + "description": "Folder organization sheet description" + }, "folderOrganizationNoneSubtitle": "All files in download folder", - "@folderOrganizationNoneSubtitle": {"description": "Subtitle for no organization option"}, + "@folderOrganizationNoneSubtitle": { + "description": "Subtitle for no organization option" + }, "folderOrganizationByArtistSubtitle": "Separate folder for each artist", - "@folderOrganizationByArtistSubtitle": {"description": "Subtitle for artist folder option"}, + "@folderOrganizationByArtistSubtitle": { + "description": "Subtitle for artist folder option" + }, "folderOrganizationByAlbumSubtitle": "Separate folder for each album", - "@folderOrganizationByAlbumSubtitle": {"description": "Subtitle for album folder option"}, + "@folderOrganizationByAlbumSubtitle": { + "description": "Subtitle for album folder option" + }, "folderOrganizationByArtistAlbumSubtitle": "Nested folders for artist and album", - "@folderOrganizationByArtistAlbumSubtitle": {"description": "Subtitle for nested folder option"}, - + "@folderOrganizationByArtistAlbumSubtitle": { + "description": "Subtitle for nested folder option" + }, "updateAvailable": "Update Available", - "@updateAvailable": {"description": "Update dialog title"}, - "updateNewVersion": "Version {version} is available", - "@updateNewVersion": { - "description": "Update available message", - "placeholders": { - "version": {"type": "String"} - } + "@updateAvailable": { + "description": "Update dialog title" }, - "updateDownload": "Download", - "@updateDownload": {"description": "Update button - download update"}, "updateLater": "Later", - "@updateLater": {"description": "Update button - dismiss"}, - "updateChangelog": "Changelog", - "@updateChangelog": {"description": "Link to changelog"}, - "updateStartingDownload": "Starting download...", - "@updateStartingDownload": {"description": "Update status - initializing"}, - "updateDownloadFailed": "Download failed", - "@updateDownloadFailed": {"description": "Update error title"}, - "updateFailedMessage": "Failed to download update", - "@updateFailedMessage": {"description": "Update error message"}, - "updateNewVersionReady": "A new version is ready", - "@updateNewVersionReady": {"description": "Update subtitle"}, - "updateCurrent": "Current", - "@updateCurrent": {"description": "Label for current version"}, - "updateNew": "New", - "@updateNew": {"description": "Label for new version"}, - "updateDownloading": "Downloading...", - "@updateDownloading": {"description": "Update status - downloading"}, - "updateWhatsNew": "What's New", - "@updateWhatsNew": {"description": "Changelog section title"}, - "updateDownloadInstall": "Download & Install", - "@updateDownloadInstall": {"description": "Update button - download and install"}, - "updateDontRemind": "Don't remind", - "@updateDontRemind": {"description": "Update button - skip this version"}, - - "providerPriority": "Provider Priority", - "@providerPriority": {"description": "Setting title - download provider order"}, - "providerPrioritySubtitle": "Drag to reorder download providers", - "@providerPrioritySubtitle": {"description": "Subtitle for provider priority"}, - "providerPriorityTitle": "Provider Priority", - "@providerPriorityTitle": {"description": "Provider priority page title"}, - "providerPriorityDescription": "Drag to reorder download providers. The app will try providers from top to bottom when downloading tracks.", - "@providerPriorityDescription": {"description": "Provider priority page description"}, - "providerPriorityInfo": "If a track is not available on the first provider, the app will automatically try the next one.", - "@providerPriorityInfo": {"description": "Info tip about fallback behavior"}, - "providerBuiltIn": "Built-in", - "@providerBuiltIn": {"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"}, - "providerExtension": "Extension", - "@providerExtension": {"description": "Label for extension-provided providers"}, - - "metadataProviderPriority": "Metadata Provider Priority", - "@metadataProviderPriority": {"description": "Setting title - metadata provider order"}, - "metadataProviderPrioritySubtitle": "Order used when fetching track metadata", - "@metadataProviderPrioritySubtitle": {"description": "Subtitle for metadata priority"}, - "metadataProviderPriorityTitle": "Metadata Priority", - "@metadataProviderPriorityTitle": {"description": "Metadata priority page title"}, - "metadataProviderPriorityDescription": "Drag to reorder metadata providers. The app will try providers from top to bottom when searching for tracks and fetching metadata.", - "@metadataProviderPriorityDescription": {"description": "Metadata priority page description"}, - "metadataProviderPriorityInfo": "Deezer has no rate limits and is recommended as primary. Spotify may rate limit after many requests.", - "@metadataProviderPriorityInfo": {"description": "Info tip about rate limits"}, - "metadataNoRateLimits": "No rate limits", - "@metadataNoRateLimits": {"description": "Deezer provider description"}, - "metadataMayRateLimit": "May rate limit", - "@metadataMayRateLimit": {"description": "Spotify provider description"}, - - "logTitle": "Logs", - "@logTitle": {"description": "Logs screen title"}, - "logCopy": "Copy Logs", - "@logCopy": {"description": "Action - copy logs to clipboard"}, - "logClear": "Clear Logs", - "@logClear": {"description": "Action - delete all logs"}, - "logShare": "Share Logs", - "@logShare": {"description": "Action - share logs file"}, - "logEmpty": "No logs yet", - "@logEmpty": {"description": "Empty state title"}, - "logCopied": "Logs copied to clipboard", - "@logCopied": {"description": "Snackbar - logs copied"}, - "logSearchHint": "Search logs...", - "@logSearchHint": {"description": "Log search placeholder"}, - "logFilterLevel": "Level", - "@logFilterLevel": {"description": "Filter by log level"}, - "logFilterSection": "Filter", - "@logFilterSection": {"description": "Filter section title"}, - "logShareLogs": "Share logs", - "@logShareLogs": {"description": "Share button tooltip"}, - "logClearLogs": "Clear logs", - "@logClearLogs": {"description": "Clear button tooltip"}, - "logClearLogsTitle": "Clear Logs", - "@logClearLogsTitle": {"description": "Clear logs dialog title"}, - "logClearLogsMessage": "Are you sure you want to clear all logs?", - "@logClearLogsMessage": {"description": "Clear logs confirmation message"}, - "logIspBlocking": "ISP BLOCKING DETECTED", - "@logIspBlocking": {"description": "Error category - ISP blocking"}, - "logRateLimited": "RATE LIMITED", - "@logRateLimited": {"description": "Error category - rate limiting"}, - "logNetworkError": "NETWORK ERROR", - "@logNetworkError": {"description": "Error category - network issues"}, - "logTrackNotFound": "TRACK NOT FOUND", - "@logTrackNotFound": {"description": "Error category - missing tracks"}, - "logFilterBySeverity": "Filter logs by severity", - "@logFilterBySeverity": {"description": "Filter dialog title"}, - "logNoLogsYet": "No logs yet", - "@logNoLogsYet": {"description": "Empty state title"}, - "logNoLogsYetSubtitle": "Logs will appear here as you use the app", - "@logNoLogsYetSubtitle": {"description": "Empty state subtitle"}, - "logIssueSummary": "Issue Summary", - "@logIssueSummary": {"description": "Section header for error summary"}, - "logIspBlockingDescription": "Your ISP may be blocking access to download services", - "@logIspBlockingDescription": {"description": "ISP blocking explanation"}, - "logIspBlockingSuggestion": "Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8", - "@logIspBlockingSuggestion": {"description": "ISP blocking fix suggestion"}, - "logRateLimitedDescription": "Too many requests to the service", - "@logRateLimitedDescription": {"description": "Rate limit explanation"}, - "logRateLimitedSuggestion": "Wait a few minutes before trying again", - "@logRateLimitedSuggestion": {"description": "Rate limit fix suggestion"}, - "logNetworkErrorDescription": "Connection issues detected", - "@logNetworkErrorDescription": {"description": "Network error explanation"}, - "logNetworkErrorSuggestion": "Check your internet connection", - "@logNetworkErrorSuggestion": {"description": "Network error fix suggestion"}, - "logTrackNotFoundDescription": "Some tracks could not be found on download services", - "@logTrackNotFoundDescription": {"description": "Track not found explanation"}, - "logTrackNotFoundSuggestion": "The track may not be available in lossless quality", - "@logTrackNotFoundSuggestion": {"description": "Track not found explanation"}, - "logTotalErrors": "Total errors: {count}", - "@logTotalErrors": { - "description": "Error count display", - "placeholders": { - "count": {"type": "int"} - } + "@updateLater": { + "description": "Update button - dismiss" }, - "logAffected": "Affected: {domains}", - "@logAffected": { - "description": "Affected domains display", - "placeholders": { - "domains": {"type": "String"} - } + "updateStartingDownload": "Starting download...", + "@updateStartingDownload": { + "description": "Update status - initializing" + }, + "updateDownloadFailed": "Download failed", + "@updateDownloadFailed": { + "description": "Update error title" + }, + "updateFailedMessage": "Failed to download update", + "@updateFailedMessage": { + "description": "Update error message" + }, + "updateNewVersionReady": "A new version is ready", + "@updateNewVersionReady": { + "description": "Update subtitle" + }, + "updateCurrent": "Current", + "@updateCurrent": { + "description": "Label for current version" + }, + "updateNew": "New", + "@updateNew": { + "description": "Label for new version" + }, + "updateDownloading": "Downloading...", + "@updateDownloading": { + "description": "Update status - downloading" + }, + "updateWhatsNew": "What's New", + "@updateWhatsNew": { + "description": "Changelog section title" + }, + "updateDownloadInstall": "Download & Install", + "@updateDownloadInstall": { + "description": "Update button - download and install" + }, + "updateDontRemind": "Don't remind", + "@updateDontRemind": { + "description": "Update button - skip this version" + }, + "providerPriorityTitle": "Provider Priority", + "@providerPriorityTitle": { + "description": "Provider priority page title" + }, + "providerPriorityDescription": "Drag to reorder download providers. The app will try providers from top to bottom when downloading tracks.", + "@providerPriorityDescription": { + "description": "Provider priority page description" + }, + "providerPriorityInfo": "If a track is not available on the first provider, the app will automatically try the next one.", + "@providerPriorityInfo": { + "description": "Info tip about fallback behavior" + }, + "providerBuiltIn": "Built-in", + "@providerBuiltIn": { + "description": "Label for built-in providers (Tidal/Qobuz/Amazon)" + }, + "providerExtension": "Extension", + "@providerExtension": { + "description": "Label for extension-provided providers" + }, + "metadataProviderPriorityTitle": "Metadata Priority", + "@metadataProviderPriorityTitle": { + "description": "Metadata priority page title" + }, + "metadataProviderPriorityDescription": "Drag to reorder metadata providers. The app will try providers from top to bottom when searching for tracks and fetching metadata.", + "@metadataProviderPriorityDescription": { + "description": "Metadata priority page description" + }, + "metadataProviderPriorityInfo": "Deezer has no rate limits and is recommended as primary. Spotify may rate limit after many requests.", + "@metadataProviderPriorityInfo": { + "description": "Info tip about rate limits" + }, + "metadataNoRateLimits": "No rate limits", + "@metadataNoRateLimits": { + "description": "Deezer provider description" + }, + "metadataMayRateLimit": "May rate limit", + "@metadataMayRateLimit": { + "description": "Spotify provider description" + }, + "logTitle": "Logs", + "@logTitle": { + "description": "Logs screen title" + }, + "logCopied": "Logs copied to clipboard", + "@logCopied": { + "description": "Snackbar - logs copied" + }, + "logSearchHint": "Search logs...", + "@logSearchHint": { + "description": "Log search placeholder" + }, + "logFilterLevel": "Level", + "@logFilterLevel": { + "description": "Filter by log level" + }, + "logFilterSection": "Filter", + "@logFilterSection": { + "description": "Filter section title" + }, + "logShareLogs": "Share logs", + "@logShareLogs": { + "description": "Share button tooltip" + }, + "logClearLogs": "Clear logs", + "@logClearLogs": { + "description": "Clear button tooltip" + }, + "logClearLogsTitle": "Clear Logs", + "@logClearLogsTitle": { + "description": "Clear logs dialog title" + }, + "logClearLogsMessage": "Are you sure you want to clear all logs?", + "@logClearLogsMessage": { + "description": "Clear logs confirmation message" + }, + "logFilterBySeverity": "Filter logs by severity", + "@logFilterBySeverity": { + "description": "Filter dialog title" + }, + "logNoLogsYet": "No logs yet", + "@logNoLogsYet": { + "description": "Empty state title" + }, + "logNoLogsYetSubtitle": "Logs will appear here as you use the app", + "@logNoLogsYetSubtitle": { + "description": "Empty state subtitle" }, "logEntriesFiltered": "Entries ({count} filtered)", "@logEntriesFiltered": { "description": "Log count with filter active", "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "logEntries": "Entries ({count})", "@logEntries": { "description": "Total log count", "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, - "credentialsTitle": "Spotify Credentials", - "@credentialsTitle": {"description": "Credentials dialog title"}, + "@credentialsTitle": { + "description": "Credentials dialog title" + }, "credentialsDescription": "Enter your Client ID and Secret to use your own Spotify application quota.", - "@credentialsDescription": {"description": "Credentials dialog explanation"}, + "@credentialsDescription": { + "description": "Credentials dialog explanation" + }, "credentialsClientId": "Client ID", - "@credentialsClientId": {"description": "Client ID field label - DO NOT TRANSLATE"}, + "@credentialsClientId": { + "description": "Client ID field label - DO NOT TRANSLATE" + }, "credentialsClientIdHint": "Paste Client ID", - "@credentialsClientIdHint": {"description": "Client ID placeholder"}, + "@credentialsClientIdHint": { + "description": "Client ID placeholder" + }, "credentialsClientSecret": "Client Secret", - "@credentialsClientSecret": {"description": "Client Secret field label - DO NOT TRANSLATE"}, + "@credentialsClientSecret": { + "description": "Client Secret field label - DO NOT TRANSLATE" + }, "credentialsClientSecretHint": "Paste Client Secret", - "@credentialsClientSecretHint": {"description": "Client Secret placeholder"}, - + "@credentialsClientSecretHint": { + "description": "Client Secret placeholder" + }, "channelStable": "Stable", - "@channelStable": {"description": "Update channel - stable releases"}, + "@channelStable": { + "description": "Update channel - stable releases" + }, "channelPreview": "Preview", - "@channelPreview": {"description": "Update channel - beta/preview releases"}, - + "@channelPreview": { + "description": "Update channel - beta/preview releases" + }, "sectionSearchSource": "Search Source", - "@sectionSearchSource": {"description": "Settings section header"}, + "@sectionSearchSource": { + "description": "Settings section header" + }, "sectionDownload": "Download", - "@sectionDownload": {"description": "Settings section header"}, + "@sectionDownload": { + "description": "Settings section header" + }, "sectionPerformance": "Performance", - "@sectionPerformance": {"description": "Settings section header"}, + "@sectionPerformance": { + "description": "Settings section header" + }, "sectionApp": "App", - "@sectionApp": {"description": "Settings section header"}, + "@sectionApp": { + "description": "Settings section header" + }, "sectionData": "Data", - "@sectionData": {"description": "Settings section header"}, + "@sectionData": { + "description": "Settings section header" + }, "sectionDebug": "Debug", - "@sectionDebug": {"description": "Settings section header"}, + "@sectionDebug": { + "description": "Settings section header" + }, "sectionService": "Service", - "@sectionService": {"description": "Settings section header"}, + "@sectionService": { + "description": "Settings section header" + }, "sectionAudioQuality": "Audio Quality", - "@sectionAudioQuality": {"description": "Settings section header"}, + "@sectionAudioQuality": { + "description": "Settings section header" + }, "sectionFileSettings": "File Settings", - "@sectionFileSettings": {"description": "Settings section header"}, + "@sectionFileSettings": { + "description": "Settings section header" + }, "sectionLyrics": "Lyrics", - "@sectionLyrics": {"description": "Settings section header"}, - + "@sectionLyrics": { + "description": "Settings section header" + }, "lyricsMode": "Lyrics Mode", - "@lyricsMode": {"description": "Setting - how to save lyrics"}, + "@lyricsMode": { + "description": "Setting - how to save lyrics" + }, "lyricsModeDescription": "Choose how lyrics are saved with your downloads", - "@lyricsModeDescription": {"description": "Lyrics mode picker description"}, + "@lyricsModeDescription": { + "description": "Lyrics mode picker description" + }, "lyricsModeEmbed": "Embed in file", - "@lyricsModeEmbed": {"description": "Lyrics mode option - embed in audio file"}, + "@lyricsModeEmbed": { + "description": "Lyrics mode option - embed in audio file" + }, "lyricsModeEmbedSubtitle": "Lyrics stored inside FLAC metadata", - "@lyricsModeEmbedSubtitle": {"description": "Subtitle for embed option"}, + "@lyricsModeEmbedSubtitle": { + "description": "Subtitle for embed option" + }, "lyricsModeExternal": "External .lrc file", - "@lyricsModeExternal": {"description": "Lyrics mode option - separate LRC file"}, + "@lyricsModeExternal": { + "description": "Lyrics mode option - separate LRC file" + }, "lyricsModeExternalSubtitle": "Separate .lrc file for players like Samsung Music", - "@lyricsModeExternalSubtitle": {"description": "Subtitle for external option"}, + "@lyricsModeExternalSubtitle": { + "description": "Subtitle for external option" + }, "lyricsModeBoth": "Both", - "@lyricsModeBoth": {"description": "Lyrics mode option - embed and external"}, + "@lyricsModeBoth": { + "description": "Lyrics mode option - embed and external" + }, "lyricsModeBothSubtitle": "Embed and save .lrc file", - "@lyricsModeBothSubtitle": {"description": "Subtitle for both option"}, - + "@lyricsModeBothSubtitle": { + "description": "Subtitle for both option" + }, "sectionColor": "Color", - "@sectionColor": {"description": "Settings section header"}, + "@sectionColor": { + "description": "Settings section header" + }, "sectionTheme": "Theme", - "@sectionTheme": {"description": "Settings section header"}, + "@sectionTheme": { + "description": "Settings section header" + }, "sectionLayout": "Layout", -"@sectionLayout": {"description": "Settings section header"}, + "@sectionLayout": { + "description": "Settings section header" + }, "sectionLanguage": "Language", - "@sectionLanguage": {"description": "Settings section header for language"}, + "@sectionLanguage": { + "description": "Settings section header for language" + }, "appearanceLanguage": "App Language", - "@appearanceLanguage": {"description": "Language setting title"}, - "appearanceLanguageSubtitle": "Choose your preferred language", - "@appearanceLanguageSubtitle": {"description": "Language setting subtitle"}, - + "@appearanceLanguage": { + "description": "Language setting title" + }, "settingsAppearanceSubtitle": "Theme, colors, display", - "@settingsAppearanceSubtitle": {"description": "Appearance settings description"}, + "@settingsAppearanceSubtitle": { + "description": "Appearance settings description" + }, "settingsDownloadSubtitle": "Service, quality, filename format", - "@settingsDownloadSubtitle": {"description": "Download settings description"}, + "@settingsDownloadSubtitle": { + "description": "Download settings description" + }, "settingsOptionsSubtitle": "Fallback, lyrics, cover art, updates", - "@settingsOptionsSubtitle": {"description": "Options settings description"}, + "@settingsOptionsSubtitle": { + "description": "Options settings description" + }, "settingsExtensionsSubtitle": "Manage download providers", - "@settingsExtensionsSubtitle": {"description": "Extensions settings description"}, + "@settingsExtensionsSubtitle": { + "description": "Extensions settings description" + }, "settingsLogsSubtitle": "View app logs for debugging", - "@settingsLogsSubtitle": {"description": "Logs settings description"}, - + "@settingsLogsSubtitle": { + "description": "Logs settings description" + }, "loadingSharedLink": "Loading shared link...", - "@loadingSharedLink": {"description": "Status when opening shared URL"}, + "@loadingSharedLink": { + "description": "Status when opening shared URL" + }, "pressBackAgainToExit": "Press back again to exit", - "@pressBackAgainToExit": {"description": "Exit confirmation message"}, - - "tracksHeader": "Tracks", - "@tracksHeader": {"description": "Section header for track list"}, + "@pressBackAgainToExit": { + "description": "Exit confirmation message" + }, "downloadAllCount": "Download All ({count})", "@downloadAllCount": { "description": "Download all button with count", "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "tracksCount": "{count, plural, =1{1 track} other{{count} tracks}}", "@tracksCount": { "description": "Track count display", "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, - "trackCopyFilePath": "Copy file path", - "@trackCopyFilePath": {"description": "Action - copy file path"}, - "trackRemoveFromDevice": "Remove from device", - "@trackRemoveFromDevice": {"description": "Action - delete downloaded file"}, - "trackLoadLyrics": "Load Lyrics", - "@trackLoadLyrics": {"description": "Action - fetch lyrics"}, - "trackMetadata": "Metadata", - "@trackMetadata": {"description": "Tab title - track metadata"}, - "trackFileInfo": "File Info", - "@trackFileInfo": {"description": "Tab title - file information"}, - "trackLyrics": "Lyrics", - "@trackLyrics": {"description": "Tab title - lyrics"}, - "trackFileNotFound": "File not found", - "@trackFileNotFound": {"description": "Error - file doesn't exist"}, - "trackOpenInDeezer": "Open in Deezer", - "@trackOpenInDeezer": {"description": "Action - open track in Deezer app"}, - "trackOpenInSpotify": "Open in Spotify", - "@trackOpenInSpotify": {"description": "Action - open track in Spotify app"}, - "trackTrackName": "Track name", - "@trackTrackName": {"description": "Metadata label - track title"}, - "trackArtist": "Artist", - "@trackArtist": {"description": "Metadata label - artist name"}, - "trackAlbumArtist": "Album artist", - "@trackAlbumArtist": {"description": "Metadata label - album artist"}, - "trackAlbum": "Album", - "@trackAlbum": {"description": "Metadata label - album name"}, - "trackTrackNumber": "Track number", - "@trackTrackNumber": {"description": "Metadata label - track number"}, - "trackDiscNumber": "Disc number", - "@trackDiscNumber": {"description": "Metadata label - disc number"}, - "trackDuration": "Duration", - "@trackDuration": {"description": "Metadata label - track length"}, - "trackAudioQuality": "Audio quality", - "@trackAudioQuality": {"description": "Metadata label - audio quality"}, - "trackReleaseDate": "Release date", - "@trackReleaseDate": {"description": "Metadata label - release date"}, - "trackGenre": "Genre", - "@trackGenre": {"description": "Metadata label - music genre"}, - "trackLabel": "Label", - "@trackLabel": {"description": "Metadata label - record label"}, - "trackCopyright": "Copyright", - "@trackCopyright": {"description": "Metadata label - copyright information"}, - "trackDownloaded": "Downloaded", - "@trackDownloaded": {"description": "Metadata label - download date"}, - "trackCopyLyrics": "Copy lyrics", - "@trackCopyLyrics": {"description": "Action - copy lyrics to clipboard"}, - "trackLyricsNotAvailable": "Lyrics not available for this track", - "@trackLyricsNotAvailable": {"description": "Message when lyrics not found"}, - "trackLyricsTimeout": "Request timed out. Try again later.", - "@trackLyricsTimeout": {"description": "Message when lyrics request times out"}, - "trackLyricsLoadFailed": "Failed to load lyrics", - "@trackLyricsLoadFailed": {"description": "Message when lyrics loading fails"}, - "trackEmbedLyrics": "Embed Lyrics", - "@trackEmbedLyrics": {"description": "Action - embed lyrics into audio file"}, - "trackLyricsEmbedded": "Lyrics embedded successfully", - "@trackLyricsEmbedded": {"description": "Snackbar - lyrics saved to file"}, - "trackInstrumental": "Instrumental track", - "@trackInstrumental": {"description": "Message when track is instrumental (no lyrics)"}, - "trackCopiedToClipboard": "Copied to clipboard", - "@trackCopiedToClipboard": {"description": "Snackbar - content copied"}, - "trackDeleteConfirmTitle": "Remove from device?", - "@trackDeleteConfirmTitle": {"description": "Delete confirmation title"}, - "trackDeleteConfirmMessage": "This will permanently delete the downloaded file and remove it from your history.", - "@trackDeleteConfirmMessage": {"description": "Delete confirmation message"}, - "trackCannotOpen": "Cannot open: {message}", - "@trackCannotOpen": { - "description": "Error opening file", - "placeholders": { - "message": {"type": "String"} - } + "@trackCopyFilePath": { + "description": "Action - copy file path" + }, + "trackRemoveFromDevice": "Remove from device", + "@trackRemoveFromDevice": { + "description": "Action - delete downloaded file" + }, + "trackLoadLyrics": "Load Lyrics", + "@trackLoadLyrics": { + "description": "Action - fetch lyrics" + }, + "trackMetadata": "Metadata", + "@trackMetadata": { + "description": "Tab title - track metadata" + }, + "trackFileInfo": "File Info", + "@trackFileInfo": { + "description": "Tab title - file information" + }, + "trackLyrics": "Lyrics", + "@trackLyrics": { + "description": "Tab title - lyrics" + }, + "trackFileNotFound": "File not found", + "@trackFileNotFound": { + "description": "Error - file doesn't exist" + }, + "trackOpenInDeezer": "Open in Deezer", + "@trackOpenInDeezer": { + "description": "Action - open track in Deezer app" + }, + "trackOpenInSpotify": "Open in Spotify", + "@trackOpenInSpotify": { + "description": "Action - open track in Spotify app" + }, + "trackTrackName": "Track name", + "@trackTrackName": { + "description": "Metadata label - track title" + }, + "trackArtist": "Artist", + "@trackArtist": { + "description": "Metadata label - artist name" + }, + "trackAlbumArtist": "Album artist", + "@trackAlbumArtist": { + "description": "Metadata label - album artist" + }, + "trackAlbum": "Album", + "@trackAlbum": { + "description": "Metadata label - album name" + }, + "trackTrackNumber": "Track number", + "@trackTrackNumber": { + "description": "Metadata label - track number" + }, + "trackDiscNumber": "Disc number", + "@trackDiscNumber": { + "description": "Metadata label - disc number" + }, + "trackDuration": "Duration", + "@trackDuration": { + "description": "Metadata label - track length" + }, + "trackAudioQuality": "Audio quality", + "@trackAudioQuality": { + "description": "Metadata label - audio quality" + }, + "trackReleaseDate": "Release date", + "@trackReleaseDate": { + "description": "Metadata label - release date" + }, + "trackGenre": "Genre", + "@trackGenre": { + "description": "Metadata label - music genre" + }, + "trackLabel": "Label", + "@trackLabel": { + "description": "Metadata label - record label" + }, + "trackCopyright": "Copyright", + "@trackCopyright": { + "description": "Metadata label - copyright information" + }, + "trackDownloaded": "Downloaded", + "@trackDownloaded": { + "description": "Metadata label - download date" + }, + "trackCopyLyrics": "Copy lyrics", + "@trackCopyLyrics": { + "description": "Action - copy lyrics to clipboard" + }, + "trackLyricsNotAvailable": "Lyrics not available for this track", + "@trackLyricsNotAvailable": { + "description": "Message when lyrics not found" + }, + "trackLyricsTimeout": "Request timed out. Try again later.", + "@trackLyricsTimeout": { + "description": "Message when lyrics request times out" + }, + "trackLyricsLoadFailed": "Failed to load lyrics", + "@trackLyricsLoadFailed": { + "description": "Message when lyrics loading fails" + }, + "trackEmbedLyrics": "Embed Lyrics", + "@trackEmbedLyrics": { + "description": "Action - embed lyrics into audio file" + }, + "trackLyricsEmbedded": "Lyrics embedded successfully", + "@trackLyricsEmbedded": { + "description": "Snackbar - lyrics saved to file" + }, + "trackInstrumental": "Instrumental track", + "@trackInstrumental": { + "description": "Message when track is instrumental (no lyrics)" + }, + "trackCopiedToClipboard": "Copied to clipboard", + "@trackCopiedToClipboard": { + "description": "Snackbar - content copied" + }, + "trackDeleteConfirmTitle": "Remove from device?", + "@trackDeleteConfirmTitle": { + "description": "Delete confirmation title" + }, + "trackDeleteConfirmMessage": "This will permanently delete the downloaded file and remove it from your history.", + "@trackDeleteConfirmMessage": { + "description": "Delete confirmation message" }, - "dateToday": "Today", - "@dateToday": {"description": "Relative date - today"}, + "@dateToday": { + "description": "Relative date - today" + }, "dateYesterday": "Yesterday", - "@dateYesterday": {"description": "Relative date - yesterday"}, + "@dateYesterday": { + "description": "Relative date - yesterday" + }, "dateDaysAgo": "{count} days ago", "@dateDaysAgo": { "description": "Relative date - days ago", "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "dateWeeksAgo": "{count} weeks ago", "@dateWeeksAgo": { "description": "Relative date - weeks ago", "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "dateMonthsAgo": "{count} months ago", "@dateMonthsAgo": { "description": "Relative date - months ago", "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, - - "concurrentSequential": "Sequential", - "@concurrentSequential": {"description": "Download mode - one at a time"}, - "concurrentParallel2": "2 Parallel", - "@concurrentParallel2": {"description": "Download mode - 2 simultaneous"}, - "concurrentParallel3": "3 Parallel", - "@concurrentParallel3": {"description": "Download mode - 3 simultaneous"}, - - "tapToSeeError": "Tap to see error details", - "@tapToSeeError": {"description": "Tooltip for failed download"}, - "storeFilterAll": "All", - "@storeFilterAll": {"description": "Store filter - all extensions"}, + "@storeFilterAll": { + "description": "Store filter - all extensions" + }, "storeFilterMetadata": "Metadata", - "@storeFilterMetadata": {"description": "Store filter - metadata providers"}, + "@storeFilterMetadata": { + "description": "Store filter - metadata providers" + }, "storeFilterDownload": "Download", - "@storeFilterDownload": {"description": "Store filter - download providers"}, + "@storeFilterDownload": { + "description": "Store filter - download providers" + }, "storeFilterUtility": "Utility", - "@storeFilterUtility": {"description": "Store filter - utility extensions"}, + "@storeFilterUtility": { + "description": "Store filter - utility extensions" + }, "storeFilterLyrics": "Lyrics", - "@storeFilterLyrics": {"description": "Store filter - lyrics providers"}, + "@storeFilterLyrics": { + "description": "Store filter - lyrics providers" + }, "storeFilterIntegration": "Integration", - "@storeFilterIntegration": {"description": "Store filter - integrations"}, + "@storeFilterIntegration": { + "description": "Store filter - integrations" + }, "storeClearFilters": "Clear filters", - "@storeClearFilters": {"description": "Button to clear all filters"}, - "storeNoResults": "No extensions found", - "@storeNoResults": {"description": "Empty state when no extensions match filters"}, - - "extensionProviderPriority": "Provider Priority", - "@extensionProviderPriority": {"description": "Extension capability - provider priority"}, - "extensionInstallButton": "Install Extension", - "@extensionInstallButton": {"description": "Button to install extension"}, + "@storeClearFilters": { + "description": "Button to clear all filters" + }, "extensionDefaultProvider": "Default (Deezer/Spotify)", - "@extensionDefaultProvider": {"description": "Default search provider option"}, + "@extensionDefaultProvider": { + "description": "Default search provider option" + }, "extensionDefaultProviderSubtitle": "Use built-in search", - "@extensionDefaultProviderSubtitle": {"description": "Subtitle for default provider"}, + "@extensionDefaultProviderSubtitle": { + "description": "Subtitle for default provider" + }, "extensionAuthor": "Author", - "@extensionAuthor": {"description": "Extension detail - author"}, + "@extensionAuthor": { + "description": "Extension detail - author" + }, "extensionId": "ID", - "@extensionId": {"description": "Extension detail - unique ID"}, + "@extensionId": { + "description": "Extension detail - unique ID" + }, "extensionError": "Error", - "@extensionError": {"description": "Extension detail - error message"}, + "@extensionError": { + "description": "Extension detail - error message" + }, "extensionCapabilities": "Capabilities", - "@extensionCapabilities": {"description": "Section header - extension features"}, + "@extensionCapabilities": { + "description": "Section header - extension features" + }, "extensionMetadataProvider": "Metadata Provider", - "@extensionMetadataProvider": {"description": "Capability - provides metadata"}, + "@extensionMetadataProvider": { + "description": "Capability - provides metadata" + }, "extensionDownloadProvider": "Download Provider", - "@extensionDownloadProvider": {"description": "Capability - provides downloads"}, + "@extensionDownloadProvider": { + "description": "Capability - provides downloads" + }, "extensionLyricsProvider": "Lyrics Provider", - "@extensionLyricsProvider": {"description": "Capability - provides lyrics"}, + "@extensionLyricsProvider": { + "description": "Capability - provides lyrics" + }, "extensionUrlHandler": "URL Handler", - "@extensionUrlHandler": {"description": "Capability - handles URLs"}, + "@extensionUrlHandler": { + "description": "Capability - handles URLs" + }, "extensionQualityOptions": "Quality Options", - "@extensionQualityOptions": {"description": "Capability - quality selection"}, + "@extensionQualityOptions": { + "description": "Capability - quality selection" + }, "extensionPostProcessingHooks": "Post-Processing Hooks", - "@extensionPostProcessingHooks": {"description": "Capability - post-processing"}, + "@extensionPostProcessingHooks": { + "description": "Capability - post-processing" + }, "extensionPermissions": "Permissions", - "@extensionPermissions": {"description": "Section header - required permissions"}, + "@extensionPermissions": { + "description": "Section header - required permissions" + }, "extensionSettings": "Settings", - "@extensionSettings": {"description": "Section header - extension settings"}, + "@extensionSettings": { + "description": "Section header - extension settings" + }, "extensionRemoveButton": "Remove Extension", - "@extensionRemoveButton": {"description": "Button to uninstall extension"}, + "@extensionRemoveButton": { + "description": "Button to uninstall extension" + }, "extensionUpdated": "Updated", - "@extensionUpdated": {"description": "Extension detail - last update"}, + "@extensionUpdated": { + "description": "Extension detail - last update" + }, "extensionMinAppVersion": "Min App Version", - "@extensionMinAppVersion": {"description": "Extension detail - minimum app version"}, + "@extensionMinAppVersion": { + "description": "Extension detail - minimum app version" + }, "extensionCustomTrackMatching": "Custom Track Matching", - "@extensionCustomTrackMatching": {"description": "Capability - custom track matching algorithm"}, + "@extensionCustomTrackMatching": { + "description": "Capability - custom track matching algorithm" + }, "extensionPostProcessing": "Post-Processing", - "@extensionPostProcessing": {"description": "Capability - post-download processing"}, + "@extensionPostProcessing": { + "description": "Capability - post-download processing" + }, "extensionHooksAvailable": "{count} hook(s) available", "@extensionHooksAvailable": { "description": "Post-processing hooks count", "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "extensionPatternsCount": "{count} pattern(s)", "@extensionPatternsCount": { "description": "URL patterns count", "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "extensionStrategy": "Strategy: {strategy}", "@extensionStrategy": { "description": "Track matching strategy name", "placeholders": { - "strategy": {"type": "String"} + "strategy": { + "type": "String" + } } }, "extensionsProviderPrioritySection": "Provider Priority", - "@extensionsProviderPrioritySection": {"description": "Section header - provider priority"}, + "@extensionsProviderPrioritySection": { + "description": "Section header - provider priority" + }, "extensionsInstalledSection": "Installed Extensions", - "@extensionsInstalledSection": {"description": "Section header - installed extensions"}, + "@extensionsInstalledSection": { + "description": "Section header - installed extensions" + }, "extensionsNoExtensions": "No extensions installed", - "@extensionsNoExtensions": {"description": "Empty state - no extensions"}, + "@extensionsNoExtensions": { + "description": "Empty state - no extensions" + }, "extensionsNoExtensionsSubtitle": "Install .spotiflac-ext files to add new providers", - "@extensionsNoExtensionsSubtitle": {"description": "Empty state subtitle"}, + "@extensionsNoExtensionsSubtitle": { + "description": "Empty state subtitle" + }, "extensionsInstallButton": "Install Extension", - "@extensionsInstallButton": {"description": "Button to install extension from file"}, + "@extensionsInstallButton": { + "description": "Button to install extension from file" + }, "extensionsInfoTip": "Extensions can add new metadata and download providers. Only install extensions from trusted sources.", - "@extensionsInfoTip": {"description": "Security warning about extensions"}, + "@extensionsInfoTip": { + "description": "Security warning about extensions" + }, "extensionsInstalledSuccess": "Extension installed successfully", - "@extensionsInstalledSuccess": {"description": "Success message after install"}, + "@extensionsInstalledSuccess": { + "description": "Success message after install" + }, "extensionsDownloadPriority": "Download Priority", - "@extensionsDownloadPriority": {"description": "Setting - download provider order"}, + "@extensionsDownloadPriority": { + "description": "Setting - download provider order" + }, "extensionsDownloadPrioritySubtitle": "Set download service order", - "@extensionsDownloadPrioritySubtitle": {"description": "Subtitle for download priority"}, + "@extensionsDownloadPrioritySubtitle": { + "description": "Subtitle for download priority" + }, "extensionsNoDownloadProvider": "No extensions with download provider", - "@extensionsNoDownloadProvider": {"description": "Empty state - no download providers"}, + "@extensionsNoDownloadProvider": { + "description": "Empty state - no download providers" + }, "extensionsMetadataPriority": "Metadata Priority", - "@extensionsMetadataPriority": {"description": "Setting - metadata provider order"}, + "@extensionsMetadataPriority": { + "description": "Setting - metadata provider order" + }, "extensionsMetadataPrioritySubtitle": "Set search & metadata source order", - "@extensionsMetadataPrioritySubtitle": {"description": "Subtitle for metadata priority"}, + "@extensionsMetadataPrioritySubtitle": { + "description": "Subtitle for metadata priority" + }, "extensionsNoMetadataProvider": "No extensions with metadata provider", - "@extensionsNoMetadataProvider": {"description": "Empty state - no metadata providers"}, + "@extensionsNoMetadataProvider": { + "description": "Empty state - no metadata providers" + }, "extensionsSearchProvider": "Search Provider", - "@extensionsSearchProvider": {"description": "Setting - search provider selection"}, + "@extensionsSearchProvider": { + "description": "Setting - search provider selection" + }, "extensionsNoCustomSearch": "No extensions with custom search", - "@extensionsNoCustomSearch": {"description": "Empty state - no search providers"}, + "@extensionsNoCustomSearch": { + "description": "Empty state - no search providers" + }, "extensionsSearchProviderDescription": "Choose which service to use for searching tracks", - "@extensionsSearchProviderDescription": {"description": "Search provider setting description"}, + "@extensionsSearchProviderDescription": { + "description": "Search provider setting description" + }, "extensionsCustomSearch": "Custom search", - "@extensionsCustomSearch": {"description": "Label for custom search provider"}, + "@extensionsCustomSearch": { + "description": "Label for custom search provider" + }, "extensionsErrorLoading": "Error loading extension", - "@extensionsErrorLoading": {"description": "Error message when extension fails to load"}, - + "@extensionsErrorLoading": { + "description": "Error message when extension fails to load" + }, "qualityFlacLossless": "FLAC Lossless", - "@qualityFlacLossless": {"description": "Quality option - CD quality FLAC"}, + "@qualityFlacLossless": { + "description": "Quality option - CD quality FLAC" + }, "qualityFlacLosslessSubtitle": "16-bit / 44.1kHz", - "@qualityFlacLosslessSubtitle": {"description": "Technical spec for lossless"}, + "@qualityFlacLosslessSubtitle": { + "description": "Technical spec for lossless" + }, "qualityHiResFlac": "Hi-Res FLAC", - "@qualityHiResFlac": {"description": "Quality option - high resolution FLAC"}, + "@qualityHiResFlac": { + "description": "Quality option - high resolution FLAC" + }, "qualityHiResFlacSubtitle": "24-bit / up to 96kHz", - "@qualityHiResFlacSubtitle": {"description": "Technical spec for hi-res"}, + "@qualityHiResFlacSubtitle": { + "description": "Technical spec for hi-res" + }, "qualityHiResFlacMax": "Hi-Res FLAC Max", - "@qualityHiResFlacMax": {"description": "Quality option - maximum resolution FLAC"}, + "@qualityHiResFlacMax": { + "description": "Quality option - maximum resolution FLAC" + }, "qualityHiResFlacMaxSubtitle": "24-bit / up to 192kHz", - "@qualityHiResFlacMaxSubtitle": {"description": "Technical spec for hi-res max"}, - "qualityLossy": "Lossy", - "@qualityLossy": {"description": "Quality option - lossy format (MP3/Opus)"}, - "qualityLossyMp3Subtitle": "MP3 320kbps (converted from FLAC)", - "@qualityLossyMp3Subtitle": {"description": "Technical spec for lossy MP3"}, - "qualityLossyOpusSubtitle": "Opus 128kbps (converted from FLAC)", - "@qualityLossyOpusSubtitle": {"description": "Technical spec for lossy Opus"}, - "enableLossyOption": "Enable Lossy Option", - "@enableLossyOption": {"description": "Setting - enable lossy quality option"}, - "enableLossyOptionSubtitleOn": "Lossy quality option is available", - "@enableLossyOptionSubtitleOn": {"description": "Subtitle when lossy is enabled"}, - "enableLossyOptionSubtitleOff": "Downloads FLAC then converts to lossy format", - "@enableLossyOptionSubtitleOff": {"description": "Subtitle when lossy is disabled"}, - "lossyFormat": "Lossy Format", - "@lossyFormat": {"description": "Setting - choose lossy format"}, - "lossyFormatDescription": "Choose the lossy format for conversion", - "@lossyFormatDescription": {"description": "Description for lossy format picker"}, - "lossyFormatMp3Subtitle": "320kbps, best compatibility", - "@lossyFormatMp3Subtitle": {"description": "MP3 format description"}, - "lossyFormatOpusSubtitle": "128kbps, better quality at smaller size", - "@lossyFormatOpusSubtitle": {"description": "Opus format description"}, + "@qualityHiResFlacMaxSubtitle": { + "description": "Technical spec for hi-res max" + }, "qualityNote": "Actual quality depends on track availability from the service", - "@qualityNote": {"description": "Note about quality availability"}, + "@qualityNote": { + "description": "Note about quality availability" + }, "youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.", - "@youtubeQualityNote": {"description": "Note for YouTube service explaining lossy-only quality"}, - + "@youtubeQualityNote": { + "description": "Note for YouTube service explaining lossy-only quality" + }, + "youtubeOpusBitrateTitle": "YouTube Opus Bitrate", + "@youtubeOpusBitrateTitle": { + "description": "Title for YouTube Opus bitrate setting" + }, + "youtubeMp3BitrateTitle": "YouTube MP3 Bitrate", + "@youtubeMp3BitrateTitle": { + "description": "Title for YouTube MP3 bitrate setting" + }, "downloadAskBeforeDownload": "Ask Before Download", - "@downloadAskBeforeDownload": {"description": "Setting - show quality picker"}, + "@downloadAskBeforeDownload": { + "description": "Setting - show quality picker" + }, "downloadDirectory": "Download Directory", - "@downloadDirectory": {"description": "Setting - download folder"}, + "@downloadDirectory": { + "description": "Setting - download folder" + }, "downloadSeparateSinglesFolder": "Separate Singles Folder", - "@downloadSeparateSinglesFolder": {"description": "Setting - separate folder for singles"}, + "@downloadSeparateSinglesFolder": { + "description": "Setting - separate folder for singles" + }, "downloadAlbumFolderStructure": "Album Folder Structure", - "@downloadAlbumFolderStructure": {"description": "Setting - album folder organization"}, + "@downloadAlbumFolderStructure": { + "description": "Setting - album folder organization" + }, "downloadUseAlbumArtistForFolders": "Use Album Artist for folders", - "@downloadUseAlbumArtistForFolders": {"description": "Setting - choose whether artist folders use Album Artist or Track Artist"}, - "downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available", - "@downloadUseAlbumArtistForFoldersAlbumSubtitle": {"description": "Subtitle when Album Artist is used for folder naming"}, - "downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only", - "@downloadUseAlbumArtistForFoldersTrackSubtitle": {"description": "Subtitle when Track Artist is used for folder naming"}, + "@downloadUseAlbumArtistForFolders": { + "description": "Setting - choose whether artist folders use Album Artist or Track Artist" + }, "downloadUsePrimaryArtistOnly": "Primary artist only for folders", - "@downloadUsePrimaryArtistOnly": {"description": "Setting - strip featured artists from folder name"}, + "@downloadUsePrimaryArtistOnly": { + "description": "Setting - strip featured artists from folder name" + }, "downloadUsePrimaryArtistOnlyEnabled": "Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)", - "@downloadUsePrimaryArtistOnlyEnabled": {"description": "Subtitle when primary artist only is enabled"}, + "@downloadUsePrimaryArtistOnlyEnabled": { + "description": "Subtitle when primary artist only is enabled" + }, "downloadUsePrimaryArtistOnlyDisabled": "Full artist string used for folder name", - "@downloadUsePrimaryArtistOnlyDisabled": {"description": "Subtitle when primary artist only is disabled"}, - "downloadSaveFormat": "Save Format", - "@downloadSaveFormat": {"description": "Setting - output file format"}, - "downloadSelectService": "Select Service", - "@downloadSelectService": {"description": "Dialog title - choose download service"}, + "@downloadUsePrimaryArtistOnlyDisabled": { + "description": "Subtitle when primary artist only is disabled" + }, "downloadSelectQuality": "Select Quality", - "@downloadSelectQuality": {"description": "Dialog title - choose audio quality"}, + "@downloadSelectQuality": { + "description": "Dialog title - choose audio quality" + }, "downloadFrom": "Download From", - "@downloadFrom": {"description": "Label - download source"}, - "downloadDefaultQualityLabel": "Default Quality", - "@downloadDefaultQualityLabel": {"description": "Label - default quality setting"}, - "downloadBestAvailable": "Best available", - "@downloadBestAvailable": {"description": "Quality option - highest available"}, - - "folderNone": "None", - "@folderNone": {"description": "Folder option - no organization"}, - "folderNoneSubtitle": "Save all files directly to download folder", - "@folderNoneSubtitle": {"description": "Subtitle for no folder organization"}, - "folderArtist": "Artist", - "@folderArtist": {"description": "Folder option - by artist"}, - "folderArtistSubtitle": "Artist Name/filename", - "@folderArtistSubtitle": {"description": "Folder structure example"}, - "folderAlbum": "Album", - "@folderAlbum": {"description": "Folder option - by album"}, - "folderAlbumSubtitle": "Album Name/filename", - "@folderAlbumSubtitle": {"description": "Folder structure example"}, - "folderArtistAlbum": "Artist/Album", - "@folderArtistAlbum": {"description": "Folder option - nested"}, - "folderArtistAlbumSubtitle": "Artist Name/Album Name/filename", - "@folderArtistAlbumSubtitle": {"description": "Folder structure example"}, - - "serviceTidal": "Tidal", - "@serviceTidal": {"description": "Service name - DO NOT TRANSLATE"}, - "serviceQobuz": "Qobuz", - "@serviceQobuz": {"description": "Service name - DO NOT TRANSLATE"}, - "serviceAmazon": "Amazon", - "@serviceAmazon": {"description": "Service name - DO NOT TRANSLATE"}, - "serviceDeezer": "Deezer", - "@serviceDeezer": {"description": "Service name - DO NOT TRANSLATE"}, - "serviceSpotify": "Spotify", - "@serviceSpotify": {"description": "Service name - DO NOT TRANSLATE"}, - + "@downloadFrom": { + "description": "Label - download source" + }, "appearanceAmoledDark": "AMOLED Dark", - "@appearanceAmoledDark": {"description": "Theme option - pure black"}, + "@appearanceAmoledDark": { + "description": "Theme option - pure black" + }, "appearanceAmoledDarkSubtitle": "Pure black background", - "@appearanceAmoledDarkSubtitle": {"description": "Subtitle for AMOLED dark"}, - "appearanceChooseAccentColor": "Choose Accent Color", - "@appearanceChooseAccentColor": {"description": "Color picker dialog title"}, - "appearanceChooseTheme": "Theme Mode", - "@appearanceChooseTheme": {"description": "Theme picker dialog title"}, - - "queueTitle": "Download Queue", - "@queueTitle": {"description": "Queue screen title"}, -"queueClearAll": "Clear All", - "@queueClearAll": {"description": "Button - clear all queue items"}, + "@appearanceAmoledDarkSubtitle": { + "description": "Subtitle for AMOLED dark" + }, + "queueClearAll": "Clear All", + "@queueClearAll": { + "description": "Button - clear all queue items" + }, "queueClearAllMessage": "Are you sure you want to clear all downloads?", - "@queueClearAllMessage": {"description": "Clear queue confirmation"}, - "queueExportFailed": "Export", - "@queueExportFailed": {"description": "Button - export failed downloads to TXT"}, - "queueExportFailedSuccess": "Failed downloads exported to TXT file", - "@queueExportFailedSuccess": {"description": "Success message after exporting failed downloads"}, - "queueExportFailedClear": "Clear Failed", - "@queueExportFailedClear": {"description": "Action to clear failed downloads after export"}, - "queueExportFailedError": "Failed to export downloads", - "@queueExportFailedError": {"description": "Error message when export fails"}, + "@queueClearAllMessage": { + "description": "Clear queue confirmation" + }, "settingsAutoExportFailed": "Auto-export failed downloads", - "@settingsAutoExportFailed": {"description": "Setting toggle for auto-export"}, + "@settingsAutoExportFailed": { + "description": "Setting toggle for auto-export" + }, "settingsAutoExportFailedSubtitle": "Save failed downloads to TXT file automatically", - "@settingsAutoExportFailedSubtitle": {"description": "Subtitle for auto-export setting"}, - + "@settingsAutoExportFailedSubtitle": { + "description": "Subtitle for auto-export setting" + }, "settingsDownloadNetwork": "Download Network", - "@settingsDownloadNetwork": {"description": "Setting for network type preference"}, + "@settingsDownloadNetwork": { + "description": "Setting for network type preference" + }, "settingsDownloadNetworkAny": "WiFi + Mobile Data", - "@settingsDownloadNetworkAny": {"description": "Network option - use any connection"}, + "@settingsDownloadNetworkAny": { + "description": "Network option - use any connection" + }, "settingsDownloadNetworkWifiOnly": "WiFi Only", - "@settingsDownloadNetworkWifiOnly": {"description": "Network option - only use WiFi"}, + "@settingsDownloadNetworkWifiOnly": { + "description": "Network option - only use WiFi" + }, "settingsDownloadNetworkSubtitle": "Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.", - "@settingsDownloadNetworkSubtitle": {"description": "Subtitle explaining network preference"}, - - "queueEmpty": "No downloads in queue", - "@queueEmpty": {"description": "Empty queue state title"}, - "queueEmptySubtitle": "Add tracks from the home screen", - "@queueEmptySubtitle": {"description": "Empty queue state subtitle"}, - "queueClearCompleted": "Clear completed", - "@queueClearCompleted": {"description": "Button - clear finished downloads"}, - "queueDownloadFailed": "Download Failed", - "@queueDownloadFailed": {"description": "Error dialog title"}, - "queueTrackLabel": "Track:", - "@queueTrackLabel": {"description": "Label in error dialog"}, - "queueArtistLabel": "Artist:", - "@queueArtistLabel": {"description": "Label in error dialog"}, - "queueErrorLabel": "Error:", - "@queueErrorLabel": {"description": "Label in error dialog"}, - "queueUnknownError": "Unknown error", - "@queueUnknownError": {"description": "Fallback error message"}, - + "@settingsDownloadNetworkSubtitle": { + "description": "Subtitle explaining network preference" + }, "albumFolderArtistAlbum": "Artist / Album", - "@albumFolderArtistAlbum": {"description": "Album folder option"}, + "@albumFolderArtistAlbum": { + "description": "Album folder option" + }, "albumFolderArtistAlbumSubtitle": "Albums/Artist Name/Album Name/", - "@albumFolderArtistAlbumSubtitle": {"description": "Folder structure example"}, + "@albumFolderArtistAlbumSubtitle": { + "description": "Folder structure example" + }, "albumFolderArtistYearAlbum": "Artist / [Year] Album", - "@albumFolderArtistYearAlbum": {"description": "Album folder option with year"}, + "@albumFolderArtistYearAlbum": { + "description": "Album folder option with year" + }, "albumFolderArtistYearAlbumSubtitle": "Albums/Artist Name/[2005] Album Name/", - "@albumFolderArtistYearAlbumSubtitle": {"description": "Folder structure example"}, + "@albumFolderArtistYearAlbumSubtitle": { + "description": "Folder structure example" + }, "albumFolderAlbumOnly": "Album Only", - "@albumFolderAlbumOnly": {"description": "Album folder option"}, + "@albumFolderAlbumOnly": { + "description": "Album folder option" + }, "albumFolderAlbumOnlySubtitle": "Albums/Album Name/", - "@albumFolderAlbumOnlySubtitle": {"description": "Folder structure example"}, + "@albumFolderAlbumOnlySubtitle": { + "description": "Folder structure example" + }, "albumFolderYearAlbum": "[Year] Album", - "@albumFolderYearAlbum": {"description": "Album folder option with year"}, + "@albumFolderYearAlbum": { + "description": "Album folder option with year" + }, "albumFolderYearAlbumSubtitle": "Albums/[2005] Album Name/", - "@albumFolderYearAlbumSubtitle": {"description": "Folder structure example"}, + "@albumFolderYearAlbumSubtitle": { + "description": "Folder structure example" + }, "albumFolderArtistAlbumSingles": "Artist / Album + Singles", - "@albumFolderArtistAlbumSingles": {"description": "Album folder option with singles inside artist"}, + "@albumFolderArtistAlbumSingles": { + "description": "Album folder option with singles inside artist" + }, "albumFolderArtistAlbumSinglesSubtitle": "Artist/Album/ and Artist/Singles/", - "@albumFolderArtistAlbumSinglesSubtitle": {"description": "Folder structure example"}, - + "@albumFolderArtistAlbumSinglesSubtitle": { + "description": "Folder structure example" + }, "downloadedAlbumDeleteSelected": "Delete Selected", - "@downloadedAlbumDeleteSelected": {"description": "Button - delete selected tracks"}, + "@downloadedAlbumDeleteSelected": { + "description": "Button - delete selected tracks" + }, "downloadedAlbumDeleteMessage": "Delete {count} {count, plural, =1{track} other{tracks}} from this album?\n\nThis will also delete the files from storage.", "@downloadedAlbumDeleteMessage": { "description": "Delete confirmation with count", "placeholders": { - "count": {"type": "int"} - } - }, - "downloadedAlbumTracksHeader": "Tracks", - "@downloadedAlbumTracksHeader": {"description": "Section header for tracks"}, - "downloadedAlbumDownloadedCount": "{count} downloaded", - "@downloadedAlbumDownloadedCount": { - "description": "Downloaded tracks count badge", - "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "downloadedAlbumSelectedCount": "{count} selected", "@downloadedAlbumSelectedCount": { "description": "Selection count indicator", "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "downloadedAlbumAllSelected": "All tracks selected", - "@downloadedAlbumAllSelected": {"description": "Status - all items selected"}, + "@downloadedAlbumAllSelected": { + "description": "Status - all items selected" + }, "downloadedAlbumTapToSelect": "Tap tracks to select", - "@downloadedAlbumTapToSelect": {"description": "Selection hint"}, + "@downloadedAlbumTapToSelect": { + "description": "Selection hint" + }, "downloadedAlbumDeleteCount": "Delete {count} {count, plural, =1{track} other{tracks}}", "@downloadedAlbumDeleteCount": { "description": "Delete button text with count", "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "downloadedAlbumSelectToDelete": "Select tracks to delete", - "@downloadedAlbumSelectToDelete": {"description": "Placeholder when nothing selected"}, + "@downloadedAlbumSelectToDelete": { + "description": "Placeholder when nothing selected" + }, "downloadedAlbumDiscHeader": "Disc {discNumber}", "@downloadedAlbumDiscHeader": { "description": "Header for disc separator in multi-disc albums", "placeholders": { - "discNumber": {"type": "int", "example": "1"} + "discNumber": { + "type": "int", + "example": "1" + } } }, - - "utilityFunctions": "Utility Functions", - "@utilityFunctions": {"description": "Extension capability - utility functions"}, - "recentTypeArtist": "Artist", - "@recentTypeArtist": {"description": "Recent access item type - artist"}, + "@recentTypeArtist": { + "description": "Recent access item type - artist" + }, "recentTypeAlbum": "Album", - "@recentTypeAlbum": {"description": "Recent access item type - album"}, + "@recentTypeAlbum": { + "description": "Recent access item type - album" + }, "recentTypeSong": "Song", - "@recentTypeSong": {"description": "Recent access item type - song/track"}, + "@recentTypeSong": { + "description": "Recent access item type - song/track" + }, "recentTypePlaylist": "Playlist", - "@recentTypePlaylist": {"description": "Recent access item type - playlist"}, + "@recentTypePlaylist": { + "description": "Recent access item type - playlist" + }, "recentEmpty": "No recent items yet", - "@recentEmpty": {"description": "Empty state text for recent access list"}, + "@recentEmpty": { + "description": "Empty state text for recent access list" + }, "recentShowAllDownloads": "Show All Downloads", "@recentShowAllDownloads": { "description": "Button label to unhide hidden downloads in recent access" }, - "recentPlaylistInfo": "Playlist: {name}", "@recentPlaylistInfo": { "description": "Snackbar message when tapping playlist in recent access", "placeholders": { - "name": {"type": "String", "description": "Playlist name"} + "name": { + "type": "String", + "description": "Playlist name" + } } }, - "errorGeneric": "Error: {message}", - "@errorGeneric": { - "description": "Generic error message format", - "placeholders": { - "message": {"type": "String", "description": "Error message"} - } - }, - "discographyDownload": "Download Discography", - "@discographyDownload": {"description": "Button - download artist discography"}, + "@discographyDownload": { + "description": "Button - download artist discography" + }, "discographyDownloadAll": "Download All", - "@discographyDownloadAll": {"description": "Option - download entire discography"}, + "@discographyDownloadAll": { + "description": "Option - download entire discography" + }, "discographyDownloadAllSubtitle": "{count} tracks from {albumCount} releases", "@discographyDownloadAllSubtitle": { "description": "Subtitle showing total tracks and albums", "placeholders": { - "count": {"type": "int"}, - "albumCount": {"type": "int"} + "count": { + "type": "int" + }, + "albumCount": { + "type": "int" + } } }, "discographyAlbumsOnly": "Albums Only", - "@discographyAlbumsOnly": {"description": "Option - download only albums"}, + "@discographyAlbumsOnly": { + "description": "Option - download only albums" + }, "discographyAlbumsOnlySubtitle": "{count} tracks from {albumCount} albums", "@discographyAlbumsOnlySubtitle": { "description": "Subtitle showing album tracks count", "placeholders": { - "count": {"type": "int"}, - "albumCount": {"type": "int"} + "count": { + "type": "int" + }, + "albumCount": { + "type": "int" + } } }, "discographySinglesOnly": "Singles & EPs Only", - "@discographySinglesOnly": {"description": "Option - download only singles"}, + "@discographySinglesOnly": { + "description": "Option - download only singles" + }, "discographySinglesOnlySubtitle": "{count} tracks from {albumCount} singles", "@discographySinglesOnlySubtitle": { "description": "Subtitle showing singles tracks count", "placeholders": { - "count": {"type": "int"}, - "albumCount": {"type": "int"} + "count": { + "type": "int" + }, + "albumCount": { + "type": "int" + } } }, "discographySelectAlbums": "Select Albums...", - "@discographySelectAlbums": {"description": "Option - manually select albums to download"}, + "@discographySelectAlbums": { + "description": "Option - manually select albums to download" + }, "discographySelectAlbumsSubtitle": "Choose specific albums or singles", - "@discographySelectAlbumsSubtitle": {"description": "Subtitle for select albums option"}, + "@discographySelectAlbumsSubtitle": { + "description": "Subtitle for select albums option" + }, "discographyFetchingTracks": "Fetching tracks...", - "@discographyFetchingTracks": {"description": "Progress - fetching album tracks"}, + "@discographyFetchingTracks": { + "description": "Progress - fetching album tracks" + }, "discographyFetchingAlbum": "Fetching {current} of {total}...", "@discographyFetchingAlbum": { "description": "Progress - fetching specific album", "placeholders": { - "current": {"type": "int"}, - "total": {"type": "int"} + "current": { + "type": "int" + }, + "total": { + "type": "int" + } } }, "discographySelectedCount": "{count} selected", "@discographySelectedCount": { "description": "Selection count badge", "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "discographyDownloadSelected": "Download Selected", - "@discographyDownloadSelected": {"description": "Button - download selected albums"}, + "@discographyDownloadSelected": { + "description": "Button - download selected albums" + }, "discographyAddedToQueue": "Added {count} tracks to queue", "@discographyAddedToQueue": { "description": "Snackbar - tracks added from discography", "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "discographySkippedDownloaded": "{added} added, {skipped} already downloaded", "@discographySkippedDownloaded": { "description": "Snackbar - with skipped tracks count", "placeholders": { - "added": {"type": "int"}, - "skipped": {"type": "int"} + "added": { + "type": "int" + }, + "skipped": { + "type": "int" + } } }, "discographyNoAlbums": "No albums available", - "@discographyNoAlbums": {"description": "Error - no albums found for artist"}, + "@discographyNoAlbums": { + "description": "Error - no albums found for artist" + }, "discographyFailedToFetch": "Failed to fetch some albums", - "@discographyFailedToFetch": {"description": "Error - some albums failed to load"}, - + "@discographyFailedToFetch": { + "description": "Error - some albums failed to load" + }, "sectionStorageAccess": "Storage Access", - "@sectionStorageAccess": {"description": "Section header for storage access settings"}, + "@sectionStorageAccess": { + "description": "Section header for storage access settings" + }, "allFilesAccess": "All Files Access", - "@allFilesAccess": {"description": "Toggle for MANAGE_EXTERNAL_STORAGE permission"}, + "@allFilesAccess": { + "description": "Toggle for MANAGE_EXTERNAL_STORAGE permission" + }, "allFilesAccessEnabledSubtitle": "Can write to any folder", - "@allFilesAccessEnabledSubtitle": {"description": "Subtitle when all files access is enabled"}, + "@allFilesAccessEnabledSubtitle": { + "description": "Subtitle when all files access is enabled" + }, "allFilesAccessDisabledSubtitle": "Limited to media folders only", - "@allFilesAccessDisabledSubtitle": {"description": "Subtitle when all files access is disabled"}, + "@allFilesAccessDisabledSubtitle": { + "description": "Subtitle when all files access is disabled" + }, "allFilesAccessDescription": "Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.", - "@allFilesAccessDescription": {"description": "Description explaining when to enable all files access"}, + "@allFilesAccessDescription": { + "description": "Description explaining when to enable all files access" + }, "allFilesAccessDeniedMessage": "Permission was denied. Please enable 'All files access' manually in system settings.", - "@allFilesAccessDeniedMessage": {"description": "Message when permission is permanently denied"}, + "@allFilesAccessDeniedMessage": { + "description": "Message when permission is permanently denied" + }, "allFilesAccessDisabledMessage": "All Files Access disabled. The app will use limited storage access.", - "@allFilesAccessDisabledMessage": {"description": "Snackbar message when user disables all files access"}, - + "@allFilesAccessDisabledMessage": { + "description": "Snackbar message when user disables all files access" + }, "settingsLocalLibrary": "Local Library", - "@settingsLocalLibrary": {"description": "Settings menu item - local library"}, + "@settingsLocalLibrary": { + "description": "Settings menu item - local library" + }, "settingsLocalLibrarySubtitle": "Scan music & detect duplicates", - "@settingsLocalLibrarySubtitle": {"description": "Subtitle for local library settings"}, + "@settingsLocalLibrarySubtitle": { + "description": "Subtitle for local library settings" + }, "settingsCache": "Storage & Cache", - "@settingsCache": {"description": "Settings menu item - cache management"}, + "@settingsCache": { + "description": "Settings menu item - cache management" + }, "settingsCacheSubtitle": "View size and clear cached data", - "@settingsCacheSubtitle": {"description": "Subtitle for cache management menu"}, + "@settingsCacheSubtitle": { + "description": "Subtitle for cache management menu" + }, "libraryTitle": "Local Library", - "@libraryTitle": {"description": "Library settings page title"}, - "libraryStatus": "Library Status", - "@libraryStatus": {"description": "Section header for library status"}, + "@libraryTitle": { + "description": "Library settings page title" + }, "libraryScanSettings": "Scan Settings", - "@libraryScanSettings": {"description": "Section header for scan settings"}, + "@libraryScanSettings": { + "description": "Section header for scan settings" + }, "libraryEnableLocalLibrary": "Enable Local Library", - "@libraryEnableLocalLibrary": {"description": "Toggle to enable library scanning"}, + "@libraryEnableLocalLibrary": { + "description": "Toggle to enable library scanning" + }, "libraryEnableLocalLibrarySubtitle": "Scan and track your existing music", - "@libraryEnableLocalLibrarySubtitle": {"description": "Subtitle for enable toggle"}, + "@libraryEnableLocalLibrarySubtitle": { + "description": "Subtitle for enable toggle" + }, "libraryFolder": "Library Folder", - "@libraryFolder": {"description": "Folder selection setting"}, + "@libraryFolder": { + "description": "Folder selection setting" + }, "libraryFolderHint": "Tap to select folder", - "@libraryFolderHint": {"description": "Placeholder when no folder selected"}, + "@libraryFolderHint": { + "description": "Placeholder when no folder selected" + }, "libraryShowDuplicateIndicator": "Show Duplicate Indicator", - "@libraryShowDuplicateIndicator": {"description": "Toggle for duplicate indicator in search"}, + "@libraryShowDuplicateIndicator": { + "description": "Toggle for duplicate indicator in search" + }, "libraryShowDuplicateIndicatorSubtitle": "Show when searching for existing tracks", - "@libraryShowDuplicateIndicatorSubtitle": {"description": "Subtitle for duplicate indicator toggle"}, + "@libraryShowDuplicateIndicatorSubtitle": { + "description": "Subtitle for duplicate indicator toggle" + }, "libraryActions": "Actions", - "@libraryActions": {"description": "Section header for library actions"}, + "@libraryActions": { + "description": "Section header for library actions" + }, "libraryScan": "Scan Library", - "@libraryScan": {"description": "Button to start library scan"}, + "@libraryScan": { + "description": "Button to start library scan" + }, "libraryScanSubtitle": "Scan for audio files", - "@libraryScanSubtitle": {"description": "Subtitle for scan button"}, + "@libraryScanSubtitle": { + "description": "Subtitle for scan button" + }, "libraryScanSelectFolderFirst": "Select a folder first", - "@libraryScanSelectFolderFirst": {"description": "Message when trying to scan without folder"}, + "@libraryScanSelectFolderFirst": { + "description": "Message when trying to scan without folder" + }, "libraryCleanupMissingFiles": "Cleanup Missing Files", - "@libraryCleanupMissingFiles": {"description": "Button to remove entries for missing files"}, + "@libraryCleanupMissingFiles": { + "description": "Button to remove entries for missing files" + }, "libraryCleanupMissingFilesSubtitle": "Remove entries for files that no longer exist", - "@libraryCleanupMissingFilesSubtitle": {"description": "Subtitle for cleanup button"}, + "@libraryCleanupMissingFilesSubtitle": { + "description": "Subtitle for cleanup button" + }, "libraryClear": "Clear Library", - "@libraryClear": {"description": "Button to clear all library entries"}, + "@libraryClear": { + "description": "Button to clear all library entries" + }, "libraryClearSubtitle": "Remove all scanned tracks", - "@libraryClearSubtitle": {"description": "Subtitle for clear button"}, + "@libraryClearSubtitle": { + "description": "Subtitle for clear button" + }, "libraryClearConfirmTitle": "Clear Library", - "@libraryClearConfirmTitle": {"description": "Dialog title for clear confirmation"}, + "@libraryClearConfirmTitle": { + "description": "Dialog title for clear confirmation" + }, "libraryClearConfirmMessage": "This will remove all scanned tracks from your library. Your actual music files will not be deleted.", - "@libraryClearConfirmMessage": {"description": "Dialog message for clear confirmation"}, + "@libraryClearConfirmMessage": { + "description": "Dialog message for clear confirmation" + }, "libraryAbout": "About Local Library", - "@libraryAbout": {"description": "Section header for about info"}, + "@libraryAbout": { + "description": "Section header for about info" + }, "libraryAboutDescription": "Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.", - "@libraryAboutDescription": {"description": "Description of local library feature"}, - "libraryTracksCount": "{count} tracks", - "@libraryTracksCount": { - "description": "Track count in library", + "@libraryAboutDescription": { + "description": "Description of local library feature" + }, + "libraryTracksUnit": "{count, plural, =1{track} other{tracks}}", + "@libraryTracksUnit": { + "description": "Unit label for tracks count (without the number itself)", "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "libraryLastScanned": "Last scanned: {time}", "@libraryLastScanned": { "description": "Last scan time display", "placeholders": { - "time": {"type": "String"} + "time": { + "type": "String" + } } }, "libraryLastScannedNever": "Never", - "@libraryLastScannedNever": {"description": "Shown when library has never been scanned"}, + "@libraryLastScannedNever": { + "description": "Shown when library has never been scanned" + }, "libraryScanning": "Scanning...", - "@libraryScanning": {"description": "Status during scan"}, + "@libraryScanning": { + "description": "Status during scan" + }, "libraryScanProgress": "{progress}% of {total} files", "@libraryScanProgress": { "description": "Scan progress display", "placeholders": { - "progress": {"type": "String"}, - "total": {"type": "int"} + "progress": { + "type": "String" + }, + "total": { + "type": "int" + } } }, "libraryInLibrary": "In Library", - "@libraryInLibrary": {"description": "Badge shown on tracks that exist in local library"}, + "@libraryInLibrary": { + "description": "Badge shown on tracks that exist in local library" + }, "libraryRemovedMissingFiles": "Removed {count} missing files from library", "@libraryRemovedMissingFiles": { "description": "Snackbar after cleanup", "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "libraryCleared": "Library cleared", - "@libraryCleared": {"description": "Snackbar after clearing library"}, - "libraryStorageAccessRequired": "Storage Access Required", - "@libraryStorageAccessRequired": {"description": "Dialog title for storage permission"}, - "libraryStorageAccessMessage": "SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.", - "@libraryStorageAccessMessage": {"description": "Dialog message for storage permission"}, - "libraryFolderNotExist": "Selected folder does not exist", - "@libraryFolderNotExist": {"description": "Error when folder doesn't exist"}, - "librarySourceDownloaded": "Downloaded", - "@librarySourceDownloaded": {"description": "Badge for tracks downloaded via SpotiFLAC"}, - "librarySourceLocal": "Local", - "@librarySourceLocal": {"description": "Badge for tracks from local library scan"}, - "libraryFilterAll": "All", - "@libraryFilterAll": {"description": "Filter chip - show all library items"}, - "libraryFilterDownloaded": "Downloaded", - "@libraryFilterDownloaded": {"description": "Filter chip - show only downloaded items"}, - "libraryFilterLocal": "Local", - "@libraryFilterLocal": {"description": "Filter chip - show only local library items"}, - - "libraryFilterTitle": "Filters", - "@libraryFilterTitle": {"description": "Filter bottom sheet title"}, - "libraryFilterReset": "Reset", - "@libraryFilterReset": {"description": "Reset all filters button"}, - "libraryFilterApply": "Apply", - "@libraryFilterApply": {"description": "Apply filters button"}, - "libraryFilterSource": "Source", - "@libraryFilterSource": {"description": "Filter section - source type"}, - "libraryFilterQuality": "Quality", - "@libraryFilterQuality": {"description": "Filter section - audio quality"}, - "libraryFilterQualityHiRes": "Hi-Res (24bit)", - "@libraryFilterQualityHiRes": {"description": "Filter option - high resolution audio"}, - "libraryFilterQualityCD": "CD (16bit)", - "@libraryFilterQualityCD": {"description": "Filter option - CD quality audio"}, - "libraryFilterQualityLossy": "Lossy", - "@libraryFilterQualityLossy": {"description": "Filter option - lossy compressed audio"}, - "libraryFilterFormat": "Format", - "@libraryFilterFormat": {"description": "Filter section - file format"}, - "libraryFilterDate": "Date Added", - "@libraryFilterDate": {"description": "Filter section - date range"}, - "libraryFilterDateToday": "Today", - "@libraryFilterDateToday": {"description": "Filter option - today only"}, - "libraryFilterDateWeek": "This Week", - "@libraryFilterDateWeek": {"description": "Filter option - this week"}, - "libraryFilterDateMonth": "This Month", - "@libraryFilterDateMonth": {"description": "Filter option - this month"}, - "libraryFilterDateYear": "This Year", - "@libraryFilterDateYear": {"description": "Filter option - this year"}, - "libraryFilterSort": "Sort", - "@libraryFilterSort": {"description": "Filter section - sort order"}, - "libraryFilterSortLatest": "Latest", - "@libraryFilterSortLatest": {"description": "Sort option - newest first"}, - "libraryFilterSortOldest": "Oldest", - "@libraryFilterSortOldest": {"description": "Sort option - oldest first"}, - "libraryFilterActive": "{count} filter(s) active", - "@libraryFilterActive": { - "description": "Badge showing number of active filters", - "placeholders": { - "count": {"type": "int"} - } + "@libraryCleared": { + "description": "Snackbar after clearing library" + }, + "libraryStorageAccessRequired": "Storage Access Required", + "@libraryStorageAccessRequired": { + "description": "Dialog title for storage permission" + }, + "libraryStorageAccessMessage": "SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.", + "@libraryStorageAccessMessage": { + "description": "Dialog message for storage permission" + }, + "libraryFolderNotExist": "Selected folder does not exist", + "@libraryFolderNotExist": { + "description": "Error when folder doesn't exist" + }, + "librarySourceDownloaded": "Downloaded", + "@librarySourceDownloaded": { + "description": "Badge for tracks downloaded via SpotiFLAC" + }, + "librarySourceLocal": "Local", + "@librarySourceLocal": { + "description": "Badge for tracks from local library scan" + }, + "libraryFilterAll": "All", + "@libraryFilterAll": { + "description": "Filter chip - show all library items" + }, + "libraryFilterDownloaded": "Downloaded", + "@libraryFilterDownloaded": { + "description": "Filter chip - show only downloaded items" + }, + "libraryFilterLocal": "Local", + "@libraryFilterLocal": { + "description": "Filter chip - show only local library items" + }, + "libraryFilterTitle": "Filters", + "@libraryFilterTitle": { + "description": "Filter bottom sheet title" + }, + "libraryFilterReset": "Reset", + "@libraryFilterReset": { + "description": "Reset all filters button" + }, + "libraryFilterApply": "Apply", + "@libraryFilterApply": { + "description": "Apply filters button" + }, + "libraryFilterSource": "Source", + "@libraryFilterSource": { + "description": "Filter section - source type" + }, + "libraryFilterQuality": "Quality", + "@libraryFilterQuality": { + "description": "Filter section - audio quality" + }, + "libraryFilterQualityHiRes": "Hi-Res (24bit)", + "@libraryFilterQualityHiRes": { + "description": "Filter option - high resolution audio" + }, + "libraryFilterQualityCD": "CD (16bit)", + "@libraryFilterQualityCD": { + "description": "Filter option - CD quality audio" + }, + "libraryFilterQualityLossy": "Lossy", + "@libraryFilterQualityLossy": { + "description": "Filter option - lossy compressed audio" + }, + "libraryFilterFormat": "Format", + "@libraryFilterFormat": { + "description": "Filter section - file format" + }, + "libraryFilterSort": "Sort", + "@libraryFilterSort": { + "description": "Filter section - sort order" + }, + "libraryFilterSortLatest": "Latest", + "@libraryFilterSortLatest": { + "description": "Sort option - newest first" + }, + "libraryFilterSortOldest": "Oldest", + "@libraryFilterSortOldest": { + "description": "Sort option - oldest first" }, - "timeJustNow": "Just now", - "@timeJustNow": {"description": "Relative time - less than a minute ago"}, + "@timeJustNow": { + "description": "Relative time - less than a minute ago" + }, "timeMinutesAgo": "{count, plural, =1{1 minute ago} other{{count} minutes ago}}", "@timeMinutesAgo": { "description": "Relative time - minutes ago", "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "timeHoursAgo": "{count, plural, =1{1 hour ago} other{{count} hours ago}}", "@timeHoursAgo": { "description": "Relative time - hours ago", "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, - - "storageSwitchTitle": "Switch Storage Mode", - "@storageSwitchTitle": {"description": "Dialog title when switching storage mode"}, - "storageSwitchToSafTitle": "Switch to SAF Storage?", - "@storageSwitchToSafTitle": {"description": "Dialog title when switching to SAF"}, - "storageSwitchToAppTitle": "Switch to App Storage?", - "@storageSwitchToAppTitle": {"description": "Dialog title when switching to app storage"}, - "storageSwitchToSafMessage": "Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.", - "@storageSwitchToSafMessage": {"description": "Explanation when switching to SAF"}, - "storageSwitchToAppMessage": "Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.", - "@storageSwitchToAppMessage": {"description": "Explanation when switching to app storage"}, - "storageSwitchExistingDownloads": "Existing Downloads", - "@storageSwitchExistingDownloads": {"description": "Section header for existing downloads info"}, - "storageSwitchExistingDownloadsInfo": "{count} tracks in {mode} storage", - "@storageSwitchExistingDownloadsInfo": { - "description": "Info about existing downloads count", - "placeholders": { - "count": {"type": "int"}, - "mode": {"type": "String"} - } - }, - "storageSwitchNewDownloads": "New Downloads", - "@storageSwitchNewDownloads": {"description": "Section header for new downloads info"}, - "storageSwitchNewDownloadsLocation": "Will be saved to: {location}", - "@storageSwitchNewDownloadsLocation": { - "description": "Shows where new downloads will go", - "placeholders": { - "location": {"type": "String"} - } - }, - "storageSwitchContinue": "Continue", - "@storageSwitchContinue": {"description": "Button to proceed with storage switch"}, - "storageSwitchSelectFolder": "Select SAF Folder", - "@storageSwitchSelectFolder": {"description": "Button to select SAF folder"}, - "storageAppStorage": "App Storage", - "@storageAppStorage": {"description": "Label for app storage mode"}, - "storageSafStorage": "SAF Storage", - "@storageSafStorage": {"description": "Label for SAF storage mode"}, - "storageModeBadge": "Storage: {mode}", - "@storageModeBadge": { - "description": "Badge showing storage mode for a track", - "placeholders": { - "mode": {"type": "String"} - } - }, - "storageStatsTitle": "Storage Statistics", - "@storageStatsTitle": {"description": "Section title for storage stats"}, - "storageStatsAppCount": "{count} tracks in App Storage", - "@storageStatsAppCount": { - "description": "Count of tracks in app storage", - "placeholders": { - "count": {"type": "int"} - } - }, - "storageStatsSafCount": "{count} tracks in SAF Storage", - "@storageStatsSafCount": { - "description": "Count of tracks in SAF storage", - "placeholders": { - "count": {"type": "int"} - } - }, - "storageModeInfo": "Your files are stored in multiple locations", - "@storageModeInfo": {"description": "Info when user has files in both storage modes"}, - "tutorialWelcomeTitle": "Welcome to SpotiFLAC!", - "@tutorialWelcomeTitle": {"description": "Tutorial welcome page title"}, + "@tutorialWelcomeTitle": { + "description": "Tutorial welcome page title" + }, "tutorialWelcomeDesc": "Let's learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.", - "@tutorialWelcomeDesc": {"description": "Tutorial welcome page description"}, + "@tutorialWelcomeDesc": { + "description": "Tutorial welcome page description" + }, "tutorialWelcomeTip1": "Download music from Spotify, Deezer, or paste any supported URL", - "@tutorialWelcomeTip1": {"description": "Tutorial welcome tip 1"}, + "@tutorialWelcomeTip1": { + "description": "Tutorial welcome tip 1" + }, "tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music", - "@tutorialWelcomeTip2": {"description": "Tutorial welcome tip 2"}, + "@tutorialWelcomeTip2": { + "description": "Tutorial welcome tip 2" + }, "tutorialWelcomeTip3": "Automatic metadata, cover art, and lyrics embedding", - "@tutorialWelcomeTip3": {"description": "Tutorial welcome tip 3"}, - + "@tutorialWelcomeTip3": { + "description": "Tutorial welcome tip 3" + }, "tutorialSearchTitle": "Finding Music", - "@tutorialSearchTitle": {"description": "Tutorial search page title"}, + "@tutorialSearchTitle": { + "description": "Tutorial search page title" + }, "tutorialSearchDesc": "There are two easy ways to find music you want to download.", - "@tutorialSearchDesc": {"description": "Tutorial search page description"}, - "tutorialSearchTip1": "Paste a Spotify or Deezer URL directly in the search box", - "@tutorialSearchTip1": {"description": "Tutorial search tip 1"}, - "tutorialSearchTip2": "Or type the song name, artist, or album to search", - "@tutorialSearchTip2": {"description": "Tutorial search tip 2"}, - "tutorialSearchTip3": "Supports tracks, albums, playlists, and artist pages", - "@tutorialSearchTip3": {"description": "Tutorial search tip 3"}, - + "@tutorialSearchDesc": { + "description": "Tutorial search page description" + }, "tutorialDownloadTitle": "Downloading Music", - "@tutorialDownloadTitle": {"description": "Tutorial download page title"}, + "@tutorialDownloadTitle": { + "description": "Tutorial download page title" + }, "tutorialDownloadDesc": "Downloading music is simple and fast. Here's how it works.", - "@tutorialDownloadDesc": {"description": "Tutorial download page description"}, - "tutorialDownloadTip1": "Tap the download button next to any track to start downloading", - "@tutorialDownloadTip1": {"description": "Tutorial download tip 1"}, - "tutorialDownloadTip2": "Choose your preferred quality (FLAC, Hi-Res, or MP3)", - "@tutorialDownloadTip2": {"description": "Tutorial download tip 2"}, - "tutorialDownloadTip3": "Download entire albums or playlists with one tap", - "@tutorialDownloadTip3": {"description": "Tutorial download tip 3"}, - + "@tutorialDownloadDesc": { + "description": "Tutorial download page description" + }, "tutorialLibraryTitle": "Your Library", - "@tutorialLibraryTitle": {"description": "Tutorial library page title"}, + "@tutorialLibraryTitle": { + "description": "Tutorial library page title" + }, "tutorialLibraryDesc": "All your downloaded music is organized in the Library tab.", - "@tutorialLibraryDesc": {"description": "Tutorial library page description"}, + "@tutorialLibraryDesc": { + "description": "Tutorial library page description" + }, "tutorialLibraryTip1": "View download progress and queue in the Library tab", - "@tutorialLibraryTip1": {"description": "Tutorial library tip 1"}, + "@tutorialLibraryTip1": { + "description": "Tutorial library tip 1" + }, "tutorialLibraryTip2": "Tap any track to play it with your music player", - "@tutorialLibraryTip2": {"description": "Tutorial library tip 2"}, + "@tutorialLibraryTip2": { + "description": "Tutorial library tip 2" + }, "tutorialLibraryTip3": "Switch between list and grid view for better browsing", - "@tutorialLibraryTip3": {"description": "Tutorial library tip 3"}, - + "@tutorialLibraryTip3": { + "description": "Tutorial library tip 3" + }, "tutorialExtensionsTitle": "Extensions", - "@tutorialExtensionsTitle": {"description": "Tutorial extensions page title"}, + "@tutorialExtensionsTitle": { + "description": "Tutorial extensions page title" + }, "tutorialExtensionsDesc": "Extend the app's capabilities with community extensions.", - "@tutorialExtensionsDesc": {"description": "Tutorial extensions page description"}, + "@tutorialExtensionsDesc": { + "description": "Tutorial extensions page description" + }, "tutorialExtensionsTip1": "Browse the Store tab to discover useful extensions", - "@tutorialExtensionsTip1": {"description": "Tutorial extensions tip 1"}, + "@tutorialExtensionsTip1": { + "description": "Tutorial extensions tip 1" + }, "tutorialExtensionsTip2": "Add new download providers or search sources", - "@tutorialExtensionsTip2": {"description": "Tutorial extensions tip 2"}, + "@tutorialExtensionsTip2": { + "description": "Tutorial extensions tip 2" + }, "tutorialExtensionsTip3": "Get lyrics, enhanced metadata, and more features", - "@tutorialExtensionsTip3": {"description": "Tutorial extensions tip 3"}, - + "@tutorialExtensionsTip3": { + "description": "Tutorial extensions tip 3" + }, "tutorialSettingsTitle": "Customize Your Experience", - "@tutorialSettingsTitle": {"description": "Tutorial settings page title"}, + "@tutorialSettingsTitle": { + "description": "Tutorial settings page title" + }, "tutorialSettingsDesc": "Personalize the app in Settings to match your preferences.", - "@tutorialSettingsDesc": {"description": "Tutorial settings page description"}, + "@tutorialSettingsDesc": { + "description": "Tutorial settings page description" + }, "tutorialSettingsTip1": "Change download location and folder organization", - "@tutorialSettingsTip1": {"description": "Tutorial settings tip 1"}, + "@tutorialSettingsTip1": { + "description": "Tutorial settings tip 1" + }, "tutorialSettingsTip2": "Set default audio quality and format preferences", - "@tutorialSettingsTip2": {"description": "Tutorial settings tip 2"}, + "@tutorialSettingsTip2": { + "description": "Tutorial settings tip 2" + }, "tutorialSettingsTip3": "Customize app theme and appearance", - "@tutorialSettingsTip3": {"description": "Tutorial settings tip 3"}, - + "@tutorialSettingsTip3": { + "description": "Tutorial settings tip 3" + }, "tutorialReadyMessage": "You're all set! Start downloading your favorite music now.", - "@tutorialReadyMessage": {"description": "Tutorial completion message"}, - "tutorialExample": "EXAMPLE", - "@tutorialExample": {"description": "Example label in tutorial"}, - + "@tutorialReadyMessage": { + "description": "Tutorial completion message" + }, "libraryForceFullScan": "Force Full Scan", - "@libraryForceFullScan": {"description": "Button to force a complete rescan of library"}, + "@libraryForceFullScan": { + "description": "Button to force a complete rescan of library" + }, "libraryForceFullScanSubtitle": "Rescan all files, ignoring cache", - "@libraryForceFullScanSubtitle": {"description": "Subtitle for force full scan button"}, - + "@libraryForceFullScanSubtitle": { + "description": "Subtitle for force full scan button" + }, "cleanupOrphanedDownloads": "Cleanup Orphaned Downloads", - "@cleanupOrphanedDownloads": {"description": "Button to remove history entries for deleted files"}, + "@cleanupOrphanedDownloads": { + "description": "Button to remove history entries for deleted files" + }, "cleanupOrphanedDownloadsSubtitle": "Remove history entries for files that no longer exist", - "@cleanupOrphanedDownloadsSubtitle": {"description": "Subtitle for orphaned cleanup button"}, + "@cleanupOrphanedDownloadsSubtitle": { + "description": "Subtitle for orphaned cleanup button" + }, "cleanupOrphanedDownloadsResult": "Removed {count} orphaned entries from history", "@cleanupOrphanedDownloadsResult": { "description": "Snackbar after orphan cleanup", "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "cleanupOrphanedDownloadsNone": "No orphaned entries found", - "@cleanupOrphanedDownloadsNone": {"description": "Snackbar when no orphans found"}, - + "@cleanupOrphanedDownloadsNone": { + "description": "Snackbar when no orphans found" + }, "cacheTitle": "Storage & Cache", - "@cacheTitle": {"description": "Cache management page title"}, + "@cacheTitle": { + "description": "Cache management page title" + }, "cacheSummaryTitle": "Cache overview", - "@cacheSummaryTitle": {"description": "Heading for cache summary card"}, + "@cacheSummaryTitle": { + "description": "Heading for cache summary card" + }, "cacheSummarySubtitle": "Clearing cache will not remove downloaded music files.", - "@cacheSummarySubtitle": {"description": "Helper text for cache summary card"}, + "@cacheSummarySubtitle": { + "description": "Helper text for cache summary card" + }, "cacheEstimatedTotal": "Estimated cache usage: {size}", "@cacheEstimatedTotal": { "description": "Total cache size shown in summary", "placeholders": { - "size": {"type": "String"} + "size": { + "type": "String" + } } }, "cacheSectionStorage": "Cached Data", - "@cacheSectionStorage": {"description": "Section header for cache entries"}, + "@cacheSectionStorage": { + "description": "Section header for cache entries" + }, "cacheSectionMaintenance": "Maintenance", - "@cacheSectionMaintenance": {"description": "Section header for cleanup actions"}, + "@cacheSectionMaintenance": { + "description": "Section header for cleanup actions" + }, "cacheAppDirectory": "App cache directory", - "@cacheAppDirectory": {"description": "Cache item title for app cache directory"}, + "@cacheAppDirectory": { + "description": "Cache item title for app cache directory" + }, "cacheAppDirectoryDesc": "HTTP responses, WebView data, and other temporary app data.", - "@cacheAppDirectoryDesc": {"description": "Description of what app cache directory contains"}, + "@cacheAppDirectoryDesc": { + "description": "Description of what app cache directory contains" + }, "cacheTempDirectory": "Temporary directory", - "@cacheTempDirectory": {"description": "Cache item title for temporary files directory"}, + "@cacheTempDirectory": { + "description": "Cache item title for temporary files directory" + }, "cacheTempDirectoryDesc": "Temporary files from downloads and audio conversion.", - "@cacheTempDirectoryDesc": {"description": "Description of what temporary directory contains"}, + "@cacheTempDirectoryDesc": { + "description": "Description of what temporary directory contains" + }, "cacheCoverImage": "Cover image cache", - "@cacheCoverImage": {"description": "Cache item title for persistent cover images"}, + "@cacheCoverImage": { + "description": "Cache item title for persistent cover images" + }, "cacheCoverImageDesc": "Downloaded album and track cover art. Will re-download when viewed.", - "@cacheCoverImageDesc": {"description": "Description of what cover image cache contains"}, + "@cacheCoverImageDesc": { + "description": "Description of what cover image cache contains" + }, "cacheLibraryCover": "Library cover cache", - "@cacheLibraryCover": {"description": "Cache item title for local library cover art images"}, + "@cacheLibraryCover": { + "description": "Cache item title for local library cover art images" + }, "cacheLibraryCoverDesc": "Cover art extracted from local music files. Will re-extract on next scan.", - "@cacheLibraryCoverDesc": {"description": "Description of what library cover cache contains"}, + "@cacheLibraryCoverDesc": { + "description": "Description of what library cover cache contains" + }, "cacheExploreFeed": "Explore feed cache", - "@cacheExploreFeed": {"description": "Cache item title for explore home feed cache"}, + "@cacheExploreFeed": { + "description": "Cache item title for explore home feed cache" + }, "cacheExploreFeedDesc": "Explore tab content (new releases, trending). Will refresh on next visit.", - "@cacheExploreFeedDesc": {"description": "Description of what explore feed cache contains"}, + "@cacheExploreFeedDesc": { + "description": "Description of what explore feed cache contains" + }, "cacheTrackLookup": "Track lookup cache", - "@cacheTrackLookup": {"description": "Cache item title for track ID lookup cache"}, + "@cacheTrackLookup": { + "description": "Cache item title for track ID lookup cache" + }, "cacheTrackLookupDesc": "Spotify/Deezer track ID lookups. Clearing may slow next few searches.", - "@cacheTrackLookupDesc": {"description": "Description of what track lookup cache contains"}, + "@cacheTrackLookupDesc": { + "description": "Description of what track lookup cache contains" + }, "cacheCleanupUnusedDesc": "Remove orphaned download history and library entries for missing files.", - "@cacheCleanupUnusedDesc": {"description": "Description of what cleanup unused data does"}, + "@cacheCleanupUnusedDesc": { + "description": "Description of what cleanup unused data does" + }, "cacheNoData": "No cached data", - "@cacheNoData": {"description": "Label when cache category has no data"}, + "@cacheNoData": { + "description": "Label when cache category has no data" + }, "cacheSizeWithFiles": "{size} in {count} files", "@cacheSizeWithFiles": { "description": "Cache size and file count", "placeholders": { - "size": {"type": "String"}, - "count": {"type": "int"} + "size": { + "type": "String" + }, + "count": { + "type": "int" + } } }, "cacheSizeOnly": "{size}", "@cacheSizeOnly": { "description": "Cache size only", "placeholders": { - "size": {"type": "String"} + "size": { + "type": "String" + } } }, "cacheEntries": "{count} entries", "@cacheEntries": { "description": "Track cache entry count", "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "cacheClearSuccess": "Cleared: {target}", "@cacheClearSuccess": { "description": "Snackbar after clearing selected cache", "placeholders": { - "target": {"type": "String"} + "target": { + "type": "String" + } } }, "cacheClearConfirmTitle": "Clear cache?", - "@cacheClearConfirmTitle": {"description": "Dialog title before clearing one cache category"}, + "@cacheClearConfirmTitle": { + "description": "Dialog title before clearing one cache category" + }, "cacheClearConfirmMessage": "This will clear cached data for {target}. Downloaded music files will not be deleted.", "@cacheClearConfirmMessage": { "description": "Dialog message before clearing selected cache", "placeholders": { - "target": {"type": "String"} + "target": { + "type": "String" + } } }, "cacheClearAllConfirmTitle": "Clear all cache?", - "@cacheClearAllConfirmTitle": {"description": "Dialog title before clearing all caches"}, + "@cacheClearAllConfirmTitle": { + "description": "Dialog title before clearing all caches" + }, "cacheClearAllConfirmMessage": "This will clear all cache categories on this page. Downloaded music files will not be deleted.", - "@cacheClearAllConfirmMessage": {"description": "Dialog message before clearing all caches"}, + "@cacheClearAllConfirmMessage": { + "description": "Dialog message before clearing all caches" + }, "cacheClearAll": "Clear all cache", - "@cacheClearAll": {"description": "Button label to clear all caches"}, + "@cacheClearAll": { + "description": "Button label to clear all caches" + }, "cacheCleanupUnused": "Cleanup unused data", - "@cacheCleanupUnused": {"description": "Action title for cleaning unused entries"}, + "@cacheCleanupUnused": { + "description": "Action title for cleaning unused entries" + }, "cacheCleanupUnusedSubtitle": "Remove orphaned download history and missing library entries", - "@cacheCleanupUnusedSubtitle": {"description": "Subtitle for cleanup unused data action"}, + "@cacheCleanupUnusedSubtitle": { + "description": "Subtitle for cleanup unused data action" + }, "cacheCleanupResult": "Cleanup completed: {downloadCount} orphaned downloads, {libraryCount} missing library entries", "@cacheCleanupResult": { "description": "Snackbar after unused data cleanup", "placeholders": { - "downloadCount": {"type": "int"}, - "libraryCount": {"type": "int"} + "downloadCount": { + "type": "int" + }, + "libraryCount": { + "type": "int" + } } }, "cacheRefreshStats": "Refresh stats", - "@cacheRefreshStats": {"description": "Button label to refresh cache statistics"}, - + "@cacheRefreshStats": { + "description": "Button label to refresh cache statistics" + }, "trackSaveCoverArt": "Save Cover Art", - "@trackSaveCoverArt": {"description": "Menu action - save album cover art as file"}, + "@trackSaveCoverArt": { + "description": "Menu action - save album cover art as file" + }, "trackSaveCoverArtSubtitle": "Save album art as .jpg file", - "@trackSaveCoverArtSubtitle": {"description": "Subtitle for save cover art action"}, + "@trackSaveCoverArtSubtitle": { + "description": "Subtitle for save cover art action" + }, "trackSaveLyrics": "Save Lyrics (.lrc)", - "@trackSaveLyrics": {"description": "Menu action - save lyrics as .lrc file"}, + "@trackSaveLyrics": { + "description": "Menu action - save lyrics as .lrc file" + }, "trackSaveLyricsSubtitle": "Fetch and save lyrics as .lrc file", - "@trackSaveLyricsSubtitle": {"description": "Subtitle for save lyrics action"}, + "@trackSaveLyricsSubtitle": { + "description": "Subtitle for save lyrics action" + }, "trackSaveLyricsProgress": "Saving lyrics...", - "@trackSaveLyricsProgress": {"description": "Snackbar while saving lyrics to file"}, - "trackReEnrich": "Re-enrich Metadata", - "@trackReEnrich": {"description": "Menu action - re-embed metadata into audio file"}, - "trackReEnrichSubtitle": "Re-embed metadata without re-downloading", - "@trackReEnrichSubtitle": {"description": "Subtitle for re-enrich metadata action"}, + "@trackSaveLyricsProgress": { + "description": "Snackbar while saving lyrics to file" + }, + "trackReEnrich": "Re-enrich", + "@trackReEnrich": { + "description": "Menu action - re-embed metadata into audio file" + }, "trackReEnrichOnlineSubtitle": "Search metadata online and embed into file", - "@trackReEnrichOnlineSubtitle": {"description": "Subtitle for re-enrich metadata action for local items"}, + "@trackReEnrichOnlineSubtitle": { + "description": "Subtitle for re-enrich metadata action for local items" + }, "trackEditMetadata": "Edit Metadata", - "@trackEditMetadata": {"description": "Menu action - edit embedded metadata"}, + "@trackEditMetadata": { + "description": "Menu action - edit embedded metadata" + }, "trackCoverSaved": "Cover art saved to {fileName}", "@trackCoverSaved": { "description": "Snackbar after cover art saved", "placeholders": { - "fileName": {"type": "String"} + "fileName": { + "type": "String" + } } }, "trackCoverNoSource": "No cover art source available", - "@trackCoverNoSource": {"description": "Snackbar when no cover art URL or embedded cover"}, + "@trackCoverNoSource": { + "description": "Snackbar when no cover art URL or embedded cover" + }, "trackLyricsSaved": "Lyrics saved to {fileName}", "@trackLyricsSaved": { "description": "Snackbar after lyrics saved", "placeholders": { - "fileName": {"type": "String"} + "fileName": { + "type": "String" + } } }, "trackReEnrichProgress": "Re-enriching metadata...", - "@trackReEnrichProgress": {"description": "Snackbar while re-enriching metadata"}, + "@trackReEnrichProgress": { + "description": "Snackbar while re-enriching metadata" + }, "trackReEnrichSearching": "Searching metadata online...", - "@trackReEnrichSearching": {"description": "Snackbar while searching metadata from internet for local items"}, + "@trackReEnrichSearching": { + "description": "Snackbar while searching metadata from internet for local items" + }, "trackReEnrichSuccess": "Metadata re-enriched successfully", - "@trackReEnrichSuccess": {"description": "Snackbar after successful re-enrichment"}, + "@trackReEnrichSuccess": { + "description": "Snackbar after successful re-enrichment" + }, "trackReEnrichFfmpegFailed": "FFmpeg metadata embed failed", - "@trackReEnrichFfmpegFailed": {"description": "Snackbar when FFmpeg embed fails for MP3/Opus"}, + "@trackReEnrichFfmpegFailed": { + "description": "Snackbar when FFmpeg embed fails for MP3/Opus" + }, "trackSaveFailed": "Failed: {error}", "@trackSaveFailed": { "description": "Snackbar when save operation fails", "placeholders": { - "error": {"type": "String"} + "error": { + "type": "String" + } } }, - "trackConvertFormat": "Convert Format", - "@trackConvertFormat": {"description": "Menu item - convert audio format"}, + "@trackConvertFormat": { + "description": "Menu item - convert audio format" + }, "trackConvertFormatSubtitle": "Convert to MP3 or Opus", - "@trackConvertFormatSubtitle": {"description": "Subtitle for convert format menu item"}, + "@trackConvertFormatSubtitle": { + "description": "Subtitle for convert format menu item" + }, "trackConvertTitle": "Convert Audio", - "@trackConvertTitle": {"description": "Title of convert bottom sheet"}, + "@trackConvertTitle": { + "description": "Title of convert bottom sheet" + }, "trackConvertTargetFormat": "Target Format", - "@trackConvertTargetFormat": {"description": "Label for format selection"}, + "@trackConvertTargetFormat": { + "description": "Label for format selection" + }, "trackConvertBitrate": "Bitrate", - "@trackConvertBitrate": {"description": "Label for bitrate selection"}, + "@trackConvertBitrate": { + "description": "Label for bitrate selection" + }, "trackConvertConfirmTitle": "Confirm Conversion", - "@trackConvertConfirmTitle": {"description": "Confirmation dialog title"}, + "@trackConvertConfirmTitle": { + "description": "Confirmation dialog title" + }, "trackConvertConfirmMessage": "Convert from {sourceFormat} to {targetFormat} at {bitrate}?\n\nThe original file will be deleted after conversion.", "@trackConvertConfirmMessage": { "description": "Confirmation dialog message", "placeholders": { - "sourceFormat": {"type": "String"}, - "targetFormat": {"type": "String"}, - "bitrate": {"type": "String"} + "sourceFormat": { + "type": "String" + }, + "targetFormat": { + "type": "String" + }, + "bitrate": { + "type": "String" + } } }, "trackConvertConverting": "Converting audio...", - "@trackConvertConverting": {"description": "Snackbar while converting"}, + "@trackConvertConverting": { + "description": "Snackbar while converting" + }, "trackConvertSuccess": "Converted to {format} successfully", "@trackConvertSuccess": { "description": "Snackbar after successful conversion", "placeholders": { - "format": {"type": "String"} + "format": { + "type": "String" + } } }, "trackConvertFailed": "Conversion failed", - "@trackConvertFailed": {"description": "Snackbar when conversion fails"} + "@trackConvertFailed": { + "description": "Snackbar when conversion fails" + }, + "actionCreate": "Create", + "@actionCreate": { + "description": "Generic action button - create" + }, + "collectionFoldersTitle": "My folders", + "@collectionFoldersTitle": { + "description": "Library section title for custom folders" + }, + "collectionWishlist": "Wishlist", + "@collectionWishlist": { + "description": "Custom folder for saved tracks to download later" + }, + "collectionLoved": "Loved", + "@collectionLoved": { + "description": "Custom folder for favorite tracks" + }, + "collectionPlaylists": "Playlists", + "@collectionPlaylists": { + "description": "Custom user playlists folder" + }, + "collectionPlaylist": "Playlist", + "@collectionPlaylist": { + "description": "Single playlist label" + }, + "collectionAddToPlaylist": "Add to playlist", + "@collectionAddToPlaylist": { + "description": "Action to add a track to user playlist" + }, + "collectionCreatePlaylist": "Create playlist", + "@collectionCreatePlaylist": { + "description": "Action to create a new playlist" + }, + "collectionNoPlaylistsYet": "No playlists yet", + "@collectionNoPlaylistsYet": { + "description": "Empty state title when user has no playlists" + }, + "collectionNoPlaylistsSubtitle": "Create a playlist to start categorizing tracks", + "@collectionNoPlaylistsSubtitle": { + "description": "Empty state subtitle when user has no playlists" + }, + "collectionPlaylistTracks": "{count, plural, =1{1 track} other{{count} tracks}}", + "@collectionPlaylistTracks": { + "description": "Track count label for custom playlists", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "collectionAddedToPlaylist": "Added to \"{playlistName}\"", + "@collectionAddedToPlaylist": { + "description": "Snackbar after adding track to playlist", + "placeholders": { + "playlistName": { + "type": "String" + } + } + }, + "collectionAlreadyInPlaylist": "Already in \"{playlistName}\"", + "@collectionAlreadyInPlaylist": { + "description": "Snackbar when track already exists in playlist", + "placeholders": { + "playlistName": { + "type": "String" + } + } + }, + "collectionPlaylistCreated": "Playlist created", + "@collectionPlaylistCreated": { + "description": "Snackbar after creating playlist" + }, + "collectionPlaylistNameHint": "Playlist name", + "@collectionPlaylistNameHint": { + "description": "Hint text for playlist name input" + }, + "collectionPlaylistNameRequired": "Playlist name is required", + "@collectionPlaylistNameRequired": { + "description": "Validation error for empty playlist name" + }, + "collectionRenamePlaylist": "Rename playlist", + "@collectionRenamePlaylist": { + "description": "Action to rename playlist" + }, + "collectionDeletePlaylist": "Delete playlist", + "@collectionDeletePlaylist": { + "description": "Action to delete playlist" + }, + "collectionDeletePlaylistMessage": "Delete \"{playlistName}\" and all tracks inside it?", + "@collectionDeletePlaylistMessage": { + "description": "Confirmation message for deleting playlist", + "placeholders": { + "playlistName": { + "type": "String" + } + } + }, + "collectionPlaylistDeleted": "Playlist deleted", + "@collectionPlaylistDeleted": { + "description": "Snackbar after deleting playlist" + }, + "collectionPlaylistRenamed": "Playlist renamed", + "@collectionPlaylistRenamed": { + "description": "Snackbar after renaming playlist" + }, + "collectionWishlistEmptyTitle": "Wishlist is empty", + "@collectionWishlistEmptyTitle": { + "description": "Wishlist empty state title" + }, + "collectionWishlistEmptySubtitle": "Tap + on tracks to save what you want to download later", + "@collectionWishlistEmptySubtitle": { + "description": "Wishlist empty state subtitle" + }, + "collectionLovedEmptyTitle": "Loved folder is empty", + "@collectionLovedEmptyTitle": { + "description": "Loved empty state title" + }, + "collectionLovedEmptySubtitle": "Tap love on tracks to keep your favorites", + "@collectionLovedEmptySubtitle": { + "description": "Loved empty state subtitle" + }, + "collectionPlaylistEmptyTitle": "Playlist is empty", + "@collectionPlaylistEmptyTitle": { + "description": "Playlist empty state title" + }, + "collectionPlaylistEmptySubtitle": "Long-press + on any track to add it here", + "@collectionPlaylistEmptySubtitle": { + "description": "Playlist empty state subtitle" + }, + "collectionRemoveFromPlaylist": "Remove from playlist", + "@collectionRemoveFromPlaylist": { + "description": "Tooltip for removing track from playlist" + }, + "collectionRemoveFromFolder": "Remove from folder", + "@collectionRemoveFromFolder": { + "description": "Tooltip for removing track from wishlist/loved folder" + }, + "collectionRemoved": "\"{trackName}\" removed", + "@collectionRemoved": { + "description": "Snackbar after removing a track from a collection", + "placeholders": { + "trackName": { + "type": "String" + } + } + }, + "collectionAddedToLoved": "\"{trackName}\" added to Loved", + "@collectionAddedToLoved": { + "description": "Snackbar after adding track to loved folder", + "placeholders": { + "trackName": { + "type": "String" + } + } + }, + "collectionRemovedFromLoved": "\"{trackName}\" removed from Loved", + "@collectionRemovedFromLoved": { + "description": "Snackbar after removing track from loved folder", + "placeholders": { + "trackName": { + "type": "String" + } + } + }, + "collectionAddedToWishlist": "\"{trackName}\" added to Wishlist", + "@collectionAddedToWishlist": { + "description": "Snackbar after adding track to wishlist", + "placeholders": { + "trackName": { + "type": "String" + } + } + }, + "collectionRemovedFromWishlist": "\"{trackName}\" removed from Wishlist", + "@collectionRemovedFromWishlist": { + "description": "Snackbar after removing track from wishlist", + "placeholders": { + "trackName": { + "type": "String" + } + } + }, + "trackOptionAddToLoved": "Add to Loved", + "@trackOptionAddToLoved": { + "description": "Bottom sheet action label - add track to loved folder" + }, + "trackOptionRemoveFromLoved": "Remove from Loved", + "@trackOptionRemoveFromLoved": { + "description": "Bottom sheet action label - remove track from loved folder" + }, + "trackOptionAddToWishlist": "Add to Wishlist", + "@trackOptionAddToWishlist": { + "description": "Bottom sheet action label - add track to wishlist" + }, + "trackOptionRemoveFromWishlist": "Remove from Wishlist", + "@trackOptionRemoveFromWishlist": { + "description": "Bottom sheet action label - remove track from wishlist" + }, + "collectionPlaylistChangeCover": "Change cover image", + "@collectionPlaylistChangeCover": { + "description": "Bottom sheet action to pick a custom cover image for a playlist" + }, + "collectionPlaylistRemoveCover": "Remove cover image", + "@collectionPlaylistRemoveCover": { + "description": "Bottom sheet action to remove custom cover image from a playlist" + }, + "selectionShareCount": "Share {count} {count, plural, =1{track} other{tracks}}", + "@selectionShareCount": { + "description": "Share button text with count in selection mode", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "selectionShareNoFiles": "No shareable files found", + "@selectionShareNoFiles": { + "description": "Snackbar when no selected files exist on disk" + }, + "selectionConvertCount": "Convert {count} {count, plural, =1{track} other{tracks}}", + "@selectionConvertCount": { + "description": "Convert button text with count in selection mode", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "selectionConvertNoConvertible": "No convertible tracks selected", + "@selectionConvertNoConvertible": { + "description": "Snackbar when no selected tracks support conversion" + }, + "selectionBatchConvertConfirmTitle": "Batch Convert", + "@selectionBatchConvertConfirmTitle": { + "description": "Confirmation dialog title for batch conversion" + }, + "selectionBatchConvertConfirmMessage": "Convert {count} {count, plural, =1{track} other{tracks}} to {format} at {bitrate}?\n\nOriginal files will be deleted after conversion.", + "@selectionBatchConvertConfirmMessage": { + "description": "Confirmation dialog message for batch conversion", + "placeholders": { + "count": { + "type": "int" + }, + "format": { + "type": "String" + }, + "bitrate": { + "type": "String" + } + } + }, + "selectionBatchConvertProgress": "Converting {current} of {total}...", + "@selectionBatchConvertProgress": { + "description": "Snackbar during batch conversion progress", + "placeholders": { + "current": { + "type": "int" + }, + "total": { + "type": "int" + } + } + }, + "selectionBatchConvertSuccess": "Converted {success} of {total} tracks to {format}", + "@selectionBatchConvertSuccess": { + "description": "Snackbar after batch conversion completes", + "placeholders": { + "success": { + "type": "int" + }, + "total": { + "type": "int" + }, + "format": { + "type": "String" + } + } + }, + "downloadedAlbumDownloadedCount": "{count} downloaded", + "@downloadedAlbumDownloadedCount": { + "description": "Downloaded tracks count badge", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available", + "@downloadUseAlbumArtistForFoldersAlbumSubtitle": { + "description": "Subtitle when Album Artist is used for folder naming" + }, + "downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only", + "@downloadUseAlbumArtistForFoldersTrackSubtitle": { + "description": "Subtitle when Track Artist is used for folder naming" + } } diff --git a/lib/l10n/arb/app_es.arb b/lib/l10n/arb/app_es.arb index f499cb14..497c11bc 100644 --- a/lib/l10n/arb/app_es.arb +++ b/lib/l10n/arb/app_es.arb @@ -5,18 +5,10 @@ "@appName": { "description": "App name - DO NOT TRANSLATE" }, - "appDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.", - "@appDescription": { - "description": "App description shown in about page" - }, "navHome": "Home", "@navHome": { "description": "Bottom navigation - Home tab" }, - "navHistory": "History", - "@navHistory": { - "description": "Bottom navigation - History tab" - }, "navSettings": "Settings", "@navSettings": { "description": "Bottom navigation - Settings tab" @@ -29,20 +21,6 @@ "@homeTitle": { "description": "Home screen title" }, - "homeSearchHint": "Paste Spotify URL or search...", - "@homeSearchHint": { - "description": "Placeholder text in search box" - }, - "homeSearchHintExtension": "Search with {extensionName}...", - "@homeSearchHintExtension": { - "description": "Placeholder when extension search is active", - "placeholders": { - "extensionName": { - "type": "String", - "description": "Name of the active extension" - } - } - }, "homeSubtitle": "Paste a Spotify link or search by name", "@homeSubtitle": { "description": "Subtitle shown below search box" @@ -55,24 +33,6 @@ "@homeRecent": { "description": "Section header for recent searches" }, - "historyTitle": "History", - "@historyTitle": { - "description": "History screen title" - }, - "historyDownloading": "Downloading ({count})", - "@historyDownloading": { - "description": "Tab showing active downloads count", - "placeholders": { - "count": { - "type": "int", - "description": "Number of active downloads" - } - } - }, - "historyDownloaded": "Downloaded", - "@historyDownloaded": { - "description": "Tab showing completed downloads" - }, "historyFilterAll": "All", "@historyFilterAll": { "description": "Filter chip - show all items" @@ -85,48 +45,6 @@ "@historyFilterSingles": { "description": "Filter chip - show singles only" }, - "historyTracksCount": "{count, plural, =1{1 track} other{{count} tracks}}", - "@historyTracksCount": { - "description": "Track count with plural form", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "historyAlbumsCount": "{count, plural, =1{1 album} other{{count} albums}}", - "@historyAlbumsCount": { - "description": "Album count with plural form", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "historyNoDownloads": "No download history", - "@historyNoDownloads": { - "description": "Empty state title" - }, - "historyNoDownloadsSubtitle": "Downloaded tracks will appear here", - "@historyNoDownloadsSubtitle": { - "description": "Empty state subtitle" - }, - "historyNoAlbums": "No album downloads", - "@historyNoAlbums": { - "description": "Empty state when filtering albums" - }, - "historyNoAlbumsSubtitle": "Download multiple tracks from an album to see them here", - "@historyNoAlbumsSubtitle": { - "description": "Empty state subtitle for albums filter" - }, - "historyNoSingles": "No single downloads", - "@historyNoSingles": { - "description": "Empty state when filtering singles" - }, - "historyNoSinglesSubtitle": "Single track downloads will appear here", - "@historyNoSinglesSubtitle": { - "description": "Empty state subtitle for singles filter" - }, "settingsTitle": "Settings", "@settingsTitle": { "description": "Settings screen title" @@ -155,34 +73,6 @@ "@downloadTitle": { "description": "Download settings page title" }, - "downloadLocation": "Download Location", - "@downloadLocation": { - "description": "Setting for download folder" - }, - "downloadLocationSubtitle": "Choose where to save files", - "@downloadLocationSubtitle": { - "description": "Subtitle for download location" - }, - "downloadLocationDefault": "Default location", - "@downloadLocationDefault": { - "description": "Shown when using default folder" - }, - "downloadDefaultService": "Default Service", - "@downloadDefaultService": { - "description": "Setting for preferred download service (Tidal/Qobuz/Amazon)" - }, - "downloadDefaultServiceSubtitle": "Service used for downloads", - "@downloadDefaultServiceSubtitle": { - "description": "Subtitle for default service" - }, - "downloadDefaultQuality": "Default Quality", - "@downloadDefaultQuality": { - "description": "Setting for audio quality" - }, - "downloadAskQuality": "Ask Quality Before Download", - "@downloadAskQuality": { - "description": "Toggle to show quality picker" - }, "downloadAskQualitySubtitle": "Show quality picker for each download", "@downloadAskQualitySubtitle": { "description": "Subtitle for ask quality toggle" @@ -195,38 +85,10 @@ "@downloadFolderOrganization": { "description": "Setting for folder structure" }, - "downloadSeparateSingles": "Separate Singles", - "@downloadSeparateSingles": { - "description": "Toggle to separate single tracks" - }, - "downloadSeparateSinglesSubtitle": "Put single tracks in a separate folder", - "@downloadSeparateSinglesSubtitle": { - "description": "Subtitle for separate singles toggle" - }, - "qualityBest": "Best Available", - "@qualityBest": { - "description": "Audio quality option - highest available" - }, - "qualityFlac": "FLAC", - "@qualityFlac": { - "description": "Audio quality option - FLAC lossless" - }, - "quality320": "320 kbps", - "@quality320": { - "description": "Audio quality option - 320kbps MP3" - }, - "quality128": "128 kbps", - "@quality128": { - "description": "Audio quality option - 128kbps MP3" - }, "appearanceTitle": "Appearance", "@appearanceTitle": { "description": "Appearance settings page title" }, - "appearanceTheme": "Theme", - "@appearanceTheme": { - "description": "Theme mode setting" - }, "appearanceThemeSystem": "System", "@appearanceThemeSystem": { "description": "Follow system theme" @@ -247,10 +109,6 @@ "@appearanceDynamicColorSubtitle": { "description": "Subtitle for dynamic color" }, - "appearanceAccentColor": "Accent Color", - "@appearanceAccentColor": { - "description": "Custom accent color picker" - }, "appearanceHistoryView": "History View", "@appearanceHistoryView": { "description": "Layout style for history" @@ -267,10 +125,6 @@ "@optionsTitle": { "description": "Options settings page title" }, - "optionsSearchSource": "Search Source", - "@optionsSearchSource": { - "description": "Section for search provider settings" - }, "optionsPrimaryProvider": "Primary Provider", "@optionsPrimaryProvider": { "description": "Main search provider setting" @@ -426,22 +280,6 @@ "@extensionsTitle": { "description": "Extensions page title" }, - "extensionsInstalled": "Installed Extensions", - "@extensionsInstalled": { - "description": "Section header for installed extensions" - }, - "extensionsNone": "No extensions installed", - "@extensionsNone": { - "description": "Empty state title" - }, - "extensionsNoneSubtitle": "Install extensions from the Store tab", - "@extensionsNoneSubtitle": { - "description": "Empty state subtitle" - }, - "extensionsEnabled": "Enabled", - "@extensionsEnabled": { - "description": "Extension status - active" - }, "extensionsDisabled": "Disabled", "@extensionsDisabled": { "description": "Extension status - inactive" @@ -468,10 +306,6 @@ "@extensionsUninstall": { "description": "Uninstall extension button" }, - "extensionsSetAsSearch": "Set as Search Provider", - "@extensionsSetAsSearch": { - "description": "Use extension for search" - }, "storeTitle": "Extension Store", "@storeTitle": { "description": "Store screen title" @@ -544,10 +378,6 @@ "@aboutFeatureRequestSubtitle": { "description": "Subtitle for feature request" }, - "aboutSupport": "Support", - "@aboutSupport": { - "description": "Section for support/donation links" - }, "aboutApp": "App", "@aboutApp": { "description": "Section for app info" @@ -564,14 +394,6 @@ "@aboutSachinsenalDesc": { "description": "Credit description for sachinsenal0x64" }, - "aboutDoubleDouble": "DoubleDouble", - "@aboutDoubleDouble": { - "description": "Name of Amazon API service - DO NOT TRANSLATE" - }, - "aboutDoubleDoubleDesc": "Amazing API for Amazon Music downloads. Thank you for making it free!", - "@aboutDoubleDoubleDesc": { - "description": "Credit for DoubleDouble API" - }, "aboutDabMusic": "DAB Music", "@aboutDabMusic": { "description": "Name of Qobuz API service - DO NOT TRANSLATE" @@ -584,35 +406,6 @@ "@aboutAppDescription": { "description": "App description in header card" }, - "albumTitle": "Album", - "@albumTitle": { - "description": "Album screen title" - }, - "albumTracks": "{count, plural, =1{1 track} other{{count} tracks}}", - "@albumTracks": { - "description": "Album track count", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "albumDownloadAll": "Download All", - "@albumDownloadAll": { - "description": "Button to download all tracks" - }, - "albumDownloadRemaining": "Download Remaining", - "@albumDownloadRemaining": { - "description": "Button to download remaining tracks" - }, - "playlistTitle": "Playlist", - "@playlistTitle": { - "description": "Playlist screen title" - }, - "artistTitle": "Artist", - "@artistTitle": { - "description": "Artist screen title" - }, "artistAlbums": "Albums", "@artistAlbums": { "description": "Section header for artist albums" @@ -625,43 +418,6 @@ "@artistCompilations": { "description": "Section header for compilations" }, - "artistReleases": "{count, plural, =1{1 release} other{{count} releases}}", - "@artistReleases": { - "description": "Artist release count", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "trackMetadataTitle": "Track Info", - "@trackMetadataTitle": { - "description": "Track metadata screen title" - }, - "trackMetadataArtist": "Artist", - "@trackMetadataArtist": { - "description": "Metadata field - artist name" - }, - "trackMetadataAlbum": "Album", - "@trackMetadataAlbum": { - "description": "Metadata field - album name" - }, - "trackMetadataDuration": "Duration", - "@trackMetadataDuration": { - "description": "Metadata field - track length" - }, - "trackMetadataQuality": "Quality", - "@trackMetadataQuality": { - "description": "Metadata field - audio quality" - }, - "trackMetadataPath": "File Path", - "@trackMetadataPath": { - "description": "Metadata field - file location" - }, - "trackMetadataDownloadedAt": "Downloaded", - "@trackMetadataDownloadedAt": { - "description": "Metadata field - download date" - }, "trackMetadataService": "Service", "@trackMetadataService": { "description": "Metadata field - download service used" @@ -678,54 +434,10 @@ "@trackMetadataDelete": { "description": "Action button - delete track" }, - "trackMetadataRedownload": "Re-download", - "@trackMetadataRedownload": { - "description": "Action button - download again" - }, - "trackMetadataOpenFolder": "Open Folder", - "@trackMetadataOpenFolder": { - "description": "Action button - open containing folder" - }, - "setupTitle": "Welcome to SpotiFLAC", - "@setupTitle": { - "description": "Setup wizard title" - }, - "setupSubtitle": "Let's get you started", - "@setupSubtitle": { - "description": "Setup wizard subtitle" - }, - "setupStoragePermission": "Storage Permission", - "@setupStoragePermission": { - "description": "Storage permission step title" - }, - "setupStoragePermissionSubtitle": "Required to save downloaded files", - "@setupStoragePermissionSubtitle": { - "description": "Explanation for storage permission" - }, - "setupStoragePermissionGranted": "Permission granted", - "@setupStoragePermissionGranted": { - "description": "Status when permission granted" - }, - "setupStoragePermissionDenied": "Permission denied", - "@setupStoragePermissionDenied": { - "description": "Status when permission denied" - }, "setupGrantPermission": "Grant Permission", "@setupGrantPermission": { "description": "Button to request permission" }, - "setupDownloadLocation": "Download Location", - "@setupDownloadLocation": { - "description": "Download folder step title" - }, - "setupChooseFolder": "Choose Folder", - "@setupChooseFolder": { - "description": "Button to pick folder" - }, - "setupContinue": "Continue", - "@setupContinue": { - "description": "Continue to next step button" - }, "setupSkip": "Skip for now", "@setupSkip": { "description": "Skip current step button" @@ -734,10 +446,6 @@ "@setupStorageAccessRequired": { "description": "Title when storage access needed" }, - "setupStorageAccessMessage": "SpotiFLAC needs \"All files access\" permission to save music files to your chosen folder.", - "@setupStorageAccessMessage": { - "description": "Explanation for storage access" - }, "setupStorageAccessMessageAndroid11": "Android 11+ requires \"All files access\" permission to save files to your chosen download folder.", "@setupStorageAccessMessageAndroid11": { "description": "Android 11+ specific explanation" @@ -769,10 +477,6 @@ } } }, - "setupSelectDownloadFolder": "Select Download Folder", - "@setupSelectDownloadFolder": { - "description": "Folder selection step title" - }, "setupUseDefaultFolder": "Use Default Folder?", "@setupUseDefaultFolder": { "description": "Dialog title for default folder" @@ -817,26 +521,6 @@ "@setupDownloadInFlac": { "description": "App tagline in setup" }, - "setupStepStorage": "Storage", - "@setupStepStorage": { - "description": "Setup step indicator - storage" - }, - "setupStepNotification": "Notification", - "@setupStepNotification": { - "description": "Setup step indicator - notification" - }, - "setupStepFolder": "Folder", - "@setupStepFolder": { - "description": "Setup step indicator - folder" - }, - "setupStepSpotify": "Spotify", - "@setupStepSpotify": { - "description": "Setup step indicator - Spotify API" - }, - "setupStepPermission": "Permission", - "@setupStepPermission": { - "description": "Setup step indicator - permission" - }, "setupStorageGranted": "Storage Permission Granted!", "@setupStorageGranted": { "description": "Success message for storage permission" @@ -857,14 +541,6 @@ "@setupNotificationEnable": { "description": "Button to enable notifications" }, - "setupNotificationDescription": "Get notified when downloads complete or require attention.", - "@setupNotificationDescription": { - "description": "Explanation for notifications" - }, - "setupFolderSelected": "Download Folder Selected!", - "@setupFolderSelected": { - "description": "Success message for folder selection" - }, "setupFolderChoose": "Choose Download Folder", "@setupFolderChoose": { "description": "Button to choose folder" @@ -873,58 +549,14 @@ "@setupFolderDescription": { "description": "Explanation for folder selection" }, - "setupChangeFolder": "Change Folder", - "@setupChangeFolder": { - "description": "Button to change selected folder" - }, "setupSelectFolder": "Select Folder", "@setupSelectFolder": { "description": "Button to select folder" }, - "setupSpotifyApiOptional": "Spotify API (Optional)", - "@setupSpotifyApiOptional": { - "description": "Spotify API step title" - }, - "setupSpotifyApiDescription": "Add your Spotify API credentials for better search results and access to Spotify-exclusive content.", - "@setupSpotifyApiDescription": { - "description": "Explanation for Spotify API" - }, - "setupUseSpotifyApi": "Use Spotify API", - "@setupUseSpotifyApi": { - "description": "Toggle to enable Spotify API" - }, - "setupEnterCredentialsBelow": "Enter your credentials below", - "@setupEnterCredentialsBelow": { - "description": "Prompt to enter credentials" - }, - "setupUsingDeezer": "Using Deezer (no account needed)", - "@setupUsingDeezer": { - "description": "Status when using Deezer" - }, - "setupEnterClientId": "Enter Spotify Client ID", - "@setupEnterClientId": { - "description": "Placeholder for client ID field" - }, - "setupEnterClientSecret": "Enter Spotify Client Secret", - "@setupEnterClientSecret": { - "description": "Placeholder for client secret field" - }, - "setupGetFreeCredentials": "Get your free API credentials from the Spotify Developer Dashboard.", - "@setupGetFreeCredentials": { - "description": "Info about getting Spotify credentials" - }, "setupEnableNotifications": "Enable Notifications", "@setupEnableNotifications": { "description": "Button to enable notifications" }, - "setupProceedToNextStep": "You can now proceed to the next step.", - "@setupProceedToNextStep": { - "description": "Message after completing a step" - }, - "setupNotificationProgressDescription": "You will receive download progress notifications.", - "@setupNotificationProgressDescription": { - "description": "Info about notification usage" - }, "setupNotificationBackgroundDescription": "Get notified about download progress and completion. This helps you track downloads when the app is in background.", "@setupNotificationBackgroundDescription": { "description": "Detailed notification explanation" @@ -933,10 +565,6 @@ "@setupSkipForNow": { "description": "Skip button text" }, - "setupBack": "Back", - "@setupBack": { - "description": "Back button text" - }, "setupNext": "Next", "@setupNext": { "description": "Next button text" @@ -945,26 +573,14 @@ "@setupGetStarted": { "description": "Final setup button" }, - "setupSkipAndStart": "Skip & Start", - "@setupSkipAndStart": { - "description": "Skip setup and start app" - }, "setupAllowAccessToManageFiles": "Please enable \"Allow access to manage all files\" in the next screen.", "@setupAllowAccessToManageFiles": { "description": "Instruction for file access permission" }, - "setupGetCredentialsFromSpotify": "Get credentials from developer.spotify.com", - "@setupGetCredentialsFromSpotify": { - "description": "Link text for Spotify developer portal" - }, "dialogCancel": "Cancel", "@dialogCancel": { "description": "Dialog button - cancel action" }, - "dialogOk": "OK", - "@dialogOk": { - "description": "Dialog button - confirm/acknowledge" - }, "dialogSave": "Save", "@dialogSave": { "description": "Dialog button - save changes" @@ -977,26 +593,10 @@ "@dialogRetry": { "description": "Dialog button - retry action" }, - "dialogClose": "Close", - "@dialogClose": { - "description": "Dialog button - close dialog" - }, - "dialogYes": "Yes", - "@dialogYes": { - "description": "Dialog button - confirm yes" - }, - "dialogNo": "No", - "@dialogNo": { - "description": "Dialog button - confirm no" - }, "dialogClear": "Clear", "@dialogClear": { "description": "Dialog button - clear items" }, - "dialogConfirm": "Confirm", - "@dialogConfirm": { - "description": "Dialog button - confirm action" - }, "dialogDone": "Done", "@dialogDone": { "description": "Dialog button - action completed" @@ -1025,34 +625,10 @@ "@dialogUnsavedChanges": { "description": "Dialog message - unsaved changes" }, - "dialogDownloadFailed": "Download Failed", - "@dialogDownloadFailed": { - "description": "Dialog title - download error" - }, - "dialogTrackLabel": "Track:", - "@dialogTrackLabel": { - "description": "Label for track name in error dialog" - }, - "dialogArtistLabel": "Artist:", - "@dialogArtistLabel": { - "description": "Label for artist name in error dialog" - }, - "dialogErrorLabel": "Error:", - "@dialogErrorLabel": { - "description": "Label for error message" - }, "dialogClearAll": "Clear All", "@dialogClearAll": { "description": "Dialog title - clear all items" }, - "dialogClearAllDownloads": "Are you sure you want to clear all downloads?", - "@dialogClearAllDownloads": { - "description": "Dialog message - clear downloads confirmation" - }, - "dialogRemoveFromDevice": "Remove from device?", - "@dialogRemoveFromDevice": { - "description": "Dialog title - delete file confirmation" - }, "dialogRemoveExtension": "Remove Extension", "@dialogRemoveExtension": { "description": "Dialog title - uninstall extension" @@ -1173,15 +749,6 @@ "@snackbarViewQueue": { "description": "Snackbar action - view download queue" }, - "snackbarFailedToLoad": "Failed to load: {error}", - "@snackbarFailedToLoad": { - "description": "Snackbar - loading error", - "placeholders": { - "error": { - "type": "String" - } - } - }, "snackbarUrlCopied": "{platform} URL copied to clipboard", "@snackbarUrlCopied": { "description": "Snackbar - URL copied", @@ -1242,16 +809,6 @@ "@errorRateLimitedMessage": { "description": "Error message - rate limit explanation" }, - "errorFailedToLoad": "Failed to load {item}", - "@errorFailedToLoad": { - "description": "Error message - loading failed", - "placeholders": { - "item": { - "type": "String", - "description": "Item that failed to load (album/playlist/etc)" - } - } - }, "errorNoTracksFound": "No tracks found", "@errorNoTracksFound": { "description": "Error - search returned no results" @@ -1265,34 +822,6 @@ } } }, - "statusQueued": "Queued", - "@statusQueued": { - "description": "Download status - waiting in queue" - }, - "statusDownloading": "Downloading", - "@statusDownloading": { - "description": "Download status - in progress" - }, - "statusFinalizing": "Finalizing", - "@statusFinalizing": { - "description": "Download status - writing metadata" - }, - "statusCompleted": "Completed", - "@statusCompleted": { - "description": "Download status - finished" - }, - "statusFailed": "Failed", - "@statusFailed": { - "description": "Download status - error occurred" - }, - "statusSkipped": "Skipped", - "@statusSkipped": { - "description": "Download status - already exists" - }, - "statusPaused": "Paused", - "@statusPaused": { - "description": "Download status - paused" - }, "actionPause": "Pause", "@actionPause": { "description": "Action button - pause download" @@ -1305,14 +834,6 @@ "@actionCancel": { "description": "Action button - cancel operation" }, - "actionStop": "Stop", - "@actionStop": { - "description": "Action button - stop operation" - }, - "actionSelect": "Select", - "@actionSelect": { - "description": "Action button - enter selection mode" - }, "actionSelectAll": "Select All", "@actionSelectAll": { "description": "Action button - select all items" @@ -1321,14 +842,6 @@ "@actionDeselect": { "description": "Action button - deselect all" }, - "actionPaste": "Paste", - "@actionPaste": { - "description": "Action button - paste from clipboard" - }, - "actionImportCsv": "Import CSV", - "@actionImportCsv": { - "description": "Action button - import CSV file" - }, "actionRemoveCredentials": "Remove Credentials", "@actionRemoveCredentials": { "description": "Action button - delete Spotify credentials" @@ -1350,19 +863,6 @@ "@selectionAllSelected": { "description": "Status - all items selected" }, - "selectionTapToSelect": "Tap tracks to select", - "@selectionTapToSelect": { - "description": "Hint - how to select items" - }, - "selectionDeleteTracks": "Delete {count} {count, plural, =1{track} other{tracks}}", - "@selectionDeleteTracks": { - "description": "Delete button with count", - "placeholders": { - "count": { - "type": "int" - } - } - }, "selectionSelectToDelete": "Select tracks to delete", "@selectionSelectToDelete": { "description": "Placeholder when nothing selected" @@ -1403,55 +903,10 @@ "@tooltipPlay": { "description": "Tooltip - play button" }, - "tooltipCancel": "Cancel", - "@tooltipCancel": { - "description": "Tooltip - cancel button" - }, - "tooltipStop": "Stop", - "@tooltipStop": { - "description": "Tooltip - stop button" - }, - "tooltipRetry": "Retry", - "@tooltipRetry": { - "description": "Tooltip - retry button" - }, - "tooltipRemove": "Remove", - "@tooltipRemove": { - "description": "Tooltip - remove button" - }, - "tooltipClear": "Clear", - "@tooltipClear": { - "description": "Tooltip - clear button" - }, - "tooltipPaste": "Paste", - "@tooltipPaste": { - "description": "Tooltip - paste button" - }, "filenameFormat": "Filename Format", "@filenameFormat": { "description": "Setting title - filename pattern" }, - "filenameFormatPreview": "Preview: {preview}", - "@filenameFormatPreview": { - "description": "Preview of filename pattern", - "placeholders": { - "preview": { - "type": "String" - } - } - }, - "filenameAvailablePlaceholders": "Available placeholders:", - "@filenameAvailablePlaceholders": { - "description": "Label for placeholder list" - }, - "filenameHint": "{artist} - {title}", - "@filenameHint": { - "description": "Default filename format hint" - }, - "folderOrganization": "Folder Organization", - "@folderOrganization": { - "description": "Setting title - folder structure" - }, "folderOrganizationNone": "No organization", "@folderOrganizationNone": { "description": "Folder option - flat structure" @@ -1492,27 +947,10 @@ "@updateAvailable": { "description": "Update dialog title" }, - "updateNewVersion": "Version {version} is available", - "@updateNewVersion": { - "description": "Update available message", - "placeholders": { - "version": { - "type": "String" - } - } - }, - "updateDownload": "Download", - "@updateDownload": { - "description": "Update button - download update" - }, "updateLater": "Later", "@updateLater": { "description": "Update button - dismiss" }, - "updateChangelog": "Changelog", - "@updateChangelog": { - "description": "Link to changelog" - }, "updateStartingDownload": "Starting download...", "@updateStartingDownload": { "description": "Update status - initializing" @@ -1553,14 +991,6 @@ "@updateDontRemind": { "description": "Update button - skip this version" }, - "providerPriority": "Provider Priority", - "@providerPriority": { - "description": "Setting title - download provider order" - }, - "providerPrioritySubtitle": "Drag to reorder download providers", - "@providerPrioritySubtitle": { - "description": "Subtitle for provider priority" - }, "providerPriorityTitle": "Provider Priority", "@providerPriorityTitle": { "description": "Provider priority page title" @@ -1581,14 +1011,6 @@ "@providerExtension": { "description": "Label for extension-provided providers" }, - "metadataProviderPriority": "Metadata Provider Priority", - "@metadataProviderPriority": { - "description": "Setting title - metadata provider order" - }, - "metadataProviderPrioritySubtitle": "Order used when fetching track metadata", - "@metadataProviderPrioritySubtitle": { - "description": "Subtitle for metadata priority" - }, "metadataProviderPriorityTitle": "Metadata Priority", "@metadataProviderPriorityTitle": { "description": "Metadata priority page title" @@ -1613,22 +1035,6 @@ "@logTitle": { "description": "Logs screen title" }, - "logCopy": "Copy Logs", - "@logCopy": { - "description": "Action - copy logs to clipboard" - }, - "logClear": "Clear Logs", - "@logClear": { - "description": "Action - delete all logs" - }, - "logShare": "Share Logs", - "@logShare": { - "description": "Action - share logs file" - }, - "logEmpty": "No logs yet", - "@logEmpty": { - "description": "Empty state title" - }, "logCopied": "Logs copied to clipboard", "@logCopied": { "description": "Snackbar - logs copied" @@ -1661,22 +1067,6 @@ "@logClearLogsMessage": { "description": "Clear logs confirmation message" }, - "logIspBlocking": "ISP BLOCKING DETECTED", - "@logIspBlocking": { - "description": "Error category - ISP blocking" - }, - "logRateLimited": "RATE LIMITED", - "@logRateLimited": { - "description": "Error category - rate limiting" - }, - "logNetworkError": "NETWORK ERROR", - "@logNetworkError": { - "description": "Error category - network issues" - }, - "logTrackNotFound": "TRACK NOT FOUND", - "@logTrackNotFound": { - "description": "Error category - missing tracks" - }, "logFilterBySeverity": "Filter logs by severity", "@logFilterBySeverity": { "description": "Filter dialog title" @@ -1689,60 +1079,6 @@ "@logNoLogsYetSubtitle": { "description": "Empty state subtitle" }, - "logIssueSummary": "Issue Summary", - "@logIssueSummary": { - "description": "Section header for error summary" - }, - "logIspBlockingDescription": "Your ISP may be blocking access to download services", - "@logIspBlockingDescription": { - "description": "ISP blocking explanation" - }, - "logIspBlockingSuggestion": "Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8", - "@logIspBlockingSuggestion": { - "description": "ISP blocking fix suggestion" - }, - "logRateLimitedDescription": "Too many requests to the service", - "@logRateLimitedDescription": { - "description": "Rate limit explanation" - }, - "logRateLimitedSuggestion": "Wait a few minutes before trying again", - "@logRateLimitedSuggestion": { - "description": "Rate limit fix suggestion" - }, - "logNetworkErrorDescription": "Connection issues detected", - "@logNetworkErrorDescription": { - "description": "Network error explanation" - }, - "logNetworkErrorSuggestion": "Check your internet connection", - "@logNetworkErrorSuggestion": { - "description": "Network error fix suggestion" - }, - "logTrackNotFoundDescription": "Some tracks could not be found on download services", - "@logTrackNotFoundDescription": { - "description": "Track not found explanation" - }, - "logTrackNotFoundSuggestion": "The track may not be available in lossless quality", - "@logTrackNotFoundSuggestion": { - "description": "Track not found explanation" - }, - "logTotalErrors": "Total errors: {count}", - "@logTotalErrors": { - "description": "Error count display", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "logAffected": "Affected: {domains}", - "@logAffected": { - "description": "Affected domains display", - "placeholders": { - "domains": { - "type": "String" - } - } - }, "logEntriesFiltered": "Entries ({count} filtered)", "@logEntriesFiltered": { "description": "Log count with filter active", @@ -1849,22 +1185,6 @@ "@appearanceLanguage": { "description": "Setting title for language selection" }, - "appearanceLanguageSubtitle": "Choose your preferred language", - "@appearanceLanguageSubtitle": { - "description": "Subtitle for language setting" - }, - "languageSystem": "System Default", - "@languageSystem": { - "description": "Use device system language" - }, - "languageEnglish": "English", - "@languageEnglish": { - "description": "English language option" - }, - "languageIndonesian": "Bahasa Indonesia", - "@languageIndonesian": { - "description": "Indonesian language option" - }, "settingsAppearanceSubtitle": "Theme, colors, display", "@settingsAppearanceSubtitle": { "description": "Appearance settings description" @@ -1893,10 +1213,6 @@ "@pressBackAgainToExit": { "description": "Exit confirmation message" }, - "tracksHeader": "Tracks", - "@tracksHeader": { - "description": "Section header for track list" - }, "downloadAllCount": "Download All ({count})", "@downloadAllCount": { "description": "Download all button with count", @@ -2019,15 +1335,6 @@ "@trackDeleteConfirmMessage": { "description": "Delete confirmation message" }, - "trackCannotOpen": "Cannot open: {message}", - "@trackCannotOpen": { - "description": "Error opening file", - "placeholders": { - "message": { - "type": "String" - } - } - }, "dateToday": "Today", "@dateToday": { "description": "Relative date - today" @@ -2063,22 +1370,6 @@ } } }, - "concurrentSequential": "Sequential", - "@concurrentSequential": { - "description": "Download mode - one at a time" - }, - "concurrentParallel2": "2 Parallel", - "@concurrentParallel2": { - "description": "Download mode - 2 simultaneous" - }, - "concurrentParallel3": "3 Parallel", - "@concurrentParallel3": { - "description": "Download mode - 3 simultaneous" - }, - "tapToSeeError": "Tap to see error details", - "@tapToSeeError": { - "description": "Tooltip for failed download" - }, "storeFilterAll": "All", "@storeFilterAll": { "description": "Store filter - all extensions" @@ -2107,18 +1398,6 @@ "@storeClearFilters": { "description": "Button to clear all filters" }, - "storeNoResults": "No extensions found", - "@storeNoResults": { - "description": "Empty state when no extensions match filters" - }, - "extensionProviderPriority": "Provider Priority", - "@extensionProviderPriority": { - "description": "Extension capability - provider priority" - }, - "extensionInstallButton": "Install Extension", - "@extensionInstallButton": { - "description": "Button to install extension" - }, "extensionDefaultProvider": "Default (Deezer/Spotify)", "@extensionDefaultProvider": { "description": "Default search provider option" @@ -2338,14 +1617,6 @@ "@downloadAlbumFolderStructure": { "description": "Setting - album folder organization" }, - "downloadSaveFormat": "Save Format", - "@downloadSaveFormat": { - "description": "Setting - output file format" - }, - "downloadSelectService": "Select Service", - "@downloadSelectService": { - "description": "Dialog title - choose download service" - }, "downloadSelectQuality": "Select Quality", "@downloadSelectQuality": { "description": "Dialog title - choose audio quality" @@ -2354,66 +1625,6 @@ "@downloadFrom": { "description": "Label - download source" }, - "downloadDefaultQualityLabel": "Default Quality", - "@downloadDefaultQualityLabel": { - "description": "Label - default quality setting" - }, - "downloadBestAvailable": "Best available", - "@downloadBestAvailable": { - "description": "Quality option - highest available" - }, - "folderNone": "None", - "@folderNone": { - "description": "Folder option - no organization" - }, - "folderNoneSubtitle": "Save all files directly to download folder", - "@folderNoneSubtitle": { - "description": "Subtitle for no folder organization" - }, - "folderArtist": "Artist", - "@folderArtist": { - "description": "Folder option - by artist" - }, - "folderArtistSubtitle": "Artist Name/filename", - "@folderArtistSubtitle": { - "description": "Folder structure example" - }, - "folderAlbum": "Album", - "@folderAlbum": { - "description": "Folder option - by album" - }, - "folderAlbumSubtitle": "Album Name/filename", - "@folderAlbumSubtitle": { - "description": "Folder structure example" - }, - "folderArtistAlbum": "Artist/Album", - "@folderArtistAlbum": { - "description": "Folder option - nested" - }, - "folderArtistAlbumSubtitle": "Artist Name/Album Name/filename", - "@folderArtistAlbumSubtitle": { - "description": "Folder structure example" - }, - "serviceTidal": "Tidal", - "@serviceTidal": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceQobuz": "Qobuz", - "@serviceQobuz": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceAmazon": "Amazon", - "@serviceAmazon": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceDeezer": "Deezer", - "@serviceDeezer": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceSpotify": "Spotify", - "@serviceSpotify": { - "description": "Service name - DO NOT TRANSLATE" - }, "appearanceAmoledDark": "AMOLED Dark", "@appearanceAmoledDark": { "description": "Theme option - pure black" @@ -2422,18 +1633,6 @@ "@appearanceAmoledDarkSubtitle": { "description": "Subtitle for AMOLED dark" }, - "appearanceChooseAccentColor": "Choose Accent Color", - "@appearanceChooseAccentColor": { - "description": "Color picker dialog title" - }, - "appearanceChooseTheme": "Theme Mode", - "@appearanceChooseTheme": { - "description": "Theme picker dialog title" - }, - "queueTitle": "Download Queue", - "@queueTitle": { - "description": "Queue screen title" - }, "queueClearAll": "Clear All", "@queueClearAll": { "description": "Button - clear all queue items" @@ -2442,38 +1641,6 @@ "@queueClearAllMessage": { "description": "Clear queue confirmation" }, - "queueEmpty": "No downloads in queue", - "@queueEmpty": { - "description": "Empty queue state title" - }, - "queueEmptySubtitle": "Add tracks from the home screen", - "@queueEmptySubtitle": { - "description": "Empty queue state subtitle" - }, - "queueClearCompleted": "Clear completed", - "@queueClearCompleted": { - "description": "Button - clear finished downloads" - }, - "queueDownloadFailed": "Download Failed", - "@queueDownloadFailed": { - "description": "Error dialog title" - }, - "queueTrackLabel": "Track:", - "@queueTrackLabel": { - "description": "Label in error dialog" - }, - "queueArtistLabel": "Artist:", - "@queueArtistLabel": { - "description": "Label in error dialog" - }, - "queueErrorLabel": "Error:", - "@queueErrorLabel": { - "description": "Label in error dialog" - }, - "queueUnknownError": "Unknown error", - "@queueUnknownError": { - "description": "Fallback error message" - }, "albumFolderArtistAlbum": "Artist / Album", "@albumFolderArtistAlbum": { "description": "Album folder option" @@ -2519,19 +1686,6 @@ } } }, - "downloadedAlbumTracksHeader": "Tracks", - "@downloadedAlbumTracksHeader": { - "description": "Section header for tracks" - }, - "downloadedAlbumDownloadedCount": "{count} downloaded", - "@downloadedAlbumDownloadedCount": { - "description": "Downloaded tracks count badge", - "placeholders": { - "count": { - "type": "int" - } - } - }, "downloadedAlbumSelectedCount": "{count} selected", "@downloadedAlbumSelectedCount": { "description": "Selection count indicator", @@ -2562,8 +1716,13 @@ "@downloadedAlbumSelectToDelete": { "description": "Placeholder when nothing selected" }, - "utilityFunctions": "Utility Functions", - "@utilityFunctions": { - "description": "Extension capability - utility functions" + "downloadedAlbumDownloadedCount": "{count} downloaded", + "@downloadedAlbumDownloadedCount": { + "description": "Downloaded tracks count badge", + "placeholders": { + "count": { + "type": "int" + } + } } -} \ 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 4e0dfd9b..304b9cd8 100644 --- a/lib/l10n/arb/app_es_ES.arb +++ b/lib/l10n/arb/app_es_ES.arb @@ -5,10 +5,6 @@ "@appName": { "description": "App name - DO NOT TRANSLATE" }, - "appDescription": "Descargue pistas de Spotify con calidad sin pérdida de Tidal, Qobuz y Amazon Music.", - "@appDescription": { - "description": "App description shown in about page" - }, "navHome": "Inicio", "@navHome": { "description": "Bottom navigation - Home tab" @@ -17,10 +13,6 @@ "@navLibrary": { "description": "Bottom navigation - Library tab" }, - "navHistory": "Historial", - "@navHistory": { - "description": "Bottom navigation - History tab (legacy)" - }, "navSettings": "Ajustes", "@navSettings": { "description": "Bottom navigation - Settings tab" @@ -33,20 +25,6 @@ "@homeTitle": { "description": "Home screen title" }, - "homeSearchHint": "Pegar URL Spotify o buscar...", - "@homeSearchHint": { - "description": "Placeholder text in search box" - }, - "homeSearchHintExtension": "Buscar con {extensionName}...", - "@homeSearchHintExtension": { - "description": "Placeholder when extension search is active", - "placeholders": { - "extensionName": { - "type": "String", - "description": "Name of the active extension" - } - } - }, "homeSubtitle": "Pegar enlace de Spotify o buscar por nombre", "@homeSubtitle": { "description": "Subtitle shown below search box" @@ -59,24 +37,6 @@ "@homeRecent": { "description": "Section header for recent searches" }, - "historyTitle": "Historial", - "@historyTitle": { - "description": "History screen title" - }, - "historyDownloading": "Descargando ({count})", - "@historyDownloading": { - "description": "Tab showing active downloads count", - "placeholders": { - "count": { - "type": "int", - "description": "Number of active downloads" - } - } - }, - "historyDownloaded": "Descargado", - "@historyDownloaded": { - "description": "Tab showing completed downloads" - }, "historyFilterAll": "Todo", "@historyFilterAll": { "description": "Filter chip - show all items" @@ -89,48 +49,6 @@ "@historyFilterSingles": { "description": "Filter chip - show singles only" }, - "historyTracksCount": "{count, plural, one {1 pista} other{{count} pistas}}", - "@historyTracksCount": { - "description": "Track count with plural form", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "historyAlbumsCount": "{count, plural, one {1 álbum} other{{count} álbumes}}", - "@historyAlbumsCount": { - "description": "Album count with plural form", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "historyNoDownloads": "No hay historial de descargas", - "@historyNoDownloads": { - "description": "Empty state title" - }, - "historyNoDownloadsSubtitle": "Las pistas descargadas aparecerán aquí", - "@historyNoDownloadsSubtitle": { - "description": "Empty state subtitle" - }, - "historyNoAlbums": "No hay descargas de álbum", - "@historyNoAlbums": { - "description": "Empty state when filtering albums" - }, - "historyNoAlbumsSubtitle": "Descargar múltiples pistas de un álbum para verlas aquí", - "@historyNoAlbumsSubtitle": { - "description": "Empty state subtitle for albums filter" - }, - "historyNoSingles": "No hay descargas", - "@historyNoSingles": { - "description": "Empty state when filtering singles" - }, - "historyNoSinglesSubtitle": "Las descargas de una sola pista aparecerán aquí", - "@historyNoSinglesSubtitle": { - "description": "Empty state subtitle for singles filter" - }, "historySearchHint": "Buscar en historial...", "@historySearchHint": { "description": "Search bar placeholder in history" @@ -163,34 +81,6 @@ "@downloadTitle": { "description": "Download settings page title" }, - "downloadLocation": "Ubicación de descarga", - "@downloadLocation": { - "description": "Setting for download folder" - }, - "downloadLocationSubtitle": "Elija dónde guardar los archivos", - "@downloadLocationSubtitle": { - "description": "Subtitle for download location" - }, - "downloadLocationDefault": "Ubicación predeterminada", - "@downloadLocationDefault": { - "description": "Shown when using default folder" - }, - "downloadDefaultService": "Servicio por defecto", - "@downloadDefaultService": { - "description": "Setting for preferred download service (Tidal/Qobuz/Amazon)" - }, - "downloadDefaultServiceSubtitle": "Servicio usado para descargas", - "@downloadDefaultServiceSubtitle": { - "description": "Subtitle for default service" - }, - "downloadDefaultQuality": "Calidad por defecto", - "@downloadDefaultQuality": { - "description": "Setting for audio quality" - }, - "downloadAskQuality": "Preguntar calidad antes de descargar", - "@downloadAskQuality": { - "description": "Toggle to show quality picker" - }, "downloadAskQualitySubtitle": "Mostrar selector de calidad para cada descarga", "@downloadAskQualitySubtitle": { "description": "Subtitle for ask quality toggle" @@ -203,38 +93,10 @@ "@downloadFolderOrganization": { "description": "Setting for folder structure" }, - "downloadSeparateSingles": "Separar Pistas", - "@downloadSeparateSingles": { - "description": "Toggle to separate single tracks" - }, - "downloadSeparateSinglesSubtitle": "Colocar pistas individuales en una carpeta separada", - "@downloadSeparateSinglesSubtitle": { - "description": "Subtitle for separate singles toggle" - }, - "qualityBest": "Mejor disponible", - "@qualityBest": { - "description": "Audio quality option - highest available" - }, - "qualityFlac": "FLAC", - "@qualityFlac": { - "description": "Audio quality option - FLAC lossless" - }, - "quality320": "320 kbps", - "@quality320": { - "description": "Audio quality option - 320kbps MP3" - }, - "quality128": "128 kbps", - "@quality128": { - "description": "Audio quality option - 128kbps MP3" - }, "appearanceTitle": "Apariencia", "@appearanceTitle": { "description": "Appearance settings page title" }, - "appearanceTheme": "Tema", - "@appearanceTheme": { - "description": "Theme mode setting" - }, "appearanceThemeSystem": "Sistema", "@appearanceThemeSystem": { "description": "Follow system theme" @@ -255,10 +117,6 @@ "@appearanceDynamicColorSubtitle": { "description": "Subtitle for dynamic color" }, - "appearanceAccentColor": "Color Secundario", - "@appearanceAccentColor": { - "description": "Custom accent color picker" - }, "appearanceHistoryView": "Vista de Historial", "@appearanceHistoryView": { "description": "Layout style for history" @@ -275,10 +133,6 @@ "@optionsTitle": { "description": "Options settings page title" }, - "optionsSearchSource": "Buscar Fuente", - "@optionsSearchSource": { - "description": "Section for search provider settings" - }, "optionsPrimaryProvider": "Proveedor Principal", "@optionsPrimaryProvider": { "description": "Main search provider setting" @@ -438,22 +292,6 @@ "@extensionsTitle": { "description": "Extensions page title" }, - "extensionsInstalled": "Extensiones instaladas", - "@extensionsInstalled": { - "description": "Section header for installed extensions" - }, - "extensionsNone": "No hay extensiones instaladas", - "@extensionsNone": { - "description": "Empty state title" - }, - "extensionsNoneSubtitle": "Instalar extensiones desde la pestaña Tienda", - "@extensionsNoneSubtitle": { - "description": "Empty state subtitle" - }, - "extensionsEnabled": "Habilitado", - "@extensionsEnabled": { - "description": "Extension status - active" - }, "extensionsDisabled": "Deshabilitado", "@extensionsDisabled": { "description": "Extension status - inactive" @@ -480,10 +318,6 @@ "@extensionsUninstall": { "description": "Uninstall extension button" }, - "extensionsSetAsSearch": "Establecer como proveedor de búsqueda", - "@extensionsSetAsSearch": { - "description": "Use extension for search" - }, "storeTitle": "Tienda de extensiones", "@storeTitle": { "description": "Store screen title" @@ -580,10 +414,6 @@ "@aboutSocial": { "description": "Section for social links" }, - "aboutSupport": "Soporte", - "@aboutSupport": { - "description": "Section for support/donation links" - }, "aboutApp": "Aplicación", "@aboutApp": { "description": "Section for app info" @@ -604,14 +434,6 @@ "@aboutSjdonadoDesc": { "description": "Credit description for sjdonado" }, - "aboutDoubleDouble": "DoubleDouble", - "@aboutDoubleDouble": { - "description": "Name of Amazon API service - DO NOT TRANSLATE" - }, - "aboutDoubleDoubleDesc": "API increible para descargas de Amazon Music. ¡Gracias por hacerla gratis!", - "@aboutDoubleDoubleDesc": { - "description": "Credit for DoubleDouble API" - }, "aboutDabMusic": "Música DAB", "@aboutDabMusic": { "description": "Name of Qobuz API service - DO NOT TRANSLATE" @@ -632,35 +454,6 @@ "@aboutAppDescription": { "description": "App description in header card" }, - "albumTitle": "Álbum", - "@albumTitle": { - "description": "Album screen title" - }, - "albumTracks": "{count, plural, one {1 pista} other{{count} pistas}}", - "@albumTracks": { - "description": "Album track count", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "albumDownloadAll": "Descargar Todo", - "@albumDownloadAll": { - "description": "Button to download all tracks" - }, - "albumDownloadRemaining": "Descargas Restantes", - "@albumDownloadRemaining": { - "description": "Button to download remaining tracks" - }, - "playlistTitle": "Lista de reproducción", - "@playlistTitle": { - "description": "Playlist screen title" - }, - "artistTitle": "Artista", - "@artistTitle": { - "description": "Artist screen title" - }, "artistAlbums": "Álbumes", "@artistAlbums": { "description": "Section header for artist albums" @@ -673,15 +466,6 @@ "@artistCompilations": { "description": "Section header for compilations" }, - "artistReleases": "{count, plural, one {1 lanzamiento} other{{count} lanzamientos}}", - "@artistReleases": { - "description": "Artist release count", - "placeholders": { - "count": { - "type": "int" - } - } - }, "artistPopular": "Populares", "@artistPopular": { "description": "Section header for popular/top tracks" @@ -696,34 +480,6 @@ } } }, - "trackMetadataTitle": "Información de pista", - "@trackMetadataTitle": { - "description": "Track metadata screen title" - }, - "trackMetadataArtist": "Artista", - "@trackMetadataArtist": { - "description": "Metadata field - artist name" - }, - "trackMetadataAlbum": "Álbum", - "@trackMetadataAlbum": { - "description": "Metadata field - album name" - }, - "trackMetadataDuration": "Duración", - "@trackMetadataDuration": { - "description": "Metadata field - track length" - }, - "trackMetadataQuality": "Calidad", - "@trackMetadataQuality": { - "description": "Metadata field - audio quality" - }, - "trackMetadataPath": "Ruta del archivo", - "@trackMetadataPath": { - "description": "Metadata field - file location" - }, - "trackMetadataDownloadedAt": "Descargado", - "@trackMetadataDownloadedAt": { - "description": "Metadata field - download date" - }, "trackMetadataService": "Servicio", "@trackMetadataService": { "description": "Metadata field - download service used" @@ -740,54 +496,10 @@ "@trackMetadataDelete": { "description": "Action button - delete track" }, - "trackMetadataRedownload": "Volver a descargar", - "@trackMetadataRedownload": { - "description": "Action button - download again" - }, - "trackMetadataOpenFolder": "Abrir carpeta", - "@trackMetadataOpenFolder": { - "description": "Action button - open containing folder" - }, - "setupTitle": "Bienvenido a SpotiFLAC", - "@setupTitle": { - "description": "Setup wizard title" - }, - "setupSubtitle": "Comencemos", - "@setupSubtitle": { - "description": "Setup wizard subtitle" - }, - "setupStoragePermission": "Permiso de almacenamiento", - "@setupStoragePermission": { - "description": "Storage permission step title" - }, - "setupStoragePermissionSubtitle": "Necesario para guardar los archivos descargados", - "@setupStoragePermissionSubtitle": { - "description": "Explanation for storage permission" - }, - "setupStoragePermissionGranted": "Permiso aprobado", - "@setupStoragePermissionGranted": { - "description": "Status when permission granted" - }, - "setupStoragePermissionDenied": "Permiso denegado", - "@setupStoragePermissionDenied": { - "description": "Status when permission denied" - }, "setupGrantPermission": "Conceder permiso", "@setupGrantPermission": { "description": "Button to request permission" }, - "setupDownloadLocation": "Ubicación de descarga", - "@setupDownloadLocation": { - "description": "Download folder step title" - }, - "setupChooseFolder": "Seleccionar Carpeta", - "@setupChooseFolder": { - "description": "Button to pick folder" - }, - "setupContinue": "Continuar", - "@setupContinue": { - "description": "Continue to next step button" - }, "setupSkip": "Omitir por ahora", "@setupSkip": { "description": "Skip current step button" @@ -796,10 +508,6 @@ "@setupStorageAccessRequired": { "description": "Title when storage access needed" }, - "setupStorageAccessMessage": "SpotiFLAC necesita permiso de \"Todos los archivos de acceso\" para guardar los archivos de música en la carpeta elegida.", - "@setupStorageAccessMessage": { - "description": "Explanation for storage access" - }, "setupStorageAccessMessageAndroid11": "Android 11+ requiere permiso \"Todos los archivos de acceso\" para guardar los archivos en la carpeta de descargas elegida.", "@setupStorageAccessMessageAndroid11": { "description": "Android 11+ specific explanation" @@ -831,10 +539,6 @@ } } }, - "setupSelectDownloadFolder": "Seleccionar carpeta de descarga", - "@setupSelectDownloadFolder": { - "description": "Folder selection step title" - }, "setupUseDefaultFolder": "¿Usar carpeta por defecto?", "@setupUseDefaultFolder": { "description": "Dialog title for default folder" @@ -883,26 +587,6 @@ "@setupDownloadInFlac": { "description": "App tagline in setup" }, - "setupStepStorage": "Almacenamiento", - "@setupStepStorage": { - "description": "Setup step indicator - storage" - }, - "setupStepNotification": "Notificación", - "@setupStepNotification": { - "description": "Setup step indicator - notification" - }, - "setupStepFolder": "Carpeta", - "@setupStepFolder": { - "description": "Setup step indicator - folder" - }, - "setupStepSpotify": "Spotify", - "@setupStepSpotify": { - "description": "Setup step indicator - Spotify API" - }, - "setupStepPermission": "Permiso", - "@setupStepPermission": { - "description": "Setup step indicator - permission" - }, "setupStorageGranted": "¡Permiso de almacenamiento concedido!", "@setupStorageGranted": { "description": "Success message for storage permission" @@ -923,14 +607,6 @@ "@setupNotificationEnable": { "description": "Button to enable notifications" }, - "setupNotificationDescription": "Recibe notificaciones cuando las descargas completen o requieran atención.", - "@setupNotificationDescription": { - "description": "Explanation for notifications" - }, - "setupFolderSelected": "¡Carpeta de descarga seleccionada!", - "@setupFolderSelected": { - "description": "Success message for folder selection" - }, "setupFolderChoose": "Cambiar carpeta de descargas", "@setupFolderChoose": { "description": "Button to choose folder" @@ -939,58 +615,14 @@ "@setupFolderDescription": { "description": "Explanation for folder selection" }, - "setupChangeFolder": "Cambiar carpeta", - "@setupChangeFolder": { - "description": "Button to change selected folder" - }, "setupSelectFolder": "Seleccionar Carpeta", "@setupSelectFolder": { "description": "Button to select folder" }, - "setupSpotifyApiOptional": "API de Spotify (opcional)", - "@setupSpotifyApiOptional": { - "description": "Spotify API step title" - }, - "setupSpotifyApiDescription": "Añade tus credenciales de la API de Spotify para mejores resultados de búsqueda y acceso al contenido exclusivo de Spotify.", - "@setupSpotifyApiDescription": { - "description": "Explanation for Spotify API" - }, - "setupUseSpotifyApi": "Usar API de Spotify", - "@setupUseSpotifyApi": { - "description": "Toggle to enable Spotify API" - }, - "setupEnterCredentialsBelow": "Ingresa tus credenciales a continuación", - "@setupEnterCredentialsBelow": { - "description": "Prompt to enter credentials" - }, - "setupUsingDeezer": "Usando Deezer (no se necesita cuenta)", - "@setupUsingDeezer": { - "description": "Status when using Deezer" - }, - "setupEnterClientId": "Introduzca el ID de cliente de Spotify", - "@setupEnterClientId": { - "description": "Placeholder for client ID field" - }, - "setupEnterClientSecret": "Ingresa el Client Secret de Spotify", - "@setupEnterClientSecret": { - "description": "Placeholder for client secret field" - }, - "setupGetFreeCredentials": "Obtén tus credenciales gratuitas de la API desde el Spotify Developer Dashboard.", - "@setupGetFreeCredentials": { - "description": "Info about getting Spotify credentials" - }, "setupEnableNotifications": "Activar notificaciones", "@setupEnableNotifications": { "description": "Button to enable notifications" }, - "setupProceedToNextStep": "Ahora puedes continuar con el siguiente paso.", - "@setupProceedToNextStep": { - "description": "Message after completing a step" - }, - "setupNotificationProgressDescription": "Recibirás notificaciones de progreso de descargas.", - "@setupNotificationProgressDescription": { - "description": "Info about notification usage" - }, "setupNotificationBackgroundDescription": "Recibe notificaciones sobre el progreso de la descarga y la finalización. Esto te ayuda a rastrear las descargas cuando la aplicación está en segundo plano.", "@setupNotificationBackgroundDescription": { "description": "Detailed notification explanation" @@ -999,10 +631,6 @@ "@setupSkipForNow": { "description": "Skip button text" }, - "setupBack": "Atrás", - "@setupBack": { - "description": "Back button text" - }, "setupNext": "Siguiente", "@setupNext": { "description": "Next button text" @@ -1011,26 +639,14 @@ "@setupGetStarted": { "description": "Final setup button" }, - "setupSkipAndStart": "Saltar y empezar", - "@setupSkipAndStart": { - "description": "Skip setup and start app" - }, "setupAllowAccessToManageFiles": "Por favor, activa \"Permitir el acceso para gestionar todos los archivos\" en la siguiente pantalla.", "@setupAllowAccessToManageFiles": { "description": "Instruction for file access permission" }, - "setupGetCredentialsFromSpotify": "Obtener credenciales de developer.spotify.com", - "@setupGetCredentialsFromSpotify": { - "description": "Link text for Spotify developer portal" - }, "dialogCancel": "Cancelar", "@dialogCancel": { "description": "Dialog button - cancel action" }, - "dialogOk": "Aceptar", - "@dialogOk": { - "description": "Dialog button - confirm/acknowledge" - }, "dialogSave": "Guardar", "@dialogSave": { "description": "Dialog button - save changes" @@ -1043,26 +659,10 @@ "@dialogRetry": { "description": "Dialog button - retry action" }, - "dialogClose": "Cerrar", - "@dialogClose": { - "description": "Dialog button - close dialog" - }, - "dialogYes": "Sí", - "@dialogYes": { - "description": "Dialog button - confirm yes" - }, - "dialogNo": "No", - "@dialogNo": { - "description": "Dialog button - confirm no" - }, "dialogClear": "Borrar", "@dialogClear": { "description": "Dialog button - clear items" }, - "dialogConfirm": "Confirmar", - "@dialogConfirm": { - "description": "Dialog button - confirm action" - }, "dialogDone": "Hecho", "@dialogDone": { "description": "Dialog button - action completed" @@ -1091,34 +691,10 @@ "@dialogUnsavedChanges": { "description": "Dialog message - unsaved changes" }, - "dialogDownloadFailed": "Descarga fallida", - "@dialogDownloadFailed": { - "description": "Dialog title - download error" - }, - "dialogTrackLabel": "Pista:", - "@dialogTrackLabel": { - "description": "Label for track name in error dialog" - }, - "dialogArtistLabel": "Artista:", - "@dialogArtistLabel": { - "description": "Label for artist name in error dialog" - }, - "dialogErrorLabel": "Error:", - "@dialogErrorLabel": { - "description": "Label for error message" - }, "dialogClearAll": "Eliminar todo", "@dialogClearAll": { "description": "Dialog title - clear all items" }, - "dialogClearAllDownloads": "¿Estás seguro de que quieres borrar todas las descargas?", - "@dialogClearAllDownloads": { - "description": "Dialog message - clear downloads confirmation" - }, - "dialogRemoveFromDevice": "¿Eliminar del dispositivo?", - "@dialogRemoveFromDevice": { - "description": "Dialog title - delete file confirmation" - }, "dialogRemoveExtension": "Eliminar extensión", "@dialogRemoveExtension": { "description": "Dialog title - uninstall extension" @@ -1257,15 +833,6 @@ "@snackbarViewQueue": { "description": "Snackbar action - view download queue" }, - "snackbarFailedToLoad": "Error al cargar: {error}", - "@snackbarFailedToLoad": { - "description": "Snackbar - loading error", - "placeholders": { - "error": { - "type": "String" - } - } - }, "snackbarUrlCopied": "URL {platform} copiada al portapapeles", "@snackbarUrlCopied": { "description": "Snackbar - URL copied", @@ -1326,16 +893,6 @@ "@errorRateLimitedMessage": { "description": "Error message - rate limit explanation" }, - "errorFailedToLoad": "Error al cargar {item}", - "@errorFailedToLoad": { - "description": "Error message - loading failed", - "placeholders": { - "item": { - "type": "String", - "description": "Item that failed to load (album/playlist/etc)" - } - } - }, "errorNoTracksFound": "No se encontraron pistas", "@errorNoTracksFound": { "description": "Error - search returned no results" @@ -1349,34 +906,6 @@ } } }, - "statusQueued": "En cola", - "@statusQueued": { - "description": "Download status - waiting in queue" - }, - "statusDownloading": "Descargando", - "@statusDownloading": { - "description": "Download status - in progress" - }, - "statusFinalizing": "Finalizando", - "@statusFinalizing": { - "description": "Download status - writing metadata" - }, - "statusCompleted": "Completado", - "@statusCompleted": { - "description": "Download status - finished" - }, - "statusFailed": "Error", - "@statusFailed": { - "description": "Download status - error occurred" - }, - "statusSkipped": "Omitido", - "@statusSkipped": { - "description": "Download status - already exists" - }, - "statusPaused": "Pausado", - "@statusPaused": { - "description": "Download status - paused" - }, "actionPause": "Pausar", "@actionPause": { "description": "Action button - pause download" @@ -1389,14 +918,6 @@ "@actionCancel": { "description": "Action button - cancel operation" }, - "actionStop": "Detener", - "@actionStop": { - "description": "Action button - stop operation" - }, - "actionSelect": "Seleccionar", - "@actionSelect": { - "description": "Action button - enter selection mode" - }, "actionSelectAll": "Seleccionar Todo", "@actionSelectAll": { "description": "Action button - select all items" @@ -1405,14 +926,6 @@ "@actionDeselect": { "description": "Action button - deselect all" }, - "actionPaste": "Pegar", - "@actionPaste": { - "description": "Action button - paste from clipboard" - }, - "actionImportCsv": "Importar CSV", - "@actionImportCsv": { - "description": "Action button - import CSV file" - }, "actionRemoveCredentials": "Eliminar credenciales", "@actionRemoveCredentials": { "description": "Action button - delete Spotify credentials" @@ -1434,19 +947,6 @@ "@selectionAllSelected": { "description": "Status - all items selected" }, - "selectionTapToSelect": "Toca las pistas para seleccionar", - "@selectionTapToSelect": { - "description": "Hint - how to select items" - }, - "selectionDeleteTracks": "¡Eliminar {count} {count, plural, one {pista} other{pistas}}", - "@selectionDeleteTracks": { - "description": "Delete button with count", - "placeholders": { - "count": { - "type": "int" - } - } - }, "selectionSelectToDelete": "Seleccionar pistas a eliminar", "@selectionSelectToDelete": { "description": "Placeholder when nothing selected" @@ -1487,55 +987,10 @@ "@tooltipPlay": { "description": "Tooltip - play button" }, - "tooltipCancel": "Cancelar", - "@tooltipCancel": { - "description": "Tooltip - cancel button" - }, - "tooltipStop": "Detener", - "@tooltipStop": { - "description": "Tooltip - stop button" - }, - "tooltipRetry": "Volver a intentar", - "@tooltipRetry": { - "description": "Tooltip - retry button" - }, - "tooltipRemove": "Eliminar", - "@tooltipRemove": { - "description": "Tooltip - remove button" - }, - "tooltipClear": "Borrar", - "@tooltipClear": { - "description": "Tooltip - clear button" - }, - "tooltipPaste": "Pegar", - "@tooltipPaste": { - "description": "Tooltip - paste button" - }, "filenameFormat": "Formato del nombre del archivo", "@filenameFormat": { "description": "Setting title - filename pattern" }, - "filenameFormatPreview": "Vista previa: {preview}", - "@filenameFormatPreview": { - "description": "Preview of filename pattern", - "placeholders": { - "preview": { - "type": "String" - } - } - }, - "filenameAvailablePlaceholders": "Marcadores disponibles:", - "@filenameAvailablePlaceholders": { - "description": "Label for placeholder list" - }, - "filenameHint": "{artist} - {title}", - "@filenameHint": { - "description": "Default filename format hint" - }, - "folderOrganization": "Organización de carpetas", - "@folderOrganization": { - "description": "Setting title - folder structure" - }, "folderOrganizationNone": "Ninguna organización", "@folderOrganizationNone": { "description": "Folder option - flat structure" @@ -1576,27 +1031,10 @@ "@updateAvailable": { "description": "Update dialog title" }, - "updateNewVersion": "Versión {version} está disponible", - "@updateNewVersion": { - "description": "Update available message", - "placeholders": { - "version": { - "type": "String" - } - } - }, - "updateDownload": "Descargar", - "@updateDownload": { - "description": "Update button - download update" - }, "updateLater": "Más tarde", "@updateLater": { "description": "Update button - dismiss" }, - "updateChangelog": "Historial de cambios", - "@updateChangelog": { - "description": "Link to changelog" - }, "updateStartingDownload": "Iniciando descarga...", "@updateStartingDownload": { "description": "Update status - initializing" @@ -1637,14 +1075,6 @@ "@updateDontRemind": { "description": "Update button - skip this version" }, - "providerPriority": "Prioridad del proveedor", - "@providerPriority": { - "description": "Setting title - download provider order" - }, - "providerPrioritySubtitle": "Arrastre para reordenar los proveedores de descarga", - "@providerPrioritySubtitle": { - "description": "Subtitle for provider priority" - }, "providerPriorityTitle": "Prioridad del proveedor", "@providerPriorityTitle": { "description": "Provider priority page title" @@ -1665,14 +1095,6 @@ "@providerExtension": { "description": "Label for extension-provided providers" }, - "metadataProviderPriority": "Prioridad del proveedor de metadatos", - "@metadataProviderPriority": { - "description": "Setting title - metadata provider order" - }, - "metadataProviderPrioritySubtitle": "Orden usado al recuperar metadatos de la pista", - "@metadataProviderPrioritySubtitle": { - "description": "Subtitle for metadata priority" - }, "metadataProviderPriorityTitle": "Prioridad de los metadatos", "@metadataProviderPriorityTitle": { "description": "Metadata priority page title" @@ -1697,22 +1119,6 @@ "@logTitle": { "description": "Logs screen title" }, - "logCopy": "Copiar Registros", - "@logCopy": { - "description": "Action - copy logs to clipboard" - }, - "logClear": "Limpiar registros", - "@logClear": { - "description": "Action - delete all logs" - }, - "logShare": "Compartir Registros", - "@logShare": { - "description": "Action - share logs file" - }, - "logEmpty": "No hay registros aún", - "@logEmpty": { - "description": "Empty state title" - }, "logCopied": "Registros copiados al portapapeles", "@logCopied": { "description": "Snackbar - logs copied" @@ -1745,22 +1151,6 @@ "@logClearLogsMessage": { "description": "Clear logs confirmation message" }, - "logIspBlocking": "BLOQUEO POR EL ISP DETECTADO", - "@logIspBlocking": { - "description": "Error category - ISP blocking" - }, - "logRateLimited": "TASA LIMITADA", - "@logRateLimited": { - "description": "Error category - rate limiting" - }, - "logNetworkError": "ERROR DE RED", - "@logNetworkError": { - "description": "Error category - network issues" - }, - "logTrackNotFound": "PISTA NO ENCONTRADA", - "@logTrackNotFound": { - "description": "Error category - missing tracks" - }, "logFilterBySeverity": "Filtrar los registros por gravedad", "@logFilterBySeverity": { "description": "Filter dialog title" @@ -1773,60 +1163,6 @@ "@logNoLogsYetSubtitle": { "description": "Empty state subtitle" }, - "logIssueSummary": "Resumen de Incidencias", - "@logIssueSummary": { - "description": "Section header for error summary" - }, - "logIspBlockingDescription": "Tu ISP puede estar bloqueando el acceso a los servicios de descarga", - "@logIspBlockingDescription": { - "description": "ISP blocking explanation" - }, - "logIspBlockingSuggestion": "Intente usar una VPN o cambie el DNS a 1.1.1.1 o 8.8.8.8", - "@logIspBlockingSuggestion": { - "description": "ISP blocking fix suggestion" - }, - "logRateLimitedDescription": "Demasiadas solicitudes al servicio", - "@logRateLimitedDescription": { - "description": "Rate limit explanation" - }, - "logRateLimitedSuggestion": "Espere unos minutos antes de volver a intentarlo", - "@logRateLimitedSuggestion": { - "description": "Rate limit fix suggestion" - }, - "logNetworkErrorDescription": "Problemas de conexión detectados", - "@logNetworkErrorDescription": { - "description": "Network error explanation" - }, - "logNetworkErrorSuggestion": "Comprueba tu conexión a internet", - "@logNetworkErrorSuggestion": { - "description": "Network error fix suggestion" - }, - "logTrackNotFoundDescription": "No se pudieron encontrar algunas pistas en los servicios de descarga", - "@logTrackNotFoundDescription": { - "description": "Track not found explanation" - }, - "logTrackNotFoundSuggestion": "La pista puede no estar disponible en calidad sin pérdida", - "@logTrackNotFoundSuggestion": { - "description": "Track not found explanation" - }, - "logTotalErrors": "Total de errores: {count}", - "@logTotalErrors": { - "description": "Error count display", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "logAffected": "Afectado: {domains}", - "@logAffected": { - "description": "Affected domains display", - "placeholders": { - "domains": { - "type": "String" - } - } - }, "logEntriesFiltered": "Entradas ({count} filtradas)", "@logEntriesFiltered": { "description": "Log count with filter active", @@ -1969,10 +1305,6 @@ "@appearanceLanguage": { "description": "Language setting title" }, - "appearanceLanguageSubtitle": "Elija su idioma preferido", - "@appearanceLanguageSubtitle": { - "description": "Language setting subtitle" - }, "settingsAppearanceSubtitle": "Tema, colores, pantalla", "@settingsAppearanceSubtitle": { "description": "Appearance settings description" @@ -2001,10 +1333,6 @@ "@pressBackAgainToExit": { "description": "Exit confirmation message" }, - "tracksHeader": "Pistas", - "@tracksHeader": { - "description": "Section header for track list" - }, "downloadAllCount": "Descargar Todo ({count})", "@downloadAllCount": { "description": "Download all button with count", @@ -2151,15 +1479,6 @@ "@trackDeleteConfirmMessage": { "description": "Delete confirmation message" }, - "trackCannotOpen": "No se puede abrir: {message}", - "@trackCannotOpen": { - "description": "Error opening file", - "placeholders": { - "message": { - "type": "String" - } - } - }, "dateToday": "Hoy", "@dateToday": { "description": "Relative date - today" @@ -2195,22 +1514,6 @@ } } }, - "concurrentSequential": "Secuencial", - "@concurrentSequential": { - "description": "Download mode - one at a time" - }, - "concurrentParallel2": "2 simultáneamente", - "@concurrentParallel2": { - "description": "Download mode - 2 simultaneous" - }, - "concurrentParallel3": "3 simultáneamente", - "@concurrentParallel3": { - "description": "Download mode - 3 simultaneous" - }, - "tapToSeeError": "Pulse para ver los detalles del error", - "@tapToSeeError": { - "description": "Tooltip for failed download" - }, "storeFilterAll": "Todo", "@storeFilterAll": { "description": "Store filter - all extensions" @@ -2239,18 +1542,6 @@ "@storeClearFilters": { "description": "Button to clear all filters" }, - "storeNoResults": "No se encontraron extensiones", - "@storeNoResults": { - "description": "Empty state when no extensions match filters" - }, - "extensionProviderPriority": "Prioridad del proveedor", - "@extensionProviderPriority": { - "description": "Extension capability - provider priority" - }, - "extensionInstallButton": "Instalar extensión", - "@extensionInstallButton": { - "description": "Button to install extension" - }, "extensionDefaultProvider": "Por defecto (Deezer/Spotify)", "@extensionDefaultProvider": { "description": "Default search provider option" @@ -2450,46 +1741,6 @@ "@qualityHiResFlacMaxSubtitle": { "description": "Technical spec for hi-res max" }, - "qualityLossy": "Con pérdidas", - "@qualityLossy": { - "description": "Quality option - lossy format (MP3/Opus)" - }, - "qualityLossyMp3Subtitle": "MP3 320kbps (convertido desde FLAC)", - "@qualityLossyMp3Subtitle": { - "description": "Technical spec for lossy MP3" - }, - "qualityLossyOpusSubtitle": "Opus 128kbps (convertido de FLAC)", - "@qualityLossyOpusSubtitle": { - "description": "Technical spec for lossy Opus" - }, - "enableLossyOption": "Habilitar opción con pérdida", - "@enableLossyOption": { - "description": "Setting - enable lossy quality option" - }, - "enableLossyOptionSubtitleOn": "La opción de calidad con pérdida está disponible", - "@enableLossyOptionSubtitleOn": { - "description": "Subtitle when lossy is enabled" - }, - "enableLossyOptionSubtitleOff": "Descargas FLAC y luego se convierten en formato con pérdida", - "@enableLossyOptionSubtitleOff": { - "description": "Subtitle when lossy is disabled" - }, - "lossyFormat": "Formato con Perdido", - "@lossyFormat": { - "description": "Setting - choose lossy format" - }, - "lossyFormatDescription": "Elegir el formato con pérdida para la conversión", - "@lossyFormatDescription": { - "description": "Description for lossy format picker" - }, - "lossyFormatMp3Subtitle": "320kbps, mejor compatibilidad", - "@lossyFormatMp3Subtitle": { - "description": "MP3 format description" - }, - "lossyFormatOpusSubtitle": "128kbps, mejor calidad a menor tamaño", - "@lossyFormatOpusSubtitle": { - "description": "Opus format description" - }, "qualityNote": "La calidad real depende de la disponibilidad de la pista del servicio", "@qualityNote": { "description": "Note about quality availability" @@ -2518,14 +1769,6 @@ "@downloadUseAlbumArtistForFolders": { "description": "Setting - choose whether artist folders use Album Artist or Track Artist" }, - "downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available", - "@downloadUseAlbumArtistForFoldersAlbumSubtitle": { - "description": "Subtitle when Album Artist is used for folder naming" - }, - "downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only", - "@downloadUseAlbumArtistForFoldersTrackSubtitle": { - "description": "Subtitle when Track Artist is used for folder naming" - }, "downloadUsePrimaryArtistOnly": "Primary artist only for folders", "@downloadUsePrimaryArtistOnly": { "description": "Setting - strip featured artists from folder name" @@ -2538,14 +1781,6 @@ "@downloadUsePrimaryArtistOnlyDisabled": { "description": "Subtitle when primary artist only is disabled" }, - "downloadSaveFormat": "Guardar Formato", - "@downloadSaveFormat": { - "description": "Setting - output file format" - }, - "downloadSelectService": "Seleccionar Servicio", - "@downloadSelectService": { - "description": "Dialog title - choose download service" - }, "downloadSelectQuality": "Seleccionar Calidad", "@downloadSelectQuality": { "description": "Dialog title - choose audio quality" @@ -2554,66 +1789,6 @@ "@downloadFrom": { "description": "Label - download source" }, - "downloadDefaultQualityLabel": "Calidad por Defecto", - "@downloadDefaultQualityLabel": { - "description": "Label - default quality setting" - }, - "downloadBestAvailable": "La mejor disponible", - "@downloadBestAvailable": { - "description": "Quality option - highest available" - }, - "folderNone": "Ninguna", - "@folderNone": { - "description": "Folder option - no organization" - }, - "folderNoneSubtitle": "Guardar todos los archivos directamente para descargar la carpeta", - "@folderNoneSubtitle": { - "description": "Subtitle for no folder organization" - }, - "folderArtist": "Artista", - "@folderArtist": { - "description": "Folder option - by artist" - }, - "folderArtistSubtitle": "Nombre del Artista/nombre de archivo", - "@folderArtistSubtitle": { - "description": "Folder structure example" - }, - "folderAlbum": "Álbum", - "@folderAlbum": { - "description": "Folder option - by album" - }, - "folderAlbumSubtitle": "Nombre del álbum/nombre de archivo", - "@folderAlbumSubtitle": { - "description": "Folder structure example" - }, - "folderArtistAlbum": "Artista/Álbum", - "@folderArtistAlbum": { - "description": "Folder option - nested" - }, - "folderArtistAlbumSubtitle": "Nombre del Artista/Nombre del Álbum/Nombre del Archivo", - "@folderArtistAlbumSubtitle": { - "description": "Folder structure example" - }, - "serviceTidal": "Tidal", - "@serviceTidal": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceQobuz": "Qobuz", - "@serviceQobuz": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceAmazon": "Amazon", - "@serviceAmazon": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceDeezer": "Deezer", - "@serviceDeezer": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceSpotify": "Spotify", - "@serviceSpotify": { - "description": "Service name - DO NOT TRANSLATE" - }, "appearanceAmoledDark": "AMOLED Oscuro", "@appearanceAmoledDark": { "description": "Theme option - pure black" @@ -2622,18 +1797,6 @@ "@appearanceAmoledDarkSubtitle": { "description": "Subtitle for AMOLED dark" }, - "appearanceChooseAccentColor": "Elegir color principal", - "@appearanceChooseAccentColor": { - "description": "Color picker dialog title" - }, - "appearanceChooseTheme": "Modo de tema", - "@appearanceChooseTheme": { - "description": "Theme picker dialog title" - }, - "queueTitle": "Descargas en proceso", - "@queueTitle": { - "description": "Queue screen title" - }, "queueClearAll": "Eliminar todo", "@queueClearAll": { "description": "Button - clear all queue items" @@ -2642,22 +1805,6 @@ "@queueClearAllMessage": { "description": "Clear queue confirmation" }, - "queueExportFailed": "Exportar", - "@queueExportFailed": { - "description": "Button - export failed downloads to TXT" - }, - "queueExportFailedSuccess": "Descarga fallida exportada al archivo TXT", - "@queueExportFailedSuccess": { - "description": "Success message after exporting failed downloads" - }, - "queueExportFailedClear": "Limpieza Fallida", - "@queueExportFailedClear": { - "description": "Action to clear failed downloads after export" - }, - "queueExportFailedError": "Error al exportar descargas", - "@queueExportFailedError": { - "description": "Error message when export fails" - }, "settingsAutoExportFailed": "Autoexportar descargas fallidas", "@settingsAutoExportFailed": { "description": "Setting toggle for auto-export" @@ -2682,38 +1829,6 @@ "@settingsDownloadNetworkSubtitle": { "description": "Subtitle explaining network preference" }, - "queueEmpty": "No hay descargas en cola", - "@queueEmpty": { - "description": "Empty queue state title" - }, - "queueEmptySubtitle": "Añadir pistas desde la pantalla de inicio", - "@queueEmptySubtitle": { - "description": "Empty queue state subtitle" - }, - "queueClearCompleted": "Limpiar tareas finalizadas", - "@queueClearCompleted": { - "description": "Button - clear finished downloads" - }, - "queueDownloadFailed": "Descarga fallida", - "@queueDownloadFailed": { - "description": "Error dialog title" - }, - "queueTrackLabel": "Pista:", - "@queueTrackLabel": { - "description": "Label in error dialog" - }, - "queueArtistLabel": "Artista:", - "@queueArtistLabel": { - "description": "Label in error dialog" - }, - "queueErrorLabel": "Error:", - "@queueErrorLabel": { - "description": "Label in error dialog" - }, - "queueUnknownError": "Error desconocido", - "@queueUnknownError": { - "description": "Fallback error message" - }, "albumFolderArtistAlbum": "Artista / Álbum", "@albumFolderArtistAlbum": { "description": "Album folder option" @@ -2767,19 +1882,6 @@ } } }, - "downloadedAlbumTracksHeader": "Pistas", - "@downloadedAlbumTracksHeader": { - "description": "Section header for tracks" - }, - "downloadedAlbumDownloadedCount": "{count} descargado", - "@downloadedAlbumDownloadedCount": { - "description": "Downloaded tracks count badge", - "placeholders": { - "count": { - "type": "int" - } - } - }, "downloadedAlbumSelectedCount": "{count} seleccionado", "@downloadedAlbumSelectedCount": { "description": "Selection count indicator", @@ -2820,10 +1922,6 @@ } } }, - "utilityFunctions": "Funciones de utilidad", - "@utilityFunctions": { - "description": "Extension capability - utility functions" - }, "recentTypeArtist": "Artista", "@recentTypeArtist": { "description": "Recent access item type - artist" @@ -2858,16 +1956,6 @@ } } }, - "errorGeneric": "Error: {message}", - "@errorGeneric": { - "description": "Generic error message format", - "placeholders": { - "message": { - "type": "String", - "description": "Error message" - } - } - }, "discographyDownload": "Descargar Discografía", "@discographyDownload": { "description": "Button - download artist discography" @@ -3034,10 +2122,6 @@ "@libraryTitle": { "description": "Library settings page title" }, - "libraryStatus": "Library Status", - "@libraryStatus": { - "description": "Section header for library status" - }, "libraryScanSettings": "Scan Settings", "@libraryScanSettings": { "description": "Section header for scan settings" @@ -3114,15 +2198,6 @@ "@libraryAboutDescription": { "description": "Description of local library feature" }, - "libraryTracksCount": "{count} tracks", - "@libraryTracksCount": { - "description": "Track count in library", - "placeholders": { - "count": { - "type": "int" - } - } - }, "libraryLastScanned": "Last scanned: {time}", "@libraryLastScanned": { "description": "Last scan time display", @@ -3237,26 +2312,6 @@ "@libraryFilterFormat": { "description": "Filter section - file format" }, - "libraryFilterDate": "Date Added", - "@libraryFilterDate": { - "description": "Filter section - date range" - }, - "libraryFilterDateToday": "Today", - "@libraryFilterDateToday": { - "description": "Filter option - today only" - }, - "libraryFilterDateWeek": "This Week", - "@libraryFilterDateWeek": { - "description": "Filter option - this week" - }, - "libraryFilterDateMonth": "This Month", - "@libraryFilterDateMonth": { - "description": "Filter option - this month" - }, - "libraryFilterDateYear": "This Year", - "@libraryFilterDateYear": { - "description": "Filter option - this year" - }, "libraryFilterSort": "Sort", "@libraryFilterSort": { "description": "Filter section - sort order" @@ -3269,15 +2324,6 @@ "@libraryFilterSortOldest": { "description": "Sort option - oldest first" }, - "libraryFilterActive": "{count} filter(s) active", - "@libraryFilterActive": { - "description": "Badge showing number of active filters", - "placeholders": { - "count": { - "type": "int" - } - } - }, "timeJustNow": "Just now", "@timeJustNow": { "description": "Relative time - less than a minute ago" @@ -3300,106 +2346,6 @@ } } }, - "storageSwitchTitle": "Switch Storage Mode", - "@storageSwitchTitle": { - "description": "Dialog title when switching storage mode" - }, - "storageSwitchToSafTitle": "Switch to SAF Storage?", - "@storageSwitchToSafTitle": { - "description": "Dialog title when switching to SAF" - }, - "storageSwitchToAppTitle": "Switch to App Storage?", - "@storageSwitchToAppTitle": { - "description": "Dialog title when switching to app storage" - }, - "storageSwitchToSafMessage": "Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.", - "@storageSwitchToSafMessage": { - "description": "Explanation when switching to SAF" - }, - "storageSwitchToAppMessage": "Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.", - "@storageSwitchToAppMessage": { - "description": "Explanation when switching to app storage" - }, - "storageSwitchExistingDownloads": "Existing Downloads", - "@storageSwitchExistingDownloads": { - "description": "Section header for existing downloads info" - }, - "storageSwitchExistingDownloadsInfo": "{count} tracks in {mode} storage", - "@storageSwitchExistingDownloadsInfo": { - "description": "Info about existing downloads count", - "placeholders": { - "count": { - "type": "int" - }, - "mode": { - "type": "String" - } - } - }, - "storageSwitchNewDownloads": "New Downloads", - "@storageSwitchNewDownloads": { - "description": "Section header for new downloads info" - }, - "storageSwitchNewDownloadsLocation": "Will be saved to: {location}", - "@storageSwitchNewDownloadsLocation": { - "description": "Shows where new downloads will go", - "placeholders": { - "location": { - "type": "String" - } - } - }, - "storageSwitchContinue": "Continue", - "@storageSwitchContinue": { - "description": "Button to proceed with storage switch" - }, - "storageSwitchSelectFolder": "Select SAF Folder", - "@storageSwitchSelectFolder": { - "description": "Button to select SAF folder" - }, - "storageAppStorage": "App Storage", - "@storageAppStorage": { - "description": "Label for app storage mode" - }, - "storageSafStorage": "SAF Storage", - "@storageSafStorage": { - "description": "Label for SAF storage mode" - }, - "storageModeBadge": "Storage: {mode}", - "@storageModeBadge": { - "description": "Badge showing storage mode for a track", - "placeholders": { - "mode": { - "type": "String" - } - } - }, - "storageStatsTitle": "Storage Statistics", - "@storageStatsTitle": { - "description": "Section title for storage stats" - }, - "storageStatsAppCount": "{count} tracks in App Storage", - "@storageStatsAppCount": { - "description": "Count of tracks in app storage", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "storageStatsSafCount": "{count} tracks in SAF Storage", - "@storageStatsSafCount": { - "description": "Count of tracks in SAF storage", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "storageModeInfo": "Your files are stored in multiple locations", - "@storageModeInfo": { - "description": "Info when user has files in both storage modes" - }, "tutorialWelcomeTitle": "Welcome to SpotiFLAC!", "@tutorialWelcomeTitle": { "description": "Tutorial welcome page title" @@ -3428,18 +2374,6 @@ "@tutorialSearchDesc": { "description": "Tutorial search page description" }, - "tutorialSearchTip1": "Paste a Spotify or Deezer URL directly in the search box", - "@tutorialSearchTip1": { - "description": "Tutorial search tip 1" - }, - "tutorialSearchTip2": "Or type the song name, artist, or album to search", - "@tutorialSearchTip2": { - "description": "Tutorial search tip 2" - }, - "tutorialSearchTip3": "Supports tracks, albums, playlists, and artist pages", - "@tutorialSearchTip3": { - "description": "Tutorial search tip 3" - }, "tutorialDownloadTitle": "Downloading Music", "@tutorialDownloadTitle": { "description": "Tutorial download page title" @@ -3448,18 +2382,6 @@ "@tutorialDownloadDesc": { "description": "Tutorial download page description" }, - "tutorialDownloadTip1": "Tap the download button next to any track to start downloading", - "@tutorialDownloadTip1": { - "description": "Tutorial download tip 1" - }, - "tutorialDownloadTip2": "Choose your preferred quality (FLAC, Hi-Res, or MP3)", - "@tutorialDownloadTip2": { - "description": "Tutorial download tip 2" - }, - "tutorialDownloadTip3": "Download entire albums or playlists with one tap", - "@tutorialDownloadTip3": { - "description": "Tutorial download tip 3" - }, "tutorialLibraryTitle": "Your Library", "@tutorialLibraryTitle": { "description": "Tutorial library page title" @@ -3524,10 +2446,6 @@ "@tutorialReadyMessage": { "description": "Tutorial completion message" }, - "tutorialExample": "EXAMPLE", - "@tutorialExample": { - "description": "Example label in tutorial" - }, "libraryForceFullScan": "Force Full Scan", "@libraryForceFullScan": { "description": "Button to force a complete rescan of library" @@ -3750,14 +2668,10 @@ "@trackSaveLyricsProgress": { "description": "Snackbar while saving lyrics to file" }, - "trackReEnrich": "Re-enrich Metadata", + "trackReEnrich": "Re-enrich", "@trackReEnrich": { "description": "Menu action - re-embed metadata into audio file" }, - "trackReEnrichSubtitle": "Re-embed metadata without re-downloading", - "@trackReEnrichSubtitle": { - "description": "Subtitle for re-enrich metadata action" - }, "trackReEnrichOnlineSubtitle": "Search metadata online and embed into file", "@trackReEnrichOnlineSubtitle": { "description": "Subtitle for re-enrich metadata action for local items" @@ -3868,5 +2782,22 @@ "trackConvertFailed": "Conversion failed", "@trackConvertFailed": { "description": "Snackbar when conversion fails" + }, + "downloadedAlbumDownloadedCount": "{count} descargado", + "@downloadedAlbumDownloadedCount": { + "description": "Downloaded tracks count badge", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available", + "@downloadUseAlbumArtistForFoldersAlbumSubtitle": { + "description": "Subtitle when Album Artist is used for folder naming" + }, + "downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only", + "@downloadUseAlbumArtistForFoldersTrackSubtitle": { + "description": "Subtitle when Track Artist is used for folder naming" } -} \ No newline at end of file +} diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index 41685440..f7e9c2d1 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -5,10 +5,6 @@ "@appName": { "description": "App name - DO NOT TRANSLATE" }, - "appDescription": "Téléchargez des pistes Spotify en qualité sans perte de Tidal, Qobuz et Amazon Music.", - "@appDescription": { - "description": "App description shown in about page" - }, "navHome": "Accueil", "@navHome": { "description": "Bottom navigation - Home tab" @@ -17,10 +13,6 @@ "@navLibrary": { "description": "Bottom navigation - Library tab" }, - "navHistory": "Historique", - "@navHistory": { - "description": "Bottom navigation - History tab (legacy)" - }, "navSettings": "Paramètres", "@navSettings": { "description": "Bottom navigation - Settings tab" @@ -33,20 +25,6 @@ "@homeTitle": { "description": "Home screen title" }, - "homeSearchHint": "Coller l'URL Spotify ou rechercher...", - "@homeSearchHint": { - "description": "Placeholder text in search box" - }, - "homeSearchHintExtension": "Rechercher avec {extensionName}...", - "@homeSearchHintExtension": { - "description": "Placeholder when extension search is active", - "placeholders": { - "extensionName": { - "type": "String", - "description": "Name of the active extension" - } - } - }, "homeSubtitle": "Coller un lien Spotify ou rechercher par nom", "@homeSubtitle": { "description": "Subtitle shown below search box" @@ -59,24 +37,6 @@ "@homeRecent": { "description": "Section header for recent searches" }, - "historyTitle": "Historique", - "@historyTitle": { - "description": "History screen title" - }, - "historyDownloading": "Téléchargement ({count})", - "@historyDownloading": { - "description": "Tab showing active downloads count", - "placeholders": { - "count": { - "type": "int", - "description": "Number of active downloads" - } - } - }, - "historyDownloaded": "Téléchargé", - "@historyDownloaded": { - "description": "Tab showing completed downloads" - }, "historyFilterAll": "Tous", "@historyFilterAll": { "description": "Filter chip - show all items" @@ -89,48 +49,6 @@ "@historyFilterSingles": { "description": "Filter chip - show singles only" }, - "historyTracksCount": "{count, plural, =1{1 track} other{{count} tracks}}", - "@historyTracksCount": { - "description": "Track count with plural form", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "historyAlbumsCount": "{count, plural, =1{1 album} other{{count} albums}}", - "@historyAlbumsCount": { - "description": "Album count with plural form", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "historyNoDownloads": "Pas d'historique de téléchargement", - "@historyNoDownloads": { - "description": "Empty state title" - }, - "historyNoDownloadsSubtitle": "Les pistes téléchargées apparaîtront ici", - "@historyNoDownloadsSubtitle": { - "description": "Empty state subtitle" - }, - "historyNoAlbums": "Pas de téléchargement d'album", - "@historyNoAlbums": { - "description": "Empty state when filtering albums" - }, - "historyNoAlbumsSubtitle": "Téléchargez plusieurs titres d'un album pour les voir ici", - "@historyNoAlbumsSubtitle": { - "description": "Empty state subtitle for albums filter" - }, - "historyNoSingles": "Pas de téléchargements uniques", - "@historyNoSingles": { - "description": "Empty state when filtering singles" - }, - "historyNoSinglesSubtitle": "Les téléchargements de pistes uniques apparaîtront ici", - "@historyNoSinglesSubtitle": { - "description": "Empty state subtitle for singles filter" - }, "historySearchHint": "Historique de recherche...", "@historySearchHint": { "description": "Search bar placeholder in history" @@ -163,34 +81,6 @@ "@downloadTitle": { "description": "Download settings page title" }, - "downloadLocation": "Télécharger Localisation", - "@downloadLocation": { - "description": "Setting for download folder" - }, - "downloadLocationSubtitle": "Choisissez où enregistrer des fichiers", - "@downloadLocationSubtitle": { - "description": "Subtitle for download location" - }, - "downloadLocationDefault": "Localisation par défaut", - "@downloadLocationDefault": { - "description": "Shown when using default folder" - }, - "downloadDefaultService": "Service par défaut", - "@downloadDefaultService": { - "description": "Setting for preferred download service (Tidal/Qobuz/Amazon)" - }, - "downloadDefaultServiceSubtitle": "Service utilisé pour les téléchargements", - "@downloadDefaultServiceSubtitle": { - "description": "Subtitle for default service" - }, - "downloadDefaultQuality": "Qualité par défaut", - "@downloadDefaultQuality": { - "description": "Setting for audio quality" - }, - "downloadAskQuality": "Demandez La Qualité Avant Le Téléchargement", - "@downloadAskQuality": { - "description": "Toggle to show quality picker" - }, "downloadAskQualitySubtitle": "Afficher le sélecteur de qualité pour chaque téléchargement", "@downloadAskQualitySubtitle": { "description": "Subtitle for ask quality toggle" @@ -203,38 +93,10 @@ "@downloadFolderOrganization": { "description": "Setting for folder structure" }, - "downloadSeparateSingles": "Titres séparés", - "@downloadSeparateSingles": { - "description": "Toggle to separate single tracks" - }, - "downloadSeparateSinglesSubtitle": "Mettre des pistes uniques dans un dossier séparé", - "@downloadSeparateSinglesSubtitle": { - "description": "Subtitle for separate singles toggle" - }, - "qualityBest": "Meilleur Disponible", - "@qualityBest": { - "description": "Audio quality option - highest available" - }, - "qualityFlac": "FLAC", - "@qualityFlac": { - "description": "Audio quality option - FLAC lossless" - }, - "quality320": "320 kbps", - "@quality320": { - "description": "Audio quality option - 320kbps MP3" - }, - "quality128": "128 kbps", - "@quality128": { - "description": "Audio quality option - 128kbps MP3" - }, "appearanceTitle": "Apparence", "@appearanceTitle": { "description": "Appearance settings page title" }, - "appearanceTheme": "Thème", - "@appearanceTheme": { - "description": "Theme mode setting" - }, "appearanceThemeSystem": "Système", "@appearanceThemeSystem": { "description": "Follow system theme" @@ -255,10 +117,6 @@ "@appearanceDynamicColorSubtitle": { "description": "Subtitle for dynamic color" }, - "appearanceAccentColor": "Couleur d'accent", - "@appearanceAccentColor": { - "description": "Custom accent color picker" - }, "appearanceHistoryView": "Historique Vue", "@appearanceHistoryView": { "description": "Layout style for history" @@ -275,10 +133,6 @@ "@optionsTitle": { "description": "Options settings page title" }, - "optionsSearchSource": "Recherche Source", - "@optionsSearchSource": { - "description": "Section for search provider settings" - }, "optionsPrimaryProvider": "Fournisseur principal", "@optionsPrimaryProvider": { "description": "Main search provider setting" @@ -438,22 +292,6 @@ "@extensionsTitle": { "description": "Extensions page title" }, - "extensionsInstalled": "Installed Extensions", - "@extensionsInstalled": { - "description": "Section header for installed extensions" - }, - "extensionsNone": "No extensions installed", - "@extensionsNone": { - "description": "Empty state title" - }, - "extensionsNoneSubtitle": "Install extensions from the Store tab", - "@extensionsNoneSubtitle": { - "description": "Empty state subtitle" - }, - "extensionsEnabled": "Enabled", - "@extensionsEnabled": { - "description": "Extension status - active" - }, "extensionsDisabled": "Disabled", "@extensionsDisabled": { "description": "Extension status - inactive" @@ -480,10 +318,6 @@ "@extensionsUninstall": { "description": "Uninstall extension button" }, - "extensionsSetAsSearch": "Défini comme fournisseur de recherche", - "@extensionsSetAsSearch": { - "description": "Use extension for search" - }, "storeTitle": "Magasin d'extension", "@storeTitle": { "description": "Store screen title" @@ -580,10 +414,6 @@ "@aboutSocial": { "description": "Section for social links" }, - "aboutSupport": "Support", - "@aboutSupport": { - "description": "Section for support/donation links" - }, "aboutApp": "App", "@aboutApp": { "description": "Section for app info" @@ -604,14 +434,6 @@ "@aboutSjdonadoDesc": { "description": "Credit description for sjdonado" }, - "aboutDoubleDouble": "DoubleDouble", - "@aboutDoubleDouble": { - "description": "Name of Amazon API service - DO NOT TRANSLATE" - }, - "aboutDoubleDoubleDesc": "Amazing API for Amazon Music downloads. Thank you for making it free!", - "@aboutDoubleDoubleDesc": { - "description": "Credit for DoubleDouble API" - }, "aboutDabMusic": "DAB Music", "@aboutDabMusic": { "description": "Name of Qobuz API service - DO NOT TRANSLATE" @@ -632,35 +454,6 @@ "@aboutAppDescription": { "description": "App description in header card" }, - "albumTitle": "Album", - "@albumTitle": { - "description": "Album screen title" - }, - "albumTracks": "{count, plural, =1{1 track} other{{count} tracks}}", - "@albumTracks": { - "description": "Album track count", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "albumDownloadAll": "Download All", - "@albumDownloadAll": { - "description": "Button to download all tracks" - }, - "albumDownloadRemaining": "Download Remaining", - "@albumDownloadRemaining": { - "description": "Button to download remaining tracks" - }, - "playlistTitle": "Playlist", - "@playlistTitle": { - "description": "Playlist screen title" - }, - "artistTitle": "Artist", - "@artistTitle": { - "description": "Artist screen title" - }, "artistAlbums": "Albums", "@artistAlbums": { "description": "Section header for artist albums" @@ -673,15 +466,6 @@ "@artistCompilations": { "description": "Section header for compilations" }, - "artistReleases": "{count, plural, =1{1 release} other{{count} releases}}", - "@artistReleases": { - "description": "Artist release count", - "placeholders": { - "count": { - "type": "int" - } - } - }, "artistPopular": "Popular", "@artistPopular": { "description": "Section header for popular/top tracks" @@ -696,34 +480,6 @@ } } }, - "trackMetadataTitle": "Track Info", - "@trackMetadataTitle": { - "description": "Track metadata screen title" - }, - "trackMetadataArtist": "Artist", - "@trackMetadataArtist": { - "description": "Metadata field - artist name" - }, - "trackMetadataAlbum": "Album", - "@trackMetadataAlbum": { - "description": "Metadata field - album name" - }, - "trackMetadataDuration": "Duration", - "@trackMetadataDuration": { - "description": "Metadata field - track length" - }, - "trackMetadataQuality": "", - "@trackMetadataQuality": { - "description": "Metadata field - audio quality" - }, - "trackMetadataPath": "File Path", - "@trackMetadataPath": { - "description": "Metadata field - file location" - }, - "trackMetadataDownloadedAt": "Downloaded", - "@trackMetadataDownloadedAt": { - "description": "Metadata field - download date" - }, "trackMetadataService": "Service", "@trackMetadataService": { "description": "Metadata field - download service used" @@ -740,54 +496,10 @@ "@trackMetadataDelete": { "description": "Action button - delete track" }, - "trackMetadataRedownload": "Re-télécharger", - "@trackMetadataRedownload": { - "description": "Action button - download again" - }, - "trackMetadataOpenFolder": "Dossier ouvert", - "@trackMetadataOpenFolder": { - "description": "Action button - open containing folder" - }, - "setupTitle": "Bienvenue chez SpotiFLAC", - "@setupTitle": { - "description": "Setup wizard title" - }, - "setupSubtitle": "On va commencer", - "@setupSubtitle": { - "description": "Setup wizard subtitle" - }, - "setupStoragePermission": "Permission de stockage", - "@setupStoragePermission": { - "description": "Storage permission step title" - }, - "setupStoragePermissionSubtitle": "Requis pour enregistrer les fichiers téléchargés", - "@setupStoragePermissionSubtitle": { - "description": "Explanation for storage permission" - }, - "setupStoragePermissionGranted": "Permission accordée", - "@setupStoragePermissionGranted": { - "description": "Status when permission granted" - }, - "setupStoragePermissionDenied": "Permission refusée", - "@setupStoragePermissionDenied": { - "description": "Status when permission denied" - }, "setupGrantPermission": "Grant Permission", "@setupGrantPermission": { "description": "Button to request permission" }, - "setupDownloadLocation": "Download Location", - "@setupDownloadLocation": { - "description": "Download folder step title" - }, - "setupChooseFolder": "Choose Folder", - "@setupChooseFolder": { - "description": "Button to pick folder" - }, - "setupContinue": "Continue", - "@setupContinue": { - "description": "Continue to next step button" - }, "setupSkip": "Skip for now", "@setupSkip": { "description": "Skip current step button" @@ -796,10 +508,6 @@ "@setupStorageAccessRequired": { "description": "Title when storage access needed" }, - "setupStorageAccessMessage": "SpotiFLAC needs \"All files access\" permission to save music files to your chosen folder.", - "@setupStorageAccessMessage": { - "description": "Explanation for storage access" - }, "setupStorageAccessMessageAndroid11": "Android 11+ requires \"All files access\" permission to save files to your chosen download folder.", "@setupStorageAccessMessageAndroid11": { "description": "Android 11+ specific explanation" @@ -831,10 +539,6 @@ } } }, - "setupSelectDownloadFolder": "Select Download Folder", - "@setupSelectDownloadFolder": { - "description": "Folder selection step title" - }, "setupUseDefaultFolder": "Use Default Folder?", "@setupUseDefaultFolder": { "description": "Dialog title for default folder" @@ -883,26 +587,6 @@ "@setupDownloadInFlac": { "description": "App tagline in setup" }, - "setupStepStorage": "Storage", - "@setupStepStorage": { - "description": "Setup step indicator - storage" - }, - "setupStepNotification": "Notification", - "@setupStepNotification": { - "description": "Setup step indicator - notification" - }, - "setupStepFolder": "Folder", - "@setupStepFolder": { - "description": "Setup step indicator - folder" - }, - "setupStepSpotify": "Spotify", - "@setupStepSpotify": { - "description": "Setup step indicator - Spotify API" - }, - "setupStepPermission": "Permission", - "@setupStepPermission": { - "description": "Setup step indicator - permission" - }, "setupStorageGranted": "Storage Permission Granted!", "@setupStorageGranted": { "description": "Success message for storage permission" @@ -923,14 +607,6 @@ "@setupNotificationEnable": { "description": "Button to enable notifications" }, - "setupNotificationDescription": "Get notified when downloads complete or require attention.", - "@setupNotificationDescription": { - "description": "Explanation for notifications" - }, - "setupFolderSelected": "Dossier de téléchargement sélectionné!", - "@setupFolderSelected": { - "description": "Success message for folder selection" - }, "setupFolderChoose": "Choisissez le dossier pour télécharger", "@setupFolderChoose": { "description": "Button to choose folder" @@ -939,58 +615,14 @@ "@setupFolderDescription": { "description": "Explanation for folder selection" }, - "setupChangeFolder": "Change Folder", - "@setupChangeFolder": { - "description": "Button to change selected folder" - }, "setupSelectFolder": "Select Folder", "@setupSelectFolder": { "description": "Button to select folder" }, - "setupSpotifyApiOptional": "Spotify API (Optional)", - "@setupSpotifyApiOptional": { - "description": "Spotify API step title" - }, - "setupSpotifyApiDescription": "Add your Spotify API credentials for better search results and access to Spotify-exclusive content.", - "@setupSpotifyApiDescription": { - "description": "Explanation for Spotify API" - }, - "setupUseSpotifyApi": "Use Spotify API", - "@setupUseSpotifyApi": { - "description": "Toggle to enable Spotify API" - }, - "setupEnterCredentialsBelow": "Enter your credentials below", - "@setupEnterCredentialsBelow": { - "description": "Prompt to enter credentials" - }, - "setupUsingDeezer": "Using Deezer (no account needed)", - "@setupUsingDeezer": { - "description": "Status when using Deezer" - }, - "setupEnterClientId": "Enter Spotify Client ID", - "@setupEnterClientId": { - "description": "Placeholder for client ID field" - }, - "setupEnterClientSecret": "Enter Spotify Client Secret", - "@setupEnterClientSecret": { - "description": "Placeholder for client secret field" - }, - "setupGetFreeCredentials": "Get your free API credentials from the Spotify Developer Dashboard.", - "@setupGetFreeCredentials": { - "description": "Info about getting Spotify credentials" - }, "setupEnableNotifications": "Enable Notifications", "@setupEnableNotifications": { "description": "Button to enable notifications" }, - "setupProceedToNextStep": "You can now proceed to the next step.", - "@setupProceedToNextStep": { - "description": "Message after completing a step" - }, - "setupNotificationProgressDescription": "You will receive download progress notifications.", - "@setupNotificationProgressDescription": { - "description": "Info about notification usage" - }, "setupNotificationBackgroundDescription": "Get notified about download progress and completion. This helps you track downloads when the app is in background.", "@setupNotificationBackgroundDescription": { "description": "Detailed notification explanation" @@ -999,10 +631,6 @@ "@setupSkipForNow": { "description": "Skip button text" }, - "setupBack": "Back", - "@setupBack": { - "description": "Back button text" - }, "setupNext": "Next", "@setupNext": { "description": "Next button text" @@ -1011,26 +639,14 @@ "@setupGetStarted": { "description": "Final setup button" }, - "setupSkipAndStart": "Skip & Start", - "@setupSkipAndStart": { - "description": "Skip setup and start app" - }, "setupAllowAccessToManageFiles": "Please enable \"Allow access to manage all files\" in the next screen.", "@setupAllowAccessToManageFiles": { "description": "Instruction for file access permission" }, - "setupGetCredentialsFromSpotify": "Get credentials from developer.spotify.com", - "@setupGetCredentialsFromSpotify": { - "description": "Link text for Spotify developer portal" - }, "dialogCancel": "Cancel", "@dialogCancel": { "description": "Dialog button - cancel action" }, - "dialogOk": "OK", - "@dialogOk": { - "description": "Dialog button - confirm/acknowledge" - }, "dialogSave": "Save", "@dialogSave": { "description": "Dialog button - save changes" @@ -1043,26 +659,10 @@ "@dialogRetry": { "description": "Dialog button - retry action" }, - "dialogClose": "Close", - "@dialogClose": { - "description": "Dialog button - close dialog" - }, - "dialogYes": "Yes", - "@dialogYes": { - "description": "Dialog button - confirm yes" - }, - "dialogNo": "No", - "@dialogNo": { - "description": "Dialog button - confirm no" - }, "dialogClear": "Clear", "@dialogClear": { "description": "Dialog button - clear items" }, - "dialogConfirm": "Confirm", - "@dialogConfirm": { - "description": "Dialog button - confirm action" - }, "dialogDone": "Done", "@dialogDone": { "description": "Dialog button - action completed" @@ -1091,34 +691,10 @@ "@dialogUnsavedChanges": { "description": "Dialog message - unsaved changes" }, - "dialogDownloadFailed": "Download Failed", - "@dialogDownloadFailed": { - "description": "Dialog title - download error" - }, - "dialogTrackLabel": "Track:", - "@dialogTrackLabel": { - "description": "Label for track name in error dialog" - }, - "dialogArtistLabel": "Artist:", - "@dialogArtistLabel": { - "description": "Label for artist name in error dialog" - }, - "dialogErrorLabel": "Error:", - "@dialogErrorLabel": { - "description": "Label for error message" - }, "dialogClearAll": "Clear All", "@dialogClearAll": { "description": "Dialog title - clear all items" }, - "dialogClearAllDownloads": "Are you sure you want to clear all downloads?", - "@dialogClearAllDownloads": { - "description": "Dialog message - clear downloads confirmation" - }, - "dialogRemoveFromDevice": "Remove from device?", - "@dialogRemoveFromDevice": { - "description": "Dialog title - delete file confirmation" - }, "dialogRemoveExtension": "Remove Extension", "@dialogRemoveExtension": { "description": "Dialog title - uninstall extension" @@ -1257,15 +833,6 @@ "@snackbarViewQueue": { "description": "Snackbar action - view download queue" }, - "snackbarFailedToLoad": "Failed to load: {error}", - "@snackbarFailedToLoad": { - "description": "Snackbar - loading error", - "placeholders": { - "error": { - "type": "String" - } - } - }, "snackbarUrlCopied": "{platform} URL copied to clipboard", "@snackbarUrlCopied": { "description": "Snackbar - URL copied", @@ -1326,16 +893,6 @@ "@errorRateLimitedMessage": { "description": "Error message - rate limit explanation" }, - "errorFailedToLoad": "Failed to load {item}", - "@errorFailedToLoad": { - "description": "Error message - loading failed", - "placeholders": { - "item": { - "type": "String", - "description": "Item that failed to load (album/playlist/etc)" - } - } - }, "errorNoTracksFound": "No tracks found", "@errorNoTracksFound": { "description": "Error - search returned no results" @@ -1349,34 +906,6 @@ } } }, - "statusQueued": "Queued", - "@statusQueued": { - "description": "Download status - waiting in queue" - }, - "statusDownloading": "Downloading", - "@statusDownloading": { - "description": "Download status - in progress" - }, - "statusFinalizing": "Finalizing", - "@statusFinalizing": { - "description": "Download status - writing metadata" - }, - "statusCompleted": "Completed", - "@statusCompleted": { - "description": "Download status - finished" - }, - "statusFailed": "Failed", - "@statusFailed": { - "description": "Download status - error occurred" - }, - "statusSkipped": "Skipped", - "@statusSkipped": { - "description": "Download status - already exists" - }, - "statusPaused": "Paused", - "@statusPaused": { - "description": "Download status - paused" - }, "actionPause": "Pause", "@actionPause": { "description": "Action button - pause download" @@ -1389,14 +918,6 @@ "@actionCancel": { "description": "Action button - cancel operation" }, - "actionStop": "Stop", - "@actionStop": { - "description": "Action button - stop operation" - }, - "actionSelect": "Select", - "@actionSelect": { - "description": "Action button - enter selection mode" - }, "actionSelectAll": "Select All", "@actionSelectAll": { "description": "Action button - select all items" @@ -1405,14 +926,6 @@ "@actionDeselect": { "description": "Action button - deselect all" }, - "actionPaste": "Paste", - "@actionPaste": { - "description": "Action button - paste from clipboard" - }, - "actionImportCsv": "Import CSV", - "@actionImportCsv": { - "description": "Action button - import CSV file" - }, "actionRemoveCredentials": "Remove Credentials", "@actionRemoveCredentials": { "description": "Action button - delete Spotify credentials" @@ -1434,19 +947,6 @@ "@selectionAllSelected": { "description": "Status - all items selected" }, - "selectionTapToSelect": "Tap tracks to select", - "@selectionTapToSelect": { - "description": "Hint - how to select items" - }, - "selectionDeleteTracks": "Delete {count} {count, plural, =1{track} other{tracks}}", - "@selectionDeleteTracks": { - "description": "Delete button with count", - "placeholders": { - "count": { - "type": "int" - } - } - }, "selectionSelectToDelete": "Select tracks to delete", "@selectionSelectToDelete": { "description": "Placeholder when nothing selected" @@ -1487,55 +987,10 @@ "@tooltipPlay": { "description": "Tooltip - play button" }, - "tooltipCancel": "Cancel", - "@tooltipCancel": { - "description": "Tooltip - cancel button" - }, - "tooltipStop": "Stop", - "@tooltipStop": { - "description": "Tooltip - stop button" - }, - "tooltipRetry": "Retry", - "@tooltipRetry": { - "description": "Tooltip - retry button" - }, - "tooltipRemove": "Remove", - "@tooltipRemove": { - "description": "Tooltip - remove button" - }, - "tooltipClear": "Clear", - "@tooltipClear": { - "description": "Tooltip - clear button" - }, - "tooltipPaste": "Paste", - "@tooltipPaste": { - "description": "Tooltip - paste button" - }, "filenameFormat": "Filename Format", "@filenameFormat": { "description": "Setting title - filename pattern" }, - "filenameFormatPreview": "Preview: {preview}", - "@filenameFormatPreview": { - "description": "Preview of filename pattern", - "placeholders": { - "preview": { - "type": "String" - } - } - }, - "filenameAvailablePlaceholders": "Available placeholders:", - "@filenameAvailablePlaceholders": { - "description": "Label for placeholder list" - }, - "filenameHint": "{artist} - {title}", - "@filenameHint": { - "description": "Default filename format hint" - }, - "folderOrganization": "Folder Organization", - "@folderOrganization": { - "description": "Setting title - folder structure" - }, "folderOrganizationNone": "No organization", "@folderOrganizationNone": { "description": "Folder option - flat structure" @@ -1576,27 +1031,10 @@ "@updateAvailable": { "description": "Update dialog title" }, - "updateNewVersion": "Version {version} is available", - "@updateNewVersion": { - "description": "Update available message", - "placeholders": { - "version": { - "type": "String" - } - } - }, - "updateDownload": "Download", - "@updateDownload": { - "description": "Update button - download update" - }, "updateLater": "Later", "@updateLater": { "description": "Update button - dismiss" }, - "updateChangelog": "Changelog", - "@updateChangelog": { - "description": "Link to changelog" - }, "updateStartingDownload": "Starting download...", "@updateStartingDownload": { "description": "Update status - initializing" @@ -1637,14 +1075,6 @@ "@updateDontRemind": { "description": "Update button - skip this version" }, - "providerPriority": "Provider Priority", - "@providerPriority": { - "description": "Setting title - download provider order" - }, - "providerPrioritySubtitle": "Drag to reorder download providers", - "@providerPrioritySubtitle": { - "description": "Subtitle for provider priority" - }, "providerPriorityTitle": "Provider Priority", "@providerPriorityTitle": { "description": "Provider priority page title" @@ -1665,14 +1095,6 @@ "@providerExtension": { "description": "Label for extension-provided providers" }, - "metadataProviderPriority": "Metadata Provider Priority", - "@metadataProviderPriority": { - "description": "Setting title - metadata provider order" - }, - "metadataProviderPrioritySubtitle": "Order used when fetching track metadata", - "@metadataProviderPrioritySubtitle": { - "description": "Subtitle for metadata priority" - }, "metadataProviderPriorityTitle": "Metadata Priority", "@metadataProviderPriorityTitle": { "description": "Metadata priority page title" @@ -1697,22 +1119,6 @@ "@logTitle": { "description": "Logs screen title" }, - "logCopy": "Copy Logs", - "@logCopy": { - "description": "Action - copy logs to clipboard" - }, - "logClear": "Clear Logs", - "@logClear": { - "description": "Action - delete all logs" - }, - "logShare": "Share Logs", - "@logShare": { - "description": "Action - share logs file" - }, - "logEmpty": "No logs yet", - "@logEmpty": { - "description": "Empty state title" - }, "logCopied": "Logs copied to clipboard", "@logCopied": { "description": "Snackbar - logs copied" @@ -1745,22 +1151,6 @@ "@logClearLogsMessage": { "description": "Clear logs confirmation message" }, - "logIspBlocking": "ISP BLOCKING DETECTED", - "@logIspBlocking": { - "description": "Error category - ISP blocking" - }, - "logRateLimited": "RATE LIMITED", - "@logRateLimited": { - "description": "Error category - rate limiting" - }, - "logNetworkError": "NETWORK ERROR", - "@logNetworkError": { - "description": "Error category - network issues" - }, - "logTrackNotFound": "TRACK NOT FOUND", - "@logTrackNotFound": { - "description": "Error category - missing tracks" - }, "logFilterBySeverity": "Filter logs by severity", "@logFilterBySeverity": { "description": "Filter dialog title" @@ -1773,60 +1163,6 @@ "@logNoLogsYetSubtitle": { "description": "Empty state subtitle" }, - "logIssueSummary": "Issue Summary", - "@logIssueSummary": { - "description": "Section header for error summary" - }, - "logIspBlockingDescription": "Your ISP may be blocking access to download services", - "@logIspBlockingDescription": { - "description": "ISP blocking explanation" - }, - "logIspBlockingSuggestion": "Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8", - "@logIspBlockingSuggestion": { - "description": "ISP blocking fix suggestion" - }, - "logRateLimitedDescription": "Too many requests to the service", - "@logRateLimitedDescription": { - "description": "Rate limit explanation" - }, - "logRateLimitedSuggestion": "Wait a few minutes before trying again", - "@logRateLimitedSuggestion": { - "description": "Rate limit fix suggestion" - }, - "logNetworkErrorDescription": "Connection issues detected", - "@logNetworkErrorDescription": { - "description": "Network error explanation" - }, - "logNetworkErrorSuggestion": "Check your internet connection", - "@logNetworkErrorSuggestion": { - "description": "Network error fix suggestion" - }, - "logTrackNotFoundDescription": "Some tracks could not be found on download services", - "@logTrackNotFoundDescription": { - "description": "Track not found explanation" - }, - "logTrackNotFoundSuggestion": "The track may not be available in lossless quality", - "@logTrackNotFoundSuggestion": { - "description": "Track not found explanation" - }, - "logTotalErrors": "Total errors: {count}", - "@logTotalErrors": { - "description": "Error count display", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "logAffected": "Affected: {domains}", - "@logAffected": { - "description": "Affected domains display", - "placeholders": { - "domains": { - "type": "String" - } - } - }, "logEntriesFiltered": "Entries ({count} filtered)", "@logEntriesFiltered": { "description": "Log count with filter active", @@ -1969,10 +1305,6 @@ "@appearanceLanguage": { "description": "Language setting title" }, - "appearanceLanguageSubtitle": "Choose your preferred language", - "@appearanceLanguageSubtitle": { - "description": "Language setting subtitle" - }, "settingsAppearanceSubtitle": "Theme, colors, display", "@settingsAppearanceSubtitle": { "description": "Appearance settings description" @@ -2001,10 +1333,6 @@ "@pressBackAgainToExit": { "description": "Exit confirmation message" }, - "tracksHeader": "Tracks", - "@tracksHeader": { - "description": "Section header for track list" - }, "downloadAllCount": "Download All ({count})", "@downloadAllCount": { "description": "Download all button with count", @@ -2151,15 +1479,6 @@ "@trackDeleteConfirmMessage": { "description": "Delete confirmation message" }, - "trackCannotOpen": "Cannot open: {message}", - "@trackCannotOpen": { - "description": "Error opening file", - "placeholders": { - "message": { - "type": "String" - } - } - }, "dateToday": "Today", "@dateToday": { "description": "Relative date - today" @@ -2195,22 +1514,6 @@ } } }, - "concurrentSequential": "Sequential", - "@concurrentSequential": { - "description": "Download mode - one at a time" - }, - "concurrentParallel2": "2 Parallel", - "@concurrentParallel2": { - "description": "Download mode - 2 simultaneous" - }, - "concurrentParallel3": "3 Parallel", - "@concurrentParallel3": { - "description": "Download mode - 3 simultaneous" - }, - "tapToSeeError": "Tap to see error details", - "@tapToSeeError": { - "description": "Tooltip for failed download" - }, "storeFilterAll": "All", "@storeFilterAll": { "description": "Store filter - all extensions" @@ -2239,18 +1542,6 @@ "@storeClearFilters": { "description": "Button to clear all filters" }, - "storeNoResults": "No extensions found", - "@storeNoResults": { - "description": "Empty state when no extensions match filters" - }, - "extensionProviderPriority": "Provider Priority", - "@extensionProviderPriority": { - "description": "Extension capability - provider priority" - }, - "extensionInstallButton": "Install Extension", - "@extensionInstallButton": { - "description": "Button to install extension" - }, "extensionDefaultProvider": "Default (Deezer/Spotify)", "@extensionDefaultProvider": { "description": "Default search provider option" @@ -2450,46 +1741,6 @@ "@qualityHiResFlacMaxSubtitle": { "description": "Technical spec for hi-res max" }, - "qualityLossy": "Lossy", - "@qualityLossy": { - "description": "Quality option - lossy format (MP3/Opus)" - }, - "qualityLossyMp3Subtitle": "MP3 320kbps (converted from FLAC)", - "@qualityLossyMp3Subtitle": { - "description": "Technical spec for lossy MP3" - }, - "qualityLossyOpusSubtitle": "Opus 128kbps (converted from FLAC)", - "@qualityLossyOpusSubtitle": { - "description": "Technical spec for lossy Opus" - }, - "enableLossyOption": "Enable Lossy Option", - "@enableLossyOption": { - "description": "Setting - enable lossy quality option" - }, - "enableLossyOptionSubtitleOn": "Lossy quality option is available", - "@enableLossyOptionSubtitleOn": { - "description": "Subtitle when lossy is enabled" - }, - "enableLossyOptionSubtitleOff": "Downloads FLAC then converts to lossy format", - "@enableLossyOptionSubtitleOff": { - "description": "Subtitle when lossy is disabled" - }, - "lossyFormat": "Lossy Format", - "@lossyFormat": { - "description": "Setting - choose lossy format" - }, - "lossyFormatDescription": "Choose the lossy format for conversion", - "@lossyFormatDescription": { - "description": "Description for lossy format picker" - }, - "lossyFormatMp3Subtitle": "320kbps, best compatibility", - "@lossyFormatMp3Subtitle": { - "description": "MP3 format description" - }, - "lossyFormatOpusSubtitle": "128kbps, better quality at smaller size", - "@lossyFormatOpusSubtitle": { - "description": "Opus format description" - }, "qualityNote": "Actual quality depends on track availability from the service", "@qualityNote": { "description": "Note about quality availability" @@ -2518,14 +1769,6 @@ "@downloadUseAlbumArtistForFolders": { "description": "Setting - choose whether artist folders use Album Artist or Track Artist" }, - "downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available", - "@downloadUseAlbumArtistForFoldersAlbumSubtitle": { - "description": "Subtitle when Album Artist is used for folder naming" - }, - "downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only", - "@downloadUseAlbumArtistForFoldersTrackSubtitle": { - "description": "Subtitle when Track Artist is used for folder naming" - }, "downloadUsePrimaryArtistOnly": "Primary artist only for folders", "@downloadUsePrimaryArtistOnly": { "description": "Setting - strip featured artists from folder name" @@ -2538,14 +1781,6 @@ "@downloadUsePrimaryArtistOnlyDisabled": { "description": "Subtitle when primary artist only is disabled" }, - "downloadSaveFormat": "Save Format", - "@downloadSaveFormat": { - "description": "Setting - output file format" - }, - "downloadSelectService": "Select Service", - "@downloadSelectService": { - "description": "Dialog title - choose download service" - }, "downloadSelectQuality": "Select Quality", "@downloadSelectQuality": { "description": "Dialog title - choose audio quality" @@ -2554,66 +1789,6 @@ "@downloadFrom": { "description": "Label - download source" }, - "downloadDefaultQualityLabel": "Default Quality", - "@downloadDefaultQualityLabel": { - "description": "Label - default quality setting" - }, - "downloadBestAvailable": "Best available", - "@downloadBestAvailable": { - "description": "Quality option - highest available" - }, - "folderNone": "None", - "@folderNone": { - "description": "Folder option - no organization" - }, - "folderNoneSubtitle": "Save all files directly to download folder", - "@folderNoneSubtitle": { - "description": "Subtitle for no folder organization" - }, - "folderArtist": "Artist", - "@folderArtist": { - "description": "Folder option - by artist" - }, - "folderArtistSubtitle": "Artist Name/filename", - "@folderArtistSubtitle": { - "description": "Folder structure example" - }, - "folderAlbum": "Album", - "@folderAlbum": { - "description": "Folder option - by album" - }, - "folderAlbumSubtitle": "Album Name/filename", - "@folderAlbumSubtitle": { - "description": "Folder structure example" - }, - "folderArtistAlbum": "Artist/Album", - "@folderArtistAlbum": { - "description": "Folder option - nested" - }, - "folderArtistAlbumSubtitle": "Artist Name/Album Name/filename", - "@folderArtistAlbumSubtitle": { - "description": "Folder structure example" - }, - "serviceTidal": "Tidal", - "@serviceTidal": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceQobuz": "Qobuz", - "@serviceQobuz": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceAmazon": "Amazon", - "@serviceAmazon": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceDeezer": "Deezer", - "@serviceDeezer": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceSpotify": "Spotify", - "@serviceSpotify": { - "description": "Service name - DO NOT TRANSLATE" - }, "appearanceAmoledDark": "AMOLED Dark", "@appearanceAmoledDark": { "description": "Theme option - pure black" @@ -2622,18 +1797,6 @@ "@appearanceAmoledDarkSubtitle": { "description": "Subtitle for AMOLED dark" }, - "appearanceChooseAccentColor": "Choose Accent Color", - "@appearanceChooseAccentColor": { - "description": "Color picker dialog title" - }, - "appearanceChooseTheme": "Theme Mode", - "@appearanceChooseTheme": { - "description": "Theme picker dialog title" - }, - "queueTitle": "Download Queue", - "@queueTitle": { - "description": "Queue screen title" - }, "queueClearAll": "Clear All", "@queueClearAll": { "description": "Button - clear all queue items" @@ -2642,22 +1805,6 @@ "@queueClearAllMessage": { "description": "Clear queue confirmation" }, - "queueExportFailed": "Export", - "@queueExportFailed": { - "description": "Button - export failed downloads to TXT" - }, - "queueExportFailedSuccess": "Failed downloads exported to TXT file", - "@queueExportFailedSuccess": { - "description": "Success message after exporting failed downloads" - }, - "queueExportFailedClear": "Clear Failed", - "@queueExportFailedClear": { - "description": "Action to clear failed downloads after export" - }, - "queueExportFailedError": "Failed to export downloads", - "@queueExportFailedError": { - "description": "Error message when export fails" - }, "settingsAutoExportFailed": "Auto-export failed downloads", "@settingsAutoExportFailed": { "description": "Setting toggle for auto-export" @@ -2682,38 +1829,6 @@ "@settingsDownloadNetworkSubtitle": { "description": "Subtitle explaining network preference" }, - "queueEmpty": "No downloads in queue", - "@queueEmpty": { - "description": "Empty queue state title" - }, - "queueEmptySubtitle": "Add tracks from the home screen", - "@queueEmptySubtitle": { - "description": "Empty queue state subtitle" - }, - "queueClearCompleted": "Clear completed", - "@queueClearCompleted": { - "description": "Button - clear finished downloads" - }, - "queueDownloadFailed": "Download Failed", - "@queueDownloadFailed": { - "description": "Error dialog title" - }, - "queueTrackLabel": "Track:", - "@queueTrackLabel": { - "description": "Label in error dialog" - }, - "queueArtistLabel": "Artist:", - "@queueArtistLabel": { - "description": "Label in error dialog" - }, - "queueErrorLabel": "Error:", - "@queueErrorLabel": { - "description": "Label in error dialog" - }, - "queueUnknownError": "Unknown error", - "@queueUnknownError": { - "description": "Fallback error message" - }, "albumFolderArtistAlbum": "Artist / Album", "@albumFolderArtistAlbum": { "description": "Album folder option" @@ -2767,19 +1882,6 @@ } } }, - "downloadedAlbumTracksHeader": "Tracks", - "@downloadedAlbumTracksHeader": { - "description": "Section header for tracks" - }, - "downloadedAlbumDownloadedCount": "{count} downloaded", - "@downloadedAlbumDownloadedCount": { - "description": "Downloaded tracks count badge", - "placeholders": { - "count": { - "type": "int" - } - } - }, "downloadedAlbumSelectedCount": "{count} selected", "@downloadedAlbumSelectedCount": { "description": "Selection count indicator", @@ -2820,10 +1922,6 @@ } } }, - "utilityFunctions": "Utility Functions", - "@utilityFunctions": { - "description": "Extension capability - utility functions" - }, "recentTypeArtist": "Artist", "@recentTypeArtist": { "description": "Recent access item type - artist" @@ -2858,16 +1956,6 @@ } } }, - "errorGeneric": "Error: {message}", - "@errorGeneric": { - "description": "Generic error message format", - "placeholders": { - "message": { - "type": "String", - "description": "Error message" - } - } - }, "discographyDownload": "Download Discography", "@discographyDownload": { "description": "Button - download artist discography" @@ -3034,10 +2122,6 @@ "@libraryTitle": { "description": "Library settings page title" }, - "libraryStatus": "Library Status", - "@libraryStatus": { - "description": "Section header for library status" - }, "libraryScanSettings": "Scan Settings", "@libraryScanSettings": { "description": "Section header for scan settings" @@ -3114,15 +2198,6 @@ "@libraryAboutDescription": { "description": "Description of local library feature" }, - "libraryTracksCount": "{count} tracks", - "@libraryTracksCount": { - "description": "Track count in library", - "placeholders": { - "count": { - "type": "int" - } - } - }, "libraryLastScanned": "Last scanned: {time}", "@libraryLastScanned": { "description": "Last scan time display", @@ -3237,26 +2312,6 @@ "@libraryFilterFormat": { "description": "Filter section - file format" }, - "libraryFilterDate": "Date Added", - "@libraryFilterDate": { - "description": "Filter section - date range" - }, - "libraryFilterDateToday": "Today", - "@libraryFilterDateToday": { - "description": "Filter option - today only" - }, - "libraryFilterDateWeek": "This Week", - "@libraryFilterDateWeek": { - "description": "Filter option - this week" - }, - "libraryFilterDateMonth": "This Month", - "@libraryFilterDateMonth": { - "description": "Filter option - this month" - }, - "libraryFilterDateYear": "This Year", - "@libraryFilterDateYear": { - "description": "Filter option - this year" - }, "libraryFilterSort": "Sort", "@libraryFilterSort": { "description": "Filter section - sort order" @@ -3269,15 +2324,6 @@ "@libraryFilterSortOldest": { "description": "Sort option - oldest first" }, - "libraryFilterActive": "{count} filter(s) active", - "@libraryFilterActive": { - "description": "Badge showing number of active filters", - "placeholders": { - "count": { - "type": "int" - } - } - }, "timeJustNow": "Just now", "@timeJustNow": { "description": "Relative time - less than a minute ago" @@ -3300,106 +2346,6 @@ } } }, - "storageSwitchTitle": "Switch Storage Mode", - "@storageSwitchTitle": { - "description": "Dialog title when switching storage mode" - }, - "storageSwitchToSafTitle": "Switch to SAF Storage?", - "@storageSwitchToSafTitle": { - "description": "Dialog title when switching to SAF" - }, - "storageSwitchToAppTitle": "Switch to App Storage?", - "@storageSwitchToAppTitle": { - "description": "Dialog title when switching to app storage" - }, - "storageSwitchToSafMessage": "Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.", - "@storageSwitchToSafMessage": { - "description": "Explanation when switching to SAF" - }, - "storageSwitchToAppMessage": "Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.", - "@storageSwitchToAppMessage": { - "description": "Explanation when switching to app storage" - }, - "storageSwitchExistingDownloads": "Existing Downloads", - "@storageSwitchExistingDownloads": { - "description": "Section header for existing downloads info" - }, - "storageSwitchExistingDownloadsInfo": "{count} tracks in {mode} storage", - "@storageSwitchExistingDownloadsInfo": { - "description": "Info about existing downloads count", - "placeholders": { - "count": { - "type": "int" - }, - "mode": { - "type": "String" - } - } - }, - "storageSwitchNewDownloads": "New Downloads", - "@storageSwitchNewDownloads": { - "description": "Section header for new downloads info" - }, - "storageSwitchNewDownloadsLocation": "Will be saved to: {location}", - "@storageSwitchNewDownloadsLocation": { - "description": "Shows where new downloads will go", - "placeholders": { - "location": { - "type": "String" - } - } - }, - "storageSwitchContinue": "Continue", - "@storageSwitchContinue": { - "description": "Button to proceed with storage switch" - }, - "storageSwitchSelectFolder": "Select SAF Folder", - "@storageSwitchSelectFolder": { - "description": "Button to select SAF folder" - }, - "storageAppStorage": "App Storage", - "@storageAppStorage": { - "description": "Label for app storage mode" - }, - "storageSafStorage": "SAF Storage", - "@storageSafStorage": { - "description": "Label for SAF storage mode" - }, - "storageModeBadge": "Storage: {mode}", - "@storageModeBadge": { - "description": "Badge showing storage mode for a track", - "placeholders": { - "mode": { - "type": "String" - } - } - }, - "storageStatsTitle": "Storage Statistics", - "@storageStatsTitle": { - "description": "Section title for storage stats" - }, - "storageStatsAppCount": "{count} tracks in App Storage", - "@storageStatsAppCount": { - "description": "Count of tracks in app storage", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "storageStatsSafCount": "{count} tracks in SAF Storage", - "@storageStatsSafCount": { - "description": "Count of tracks in SAF storage", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "storageModeInfo": "Your files are stored in multiple locations", - "@storageModeInfo": { - "description": "Info when user has files in both storage modes" - }, "tutorialWelcomeTitle": "Welcome to SpotiFLAC!", "@tutorialWelcomeTitle": { "description": "Tutorial welcome page title" @@ -3428,18 +2374,6 @@ "@tutorialSearchDesc": { "description": "Tutorial search page description" }, - "tutorialSearchTip1": "Paste a Spotify or Deezer URL directly in the search box", - "@tutorialSearchTip1": { - "description": "Tutorial search tip 1" - }, - "tutorialSearchTip2": "Or type the song name, artist, or album to search", - "@tutorialSearchTip2": { - "description": "Tutorial search tip 2" - }, - "tutorialSearchTip3": "Supports tracks, albums, playlists, and artist pages", - "@tutorialSearchTip3": { - "description": "Tutorial search tip 3" - }, "tutorialDownloadTitle": "Downloading Music", "@tutorialDownloadTitle": { "description": "Tutorial download page title" @@ -3448,18 +2382,6 @@ "@tutorialDownloadDesc": { "description": "Tutorial download page description" }, - "tutorialDownloadTip1": "Tap the download button next to any track to start downloading", - "@tutorialDownloadTip1": { - "description": "Tutorial download tip 1" - }, - "tutorialDownloadTip2": "Choose your preferred quality (FLAC, Hi-Res, or MP3)", - "@tutorialDownloadTip2": { - "description": "Tutorial download tip 2" - }, - "tutorialDownloadTip3": "Download entire albums or playlists with one tap", - "@tutorialDownloadTip3": { - "description": "Tutorial download tip 3" - }, "tutorialLibraryTitle": "Your Library", "@tutorialLibraryTitle": { "description": "Tutorial library page title" @@ -3524,10 +2446,6 @@ "@tutorialReadyMessage": { "description": "Tutorial completion message" }, - "tutorialExample": "EXAMPLE", - "@tutorialExample": { - "description": "Example label in tutorial" - }, "libraryForceFullScan": "Force Full Scan", "@libraryForceFullScan": { "description": "Button to force a complete rescan of library" @@ -3750,14 +2668,10 @@ "@trackSaveLyricsProgress": { "description": "Snackbar while saving lyrics to file" }, - "trackReEnrich": "Re-enrich Metadata", + "trackReEnrich": "Re-enrich", "@trackReEnrich": { "description": "Menu action - re-embed metadata into audio file" }, - "trackReEnrichSubtitle": "Re-embed metadata without re-downloading", - "@trackReEnrichSubtitle": { - "description": "Subtitle for re-enrich metadata action" - }, "trackReEnrichOnlineSubtitle": "Search metadata online and embed into file", "@trackReEnrichOnlineSubtitle": { "description": "Subtitle for re-enrich metadata action for local items" @@ -3868,5 +2782,22 @@ "trackConvertFailed": "Conversion failed", "@trackConvertFailed": { "description": "Snackbar when conversion fails" + }, + "downloadedAlbumDownloadedCount": "{count} downloaded", + "@downloadedAlbumDownloadedCount": { + "description": "Downloaded tracks count badge", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available", + "@downloadUseAlbumArtistForFoldersAlbumSubtitle": { + "description": "Subtitle when Album Artist is used for folder naming" + }, + "downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only", + "@downloadUseAlbumArtistForFoldersTrackSubtitle": { + "description": "Subtitle when Track Artist is used for folder naming" } -} \ No newline at end of file +} diff --git a/lib/l10n/arb/app_hi.arb b/lib/l10n/arb/app_hi.arb index 8f6ab38f..064202f2 100644 --- a/lib/l10n/arb/app_hi.arb +++ b/lib/l10n/arb/app_hi.arb @@ -5,10 +5,6 @@ "@appName": { "description": "App name - DO NOT TRANSLATE" }, - "appDescription": "स्पॉटीफाई ट्रैक डाउनलोड करें टाइडल, क्वाबज एवं एवं अमेजन म्यूजिक से उच्चतम क्वालिटी में।", - "@appDescription": { - "description": "App description shown in about page" - }, "navHome": "होम", "@navHome": { "description": "Bottom navigation - Home tab" @@ -17,10 +13,6 @@ "@navLibrary": { "description": "Bottom navigation - Library tab" }, - "navHistory": "इतिहास", - "@navHistory": { - "description": "Bottom navigation - History tab (legacy)" - }, "navSettings": "विकल्प", "@navSettings": { "description": "Bottom navigation - Settings tab" @@ -33,20 +25,6 @@ "@homeTitle": { "description": "Home screen title" }, - "homeSearchHint": "Paste Spotify URL or search...", - "@homeSearchHint": { - "description": "Placeholder text in search box" - }, - "homeSearchHintExtension": "Search with {extensionName}...", - "@homeSearchHintExtension": { - "description": "Placeholder when extension search is active", - "placeholders": { - "extensionName": { - "type": "String", - "description": "Name of the active extension" - } - } - }, "homeSubtitle": "Paste a Spotify link or search by name", "@homeSubtitle": { "description": "Subtitle shown below search box" @@ -59,24 +37,6 @@ "@homeRecent": { "description": "Section header for recent searches" }, - "historyTitle": "History", - "@historyTitle": { - "description": "History screen title" - }, - "historyDownloading": "Downloading ({count})", - "@historyDownloading": { - "description": "Tab showing active downloads count", - "placeholders": { - "count": { - "type": "int", - "description": "Number of active downloads" - } - } - }, - "historyDownloaded": "Downloaded", - "@historyDownloaded": { - "description": "Tab showing completed downloads" - }, "historyFilterAll": "All", "@historyFilterAll": { "description": "Filter chip - show all items" @@ -89,48 +49,6 @@ "@historyFilterSingles": { "description": "Filter chip - show singles only" }, - "historyTracksCount": "{count, plural, =1{1 track} other{{count} tracks}}", - "@historyTracksCount": { - "description": "Track count with plural form", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "historyAlbumsCount": "{count, plural, =1{1 album} other{{count} albums}}", - "@historyAlbumsCount": { - "description": "Album count with plural form", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "historyNoDownloads": "No download history", - "@historyNoDownloads": { - "description": "Empty state title" - }, - "historyNoDownloadsSubtitle": "Downloaded tracks will appear here", - "@historyNoDownloadsSubtitle": { - "description": "Empty state subtitle" - }, - "historyNoAlbums": "No album downloads", - "@historyNoAlbums": { - "description": "Empty state when filtering albums" - }, - "historyNoAlbumsSubtitle": "Download multiple tracks from an album to see them here", - "@historyNoAlbumsSubtitle": { - "description": "Empty state subtitle for albums filter" - }, - "historyNoSingles": "No single downloads", - "@historyNoSingles": { - "description": "Empty state when filtering singles" - }, - "historyNoSinglesSubtitle": "Single track downloads will appear here", - "@historyNoSinglesSubtitle": { - "description": "Empty state subtitle for singles filter" - }, "historySearchHint": "Search history...", "@historySearchHint": { "description": "Search bar placeholder in history" @@ -163,34 +81,6 @@ "@downloadTitle": { "description": "Download settings page title" }, - "downloadLocation": "Download Location", - "@downloadLocation": { - "description": "Setting for download folder" - }, - "downloadLocationSubtitle": "Choose where to save files", - "@downloadLocationSubtitle": { - "description": "Subtitle for download location" - }, - "downloadLocationDefault": "Default location", - "@downloadLocationDefault": { - "description": "Shown when using default folder" - }, - "downloadDefaultService": "Default Service", - "@downloadDefaultService": { - "description": "Setting for preferred download service (Tidal/Qobuz/Amazon)" - }, - "downloadDefaultServiceSubtitle": "Service used for downloads", - "@downloadDefaultServiceSubtitle": { - "description": "Subtitle for default service" - }, - "downloadDefaultQuality": "Default Quality", - "@downloadDefaultQuality": { - "description": "Setting for audio quality" - }, - "downloadAskQuality": "Ask Quality Before Download", - "@downloadAskQuality": { - "description": "Toggle to show quality picker" - }, "downloadAskQualitySubtitle": "Show quality picker for each download", "@downloadAskQualitySubtitle": { "description": "Subtitle for ask quality toggle" @@ -203,38 +93,10 @@ "@downloadFolderOrganization": { "description": "Setting for folder structure" }, - "downloadSeparateSingles": "Separate Singles", - "@downloadSeparateSingles": { - "description": "Toggle to separate single tracks" - }, - "downloadSeparateSinglesSubtitle": "Put single tracks in a separate folder", - "@downloadSeparateSinglesSubtitle": { - "description": "Subtitle for separate singles toggle" - }, - "qualityBest": "Best Available", - "@qualityBest": { - "description": "Audio quality option - highest available" - }, - "qualityFlac": "FLAC", - "@qualityFlac": { - "description": "Audio quality option - FLAC lossless" - }, - "quality320": "320 kbps", - "@quality320": { - "description": "Audio quality option - 320kbps MP3" - }, - "quality128": "128 kbps", - "@quality128": { - "description": "Audio quality option - 128kbps MP3" - }, "appearanceTitle": "दिखावट", "@appearanceTitle": { "description": "Appearance settings page title" }, - "appearanceTheme": "Theme", - "@appearanceTheme": { - "description": "Theme mode setting" - }, "appearanceThemeSystem": "System", "@appearanceThemeSystem": { "description": "Follow system theme" @@ -255,10 +117,6 @@ "@appearanceDynamicColorSubtitle": { "description": "Subtitle for dynamic color" }, - "appearanceAccentColor": "Accent Color", - "@appearanceAccentColor": { - "description": "Custom accent color picker" - }, "appearanceHistoryView": "History View", "@appearanceHistoryView": { "description": "Layout style for history" @@ -275,10 +133,6 @@ "@optionsTitle": { "description": "Options settings page title" }, - "optionsSearchSource": "Search Source", - "@optionsSearchSource": { - "description": "Section for search provider settings" - }, "optionsPrimaryProvider": "Primary Provider", "@optionsPrimaryProvider": { "description": "Main search provider setting" @@ -438,22 +292,6 @@ "@extensionsTitle": { "description": "Extensions page title" }, - "extensionsInstalled": "Installed Extensions", - "@extensionsInstalled": { - "description": "Section header for installed extensions" - }, - "extensionsNone": "No extensions installed", - "@extensionsNone": { - "description": "Empty state title" - }, - "extensionsNoneSubtitle": "Install extensions from the Store tab", - "@extensionsNoneSubtitle": { - "description": "Empty state subtitle" - }, - "extensionsEnabled": "Enabled", - "@extensionsEnabled": { - "description": "Extension status - active" - }, "extensionsDisabled": "Disabled", "@extensionsDisabled": { "description": "Extension status - inactive" @@ -480,10 +318,6 @@ "@extensionsUninstall": { "description": "Uninstall extension button" }, - "extensionsSetAsSearch": "Set as Search Provider", - "@extensionsSetAsSearch": { - "description": "Use extension for search" - }, "storeTitle": "Extension Store", "@storeTitle": { "description": "Store screen title" @@ -580,10 +414,6 @@ "@aboutSocial": { "description": "Section for social links" }, - "aboutSupport": "Support", - "@aboutSupport": { - "description": "Section for support/donation links" - }, "aboutApp": "App", "@aboutApp": { "description": "Section for app info" @@ -604,14 +434,6 @@ "@aboutSjdonadoDesc": { "description": "Credit description for sjdonado" }, - "aboutDoubleDouble": "DoubleDouble", - "@aboutDoubleDouble": { - "description": "Name of Amazon API service - DO NOT TRANSLATE" - }, - "aboutDoubleDoubleDesc": "Amazing API for Amazon Music downloads. Thank you for making it free!", - "@aboutDoubleDoubleDesc": { - "description": "Credit for DoubleDouble API" - }, "aboutDabMusic": "DAB Music", "@aboutDabMusic": { "description": "Name of Qobuz API service - DO NOT TRANSLATE" @@ -632,35 +454,6 @@ "@aboutAppDescription": { "description": "App description in header card" }, - "albumTitle": "Album", - "@albumTitle": { - "description": "Album screen title" - }, - "albumTracks": "{count, plural, =1{1 track} other{{count} tracks}}", - "@albumTracks": { - "description": "Album track count", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "albumDownloadAll": "Download All", - "@albumDownloadAll": { - "description": "Button to download all tracks" - }, - "albumDownloadRemaining": "Download Remaining", - "@albumDownloadRemaining": { - "description": "Button to download remaining tracks" - }, - "playlistTitle": "Playlist", - "@playlistTitle": { - "description": "Playlist screen title" - }, - "artistTitle": "Artist", - "@artistTitle": { - "description": "Artist screen title" - }, "artistAlbums": "Albums", "@artistAlbums": { "description": "Section header for artist albums" @@ -673,15 +466,6 @@ "@artistCompilations": { "description": "Section header for compilations" }, - "artistReleases": "{count, plural, =1{1 release} other{{count} releases}}", - "@artistReleases": { - "description": "Artist release count", - "placeholders": { - "count": { - "type": "int" - } - } - }, "artistPopular": "Popular", "@artistPopular": { "description": "Section header for popular/top tracks" @@ -696,34 +480,6 @@ } } }, - "trackMetadataTitle": "Track Info", - "@trackMetadataTitle": { - "description": "Track metadata screen title" - }, - "trackMetadataArtist": "Artist", - "@trackMetadataArtist": { - "description": "Metadata field - artist name" - }, - "trackMetadataAlbum": "Album", - "@trackMetadataAlbum": { - "description": "Metadata field - album name" - }, - "trackMetadataDuration": "Duration", - "@trackMetadataDuration": { - "description": "Metadata field - track length" - }, - "trackMetadataQuality": "Quality", - "@trackMetadataQuality": { - "description": "Metadata field - audio quality" - }, - "trackMetadataPath": "File Path", - "@trackMetadataPath": { - "description": "Metadata field - file location" - }, - "trackMetadataDownloadedAt": "Downloaded", - "@trackMetadataDownloadedAt": { - "description": "Metadata field - download date" - }, "trackMetadataService": "Service", "@trackMetadataService": { "description": "Metadata field - download service used" @@ -740,54 +496,10 @@ "@trackMetadataDelete": { "description": "Action button - delete track" }, - "trackMetadataRedownload": "Re-download", - "@trackMetadataRedownload": { - "description": "Action button - download again" - }, - "trackMetadataOpenFolder": "Open Folder", - "@trackMetadataOpenFolder": { - "description": "Action button - open containing folder" - }, - "setupTitle": "Welcome to SpotiFLAC", - "@setupTitle": { - "description": "Setup wizard title" - }, - "setupSubtitle": "Let's get you started", - "@setupSubtitle": { - "description": "Setup wizard subtitle" - }, - "setupStoragePermission": "Storage Permission", - "@setupStoragePermission": { - "description": "Storage permission step title" - }, - "setupStoragePermissionSubtitle": "Required to save downloaded files", - "@setupStoragePermissionSubtitle": { - "description": "Explanation for storage permission" - }, - "setupStoragePermissionGranted": "Permission granted", - "@setupStoragePermissionGranted": { - "description": "Status when permission granted" - }, - "setupStoragePermissionDenied": "Permission denied", - "@setupStoragePermissionDenied": { - "description": "Status when permission denied" - }, "setupGrantPermission": "Grant Permission", "@setupGrantPermission": { "description": "Button to request permission" }, - "setupDownloadLocation": "Download Location", - "@setupDownloadLocation": { - "description": "Download folder step title" - }, - "setupChooseFolder": "Choose Folder", - "@setupChooseFolder": { - "description": "Button to pick folder" - }, - "setupContinue": "Continue", - "@setupContinue": { - "description": "Continue to next step button" - }, "setupSkip": "Skip for now", "@setupSkip": { "description": "Skip current step button" @@ -796,10 +508,6 @@ "@setupStorageAccessRequired": { "description": "Title when storage access needed" }, - "setupStorageAccessMessage": "SpotiFLAC needs \"All files access\" permission to save music files to your chosen folder.", - "@setupStorageAccessMessage": { - "description": "Explanation for storage access" - }, "setupStorageAccessMessageAndroid11": "Android 11+ requires \"All files access\" permission to save files to your chosen download folder.", "@setupStorageAccessMessageAndroid11": { "description": "Android 11+ specific explanation" @@ -831,10 +539,6 @@ } } }, - "setupSelectDownloadFolder": "Select Download Folder", - "@setupSelectDownloadFolder": { - "description": "Folder selection step title" - }, "setupUseDefaultFolder": "Use Default Folder?", "@setupUseDefaultFolder": { "description": "Dialog title for default folder" @@ -883,26 +587,6 @@ "@setupDownloadInFlac": { "description": "App tagline in setup" }, - "setupStepStorage": "Storage", - "@setupStepStorage": { - "description": "Setup step indicator - storage" - }, - "setupStepNotification": "Notification", - "@setupStepNotification": { - "description": "Setup step indicator - notification" - }, - "setupStepFolder": "Folder", - "@setupStepFolder": { - "description": "Setup step indicator - folder" - }, - "setupStepSpotify": "Spotify", - "@setupStepSpotify": { - "description": "Setup step indicator - Spotify API" - }, - "setupStepPermission": "Permission", - "@setupStepPermission": { - "description": "Setup step indicator - permission" - }, "setupStorageGranted": "Storage Permission Granted!", "@setupStorageGranted": { "description": "Success message for storage permission" @@ -923,14 +607,6 @@ "@setupNotificationEnable": { "description": "Button to enable notifications" }, - "setupNotificationDescription": "Get notified when downloads complete or require attention.", - "@setupNotificationDescription": { - "description": "Explanation for notifications" - }, - "setupFolderSelected": "Download Folder Selected!", - "@setupFolderSelected": { - "description": "Success message for folder selection" - }, "setupFolderChoose": "Choose Download Folder", "@setupFolderChoose": { "description": "Button to choose folder" @@ -939,58 +615,14 @@ "@setupFolderDescription": { "description": "Explanation for folder selection" }, - "setupChangeFolder": "Change Folder", - "@setupChangeFolder": { - "description": "Button to change selected folder" - }, "setupSelectFolder": "Select Folder", "@setupSelectFolder": { "description": "Button to select folder" }, - "setupSpotifyApiOptional": "Spotify API (Optional)", - "@setupSpotifyApiOptional": { - "description": "Spotify API step title" - }, - "setupSpotifyApiDescription": "Add your Spotify API credentials for better search results and access to Spotify-exclusive content.", - "@setupSpotifyApiDescription": { - "description": "Explanation for Spotify API" - }, - "setupUseSpotifyApi": "Use Spotify API", - "@setupUseSpotifyApi": { - "description": "Toggle to enable Spotify API" - }, - "setupEnterCredentialsBelow": "Enter your credentials below", - "@setupEnterCredentialsBelow": { - "description": "Prompt to enter credentials" - }, - "setupUsingDeezer": "Using Deezer (no account needed)", - "@setupUsingDeezer": { - "description": "Status when using Deezer" - }, - "setupEnterClientId": "Enter Spotify Client ID", - "@setupEnterClientId": { - "description": "Placeholder for client ID field" - }, - "setupEnterClientSecret": "Enter Spotify Client Secret", - "@setupEnterClientSecret": { - "description": "Placeholder for client secret field" - }, - "setupGetFreeCredentials": "Get your free API credentials from the Spotify Developer Dashboard.", - "@setupGetFreeCredentials": { - "description": "Info about getting Spotify credentials" - }, "setupEnableNotifications": "Enable Notifications", "@setupEnableNotifications": { "description": "Button to enable notifications" }, - "setupProceedToNextStep": "You can now proceed to the next step.", - "@setupProceedToNextStep": { - "description": "Message after completing a step" - }, - "setupNotificationProgressDescription": "You will receive download progress notifications.", - "@setupNotificationProgressDescription": { - "description": "Info about notification usage" - }, "setupNotificationBackgroundDescription": "Get notified about download progress and completion. This helps you track downloads when the app is in background.", "@setupNotificationBackgroundDescription": { "description": "Detailed notification explanation" @@ -999,10 +631,6 @@ "@setupSkipForNow": { "description": "Skip button text" }, - "setupBack": "Back", - "@setupBack": { - "description": "Back button text" - }, "setupNext": "Next", "@setupNext": { "description": "Next button text" @@ -1011,26 +639,14 @@ "@setupGetStarted": { "description": "Final setup button" }, - "setupSkipAndStart": "Skip & Start", - "@setupSkipAndStart": { - "description": "Skip setup and start app" - }, "setupAllowAccessToManageFiles": "Please enable \"Allow access to manage all files\" in the next screen.", "@setupAllowAccessToManageFiles": { "description": "Instruction for file access permission" }, - "setupGetCredentialsFromSpotify": "Get credentials from developer.spotify.com", - "@setupGetCredentialsFromSpotify": { - "description": "Link text for Spotify developer portal" - }, "dialogCancel": "Cancel", "@dialogCancel": { "description": "Dialog button - cancel action" }, - "dialogOk": "OK", - "@dialogOk": { - "description": "Dialog button - confirm/acknowledge" - }, "dialogSave": "Save", "@dialogSave": { "description": "Dialog button - save changes" @@ -1043,26 +659,10 @@ "@dialogRetry": { "description": "Dialog button - retry action" }, - "dialogClose": "Close", - "@dialogClose": { - "description": "Dialog button - close dialog" - }, - "dialogYes": "Yes", - "@dialogYes": { - "description": "Dialog button - confirm yes" - }, - "dialogNo": "No", - "@dialogNo": { - "description": "Dialog button - confirm no" - }, "dialogClear": "Clear", "@dialogClear": { "description": "Dialog button - clear items" }, - "dialogConfirm": "Confirm", - "@dialogConfirm": { - "description": "Dialog button - confirm action" - }, "dialogDone": "Done", "@dialogDone": { "description": "Dialog button - action completed" @@ -1091,34 +691,10 @@ "@dialogUnsavedChanges": { "description": "Dialog message - unsaved changes" }, - "dialogDownloadFailed": "Download Failed", - "@dialogDownloadFailed": { - "description": "Dialog title - download error" - }, - "dialogTrackLabel": "Track:", - "@dialogTrackLabel": { - "description": "Label for track name in error dialog" - }, - "dialogArtistLabel": "Artist:", - "@dialogArtistLabel": { - "description": "Label for artist name in error dialog" - }, - "dialogErrorLabel": "Error:", - "@dialogErrorLabel": { - "description": "Label for error message" - }, "dialogClearAll": "Clear All", "@dialogClearAll": { "description": "Dialog title - clear all items" }, - "dialogClearAllDownloads": "Are you sure you want to clear all downloads?", - "@dialogClearAllDownloads": { - "description": "Dialog message - clear downloads confirmation" - }, - "dialogRemoveFromDevice": "Remove from device?", - "@dialogRemoveFromDevice": { - "description": "Dialog title - delete file confirmation" - }, "dialogRemoveExtension": "Remove Extension", "@dialogRemoveExtension": { "description": "Dialog title - uninstall extension" @@ -1257,15 +833,6 @@ "@snackbarViewQueue": { "description": "Snackbar action - view download queue" }, - "snackbarFailedToLoad": "Failed to load: {error}", - "@snackbarFailedToLoad": { - "description": "Snackbar - loading error", - "placeholders": { - "error": { - "type": "String" - } - } - }, "snackbarUrlCopied": "{platform} URL copied to clipboard", "@snackbarUrlCopied": { "description": "Snackbar - URL copied", @@ -1326,16 +893,6 @@ "@errorRateLimitedMessage": { "description": "Error message - rate limit explanation" }, - "errorFailedToLoad": "Failed to load {item}", - "@errorFailedToLoad": { - "description": "Error message - loading failed", - "placeholders": { - "item": { - "type": "String", - "description": "Item that failed to load (album/playlist/etc)" - } - } - }, "errorNoTracksFound": "No tracks found", "@errorNoTracksFound": { "description": "Error - search returned no results" @@ -1349,34 +906,6 @@ } } }, - "statusQueued": "Queued", - "@statusQueued": { - "description": "Download status - waiting in queue" - }, - "statusDownloading": "Downloading", - "@statusDownloading": { - "description": "Download status - in progress" - }, - "statusFinalizing": "Finalizing", - "@statusFinalizing": { - "description": "Download status - writing metadata" - }, - "statusCompleted": "Completed", - "@statusCompleted": { - "description": "Download status - finished" - }, - "statusFailed": "Failed", - "@statusFailed": { - "description": "Download status - error occurred" - }, - "statusSkipped": "Skipped", - "@statusSkipped": { - "description": "Download status - already exists" - }, - "statusPaused": "Paused", - "@statusPaused": { - "description": "Download status - paused" - }, "actionPause": "Pause", "@actionPause": { "description": "Action button - pause download" @@ -1389,14 +918,6 @@ "@actionCancel": { "description": "Action button - cancel operation" }, - "actionStop": "Stop", - "@actionStop": { - "description": "Action button - stop operation" - }, - "actionSelect": "Select", - "@actionSelect": { - "description": "Action button - enter selection mode" - }, "actionSelectAll": "Select All", "@actionSelectAll": { "description": "Action button - select all items" @@ -1405,14 +926,6 @@ "@actionDeselect": { "description": "Action button - deselect all" }, - "actionPaste": "Paste", - "@actionPaste": { - "description": "Action button - paste from clipboard" - }, - "actionImportCsv": "Import CSV", - "@actionImportCsv": { - "description": "Action button - import CSV file" - }, "actionRemoveCredentials": "Remove Credentials", "@actionRemoveCredentials": { "description": "Action button - delete Spotify credentials" @@ -1434,19 +947,6 @@ "@selectionAllSelected": { "description": "Status - all items selected" }, - "selectionTapToSelect": "Tap tracks to select", - "@selectionTapToSelect": { - "description": "Hint - how to select items" - }, - "selectionDeleteTracks": "Delete {count} {count, plural, =1{track} other{tracks}}", - "@selectionDeleteTracks": { - "description": "Delete button with count", - "placeholders": { - "count": { - "type": "int" - } - } - }, "selectionSelectToDelete": "Select tracks to delete", "@selectionSelectToDelete": { "description": "Placeholder when nothing selected" @@ -1487,55 +987,10 @@ "@tooltipPlay": { "description": "Tooltip - play button" }, - "tooltipCancel": "Cancel", - "@tooltipCancel": { - "description": "Tooltip - cancel button" - }, - "tooltipStop": "Stop", - "@tooltipStop": { - "description": "Tooltip - stop button" - }, - "tooltipRetry": "Retry", - "@tooltipRetry": { - "description": "Tooltip - retry button" - }, - "tooltipRemove": "Remove", - "@tooltipRemove": { - "description": "Tooltip - remove button" - }, - "tooltipClear": "Clear", - "@tooltipClear": { - "description": "Tooltip - clear button" - }, - "tooltipPaste": "Paste", - "@tooltipPaste": { - "description": "Tooltip - paste button" - }, "filenameFormat": "Filename Format", "@filenameFormat": { "description": "Setting title - filename pattern" }, - "filenameFormatPreview": "Preview: {preview}", - "@filenameFormatPreview": { - "description": "Preview of filename pattern", - "placeholders": { - "preview": { - "type": "String" - } - } - }, - "filenameAvailablePlaceholders": "Available placeholders:", - "@filenameAvailablePlaceholders": { - "description": "Label for placeholder list" - }, - "filenameHint": "{artist} - {title}", - "@filenameHint": { - "description": "Default filename format hint" - }, - "folderOrganization": "Folder Organization", - "@folderOrganization": { - "description": "Setting title - folder structure" - }, "folderOrganizationNone": "No organization", "@folderOrganizationNone": { "description": "Folder option - flat structure" @@ -1576,27 +1031,10 @@ "@updateAvailable": { "description": "Update dialog title" }, - "updateNewVersion": "Version {version} is available", - "@updateNewVersion": { - "description": "Update available message", - "placeholders": { - "version": { - "type": "String" - } - } - }, - "updateDownload": "Download", - "@updateDownload": { - "description": "Update button - download update" - }, "updateLater": "Later", "@updateLater": { "description": "Update button - dismiss" }, - "updateChangelog": "Changelog", - "@updateChangelog": { - "description": "Link to changelog" - }, "updateStartingDownload": "Starting download...", "@updateStartingDownload": { "description": "Update status - initializing" @@ -1637,14 +1075,6 @@ "@updateDontRemind": { "description": "Update button - skip this version" }, - "providerPriority": "Provider Priority", - "@providerPriority": { - "description": "Setting title - download provider order" - }, - "providerPrioritySubtitle": "Drag to reorder download providers", - "@providerPrioritySubtitle": { - "description": "Subtitle for provider priority" - }, "providerPriorityTitle": "Provider Priority", "@providerPriorityTitle": { "description": "Provider priority page title" @@ -1665,14 +1095,6 @@ "@providerExtension": { "description": "Label for extension-provided providers" }, - "metadataProviderPriority": "Metadata Provider Priority", - "@metadataProviderPriority": { - "description": "Setting title - metadata provider order" - }, - "metadataProviderPrioritySubtitle": "Order used when fetching track metadata", - "@metadataProviderPrioritySubtitle": { - "description": "Subtitle for metadata priority" - }, "metadataProviderPriorityTitle": "Metadata Priority", "@metadataProviderPriorityTitle": { "description": "Metadata priority page title" @@ -1697,22 +1119,6 @@ "@logTitle": { "description": "Logs screen title" }, - "logCopy": "Copy Logs", - "@logCopy": { - "description": "Action - copy logs to clipboard" - }, - "logClear": "Clear Logs", - "@logClear": { - "description": "Action - delete all logs" - }, - "logShare": "Share Logs", - "@logShare": { - "description": "Action - share logs file" - }, - "logEmpty": "No logs yet", - "@logEmpty": { - "description": "Empty state title" - }, "logCopied": "Logs copied to clipboard", "@logCopied": { "description": "Snackbar - logs copied" @@ -1745,22 +1151,6 @@ "@logClearLogsMessage": { "description": "Clear logs confirmation message" }, - "logIspBlocking": "ISP BLOCKING DETECTED", - "@logIspBlocking": { - "description": "Error category - ISP blocking" - }, - "logRateLimited": "RATE LIMITED", - "@logRateLimited": { - "description": "Error category - rate limiting" - }, - "logNetworkError": "NETWORK ERROR", - "@logNetworkError": { - "description": "Error category - network issues" - }, - "logTrackNotFound": "TRACK NOT FOUND", - "@logTrackNotFound": { - "description": "Error category - missing tracks" - }, "logFilterBySeverity": "Filter logs by severity", "@logFilterBySeverity": { "description": "Filter dialog title" @@ -1773,60 +1163,6 @@ "@logNoLogsYetSubtitle": { "description": "Empty state subtitle" }, - "logIssueSummary": "Issue Summary", - "@logIssueSummary": { - "description": "Section header for error summary" - }, - "logIspBlockingDescription": "Your ISP may be blocking access to download services", - "@logIspBlockingDescription": { - "description": "ISP blocking explanation" - }, - "logIspBlockingSuggestion": "Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8", - "@logIspBlockingSuggestion": { - "description": "ISP blocking fix suggestion" - }, - "logRateLimitedDescription": "Too many requests to the service", - "@logRateLimitedDescription": { - "description": "Rate limit explanation" - }, - "logRateLimitedSuggestion": "Wait a few minutes before trying again", - "@logRateLimitedSuggestion": { - "description": "Rate limit fix suggestion" - }, - "logNetworkErrorDescription": "Connection issues detected", - "@logNetworkErrorDescription": { - "description": "Network error explanation" - }, - "logNetworkErrorSuggestion": "Check your internet connection", - "@logNetworkErrorSuggestion": { - "description": "Network error fix suggestion" - }, - "logTrackNotFoundDescription": "Some tracks could not be found on download services", - "@logTrackNotFoundDescription": { - "description": "Track not found explanation" - }, - "logTrackNotFoundSuggestion": "The track may not be available in lossless quality", - "@logTrackNotFoundSuggestion": { - "description": "Track not found explanation" - }, - "logTotalErrors": "Total errors: {count}", - "@logTotalErrors": { - "description": "Error count display", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "logAffected": "Affected: {domains}", - "@logAffected": { - "description": "Affected domains display", - "placeholders": { - "domains": { - "type": "String" - } - } - }, "logEntriesFiltered": "Entries ({count} filtered)", "@logEntriesFiltered": { "description": "Log count with filter active", @@ -1969,10 +1305,6 @@ "@appearanceLanguage": { "description": "Language setting title" }, - "appearanceLanguageSubtitle": "Choose your preferred language", - "@appearanceLanguageSubtitle": { - "description": "Language setting subtitle" - }, "settingsAppearanceSubtitle": "Theme, colors, display", "@settingsAppearanceSubtitle": { "description": "Appearance settings description" @@ -2001,10 +1333,6 @@ "@pressBackAgainToExit": { "description": "Exit confirmation message" }, - "tracksHeader": "Tracks", - "@tracksHeader": { - "description": "Section header for track list" - }, "downloadAllCount": "Download All ({count})", "@downloadAllCount": { "description": "Download all button with count", @@ -2151,15 +1479,6 @@ "@trackDeleteConfirmMessage": { "description": "Delete confirmation message" }, - "trackCannotOpen": "Cannot open: {message}", - "@trackCannotOpen": { - "description": "Error opening file", - "placeholders": { - "message": { - "type": "String" - } - } - }, "dateToday": "Today", "@dateToday": { "description": "Relative date - today" @@ -2195,22 +1514,6 @@ } } }, - "concurrentSequential": "Sequential", - "@concurrentSequential": { - "description": "Download mode - one at a time" - }, - "concurrentParallel2": "2 Parallel", - "@concurrentParallel2": { - "description": "Download mode - 2 simultaneous" - }, - "concurrentParallel3": "3 Parallel", - "@concurrentParallel3": { - "description": "Download mode - 3 simultaneous" - }, - "tapToSeeError": "Tap to see error details", - "@tapToSeeError": { - "description": "Tooltip for failed download" - }, "storeFilterAll": "All", "@storeFilterAll": { "description": "Store filter - all extensions" @@ -2239,18 +1542,6 @@ "@storeClearFilters": { "description": "Button to clear all filters" }, - "storeNoResults": "No extensions found", - "@storeNoResults": { - "description": "Empty state when no extensions match filters" - }, - "extensionProviderPriority": "Provider Priority", - "@extensionProviderPriority": { - "description": "Extension capability - provider priority" - }, - "extensionInstallButton": "Install Extension", - "@extensionInstallButton": { - "description": "Button to install extension" - }, "extensionDefaultProvider": "Default (Deezer/Spotify)", "@extensionDefaultProvider": { "description": "Default search provider option" @@ -2450,46 +1741,6 @@ "@qualityHiResFlacMaxSubtitle": { "description": "Technical spec for hi-res max" }, - "qualityLossy": "Lossy", - "@qualityLossy": { - "description": "Quality option - lossy format (MP3/Opus)" - }, - "qualityLossyMp3Subtitle": "MP3 320kbps (converted from FLAC)", - "@qualityLossyMp3Subtitle": { - "description": "Technical spec for lossy MP3" - }, - "qualityLossyOpusSubtitle": "Opus 128kbps (converted from FLAC)", - "@qualityLossyOpusSubtitle": { - "description": "Technical spec for lossy Opus" - }, - "enableLossyOption": "Enable Lossy Option", - "@enableLossyOption": { - "description": "Setting - enable lossy quality option" - }, - "enableLossyOptionSubtitleOn": "Lossy quality option is available", - "@enableLossyOptionSubtitleOn": { - "description": "Subtitle when lossy is enabled" - }, - "enableLossyOptionSubtitleOff": "Downloads FLAC then converts to lossy format", - "@enableLossyOptionSubtitleOff": { - "description": "Subtitle when lossy is disabled" - }, - "lossyFormat": "Lossy Format", - "@lossyFormat": { - "description": "Setting - choose lossy format" - }, - "lossyFormatDescription": "Choose the lossy format for conversion", - "@lossyFormatDescription": { - "description": "Description for lossy format picker" - }, - "lossyFormatMp3Subtitle": "320kbps, best compatibility", - "@lossyFormatMp3Subtitle": { - "description": "MP3 format description" - }, - "lossyFormatOpusSubtitle": "128kbps, better quality at smaller size", - "@lossyFormatOpusSubtitle": { - "description": "Opus format description" - }, "qualityNote": "Actual quality depends on track availability from the service", "@qualityNote": { "description": "Note about quality availability" @@ -2518,14 +1769,6 @@ "@downloadUseAlbumArtistForFolders": { "description": "Setting - choose whether artist folders use Album Artist or Track Artist" }, - "downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available", - "@downloadUseAlbumArtistForFoldersAlbumSubtitle": { - "description": "Subtitle when Album Artist is used for folder naming" - }, - "downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only", - "@downloadUseAlbumArtistForFoldersTrackSubtitle": { - "description": "Subtitle when Track Artist is used for folder naming" - }, "downloadUsePrimaryArtistOnly": "Primary artist only for folders", "@downloadUsePrimaryArtistOnly": { "description": "Setting - strip featured artists from folder name" @@ -2538,14 +1781,6 @@ "@downloadUsePrimaryArtistOnlyDisabled": { "description": "Subtitle when primary artist only is disabled" }, - "downloadSaveFormat": "Save Format", - "@downloadSaveFormat": { - "description": "Setting - output file format" - }, - "downloadSelectService": "Select Service", - "@downloadSelectService": { - "description": "Dialog title - choose download service" - }, "downloadSelectQuality": "Select Quality", "@downloadSelectQuality": { "description": "Dialog title - choose audio quality" @@ -2554,66 +1789,6 @@ "@downloadFrom": { "description": "Label - download source" }, - "downloadDefaultQualityLabel": "Default Quality", - "@downloadDefaultQualityLabel": { - "description": "Label - default quality setting" - }, - "downloadBestAvailable": "Best available", - "@downloadBestAvailable": { - "description": "Quality option - highest available" - }, - "folderNone": "None", - "@folderNone": { - "description": "Folder option - no organization" - }, - "folderNoneSubtitle": "Save all files directly to download folder", - "@folderNoneSubtitle": { - "description": "Subtitle for no folder organization" - }, - "folderArtist": "Artist", - "@folderArtist": { - "description": "Folder option - by artist" - }, - "folderArtistSubtitle": "Artist Name/filename", - "@folderArtistSubtitle": { - "description": "Folder structure example" - }, - "folderAlbum": "Album", - "@folderAlbum": { - "description": "Folder option - by album" - }, - "folderAlbumSubtitle": "Album Name/filename", - "@folderAlbumSubtitle": { - "description": "Folder structure example" - }, - "folderArtistAlbum": "Artist/Album", - "@folderArtistAlbum": { - "description": "Folder option - nested" - }, - "folderArtistAlbumSubtitle": "Artist Name/Album Name/filename", - "@folderArtistAlbumSubtitle": { - "description": "Folder structure example" - }, - "serviceTidal": "Tidal", - "@serviceTidal": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceQobuz": "Qobuz", - "@serviceQobuz": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceAmazon": "Amazon", - "@serviceAmazon": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceDeezer": "Deezer", - "@serviceDeezer": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceSpotify": "Spotify", - "@serviceSpotify": { - "description": "Service name - DO NOT TRANSLATE" - }, "appearanceAmoledDark": "AMOLED Dark", "@appearanceAmoledDark": { "description": "Theme option - pure black" @@ -2622,18 +1797,6 @@ "@appearanceAmoledDarkSubtitle": { "description": "Subtitle for AMOLED dark" }, - "appearanceChooseAccentColor": "Choose Accent Color", - "@appearanceChooseAccentColor": { - "description": "Color picker dialog title" - }, - "appearanceChooseTheme": "Theme Mode", - "@appearanceChooseTheme": { - "description": "Theme picker dialog title" - }, - "queueTitle": "Download Queue", - "@queueTitle": { - "description": "Queue screen title" - }, "queueClearAll": "Clear All", "@queueClearAll": { "description": "Button - clear all queue items" @@ -2642,22 +1805,6 @@ "@queueClearAllMessage": { "description": "Clear queue confirmation" }, - "queueExportFailed": "Export", - "@queueExportFailed": { - "description": "Button - export failed downloads to TXT" - }, - "queueExportFailedSuccess": "Failed downloads exported to TXT file", - "@queueExportFailedSuccess": { - "description": "Success message after exporting failed downloads" - }, - "queueExportFailedClear": "Clear Failed", - "@queueExportFailedClear": { - "description": "Action to clear failed downloads after export" - }, - "queueExportFailedError": "Failed to export downloads", - "@queueExportFailedError": { - "description": "Error message when export fails" - }, "settingsAutoExportFailed": "Auto-export failed downloads", "@settingsAutoExportFailed": { "description": "Setting toggle for auto-export" @@ -2682,38 +1829,6 @@ "@settingsDownloadNetworkSubtitle": { "description": "Subtitle explaining network preference" }, - "queueEmpty": "No downloads in queue", - "@queueEmpty": { - "description": "Empty queue state title" - }, - "queueEmptySubtitle": "Add tracks from the home screen", - "@queueEmptySubtitle": { - "description": "Empty queue state subtitle" - }, - "queueClearCompleted": "Clear completed", - "@queueClearCompleted": { - "description": "Button - clear finished downloads" - }, - "queueDownloadFailed": "Download Failed", - "@queueDownloadFailed": { - "description": "Error dialog title" - }, - "queueTrackLabel": "Track:", - "@queueTrackLabel": { - "description": "Label in error dialog" - }, - "queueArtistLabel": "Artist:", - "@queueArtistLabel": { - "description": "Label in error dialog" - }, - "queueErrorLabel": "Error:", - "@queueErrorLabel": { - "description": "Label in error dialog" - }, - "queueUnknownError": "Unknown error", - "@queueUnknownError": { - "description": "Fallback error message" - }, "albumFolderArtistAlbum": "Artist / Album", "@albumFolderArtistAlbum": { "description": "Album folder option" @@ -2767,19 +1882,6 @@ } } }, - "downloadedAlbumTracksHeader": "Tracks", - "@downloadedAlbumTracksHeader": { - "description": "Section header for tracks" - }, - "downloadedAlbumDownloadedCount": "{count} downloaded", - "@downloadedAlbumDownloadedCount": { - "description": "Downloaded tracks count badge", - "placeholders": { - "count": { - "type": "int" - } - } - }, "downloadedAlbumSelectedCount": "{count} selected", "@downloadedAlbumSelectedCount": { "description": "Selection count indicator", @@ -2820,10 +1922,6 @@ } } }, - "utilityFunctions": "Utility Functions", - "@utilityFunctions": { - "description": "Extension capability - utility functions" - }, "recentTypeArtist": "Artist", "@recentTypeArtist": { "description": "Recent access item type - artist" @@ -2858,16 +1956,6 @@ } } }, - "errorGeneric": "Error: {message}", - "@errorGeneric": { - "description": "Generic error message format", - "placeholders": { - "message": { - "type": "String", - "description": "Error message" - } - } - }, "discographyDownload": "Download Discography", "@discographyDownload": { "description": "Button - download artist discography" @@ -3034,10 +2122,6 @@ "@libraryTitle": { "description": "Library settings page title" }, - "libraryStatus": "Library Status", - "@libraryStatus": { - "description": "Section header for library status" - }, "libraryScanSettings": "Scan Settings", "@libraryScanSettings": { "description": "Section header for scan settings" @@ -3114,15 +2198,6 @@ "@libraryAboutDescription": { "description": "Description of local library feature" }, - "libraryTracksCount": "{count} tracks", - "@libraryTracksCount": { - "description": "Track count in library", - "placeholders": { - "count": { - "type": "int" - } - } - }, "libraryLastScanned": "Last scanned: {time}", "@libraryLastScanned": { "description": "Last scan time display", @@ -3237,26 +2312,6 @@ "@libraryFilterFormat": { "description": "Filter section - file format" }, - "libraryFilterDate": "Date Added", - "@libraryFilterDate": { - "description": "Filter section - date range" - }, - "libraryFilterDateToday": "Today", - "@libraryFilterDateToday": { - "description": "Filter option - today only" - }, - "libraryFilterDateWeek": "This Week", - "@libraryFilterDateWeek": { - "description": "Filter option - this week" - }, - "libraryFilterDateMonth": "This Month", - "@libraryFilterDateMonth": { - "description": "Filter option - this month" - }, - "libraryFilterDateYear": "This Year", - "@libraryFilterDateYear": { - "description": "Filter option - this year" - }, "libraryFilterSort": "Sort", "@libraryFilterSort": { "description": "Filter section - sort order" @@ -3269,15 +2324,6 @@ "@libraryFilterSortOldest": { "description": "Sort option - oldest first" }, - "libraryFilterActive": "{count} filter(s) active", - "@libraryFilterActive": { - "description": "Badge showing number of active filters", - "placeholders": { - "count": { - "type": "int" - } - } - }, "timeJustNow": "Just now", "@timeJustNow": { "description": "Relative time - less than a minute ago" @@ -3300,106 +2346,6 @@ } } }, - "storageSwitchTitle": "Switch Storage Mode", - "@storageSwitchTitle": { - "description": "Dialog title when switching storage mode" - }, - "storageSwitchToSafTitle": "Switch to SAF Storage?", - "@storageSwitchToSafTitle": { - "description": "Dialog title when switching to SAF" - }, - "storageSwitchToAppTitle": "Switch to App Storage?", - "@storageSwitchToAppTitle": { - "description": "Dialog title when switching to app storage" - }, - "storageSwitchToSafMessage": "Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.", - "@storageSwitchToSafMessage": { - "description": "Explanation when switching to SAF" - }, - "storageSwitchToAppMessage": "Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.", - "@storageSwitchToAppMessage": { - "description": "Explanation when switching to app storage" - }, - "storageSwitchExistingDownloads": "Existing Downloads", - "@storageSwitchExistingDownloads": { - "description": "Section header for existing downloads info" - }, - "storageSwitchExistingDownloadsInfo": "{count} tracks in {mode} storage", - "@storageSwitchExistingDownloadsInfo": { - "description": "Info about existing downloads count", - "placeholders": { - "count": { - "type": "int" - }, - "mode": { - "type": "String" - } - } - }, - "storageSwitchNewDownloads": "New Downloads", - "@storageSwitchNewDownloads": { - "description": "Section header for new downloads info" - }, - "storageSwitchNewDownloadsLocation": "Will be saved to: {location}", - "@storageSwitchNewDownloadsLocation": { - "description": "Shows where new downloads will go", - "placeholders": { - "location": { - "type": "String" - } - } - }, - "storageSwitchContinue": "Continue", - "@storageSwitchContinue": { - "description": "Button to proceed with storage switch" - }, - "storageSwitchSelectFolder": "Select SAF Folder", - "@storageSwitchSelectFolder": { - "description": "Button to select SAF folder" - }, - "storageAppStorage": "App Storage", - "@storageAppStorage": { - "description": "Label for app storage mode" - }, - "storageSafStorage": "SAF Storage", - "@storageSafStorage": { - "description": "Label for SAF storage mode" - }, - "storageModeBadge": "Storage: {mode}", - "@storageModeBadge": { - "description": "Badge showing storage mode for a track", - "placeholders": { - "mode": { - "type": "String" - } - } - }, - "storageStatsTitle": "Storage Statistics", - "@storageStatsTitle": { - "description": "Section title for storage stats" - }, - "storageStatsAppCount": "{count} tracks in App Storage", - "@storageStatsAppCount": { - "description": "Count of tracks in app storage", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "storageStatsSafCount": "{count} tracks in SAF Storage", - "@storageStatsSafCount": { - "description": "Count of tracks in SAF storage", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "storageModeInfo": "Your files are stored in multiple locations", - "@storageModeInfo": { - "description": "Info when user has files in both storage modes" - }, "tutorialWelcomeTitle": "Welcome to SpotiFLAC!", "@tutorialWelcomeTitle": { "description": "Tutorial welcome page title" @@ -3428,18 +2374,6 @@ "@tutorialSearchDesc": { "description": "Tutorial search page description" }, - "tutorialSearchTip1": "Paste a Spotify or Deezer URL directly in the search box", - "@tutorialSearchTip1": { - "description": "Tutorial search tip 1" - }, - "tutorialSearchTip2": "Or type the song name, artist, or album to search", - "@tutorialSearchTip2": { - "description": "Tutorial search tip 2" - }, - "tutorialSearchTip3": "Supports tracks, albums, playlists, and artist pages", - "@tutorialSearchTip3": { - "description": "Tutorial search tip 3" - }, "tutorialDownloadTitle": "Downloading Music", "@tutorialDownloadTitle": { "description": "Tutorial download page title" @@ -3448,18 +2382,6 @@ "@tutorialDownloadDesc": { "description": "Tutorial download page description" }, - "tutorialDownloadTip1": "Tap the download button next to any track to start downloading", - "@tutorialDownloadTip1": { - "description": "Tutorial download tip 1" - }, - "tutorialDownloadTip2": "Choose your preferred quality (FLAC, Hi-Res, or MP3)", - "@tutorialDownloadTip2": { - "description": "Tutorial download tip 2" - }, - "tutorialDownloadTip3": "Download entire albums or playlists with one tap", - "@tutorialDownloadTip3": { - "description": "Tutorial download tip 3" - }, "tutorialLibraryTitle": "Your Library", "@tutorialLibraryTitle": { "description": "Tutorial library page title" @@ -3524,10 +2446,6 @@ "@tutorialReadyMessage": { "description": "Tutorial completion message" }, - "tutorialExample": "EXAMPLE", - "@tutorialExample": { - "description": "Example label in tutorial" - }, "libraryForceFullScan": "Force Full Scan", "@libraryForceFullScan": { "description": "Button to force a complete rescan of library" @@ -3750,14 +2668,10 @@ "@trackSaveLyricsProgress": { "description": "Snackbar while saving lyrics to file" }, - "trackReEnrich": "Re-enrich Metadata", + "trackReEnrich": "Re-enrich", "@trackReEnrich": { "description": "Menu action - re-embed metadata into audio file" }, - "trackReEnrichSubtitle": "Re-embed metadata without re-downloading", - "@trackReEnrichSubtitle": { - "description": "Subtitle for re-enrich metadata action" - }, "trackReEnrichOnlineSubtitle": "Search metadata online and embed into file", "@trackReEnrichOnlineSubtitle": { "description": "Subtitle for re-enrich metadata action for local items" @@ -3868,5 +2782,22 @@ "trackConvertFailed": "Conversion failed", "@trackConvertFailed": { "description": "Snackbar when conversion fails" + }, + "downloadedAlbumDownloadedCount": "{count} downloaded", + "@downloadedAlbumDownloadedCount": { + "description": "Downloaded tracks count badge", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available", + "@downloadUseAlbumArtistForFoldersAlbumSubtitle": { + "description": "Subtitle when Album Artist is used for folder naming" + }, + "downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only", + "@downloadUseAlbumArtistForFoldersTrackSubtitle": { + "description": "Subtitle when Track Artist is used for folder naming" } -} \ No newline at end of file +} diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index 72f43405..0d7caf67 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -1,1536 +1,995 @@ -{ - "@@locale": "id", - "@@last_modified": "2026-01-16", - "appName": "SpotiFLAC", - "@appName": { - "description": "App name - DO NOT TRANSLATE" - }, - "appDescription": "Unduh lagu Spotify dalam kualitas lossless dari Tidal, Qobuz, dan Amazon Music.", - "@appDescription": { - "description": "App description shown in about page" - }, - "navHome": "Beranda", - "@navHome": { - "description": "Bottom navigation - Home tab" - }, - "navLibrary": "Library", - "@navLibrary": { - "description": "Bottom navigation - Library tab" - }, - "navHistory": "Riwayat", - "@navHistory": { - "description": "Bottom navigation - History tab (legacy)" - }, - "navSettings": "Pengaturan", - "@navSettings": { - "description": "Bottom navigation - Settings tab" - }, - "navStore": "Toko", - "@navStore": { - "description": "Bottom navigation - Extension store tab" - }, - "homeTitle": "Beranda", - "@homeTitle": { - "description": "Home screen title" - }, - "homeSearchHint": "Tempel URL Spotify atau cari...", - "@homeSearchHint": { - "description": "Placeholder text in search box" - }, - "homeSearchHintExtension": "Cari dengan {extensionName}...", - "@homeSearchHintExtension": { - "description": "Placeholder when extension search is active", - "placeholders": { - "extensionName": { - "type": "String", - "description": "Name of the active extension" - } - } - }, - "homeSubtitle": "Tempel link Spotify atau cari berdasarkan nama", - "@homeSubtitle": { - "description": "Subtitle shown below search box" - }, - "homeSupports": "Mendukung: URL Track, Album, Playlist, Artis", - "@homeSupports": { - "description": "Info text about supported URL types" - }, - "homeRecent": "Terbaru", - "@homeRecent": { - "description": "Section header for recent searches" - }, - "historyTitle": "Riwayat", - "@historyTitle": { - "description": "History screen title" - }, - "historyDownloading": "Mengunduh ({count})", - "@historyDownloading": { - "description": "Tab showing active downloads count", - "placeholders": { - "count": { - "type": "int", - "description": "Number of active downloads" - } - } - }, - "historyDownloaded": "Terunduh", - "@historyDownloaded": { - "description": "Tab showing completed downloads" - }, - "historyFilterAll": "Semua", - "@historyFilterAll": { - "description": "Filter chip - show all items" - }, - "historyFilterAlbums": "Album", - "@historyFilterAlbums": { - "description": "Filter chip - show albums only" - }, - "historyFilterSingles": "Single", - "@historyFilterSingles": { - "description": "Filter chip - show singles only" - }, - "historyTracksCount": "{count, plural, =1{1 lagu} other{{count} lagu}}", - "@historyTracksCount": { - "description": "Track count with plural form", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "historyAlbumsCount": "{count, plural, =1{1 album} other{{count} album}}", - "@historyAlbumsCount": { - "description": "Album count with plural form", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "historyNoDownloads": "Tidak ada riwayat unduhan", - "@historyNoDownloads": { - "description": "Empty state title" - }, - "historyNoDownloadsSubtitle": "Lagu yang diunduh akan muncul di sini", - "@historyNoDownloadsSubtitle": { - "description": "Empty state subtitle" - }, - "historyNoAlbums": "Tidak ada unduhan album", - "@historyNoAlbums": { - "description": "Empty state when filtering albums" - }, - "historyNoAlbumsSubtitle": "Unduh beberapa lagu dari album untuk melihatnya di sini", - "@historyNoAlbumsSubtitle": { - "description": "Empty state subtitle for albums filter" - }, - "historyNoSingles": "Tidak ada unduhan single", - "@historyNoSingles": { - "description": "Empty state when filtering singles" - }, - "historyNoSinglesSubtitle": "Unduhan lagu satuan akan muncul di sini", - "@historyNoSinglesSubtitle": { - "description": "Empty state subtitle for singles filter" - }, - "historySearchHint": "Search history...", - "@historySearchHint": { - "description": "Search bar placeholder in history" - }, - "settingsTitle": "Pengaturan", - "@settingsTitle": { - "description": "Settings screen title" - }, - "settingsDownload": "Unduhan", - "@settingsDownload": { - "description": "Settings section - download options" - }, - "settingsAppearance": "Tampilan", - "@settingsAppearance": { - "description": "Settings section - visual customization" - }, - "settingsOptions": "Opsi", - "@settingsOptions": { - "description": "Settings section - app options" - }, - "settingsExtensions": "Ekstensi", - "@settingsExtensions": { - "description": "Settings section - extension management" - }, - "settingsAbout": "Tentang", - "@settingsAbout": { - "description": "Settings section - app info" - }, - "downloadTitle": "Unduhan", - "@downloadTitle": { - "description": "Download settings page title" - }, - "downloadLocation": "Lokasi Unduhan", - "@downloadLocation": { - "description": "Setting for download folder" - }, - "downloadLocationSubtitle": "Pilih tempat menyimpan file", - "@downloadLocationSubtitle": { - "description": "Subtitle for download location" - }, - "downloadLocationDefault": "Lokasi default", - "@downloadLocationDefault": { - "description": "Shown when using default folder" - }, - "downloadDefaultService": "Layanan Default", - "@downloadDefaultService": { - "description": "Setting for preferred download service (Tidal/Qobuz/Amazon)" - }, - "downloadDefaultServiceSubtitle": "Layanan yang digunakan untuk unduhan", - "@downloadDefaultServiceSubtitle": { - "description": "Subtitle for default service" - }, - "downloadDefaultQuality": "Kualitas Default", - "@downloadDefaultQuality": { - "description": "Setting for audio quality" - }, - "downloadAskQuality": "Tanya Kualitas Sebelum Unduh", - "@downloadAskQuality": { - "description": "Toggle to show quality picker" - }, - "downloadAskQualitySubtitle": "Tampilkan pemilih kualitas untuk setiap unduhan", - "@downloadAskQualitySubtitle": { - "description": "Subtitle for ask quality toggle" - }, - "downloadFilenameFormat": "Format Nama File", - "@downloadFilenameFormat": { - "description": "Setting for output filename pattern" - }, - "downloadFolderOrganization": "Organisasi Folder", - "@downloadFolderOrganization": { - "description": "Setting for folder structure" - }, - "downloadSeparateSingles": "Pisahkan Single", - "@downloadSeparateSingles": { - "description": "Toggle to separate single tracks" - }, - "downloadSeparateSinglesSubtitle": "Letakkan lagu satuan di folder terpisah", - "@downloadSeparateSinglesSubtitle": { - "description": "Subtitle for separate singles toggle" - }, - "qualityBest": "Terbaik", - "@qualityBest": { - "description": "Audio quality option - highest available" - }, - "qualityFlac": "FLAC", - "@qualityFlac": { - "description": "Audio quality option - FLAC lossless" - }, - "quality320": "320 kbps", - "@quality320": { - "description": "Audio quality option - 320kbps MP3" - }, - "quality128": "128 kbps", - "@quality128": { - "description": "Audio quality option - 128kbps MP3" - }, - "appearanceTitle": "Tampilan", - "@appearanceTitle": { - "description": "Appearance settings page title" - }, - "appearanceTheme": "Tema", - "@appearanceTheme": { - "description": "Theme mode setting" - }, - "appearanceThemeSystem": "Sistem", - "@appearanceThemeSystem": { - "description": "Follow system theme" - }, - "appearanceThemeLight": "Terang", - "@appearanceThemeLight": { - "description": "Light theme" - }, - "appearanceThemeDark": "Gelap", - "@appearanceThemeDark": { - "description": "Dark theme" - }, - "appearanceDynamicColor": "Warna Dinamis", - "@appearanceDynamicColor": { - "description": "Material You dynamic colors" - }, - "appearanceDynamicColorSubtitle": "Gunakan warna dari wallpaper Anda", - "@appearanceDynamicColorSubtitle": { - "description": "Subtitle for dynamic color" - }, - "appearanceAccentColor": "Warna Aksen", - "@appearanceAccentColor": { - "description": "Custom accent color picker" - }, - "appearanceHistoryView": "Tampilan Riwayat", - "@appearanceHistoryView": { - "description": "Layout style for history" - }, - "appearanceHistoryViewList": "Daftar", - "@appearanceHistoryViewList": { - "description": "List layout option" - }, - "appearanceHistoryViewGrid": "Grid", - "@appearanceHistoryViewGrid": { - "description": "Grid layout option" - }, - "optionsTitle": "Opsi", - "@optionsTitle": { - "description": "Options settings page title" - }, - "optionsSearchSource": "Sumber Pencarian", - "@optionsSearchSource": { - "description": "Section for search provider settings" - }, - "optionsPrimaryProvider": "Provider Utama", - "@optionsPrimaryProvider": { - "description": "Main search provider setting" - }, - "optionsPrimaryProviderSubtitle": "Layanan yang digunakan saat mencari berdasarkan nama lagu.", - "@optionsPrimaryProviderSubtitle": { - "description": "Subtitle for primary provider" - }, - "optionsUsingExtension": "Menggunakan ekstensi: {extensionName}", - "@optionsUsingExtension": { - "description": "Shows active extension name", - "placeholders": { - "extensionName": { - "type": "String" - } - } - }, - "optionsSwitchBack": "Ketuk Deezer atau Spotify untuk beralih dari ekstensi", - "@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", - "@optionsUseExtensionProviders": { - "description": "Enable extension download providers" - }, - "optionsUseExtensionProvidersOn": "Ekstensi akan dicoba terlebih dahulu", - "@optionsUseExtensionProvidersOn": { - "description": "Status when extension providers enabled" - }, - "optionsUseExtensionProvidersOff": "Hanya menggunakan provider bawaan", - "@optionsUseExtensionProvidersOff": { - "description": "Status when extension providers disabled" - }, - "optionsEmbedLyrics": "Sematkan Lirik", - "@optionsEmbedLyrics": { - "description": "Embed lyrics in audio files" - }, - "optionsEmbedLyricsSubtitle": "Sematkan lirik sinkron ke file FLAC", - "@optionsEmbedLyricsSubtitle": { - "description": "Subtitle for embed lyrics" - }, - "optionsMaxQualityCover": "Cover Kualitas Maksimal", - "@optionsMaxQualityCover": { - "description": "Download highest quality album art" - }, - "optionsMaxQualityCoverSubtitle": "Unduh cover art resolusi tertinggi", - "@optionsMaxQualityCoverSubtitle": { - "description": "Subtitle for max quality cover" - }, - "optionsConcurrentDownloads": "Unduhan Bersamaan", - "@optionsConcurrentDownloads": { - "description": "Number of parallel downloads" - }, - "optionsConcurrentSequential": "Berurutan (1 per waktu)", - "@optionsConcurrentSequential": { - "description": "Download one at a time" - }, - "optionsConcurrentParallel": "{count} unduhan paralel", - "@optionsConcurrentParallel": { - "description": "Multiple parallel downloads", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "optionsConcurrentWarning": "Unduhan paralel dapat memicu pembatasan rate", - "@optionsConcurrentWarning": { - "description": "Warning about rate limits" - }, - "optionsExtensionStore": "Toko Ekstensi", - "@optionsExtensionStore": { - "description": "Show/hide store tab" - }, - "optionsExtensionStoreSubtitle": "Tampilkan tab Toko di navigasi", - "@optionsExtensionStoreSubtitle": { - "description": "Subtitle for extension store toggle" - }, - "optionsCheckUpdates": "Periksa Pembaruan", - "@optionsCheckUpdates": { - "description": "Auto update check toggle" - }, - "optionsCheckUpdatesSubtitle": "Beritahu saat versi baru tersedia", - "@optionsCheckUpdatesSubtitle": { - "description": "Subtitle for update check" - }, - "optionsUpdateChannel": "Saluran Pembaruan", - "@optionsUpdateChannel": { - "description": "Stable vs preview releases" - }, - "optionsUpdateChannelStable": "Hanya rilis stabil", - "@optionsUpdateChannelStable": { - "description": "Only stable updates" - }, - "optionsUpdateChannelPreview": "Dapatkan rilis preview", - "@optionsUpdateChannelPreview": { - "description": "Include beta/preview updates" - }, - "optionsUpdateChannelWarning": "Preview mungkin mengandung bug atau fitur belum lengkap", - "@optionsUpdateChannelWarning": { - "description": "Warning about preview channel" - }, - "optionsClearHistory": "Hapus Riwayat Unduhan", - "@optionsClearHistory": { - "description": "Delete all download history" - }, - "optionsClearHistorySubtitle": "Hapus semua lagu dari riwayat", - "@optionsClearHistorySubtitle": { - "description": "Subtitle for clear history" - }, - "optionsDetailedLogging": "Log Detail", - "@optionsDetailedLogging": { - "description": "Enable verbose logs for debugging" - }, - "optionsDetailedLoggingOn": "Log detail sedang direkam", - "@optionsDetailedLoggingOn": { - "description": "Status when logging enabled" - }, - "optionsDetailedLoggingOff": "Aktifkan untuk laporan bug", - "@optionsDetailedLoggingOff": { - "description": "Status when logging disabled" - }, - "optionsSpotifyCredentials": "Kredensial Spotify", - "@optionsSpotifyCredentials": { - "description": "Spotify API credentials setting" - }, - "optionsSpotifyCredentialsConfigured": "Client ID: {clientId}...", - "@optionsSpotifyCredentialsConfigured": { - "description": "Shows configured client ID preview", - "placeholders": { - "clientId": { - "type": "String" - } - } - }, - "optionsSpotifyCredentialsRequired": "Diperlukan - ketuk untuk mengatur", - "@optionsSpotifyCredentialsRequired": { - "description": "Prompt to set up credentials" - }, - "optionsSpotifyWarning": "Spotify memerlukan kredensial API Anda sendiri. Dapatkan gratis dari developer.spotify.com", - "@optionsSpotifyWarning": { - "description": "Info about Spotify API requirement" - }, - "optionsSpotifyDeprecationWarning": "Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.", - "@optionsSpotifyDeprecationWarning": { - "description": "Warning about Spotify API deprecation" - }, - "extensionsTitle": "Ekstensi", - "@extensionsTitle": { - "description": "Extensions page title" - }, - "extensionsInstalled": "Ekstensi Terpasang", - "@extensionsInstalled": { - "description": "Section header for installed extensions" - }, - "extensionsNone": "Tidak ada ekstensi terpasang", - "@extensionsNone": { - "description": "Empty state title" - }, - "extensionsNoneSubtitle": "Pasang ekstensi dari tab Toko", - "@extensionsNoneSubtitle": { - "description": "Empty state subtitle" - }, - "extensionsEnabled": "Aktif", - "@extensionsEnabled": { - "description": "Extension status - active" - }, - "extensionsDisabled": "Nonaktif", - "@extensionsDisabled": { - "description": "Extension status - inactive" - }, - "extensionsVersion": "Versi {version}", - "@extensionsVersion": { - "description": "Extension version display", - "placeholders": { - "version": { - "type": "String" - } - } - }, - "extensionsAuthor": "oleh {author}", - "@extensionsAuthor": { - "description": "Extension author credit", - "placeholders": { - "author": { - "type": "String" - } - } - }, - "extensionsUninstall": "Copot", - "@extensionsUninstall": { - "description": "Uninstall extension button" - }, - "extensionsSetAsSearch": "Jadikan Provider Pencarian", - "@extensionsSetAsSearch": { - "description": "Use extension for search" - }, - "storeTitle": "Toko Ekstensi", - "@storeTitle": { - "description": "Store screen title" - }, - "storeSearch": "Cari ekstensi...", - "@storeSearch": { - "description": "Store search placeholder" - }, - "storeInstall": "Pasang", - "@storeInstall": { - "description": "Install extension button" - }, - "storeInstalled": "Terpasang", - "@storeInstalled": { - "description": "Already installed badge" - }, - "storeUpdate": "Perbarui", - "@storeUpdate": { - "description": "Update available button" - }, - "aboutTitle": "Tentang", - "@aboutTitle": { - "description": "About page title" - }, - "aboutContributors": "Kontributor", - "@aboutContributors": { - "description": "Section for contributors" - }, - "aboutMobileDeveloper": "Pengembang versi mobile", - "@aboutMobileDeveloper": { - "description": "Role description for mobile dev" - }, - "aboutOriginalCreator": "Pembuat SpotiFLAC asli", - "@aboutOriginalCreator": { - "description": "Role description for original creator" - }, - "aboutLogoArtist": "Seniman berbakat yang membuat logo aplikasi kita yang indah!", - "@aboutLogoArtist": { - "description": "Role description for logo artist" - }, - "aboutTranslators": "Translators", - "@aboutTranslators": { - "description": "Section for translators" - }, - "aboutSpecialThanks": "Terima Kasih Khusus", - "@aboutSpecialThanks": { - "description": "Section for special thanks" - }, - "aboutLinks": "Tautan", - "@aboutLinks": { - "description": "Section for external links" - }, - "aboutMobileSource": "Kode sumber mobile", - "@aboutMobileSource": { - "description": "Link to mobile GitHub repo" - }, - "aboutPCSource": "Kode sumber PC", - "@aboutPCSource": { - "description": "Link to PC GitHub repo" - }, - "aboutReportIssue": "Laporkan masalah", - "@aboutReportIssue": { - "description": "Link to report bugs" - }, - "aboutReportIssueSubtitle": "Laporkan masalah yang Anda temui", - "@aboutReportIssueSubtitle": { - "description": "Subtitle for report issue" - }, - "aboutFeatureRequest": "Permintaan fitur", - "@aboutFeatureRequest": { - "description": "Link to suggest features" - }, - "aboutFeatureRequestSubtitle": "Sarankan fitur baru untuk aplikasi", - "@aboutFeatureRequestSubtitle": { - "description": "Subtitle for feature request" - }, - "aboutTelegramChannel": "Telegram Channel", - "@aboutTelegramChannel": { - "description": "Link to Telegram channel" - }, - "aboutTelegramChannelSubtitle": "Announcements and updates", - "@aboutTelegramChannelSubtitle": { - "description": "Subtitle for Telegram channel" - }, - "aboutTelegramChat": "Telegram Community", - "@aboutTelegramChat": { - "description": "Link to Telegram chat group" - }, - "aboutTelegramChatSubtitle": "Chat with other users", - "@aboutTelegramChatSubtitle": { - "description": "Subtitle for Telegram chat" - }, - "aboutSocial": "Social", - "@aboutSocial": { - "description": "Section for social links" - }, - "aboutSupport": "Dukungan", - "@aboutSupport": { - "description": "Section for support/donation links" - }, - "aboutApp": "Aplikasi", - "@aboutApp": { - "description": "Section for app info" - }, - "aboutVersion": "Versi", - "@aboutVersion": { - "description": "Version info label" - }, - "aboutBinimumDesc": "Pembuat QQDL & HiFi API. Tanpa API ini, unduhan Tidal tidak akan ada!", - "@aboutBinimumDesc": { - "description": "Credit description for binimum" - }, - "aboutSachinsenalDesc": "Pembuat proyek HiFi asli. Fondasi dari integrasi Tidal!", - "@aboutSachinsenalDesc": { - "description": "Credit description for sachinsenal0x64" - }, - "aboutSjdonadoDesc": "Creator of I Don't Have Spotify (IDHS). The fallback link resolver that saves the day!", - "@aboutSjdonadoDesc": { - "description": "Credit description for sjdonado" - }, - "aboutDoubleDouble": "DoubleDouble", - "@aboutDoubleDouble": { - "description": "Name of Amazon API service - DO NOT TRANSLATE" - }, - "aboutDoubleDoubleDesc": "API luar biasa untuk unduhan Amazon Music. Terima kasih sudah membuatnya gratis!", - "@aboutDoubleDoubleDesc": { - "description": "Credit for DoubleDouble API" - }, - "aboutDabMusic": "DAB Music", - "@aboutDabMusic": { - "description": "Name of Qobuz API service - DO NOT TRANSLATE" - }, - "aboutDabMusicDesc": "API streaming Qobuz terbaik. Unduhan Hi-Res tidak akan mungkin tanpa ini!", - "@aboutDabMusicDesc": { - "description": "Credit for DAB Music API" - }, - "aboutSpotiSaver": "SpotiSaver", - "@aboutSpotiSaver": { - "description": "Name of SpotiSaver API service - DO NOT TRANSLATE" - }, - "aboutSpotiSaverDesc": "Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!", - "@aboutSpotiSaverDesc": { - "description": "Credit for SpotiSaver API" - }, - "aboutAppDescription": "Unduh lagu Spotify dalam kualitas lossless dari Tidal, Qobuz, dan Amazon Music.", - "@aboutAppDescription": { - "description": "App description in header card" - }, - "albumTitle": "Album", - "@albumTitle": { - "description": "Album screen title" - }, - "albumTracks": "{count, plural, =1{1 lagu} other{{count} lagu}}", - "@albumTracks": { - "description": "Album track count", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "albumDownloadAll": "Unduh Semua", - "@albumDownloadAll": { - "description": "Button to download all tracks" - }, - "albumDownloadRemaining": "Unduh Sisanya", - "@albumDownloadRemaining": { - "description": "Button to download remaining tracks" - }, - "playlistTitle": "Playlist", - "@playlistTitle": { - "description": "Playlist screen title" - }, - "artistTitle": "Artis", - "@artistTitle": { - "description": "Artist screen title" - }, - "artistAlbums": "Album", - "@artistAlbums": { - "description": "Section header for artist albums" - }, - "artistSingles": "Single & EP", - "@artistSingles": { - "description": "Section header for singles/EPs" - }, - "artistCompilations": "Kompilasi", - "@artistCompilations": { - "description": "Section header for compilations" - }, - "artistReleases": "{count, plural, =1{1 rilis} other{{count} rilis}}", - "@artistReleases": { - "description": "Artist release count", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "artistPopular": "Populer", - "@artistPopular": { - "description": "Section header for popular/top tracks" - }, - "artistMonthlyListeners": "{count} pendengar bulanan", - "@artistMonthlyListeners": { - "description": "Monthly listener count display", - "placeholders": { - "count": { - "type": "String", - "description": "Formatted listener count" - } - } - }, - "trackMetadataTitle": "Info Lagu", - "@trackMetadataTitle": { - "description": "Track metadata screen title" - }, - "trackMetadataArtist": "Artis", - "@trackMetadataArtist": { - "description": "Metadata field - artist name" - }, - "trackMetadataAlbum": "Album", - "@trackMetadataAlbum": { - "description": "Metadata field - album name" - }, - "trackMetadataDuration": "Durasi", - "@trackMetadataDuration": { - "description": "Metadata field - track length" - }, - "trackMetadataQuality": "Kualitas", - "@trackMetadataQuality": { - "description": "Metadata field - audio quality" - }, - "trackMetadataPath": "Lokasi File", - "@trackMetadataPath": { - "description": "Metadata field - file location" - }, - "trackMetadataDownloadedAt": "Diunduh", - "@trackMetadataDownloadedAt": { - "description": "Metadata field - download date" - }, - "trackMetadataService": "Layanan", - "@trackMetadataService": { - "description": "Metadata field - download service used" - }, - "trackMetadataPlay": "Putar", - "@trackMetadataPlay": { - "description": "Action button - play track" - }, - "trackMetadataShare": "Bagikan", - "@trackMetadataShare": { - "description": "Action button - share track" - }, - "trackMetadataDelete": "Hapus", - "@trackMetadataDelete": { - "description": "Action button - delete track" - }, - "trackMetadataRedownload": "Unduh ulang", - "@trackMetadataRedownload": { - "description": "Action button - download again" - }, - "trackMetadataOpenFolder": "Buka Folder", - "@trackMetadataOpenFolder": { - "description": "Action button - open containing folder" - }, - "setupTitle": "Selamat Datang di SpotiFLAC", - "@setupTitle": { - "description": "Setup wizard title" - }, - "setupSubtitle": "Mari mulai pengaturan", - "@setupSubtitle": { - "description": "Setup wizard subtitle" - }, - "setupStoragePermission": "Izin Penyimpanan", - "@setupStoragePermission": { - "description": "Storage permission step title" - }, - "setupStoragePermissionSubtitle": "Diperlukan untuk menyimpan file unduhan", - "@setupStoragePermissionSubtitle": { - "description": "Explanation for storage permission" - }, - "setupStoragePermissionGranted": "Izin diberikan", - "@setupStoragePermissionGranted": { - "description": "Status when permission granted" - }, - "setupStoragePermissionDenied": "Izin ditolak", - "@setupStoragePermissionDenied": { - "description": "Status when permission denied" - }, - "setupGrantPermission": "Berikan Izin", - "@setupGrantPermission": { - "description": "Button to request permission" - }, - "setupDownloadLocation": "Lokasi Unduhan", - "@setupDownloadLocation": { - "description": "Download folder step title" - }, - "setupChooseFolder": "Pilih Folder", - "@setupChooseFolder": { - "description": "Button to pick folder" - }, - "setupContinue": "Lanjutkan", - "@setupContinue": { - "description": "Continue to next step button" - }, - "setupSkip": "Lewati untuk sekarang", - "@setupSkip": { - "description": "Skip current step button" - }, - "setupStorageAccessRequired": "Akses Penyimpanan Diperlukan", - "@setupStorageAccessRequired": { - "description": "Title when storage access needed" - }, - "setupStorageAccessMessage": "SpotiFLAC membutuhkan izin \"Akses semua file\" untuk menyimpan file musik ke folder pilihan Anda.", - "@setupStorageAccessMessage": { - "description": "Explanation for storage access" - }, - "setupStorageAccessMessageAndroid11": "Android 11+ memerlukan izin \"Akses semua file\" untuk menyimpan file ke folder unduhan pilihan Anda.", - "@setupStorageAccessMessageAndroid11": { - "description": "Android 11+ specific explanation" - }, - "setupOpenSettings": "Buka Pengaturan", - "@setupOpenSettings": { - "description": "Button to open system settings" - }, - "setupPermissionDeniedMessage": "Izin ditolak. Harap berikan semua izin untuk melanjutkan.", - "@setupPermissionDeniedMessage": { - "description": "Error when permission denied" - }, - "setupPermissionRequired": "Izin {permissionType} Diperlukan", - "@setupPermissionRequired": { - "description": "Generic permission required title", - "placeholders": { - "permissionType": { - "type": "String", - "description": "Type of permission (Storage/Notification)" - } - } - }, - "setupPermissionRequiredMessage": "Izin {permissionType} diperlukan untuk pengalaman terbaik. Anda dapat mengubahnya nanti di Pengaturan.", - "@setupPermissionRequiredMessage": { - "description": "Generic permission required message", - "placeholders": { - "permissionType": { - "type": "String" - } - } - }, - "setupSelectDownloadFolder": "Pilih Folder Unduhan", - "@setupSelectDownloadFolder": { - "description": "Folder selection step title" - }, - "setupUseDefaultFolder": "Gunakan Folder Default?", - "@setupUseDefaultFolder": { - "description": "Dialog title for default folder" - }, - "setupNoFolderSelected": "Tidak ada folder dipilih. Apakah Anda ingin menggunakan folder Musik default?", - "@setupNoFolderSelected": { - "description": "Prompt when no folder selected" - }, - "setupUseDefault": "Gunakan Default", - "@setupUseDefault": { - "description": "Button to use default folder" - }, - "setupDownloadLocationTitle": "Lokasi Unduhan", - "@setupDownloadLocationTitle": { - "description": "Download location dialog title" - }, - "setupDownloadLocationIosMessage": "Di iOS, unduhan disimpan ke folder Documents aplikasi. Anda dapat mengaksesnya melalui aplikasi Files.", - "@setupDownloadLocationIosMessage": { - "description": "iOS-specific folder info" - }, - "setupAppDocumentsFolder": "Folder Documents Aplikasi", - "@setupAppDocumentsFolder": { - "description": "iOS documents folder option" - }, - "setupAppDocumentsFolderSubtitle": "Direkomendasikan - dapat diakses via aplikasi Files", - "@setupAppDocumentsFolderSubtitle": { - "description": "Subtitle for documents folder" - }, - "setupChooseFromFiles": "Pilih dari Files", - "@setupChooseFromFiles": { - "description": "iOS file picker option" - }, - "setupChooseFromFilesSubtitle": "Pilih lokasi iCloud atau lainnya", - "@setupChooseFromFilesSubtitle": { - "description": "Subtitle for file picker" - }, - "setupIosEmptyFolderWarning": "Batasan iOS: Folder kosong tidak dapat dipilih. Pilih folder dengan minimal satu file.", - "@setupIosEmptyFolderWarning": { - "description": "iOS folder selection warning" - }, - "setupIcloudNotSupported": "iCloud Drive is not supported. Please use the app Documents folder.", - "@setupIcloudNotSupported": { - "description": "Error when user selects iCloud Drive on iOS" - }, - "setupDownloadInFlac": "Unduh lagu Spotify dalam format FLAC", - "@setupDownloadInFlac": { - "description": "App tagline in setup" - }, - "setupStepStorage": "Penyimpanan", - "@setupStepStorage": { - "description": "Setup step indicator - storage" - }, - "setupStepNotification": "Notifikasi", - "@setupStepNotification": { - "description": "Setup step indicator - notification" - }, - "setupStepFolder": "Folder", - "@setupStepFolder": { - "description": "Setup step indicator - folder" - }, - "setupStepSpotify": "Spotify", - "@setupStepSpotify": { - "description": "Setup step indicator - Spotify API" - }, - "setupStepPermission": "Izin", - "@setupStepPermission": { - "description": "Setup step indicator - permission" - }, - "setupStorageGranted": "Izin Penyimpanan Diberikan!", - "@setupStorageGranted": { - "description": "Success message for storage permission" - }, - "setupStorageRequired": "Izin Penyimpanan Diperlukan", - "@setupStorageRequired": { - "description": "Title when storage permission needed" - }, - "setupStorageDescription": "SpotiFLAC membutuhkan izin penyimpanan untuk menyimpan file musik yang diunduh.", - "@setupStorageDescription": { - "description": "Explanation for storage permission" - }, - "setupNotificationGranted": "Izin Notifikasi Diberikan!", - "@setupNotificationGranted": { - "description": "Success message for notification permission" - }, - "setupNotificationEnable": "Aktifkan Notifikasi", - "@setupNotificationEnable": { - "description": "Button to enable notifications" - }, - "setupNotificationDescription": "Dapatkan pemberitahuan saat unduhan selesai atau membutuhkan perhatian.", - "@setupNotificationDescription": { - "description": "Explanation for notifications" - }, - "setupFolderSelected": "Folder Unduhan Dipilih!", - "@setupFolderSelected": { - "description": "Success message for folder selection" - }, - "setupFolderChoose": "Pilih Folder Unduhan", - "@setupFolderChoose": { - "description": "Button to choose folder" - }, - "setupFolderDescription": "Pilih folder tempat musik yang diunduh akan disimpan.", - "@setupFolderDescription": { - "description": "Explanation for folder selection" - }, - "setupChangeFolder": "Ubah Folder", - "@setupChangeFolder": { - "description": "Button to change selected folder" - }, - "setupSelectFolder": "Pilih Folder", - "@setupSelectFolder": { - "description": "Button to select folder" - }, - "setupSpotifyApiOptional": "Spotify API (Opsional)", - "@setupSpotifyApiOptional": { - "description": "Spotify API step title" - }, - "setupSpotifyApiDescription": "Tambahkan kredensial Spotify API untuk hasil pencarian lebih baik dan akses ke konten eksklusif Spotify.", - "@setupSpotifyApiDescription": { - "description": "Explanation for Spotify API" - }, - "setupUseSpotifyApi": "Gunakan Spotify API", - "@setupUseSpotifyApi": { - "description": "Toggle to enable Spotify API" - }, - "setupEnterCredentialsBelow": "Masukkan kredensial Anda di bawah", - "@setupEnterCredentialsBelow": { - "description": "Prompt to enter credentials" - }, - "setupUsingDeezer": "Menggunakan Deezer (tidak perlu akun)", - "@setupUsingDeezer": { - "description": "Status when using Deezer" - }, - "setupEnterClientId": "Masukkan Spotify Client ID", - "@setupEnterClientId": { - "description": "Placeholder for client ID field" - }, - "setupEnterClientSecret": "Masukkan Spotify Client Secret", - "@setupEnterClientSecret": { - "description": "Placeholder for client secret field" - }, - "setupGetFreeCredentials": "Dapatkan kredensial API gratis dari Spotify Developer Dashboard.", - "@setupGetFreeCredentials": { - "description": "Info about getting Spotify credentials" - }, - "setupEnableNotifications": "Aktifkan Notifikasi", - "@setupEnableNotifications": { - "description": "Button to enable notifications" - }, - "setupProceedToNextStep": "Anda dapat melanjutkan ke langkah berikutnya.", - "@setupProceedToNextStep": { - "description": "Message after completing a step" - }, - "setupNotificationProgressDescription": "Anda akan menerima notifikasi progres unduhan.", - "@setupNotificationProgressDescription": { - "description": "Info about notification usage" - }, - "setupNotificationBackgroundDescription": "Dapatkan notifikasi tentang progres dan penyelesaian unduhan. Ini membantu Anda melacak unduhan saat aplikasi di latar belakang.", - "@setupNotificationBackgroundDescription": { - "description": "Detailed notification explanation" - }, - "setupSkipForNow": "Lewati untuk sekarang", - "@setupSkipForNow": { - "description": "Skip button text" - }, - "setupBack": "Kembali", - "@setupBack": { - "description": "Back button text" - }, - "setupNext": "Lanjut", - "@setupNext": { - "description": "Next button text" - }, - "setupGetStarted": "Mulai", - "@setupGetStarted": { - "description": "Final setup button" - }, - "setupSkipAndStart": "Lewati & Mulai", - "@setupSkipAndStart": { - "description": "Skip setup and start app" - }, - "setupAllowAccessToManageFiles": "Harap aktifkan \"Izinkan akses untuk mengelola semua file\" di layar berikutnya.", - "@setupAllowAccessToManageFiles": { - "description": "Instruction for file access permission" - }, - "setupGetCredentialsFromSpotify": "Dapatkan kredensial dari developer.spotify.com", - "@setupGetCredentialsFromSpotify": { - "description": "Link text for Spotify developer portal" - }, - "dialogCancel": "Batal", - "@dialogCancel": { - "description": "Dialog button - cancel action" - }, - "dialogOk": "OK", - "@dialogOk": { - "description": "Dialog button - confirm/acknowledge" - }, - "dialogSave": "Simpan", - "@dialogSave": { - "description": "Dialog button - save changes" - }, - "dialogDelete": "Hapus", - "@dialogDelete": { - "description": "Dialog button - delete item" - }, - "dialogRetry": "Coba Lagi", - "@dialogRetry": { - "description": "Dialog button - retry action" - }, - "dialogClose": "Tutup", - "@dialogClose": { - "description": "Dialog button - close dialog" - }, - "dialogYes": "Ya", - "@dialogYes": { - "description": "Dialog button - confirm yes" - }, - "dialogNo": "Tidak", - "@dialogNo": { - "description": "Dialog button - confirm no" - }, - "dialogClear": "Hapus", - "@dialogClear": { - "description": "Dialog button - clear items" - }, - "dialogConfirm": "Konfirmasi", - "@dialogConfirm": { - "description": "Dialog button - confirm action" - }, - "dialogDone": "Selesai", - "@dialogDone": { - "description": "Dialog button - action completed" - }, - "dialogImport": "Impor", - "@dialogImport": { - "description": "Dialog button - import data" - }, - "dialogDiscard": "Buang", - "@dialogDiscard": { - "description": "Dialog button - discard changes" - }, - "dialogRemove": "Hapus", - "@dialogRemove": { - "description": "Dialog button - remove item" - }, - "dialogUninstall": "Copot", - "@dialogUninstall": { - "description": "Dialog button - uninstall extension" - }, - "dialogDiscardChanges": "Buang Perubahan?", - "@dialogDiscardChanges": { - "description": "Dialog title - unsaved changes warning" - }, - "dialogUnsavedChanges": "Anda memiliki perubahan yang belum disimpan. Apakah Anda ingin membuangnya?", - "@dialogUnsavedChanges": { - "description": "Dialog message - unsaved changes" - }, - "dialogDownloadFailed": "Unduhan Gagal", - "@dialogDownloadFailed": { - "description": "Dialog title - download error" - }, - "dialogTrackLabel": "Lagu:", - "@dialogTrackLabel": { - "description": "Label for track name in error dialog" - }, - "dialogArtistLabel": "Artis:", - "@dialogArtistLabel": { - "description": "Label for artist name in error dialog" - }, - "dialogErrorLabel": "Error:", - "@dialogErrorLabel": { - "description": "Label for error message" - }, - "dialogClearAll": "Hapus Semua", - "@dialogClearAll": { - "description": "Dialog title - clear all items" - }, - "dialogClearAllDownloads": "Apakah Anda yakin ingin menghapus semua unduhan?", - "@dialogClearAllDownloads": { - "description": "Dialog message - clear downloads confirmation" - }, - "dialogRemoveFromDevice": "Hapus dari perangkat?", - "@dialogRemoveFromDevice": { - "description": "Dialog title - delete file confirmation" - }, - "dialogRemoveExtension": "Hapus Ekstensi", - "@dialogRemoveExtension": { - "description": "Dialog title - uninstall extension" - }, - "dialogRemoveExtensionMessage": "Apakah Anda yakin ingin menghapus ekstensi ini? Tindakan ini tidak dapat dibatalkan.", - "@dialogRemoveExtensionMessage": { - "description": "Dialog message - uninstall confirmation" - }, - "dialogUninstallExtension": "Copot Ekstensi?", - "@dialogUninstallExtension": { - "description": "Dialog title - uninstall extension" - }, - "dialogUninstallExtensionMessage": "Apakah Anda yakin ingin menghapus {extensionName}?", - "@dialogUninstallExtensionMessage": { - "description": "Dialog message - uninstall specific extension", - "placeholders": { - "extensionName": { - "type": "String" - } - } - }, - "dialogClearHistoryTitle": "Hapus Riwayat", - "@dialogClearHistoryTitle": { - "description": "Dialog title - clear download history" - }, - "dialogClearHistoryMessage": "Apakah Anda yakin ingin menghapus semua riwayat unduhan? Ini tidak dapat dibatalkan.", - "@dialogClearHistoryMessage": { - "description": "Dialog message - clear history confirmation" - }, - "dialogDeleteSelectedTitle": "Hapus yang Dipilih", - "@dialogDeleteSelectedTitle": { - "description": "Dialog title - delete selected items" - }, - "dialogDeleteSelectedMessage": "Hapus {count} {count, plural, =1{lagu} other{lagu}} dari riwayat?\n\nIni juga akan menghapus file dari penyimpanan.", - "@dialogDeleteSelectedMessage": { - "description": "Dialog message - delete selected tracks", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "dialogImportPlaylistTitle": "Impor Playlist", - "@dialogImportPlaylistTitle": { - "description": "Dialog title - import CSV playlist" - }, - "dialogImportPlaylistMessage": "Ditemukan {count} lagu di CSV. Tambahkan ke antrian unduhan?", - "csvImportTracks": "{count} tracks from CSV", - "@csvImportTracks": { - "description": "Label shown in quality picker for CSV import", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "@dialogImportPlaylistMessage": { - "description": "Dialog message - import playlist confirmation", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "snackbarAddedToQueue": "Menambahkan \"{trackName}\" ke antrian", - "@snackbarAddedToQueue": { - "description": "Snackbar - track added to download queue", - "placeholders": { - "trackName": { - "type": "String" - } - } - }, - "snackbarAddedTracksToQueue": "Menambahkan {count} lagu ke antrian", - "@snackbarAddedTracksToQueue": { - "description": "Snackbar - multiple tracks added to queue", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "snackbarAlreadyDownloaded": "\"{trackName}\" sudah diunduh", - "@snackbarAlreadyDownloaded": { - "description": "Snackbar - track already exists", - "placeholders": { - "trackName": { - "type": "String" - } - } - }, - "snackbarAlreadyInLibrary": "\"{trackName}\" already exists in your library", - "@snackbarAlreadyInLibrary": { - "description": "Snackbar - track already exists in local library", - "placeholders": { - "trackName": { - "type": "String" - } - } - }, - "snackbarHistoryCleared": "Riwayat dihapus", - "@snackbarHistoryCleared": { - "description": "Snackbar - history deleted" - }, - "snackbarCredentialsSaved": "Kredensial disimpan", - "@snackbarCredentialsSaved": { - "description": "Snackbar - Spotify credentials saved" - }, - "snackbarCredentialsCleared": "Kredensial dihapus", - "@snackbarCredentialsCleared": { - "description": "Snackbar - Spotify credentials removed" - }, - "snackbarDeletedTracks": "Menghapus {count} {count, plural, =1{lagu} other{lagu}}", - "@snackbarDeletedTracks": { - "description": "Snackbar - tracks deleted", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "snackbarCannotOpenFile": "Tidak dapat membuka file: {error}", - "@snackbarCannotOpenFile": { - "description": "Snackbar - file open error", - "placeholders": { - "error": { - "type": "String" - } - } - }, - "snackbarFillAllFields": "Harap isi semua field", - "@snackbarFillAllFields": { - "description": "Snackbar - validation error" - }, - "snackbarViewQueue": "Lihat Antrian", - "@snackbarViewQueue": { - "description": "Snackbar action - view download queue" - }, - "snackbarFailedToLoad": "Gagal memuat: {error}", - "@snackbarFailedToLoad": { - "description": "Snackbar - loading error", - "placeholders": { - "error": { - "type": "String" - } - } - }, - "snackbarUrlCopied": "URL {platform} disalin ke clipboard", - "@snackbarUrlCopied": { - "description": "Snackbar - URL copied", - "placeholders": { - "platform": { - "type": "String", - "description": "Platform name (Spotify/Deezer)" - } - } - }, - "snackbarFileNotFound": "File tidak ditemukan", - "@snackbarFileNotFound": { - "description": "Snackbar - file doesn't exist" - }, - "snackbarSelectExtFile": "Harap pilih file .spotiflac-ext", - "@snackbarSelectExtFile": { - "description": "Snackbar - wrong file type selected" - }, - "snackbarProviderPrioritySaved": "Prioritas provider disimpan", - "@snackbarProviderPrioritySaved": { - "description": "Snackbar - provider order saved" - }, - "snackbarMetadataProviderSaved": "Prioritas provider metadata disimpan", - "@snackbarMetadataProviderSaved": { - "description": "Snackbar - metadata provider order saved" - }, - "snackbarExtensionInstalled": "{extensionName} terpasang.", - "@snackbarExtensionInstalled": { - "description": "Snackbar - extension installed successfully", - "placeholders": { - "extensionName": { - "type": "String" - } - } - }, - "snackbarExtensionUpdated": "{extensionName} diperbarui.", - "@snackbarExtensionUpdated": { - "description": "Snackbar - extension updated successfully", - "placeholders": { - "extensionName": { - "type": "String" - } - } - }, - "snackbarFailedToInstall": "Gagal memasang ekstensi", - "@snackbarFailedToInstall": { - "description": "Snackbar - extension install error" - }, - "snackbarFailedToUpdate": "Gagal memperbarui ekstensi", - "@snackbarFailedToUpdate": { - "description": "Snackbar - extension update error" - }, - "errorRateLimited": "Dibatasi", - "@errorRateLimited": { - "description": "Error title - too many requests" - }, - "errorRateLimitedMessage": "Terlalu banyak permintaan. Harap tunggu sebentar sebelum mencari lagi.", - "@errorRateLimitedMessage": { - "description": "Error message - rate limit explanation" - }, - "errorFailedToLoad": "Gagal memuat {item}", - "@errorFailedToLoad": { - "description": "Error message - loading failed", - "placeholders": { - "item": { - "type": "String", - "description": "Item that failed to load (album/playlist/etc)" - } - } - }, - "errorNoTracksFound": "Tidak ada lagu ditemukan", - "@errorNoTracksFound": { - "description": "Error - search returned no results" - }, - "errorMissingExtensionSource": "Tidak dapat memuat {item}: sumber ekstensi tidak ada", - "@errorMissingExtensionSource": { - "description": "Error - extension source not available", - "placeholders": { - "item": { - "type": "String" - } - } - }, - "statusQueued": "Mengantri", - "@statusQueued": { - "description": "Download status - waiting in queue" - }, - "statusDownloading": "Mengunduh", - "@statusDownloading": { - "description": "Download status - in progress" - }, - "statusFinalizing": "Menyelesaikan", - "@statusFinalizing": { - "description": "Download status - writing metadata" - }, - "statusCompleted": "Selesai", - "@statusCompleted": { - "description": "Download status - finished" - }, - "statusFailed": "Gagal", - "@statusFailed": { - "description": "Download status - error occurred" - }, - "statusSkipped": "Dilewati", - "@statusSkipped": { - "description": "Download status - already exists" - }, - "statusPaused": "Dijeda", - "@statusPaused": { - "description": "Download status - paused" - }, - "actionPause": "Jeda", - "@actionPause": { - "description": "Action button - pause download" - }, - "actionResume": "Lanjutkan", - "@actionResume": { - "description": "Action button - resume download" - }, - "actionCancel": "Batal", - "@actionCancel": { - "description": "Action button - cancel operation" - }, - "actionStop": "Hentikan", - "@actionStop": { - "description": "Action button - stop operation" - }, - "actionSelect": "Pilih", - "@actionSelect": { - "description": "Action button - enter selection mode" - }, - "actionSelectAll": "Pilih Semua", - "@actionSelectAll": { - "description": "Action button - select all items" - }, - "actionDeselect": "Batal Pilih", - "@actionDeselect": { - "description": "Action button - deselect all" - }, - "actionPaste": "Tempel", - "@actionPaste": { - "description": "Action button - paste from clipboard" - }, - "actionImportCsv": "Impor CSV", - "@actionImportCsv": { - "description": "Action button - import CSV file" - }, - "actionRemoveCredentials": "Hapus Kredensial", - "@actionRemoveCredentials": { - "description": "Action button - delete Spotify credentials" - }, - "actionSaveCredentials": "Simpan Kredensial", - "@actionSaveCredentials": { - "description": "Action button - save Spotify credentials" - }, - "selectionSelected": "{count} dipilih", - "@selectionSelected": { - "description": "Selection count indicator", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "selectionAllSelected": "Semua lagu dipilih", - "@selectionAllSelected": { - "description": "Status - all items selected" - }, - "selectionTapToSelect": "Ketuk lagu untuk memilih", - "@selectionTapToSelect": { - "description": "Hint - how to select items" - }, - "selectionDeleteTracks": "Hapus {count} {count, plural, =1{lagu} other{lagu}}", - "@selectionDeleteTracks": { - "description": "Delete button with count", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "selectionSelectToDelete": "Pilih lagu untuk dihapus", - "@selectionSelectToDelete": { - "description": "Placeholder when nothing selected" - }, - "progressFetchingMetadata": "Mengambil metadata... {current}/{total}", - "@progressFetchingMetadata": { - "description": "Progress indicator - loading track info", - "placeholders": { - "current": { - "type": "int" - }, - "total": { - "type": "int" - } - } - }, - "progressReadingCsv": "Membaca CSV...", - "@progressReadingCsv": { - "description": "Progress indicator - parsing CSV file" - }, - "searchSongs": "Lagu", - "@searchSongs": { - "description": "Search result category - songs" - }, - "searchArtists": "Artis", - "@searchArtists": { - "description": "Search result category - artists" - }, - "searchAlbums": "Album", - "@searchAlbums": { - "description": "Search result category - albums" - }, - "searchPlaylists": "Playlist", - "@searchPlaylists": { - "description": "Search result category - playlists" - }, - "tooltipPlay": "Putar", - "@tooltipPlay": { - "description": "Tooltip - play button" - }, - "tooltipCancel": "Batal", - "@tooltipCancel": { - "description": "Tooltip - cancel button" - }, - "tooltipStop": "Hentikan", - "@tooltipStop": { - "description": "Tooltip - stop button" - }, - "tooltipRetry": "Coba Lagi", - "@tooltipRetry": { - "description": "Tooltip - retry button" - }, - "tooltipRemove": "Hapus", - "@tooltipRemove": { - "description": "Tooltip - remove button" - }, - "tooltipClear": "Hapus", - "@tooltipClear": { - "description": "Tooltip - clear button" - }, - "tooltipPaste": "Tempel", - "@tooltipPaste": { - "description": "Tooltip - paste button" - }, - "filenameFormat": "Format Nama File", - "@filenameFormat": { - "description": "Setting title - filename pattern" - }, - "filenameFormatPreview": "Pratinjau: {preview}", - "@filenameFormatPreview": { - "description": "Preview of filename pattern", - "placeholders": { - "preview": { - "type": "String" - } - } - }, - "filenameAvailablePlaceholders": "Placeholder yang tersedia:", - "@filenameAvailablePlaceholders": { - "description": "Label for placeholder list" +{ + "@@locale": "id", + "@@last_modified": "2026-01-16", + "appName": "SpotiFLAC", + "@appName": { + "description": "App name - DO NOT TRANSLATE" }, - "filenameHint": "{artist} - {title}", - "@filenameHint": { - "description": "Default filename format hint" + "navHome": "Beranda", + "@navHome": { + "description": "Bottom navigation - Home tab" + }, + "navLibrary": "Library", + "@navLibrary": { + "description": "Bottom navigation - Library tab" + }, + "navSettings": "Pengaturan", + "@navSettings": { + "description": "Bottom navigation - Settings tab" + }, + "navStore": "Toko", + "@navStore": { + "description": "Bottom navigation - Extension store tab" + }, + "homeTitle": "Beranda", + "@homeTitle": { + "description": "Home screen title" + }, + "homeSubtitle": "Tempel link Spotify atau cari berdasarkan nama", + "@homeSubtitle": { + "description": "Subtitle shown below search box" + }, + "homeSupports": "Mendukung: URL Track, Album, Playlist, Artis", + "@homeSupports": { + "description": "Info text about supported URL types" + }, + "homeRecent": "Terbaru", + "@homeRecent": { + "description": "Section header for recent searches" + }, + "historyFilterAll": "Semua", + "@historyFilterAll": { + "description": "Filter chip - show all items" + }, + "historyFilterAlbums": "Album", + "@historyFilterAlbums": { + "description": "Filter chip - show albums only" + }, + "historyFilterSingles": "Single", + "@historyFilterSingles": { + "description": "Filter chip - show singles only" + }, + "historySearchHint": "Search history...", + "@historySearchHint": { + "description": "Search bar placeholder in history" + }, + "settingsTitle": "Pengaturan", + "@settingsTitle": { + "description": "Settings screen title" + }, + "settingsDownload": "Unduhan", + "@settingsDownload": { + "description": "Settings section - download options" + }, + "settingsAppearance": "Tampilan", + "@settingsAppearance": { + "description": "Settings section - visual customization" + }, + "settingsOptions": "Opsi", + "@settingsOptions": { + "description": "Settings section - app options" + }, + "settingsExtensions": "Ekstensi", + "@settingsExtensions": { + "description": "Settings section - extension management" + }, + "settingsAbout": "Tentang", + "@settingsAbout": { + "description": "Settings section - app info" + }, + "downloadTitle": "Unduhan", + "@downloadTitle": { + "description": "Download settings page title" + }, + "downloadAskQualitySubtitle": "Tampilkan pemilih kualitas untuk setiap unduhan", + "@downloadAskQualitySubtitle": { + "description": "Subtitle for ask quality toggle" + }, + "downloadFilenameFormat": "Format Nama File", + "@downloadFilenameFormat": { + "description": "Setting for output filename pattern" + }, + "downloadFolderOrganization": "Organisasi Folder", + "@downloadFolderOrganization": { + "description": "Setting for folder structure" + }, + "appearanceTitle": "Tampilan", + "@appearanceTitle": { + "description": "Appearance settings page title" + }, + "appearanceThemeSystem": "Sistem", + "@appearanceThemeSystem": { + "description": "Follow system theme" + }, + "appearanceThemeLight": "Terang", + "@appearanceThemeLight": { + "description": "Light theme" + }, + "appearanceThemeDark": "Gelap", + "@appearanceThemeDark": { + "description": "Dark theme" + }, + "appearanceDynamicColor": "Warna Dinamis", + "@appearanceDynamicColor": { + "description": "Material You dynamic colors" + }, + "appearanceDynamicColorSubtitle": "Gunakan warna dari wallpaper Anda", + "@appearanceDynamicColorSubtitle": { + "description": "Subtitle for dynamic color" + }, + "appearanceHistoryView": "Tampilan Riwayat", + "@appearanceHistoryView": { + "description": "Layout style for history" + }, + "appearanceHistoryViewList": "Daftar", + "@appearanceHistoryViewList": { + "description": "List layout option" + }, + "appearanceHistoryViewGrid": "Grid", + "@appearanceHistoryViewGrid": { + "description": "Grid layout option" + }, + "optionsTitle": "Opsi", + "@optionsTitle": { + "description": "Options settings page title" + }, + "optionsPrimaryProvider": "Provider Utama", + "@optionsPrimaryProvider": { + "description": "Main search provider setting" + }, + "optionsPrimaryProviderSubtitle": "Layanan yang digunakan saat mencari berdasarkan nama lagu.", + "@optionsPrimaryProviderSubtitle": { + "description": "Subtitle for primary provider" + }, + "optionsUsingExtension": "Menggunakan ekstensi: {extensionName}", + "@optionsUsingExtension": { + "description": "Shows active extension name", + "placeholders": { + "extensionName": { + "type": "String" + } + } + }, + "optionsSwitchBack": "Ketuk Deezer atau Spotify untuk beralih dari ekstensi", + "@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", + "@optionsUseExtensionProviders": { + "description": "Enable extension download providers" + }, + "optionsUseExtensionProvidersOn": "Ekstensi akan dicoba terlebih dahulu", + "@optionsUseExtensionProvidersOn": { + "description": "Status when extension providers enabled" + }, + "optionsUseExtensionProvidersOff": "Hanya menggunakan provider bawaan", + "@optionsUseExtensionProvidersOff": { + "description": "Status when extension providers disabled" + }, + "optionsEmbedLyrics": "Sematkan Lirik", + "@optionsEmbedLyrics": { + "description": "Embed lyrics in audio files" + }, + "optionsEmbedLyricsSubtitle": "Sematkan lirik sinkron ke file FLAC", + "@optionsEmbedLyricsSubtitle": { + "description": "Subtitle for embed lyrics" + }, + "optionsMaxQualityCover": "Cover Kualitas Maksimal", + "@optionsMaxQualityCover": { + "description": "Download highest quality album art" + }, + "optionsMaxQualityCoverSubtitle": "Unduh cover art resolusi tertinggi", + "@optionsMaxQualityCoverSubtitle": { + "description": "Subtitle for max quality cover" + }, + "optionsConcurrentDownloads": "Unduhan Bersamaan", + "@optionsConcurrentDownloads": { + "description": "Number of parallel downloads" + }, + "optionsConcurrentSequential": "Berurutan (1 per waktu)", + "@optionsConcurrentSequential": { + "description": "Download one at a time" + }, + "optionsConcurrentParallel": "{count} unduhan paralel", + "@optionsConcurrentParallel": { + "description": "Multiple parallel downloads", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "optionsConcurrentWarning": "Unduhan paralel dapat memicu pembatasan rate", + "@optionsConcurrentWarning": { + "description": "Warning about rate limits" + }, + "optionsExtensionStore": "Toko Ekstensi", + "@optionsExtensionStore": { + "description": "Show/hide store tab" + }, + "optionsExtensionStoreSubtitle": "Tampilkan tab Toko di navigasi", + "@optionsExtensionStoreSubtitle": { + "description": "Subtitle for extension store toggle" + }, + "optionsCheckUpdates": "Periksa Pembaruan", + "@optionsCheckUpdates": { + "description": "Auto update check toggle" + }, + "optionsCheckUpdatesSubtitle": "Beritahu saat versi baru tersedia", + "@optionsCheckUpdatesSubtitle": { + "description": "Subtitle for update check" + }, + "optionsUpdateChannel": "Saluran Pembaruan", + "@optionsUpdateChannel": { + "description": "Stable vs preview releases" + }, + "optionsUpdateChannelStable": "Hanya rilis stabil", + "@optionsUpdateChannelStable": { + "description": "Only stable updates" + }, + "optionsUpdateChannelPreview": "Dapatkan rilis preview", + "@optionsUpdateChannelPreview": { + "description": "Include beta/preview updates" + }, + "optionsUpdateChannelWarning": "Preview mungkin mengandung bug atau fitur belum lengkap", + "@optionsUpdateChannelWarning": { + "description": "Warning about preview channel" + }, + "optionsClearHistory": "Hapus Riwayat Unduhan", + "@optionsClearHistory": { + "description": "Delete all download history" + }, + "optionsClearHistorySubtitle": "Hapus semua lagu dari riwayat", + "@optionsClearHistorySubtitle": { + "description": "Subtitle for clear history" + }, + "optionsDetailedLogging": "Log Detail", + "@optionsDetailedLogging": { + "description": "Enable verbose logs for debugging" + }, + "optionsDetailedLoggingOn": "Log detail sedang direkam", + "@optionsDetailedLoggingOn": { + "description": "Status when logging enabled" + }, + "optionsDetailedLoggingOff": "Aktifkan untuk laporan bug", + "@optionsDetailedLoggingOff": { + "description": "Status when logging disabled" + }, + "optionsSpotifyCredentials": "Kredensial Spotify", + "@optionsSpotifyCredentials": { + "description": "Spotify API credentials setting" + }, + "optionsSpotifyCredentialsConfigured": "Client ID: {clientId}...", + "@optionsSpotifyCredentialsConfigured": { + "description": "Shows configured client ID preview", + "placeholders": { + "clientId": { + "type": "String" + } + } + }, + "optionsSpotifyCredentialsRequired": "Diperlukan - ketuk untuk mengatur", + "@optionsSpotifyCredentialsRequired": { + "description": "Prompt to set up credentials" + }, + "optionsSpotifyWarning": "Spotify memerlukan kredensial API Anda sendiri. Dapatkan gratis dari developer.spotify.com", + "@optionsSpotifyWarning": { + "description": "Info about Spotify API requirement" + }, + "optionsSpotifyDeprecationWarning": "Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.", + "@optionsSpotifyDeprecationWarning": { + "description": "Warning about Spotify API deprecation" + }, + "extensionsTitle": "Ekstensi", + "@extensionsTitle": { + "description": "Extensions page title" + }, + "extensionsDisabled": "Nonaktif", + "@extensionsDisabled": { + "description": "Extension status - inactive" + }, + "extensionsVersion": "Versi {version}", + "@extensionsVersion": { + "description": "Extension version display", + "placeholders": { + "version": { + "type": "String" + } + } + }, + "extensionsAuthor": "oleh {author}", + "@extensionsAuthor": { + "description": "Extension author credit", + "placeholders": { + "author": { + "type": "String" + } + } + }, + "extensionsUninstall": "Copot", + "@extensionsUninstall": { + "description": "Uninstall extension button" + }, + "storeTitle": "Toko Ekstensi", + "@storeTitle": { + "description": "Store screen title" + }, + "storeSearch": "Cari ekstensi...", + "@storeSearch": { + "description": "Store search placeholder" + }, + "storeInstall": "Pasang", + "@storeInstall": { + "description": "Install extension button" + }, + "storeInstalled": "Terpasang", + "@storeInstalled": { + "description": "Already installed badge" + }, + "storeUpdate": "Perbarui", + "@storeUpdate": { + "description": "Update available button" + }, + "aboutTitle": "Tentang", + "@aboutTitle": { + "description": "About page title" + }, + "aboutContributors": "Kontributor", + "@aboutContributors": { + "description": "Section for contributors" + }, + "aboutMobileDeveloper": "Pengembang versi mobile", + "@aboutMobileDeveloper": { + "description": "Role description for mobile dev" + }, + "aboutOriginalCreator": "Pembuat SpotiFLAC asli", + "@aboutOriginalCreator": { + "description": "Role description for original creator" + }, + "aboutLogoArtist": "Seniman berbakat yang membuat logo aplikasi kita yang indah!", + "@aboutLogoArtist": { + "description": "Role description for logo artist" + }, + "aboutTranslators": "Translators", + "@aboutTranslators": { + "description": "Section for translators" + }, + "aboutSpecialThanks": "Terima Kasih Khusus", + "@aboutSpecialThanks": { + "description": "Section for special thanks" + }, + "aboutLinks": "Tautan", + "@aboutLinks": { + "description": "Section for external links" + }, + "aboutMobileSource": "Kode sumber mobile", + "@aboutMobileSource": { + "description": "Link to mobile GitHub repo" + }, + "aboutPCSource": "Kode sumber PC", + "@aboutPCSource": { + "description": "Link to PC GitHub repo" + }, + "aboutReportIssue": "Laporkan masalah", + "@aboutReportIssue": { + "description": "Link to report bugs" + }, + "aboutReportIssueSubtitle": "Laporkan masalah yang Anda temui", + "@aboutReportIssueSubtitle": { + "description": "Subtitle for report issue" + }, + "aboutFeatureRequest": "Permintaan fitur", + "@aboutFeatureRequest": { + "description": "Link to suggest features" + }, + "aboutFeatureRequestSubtitle": "Sarankan fitur baru untuk aplikasi", + "@aboutFeatureRequestSubtitle": { + "description": "Subtitle for feature request" + }, + "aboutTelegramChannel": "Telegram Channel", + "@aboutTelegramChannel": { + "description": "Link to Telegram channel" + }, + "aboutTelegramChannelSubtitle": "Announcements and updates", + "@aboutTelegramChannelSubtitle": { + "description": "Subtitle for Telegram channel" + }, + "aboutTelegramChat": "Telegram Community", + "@aboutTelegramChat": { + "description": "Link to Telegram chat group" + }, + "aboutTelegramChatSubtitle": "Chat with other users", + "@aboutTelegramChatSubtitle": { + "description": "Subtitle for Telegram chat" + }, + "aboutSocial": "Social", + "@aboutSocial": { + "description": "Section for social links" + }, + "aboutApp": "Aplikasi", + "@aboutApp": { + "description": "Section for app info" + }, + "aboutVersion": "Versi", + "@aboutVersion": { + "description": "Version info label" + }, + "aboutBinimumDesc": "Pembuat QQDL & HiFi API. Tanpa API ini, unduhan Tidal tidak akan ada!", + "@aboutBinimumDesc": { + "description": "Credit description for binimum" + }, + "aboutSachinsenalDesc": "Pembuat proyek HiFi asli. Fondasi dari integrasi Tidal!", + "@aboutSachinsenalDesc": { + "description": "Credit description for sachinsenal0x64" + }, + "aboutSjdonadoDesc": "Creator of I Don't Have Spotify (IDHS). The fallback link resolver that saves the day!", + "@aboutSjdonadoDesc": { + "description": "Credit description for sjdonado" + }, + "aboutDabMusic": "DAB Music", + "@aboutDabMusic": { + "description": "Name of Qobuz API service - DO NOT TRANSLATE" + }, + "aboutDabMusicDesc": "API streaming Qobuz terbaik. Unduhan Hi-Res tidak akan mungkin tanpa ini!", + "@aboutDabMusicDesc": { + "description": "Credit for DAB Music API" + }, + "aboutSpotiSaver": "SpotiSaver", + "@aboutSpotiSaver": { + "description": "Name of SpotiSaver API service - DO NOT TRANSLATE" + }, + "aboutSpotiSaverDesc": "Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!", + "@aboutSpotiSaverDesc": { + "description": "Credit for SpotiSaver API" + }, + "aboutAppDescription": "Unduh lagu Spotify dalam kualitas lossless dari Tidal, Qobuz, dan Amazon Music.", + "@aboutAppDescription": { + "description": "App description in header card" + }, + "artistAlbums": "Album", + "@artistAlbums": { + "description": "Section header for artist albums" + }, + "artistSingles": "Single & EP", + "@artistSingles": { + "description": "Section header for singles/EPs" + }, + "artistCompilations": "Kompilasi", + "@artistCompilations": { + "description": "Section header for compilations" + }, + "artistPopular": "Populer", + "@artistPopular": { + "description": "Section header for popular/top tracks" + }, + "artistMonthlyListeners": "{count} pendengar bulanan", + "@artistMonthlyListeners": { + "description": "Monthly listener count display", + "placeholders": { + "count": { + "type": "String", + "description": "Formatted listener count" + } + } + }, + "trackMetadataService": "Layanan", + "@trackMetadataService": { + "description": "Metadata field - download service used" + }, + "trackMetadataPlay": "Putar", + "@trackMetadataPlay": { + "description": "Action button - play track" + }, + "trackMetadataShare": "Bagikan", + "@trackMetadataShare": { + "description": "Action button - share track" + }, + "trackMetadataDelete": "Hapus", + "@trackMetadataDelete": { + "description": "Action button - delete track" + }, + "setupGrantPermission": "Berikan Izin", + "@setupGrantPermission": { + "description": "Button to request permission" + }, + "setupSkip": "Lewati untuk sekarang", + "@setupSkip": { + "description": "Skip current step button" + }, + "setupStorageAccessRequired": "Akses Penyimpanan Diperlukan", + "@setupStorageAccessRequired": { + "description": "Title when storage access needed" + }, + "setupStorageAccessMessageAndroid11": "Android 11+ memerlukan izin \"Akses semua file\" untuk menyimpan file ke folder unduhan pilihan Anda.", + "@setupStorageAccessMessageAndroid11": { + "description": "Android 11+ specific explanation" + }, + "setupOpenSettings": "Buka Pengaturan", + "@setupOpenSettings": { + "description": "Button to open system settings" + }, + "setupPermissionDeniedMessage": "Izin ditolak. Harap berikan semua izin untuk melanjutkan.", + "@setupPermissionDeniedMessage": { + "description": "Error when permission denied" + }, + "setupPermissionRequired": "Izin {permissionType} Diperlukan", + "@setupPermissionRequired": { + "description": "Generic permission required title", + "placeholders": { + "permissionType": { + "type": "String", + "description": "Type of permission (Storage/Notification)" + } + } + }, + "setupPermissionRequiredMessage": "Izin {permissionType} diperlukan untuk pengalaman terbaik. Anda dapat mengubahnya nanti di Pengaturan.", + "@setupPermissionRequiredMessage": { + "description": "Generic permission required message", + "placeholders": { + "permissionType": { + "type": "String" + } + } + }, + "setupUseDefaultFolder": "Gunakan Folder Default?", + "@setupUseDefaultFolder": { + "description": "Dialog title for default folder" + }, + "setupNoFolderSelected": "Tidak ada folder dipilih. Apakah Anda ingin menggunakan folder Musik default?", + "@setupNoFolderSelected": { + "description": "Prompt when no folder selected" + }, + "setupUseDefault": "Gunakan Default", + "@setupUseDefault": { + "description": "Button to use default folder" + }, + "setupDownloadLocationTitle": "Lokasi Unduhan", + "@setupDownloadLocationTitle": { + "description": "Download location dialog title" + }, + "setupDownloadLocationIosMessage": "Di iOS, unduhan disimpan ke folder Documents aplikasi. Anda dapat mengaksesnya melalui aplikasi Files.", + "@setupDownloadLocationIosMessage": { + "description": "iOS-specific folder info" + }, + "setupAppDocumentsFolder": "Folder Documents Aplikasi", + "@setupAppDocumentsFolder": { + "description": "iOS documents folder option" + }, + "setupAppDocumentsFolderSubtitle": "Direkomendasikan - dapat diakses via aplikasi Files", + "@setupAppDocumentsFolderSubtitle": { + "description": "Subtitle for documents folder" + }, + "setupChooseFromFiles": "Pilih dari Files", + "@setupChooseFromFiles": { + "description": "iOS file picker option" + }, + "setupChooseFromFilesSubtitle": "Pilih lokasi iCloud atau lainnya", + "@setupChooseFromFilesSubtitle": { + "description": "Subtitle for file picker" + }, + "setupIosEmptyFolderWarning": "Batasan iOS: Folder kosong tidak dapat dipilih. Pilih folder dengan minimal satu file.", + "@setupIosEmptyFolderWarning": { + "description": "iOS folder selection warning" + }, + "setupIcloudNotSupported": "iCloud Drive is not supported. Please use the app Documents folder.", + "@setupIcloudNotSupported": { + "description": "Error when user selects iCloud Drive on iOS" + }, + "setupDownloadInFlac": "Unduh lagu Spotify dalam format FLAC", + "@setupDownloadInFlac": { + "description": "App tagline in setup" + }, + "setupStorageGranted": "Izin Penyimpanan Diberikan!", + "@setupStorageGranted": { + "description": "Success message for storage permission" + }, + "setupStorageRequired": "Izin Penyimpanan Diperlukan", + "@setupStorageRequired": { + "description": "Title when storage permission needed" + }, + "setupStorageDescription": "SpotiFLAC membutuhkan izin penyimpanan untuk menyimpan file musik yang diunduh.", + "@setupStorageDescription": { + "description": "Explanation for storage permission" + }, + "setupNotificationGranted": "Izin Notifikasi Diberikan!", + "@setupNotificationGranted": { + "description": "Success message for notification permission" + }, + "setupNotificationEnable": "Aktifkan Notifikasi", + "@setupNotificationEnable": { + "description": "Button to enable notifications" + }, + "setupFolderChoose": "Pilih Folder Unduhan", + "@setupFolderChoose": { + "description": "Button to choose folder" + }, + "setupFolderDescription": "Pilih folder tempat musik yang diunduh akan disimpan.", + "@setupFolderDescription": { + "description": "Explanation for folder selection" + }, + "setupSelectFolder": "Pilih Folder", + "@setupSelectFolder": { + "description": "Button to select folder" + }, + "setupEnableNotifications": "Aktifkan Notifikasi", + "@setupEnableNotifications": { + "description": "Button to enable notifications" + }, + "setupNotificationBackgroundDescription": "Dapatkan notifikasi tentang progres dan penyelesaian unduhan. Ini membantu Anda melacak unduhan saat aplikasi di latar belakang.", + "@setupNotificationBackgroundDescription": { + "description": "Detailed notification explanation" + }, + "setupSkipForNow": "Lewati untuk sekarang", + "@setupSkipForNow": { + "description": "Skip button text" + }, + "setupNext": "Lanjut", + "@setupNext": { + "description": "Next button text" + }, + "setupGetStarted": "Mulai", + "@setupGetStarted": { + "description": "Final setup button" + }, + "setupAllowAccessToManageFiles": "Harap aktifkan \"Izinkan akses untuk mengelola semua file\" di layar berikutnya.", + "@setupAllowAccessToManageFiles": { + "description": "Instruction for file access permission" + }, + "dialogCancel": "Batal", + "@dialogCancel": { + "description": "Dialog button - cancel action" + }, + "dialogSave": "Simpan", + "@dialogSave": { + "description": "Dialog button - save changes" + }, + "dialogDelete": "Hapus", + "@dialogDelete": { + "description": "Dialog button - delete item" + }, + "dialogRetry": "Coba Lagi", + "@dialogRetry": { + "description": "Dialog button - retry action" + }, + "dialogClear": "Hapus", + "@dialogClear": { + "description": "Dialog button - clear items" + }, + "dialogDone": "Selesai", + "@dialogDone": { + "description": "Dialog button - action completed" + }, + "dialogImport": "Impor", + "@dialogImport": { + "description": "Dialog button - import data" + }, + "dialogDiscard": "Buang", + "@dialogDiscard": { + "description": "Dialog button - discard changes" + }, + "dialogRemove": "Hapus", + "@dialogRemove": { + "description": "Dialog button - remove item" + }, + "dialogUninstall": "Copot", + "@dialogUninstall": { + "description": "Dialog button - uninstall extension" + }, + "dialogDiscardChanges": "Buang Perubahan?", + "@dialogDiscardChanges": { + "description": "Dialog title - unsaved changes warning" + }, + "dialogUnsavedChanges": "Anda memiliki perubahan yang belum disimpan. Apakah Anda ingin membuangnya?", + "@dialogUnsavedChanges": { + "description": "Dialog message - unsaved changes" + }, + "dialogClearAll": "Hapus Semua", + "@dialogClearAll": { + "description": "Dialog title - clear all items" + }, + "dialogRemoveExtension": "Hapus Ekstensi", + "@dialogRemoveExtension": { + "description": "Dialog title - uninstall extension" + }, + "dialogRemoveExtensionMessage": "Apakah Anda yakin ingin menghapus ekstensi ini? Tindakan ini tidak dapat dibatalkan.", + "@dialogRemoveExtensionMessage": { + "description": "Dialog message - uninstall confirmation" + }, + "dialogUninstallExtension": "Copot Ekstensi?", + "@dialogUninstallExtension": { + "description": "Dialog title - uninstall extension" + }, + "dialogUninstallExtensionMessage": "Apakah Anda yakin ingin menghapus {extensionName}?", + "@dialogUninstallExtensionMessage": { + "description": "Dialog message - uninstall specific extension", + "placeholders": { + "extensionName": { + "type": "String" + } + } + }, + "dialogClearHistoryTitle": "Hapus Riwayat", + "@dialogClearHistoryTitle": { + "description": "Dialog title - clear download history" + }, + "dialogClearHistoryMessage": "Apakah Anda yakin ingin menghapus semua riwayat unduhan? Ini tidak dapat dibatalkan.", + "@dialogClearHistoryMessage": { + "description": "Dialog message - clear history confirmation" + }, + "dialogDeleteSelectedTitle": "Hapus yang Dipilih", + "@dialogDeleteSelectedTitle": { + "description": "Dialog title - delete selected items" + }, + "dialogDeleteSelectedMessage": "Hapus {count} {count, plural, =1{lagu} other{lagu}} dari riwayat?\n\nIni juga akan menghapus file dari penyimpanan.", + "@dialogDeleteSelectedMessage": { + "description": "Dialog message - delete selected tracks", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dialogImportPlaylistTitle": "Impor Playlist", + "@dialogImportPlaylistTitle": { + "description": "Dialog title - import CSV playlist" + }, + "dialogImportPlaylistMessage": "Ditemukan {count} lagu di CSV. Tambahkan ke antrian unduhan?", + "csvImportTracks": "{count} tracks from CSV", + "@csvImportTracks": { + "description": "Label shown in quality picker for CSV import", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "@dialogImportPlaylistMessage": { + "description": "Dialog message - import playlist confirmation", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "snackbarAddedToQueue": "Menambahkan \"{trackName}\" ke antrian", + "@snackbarAddedToQueue": { + "description": "Snackbar - track added to download queue", + "placeholders": { + "trackName": { + "type": "String" + } + } + }, + "snackbarAddedTracksToQueue": "Menambahkan {count} lagu ke antrian", + "@snackbarAddedTracksToQueue": { + "description": "Snackbar - multiple tracks added to queue", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "snackbarAlreadyDownloaded": "\"{trackName}\" sudah diunduh", + "@snackbarAlreadyDownloaded": { + "description": "Snackbar - track already exists", + "placeholders": { + "trackName": { + "type": "String" + } + } + }, + "snackbarAlreadyInLibrary": "\"{trackName}\" already exists in your library", + "@snackbarAlreadyInLibrary": { + "description": "Snackbar - track already exists in local library", + "placeholders": { + "trackName": { + "type": "String" + } + } + }, + "snackbarHistoryCleared": "Riwayat dihapus", + "@snackbarHistoryCleared": { + "description": "Snackbar - history deleted" + }, + "snackbarCredentialsSaved": "Kredensial disimpan", + "@snackbarCredentialsSaved": { + "description": "Snackbar - Spotify credentials saved" + }, + "snackbarCredentialsCleared": "Kredensial dihapus", + "@snackbarCredentialsCleared": { + "description": "Snackbar - Spotify credentials removed" + }, + "snackbarDeletedTracks": "Menghapus {count} {count, plural, =1{lagu} other{lagu}}", + "@snackbarDeletedTracks": { + "description": "Snackbar - tracks deleted", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "snackbarCannotOpenFile": "Tidak dapat membuka file: {error}", + "@snackbarCannotOpenFile": { + "description": "Snackbar - file open error", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "snackbarFillAllFields": "Harap isi semua field", + "@snackbarFillAllFields": { + "description": "Snackbar - validation error" + }, + "snackbarViewQueue": "Lihat Antrian", + "@snackbarViewQueue": { + "description": "Snackbar action - view download queue" + }, + "snackbarUrlCopied": "URL {platform} disalin ke clipboard", + "@snackbarUrlCopied": { + "description": "Snackbar - URL copied", + "placeholders": { + "platform": { + "type": "String", + "description": "Platform name (Spotify/Deezer)" + } + } + }, + "snackbarFileNotFound": "File tidak ditemukan", + "@snackbarFileNotFound": { + "description": "Snackbar - file doesn't exist" + }, + "snackbarSelectExtFile": "Harap pilih file .spotiflac-ext", + "@snackbarSelectExtFile": { + "description": "Snackbar - wrong file type selected" + }, + "snackbarProviderPrioritySaved": "Prioritas provider disimpan", + "@snackbarProviderPrioritySaved": { + "description": "Snackbar - provider order saved" + }, + "snackbarMetadataProviderSaved": "Prioritas provider metadata disimpan", + "@snackbarMetadataProviderSaved": { + "description": "Snackbar - metadata provider order saved" + }, + "snackbarExtensionInstalled": "{extensionName} terpasang.", + "@snackbarExtensionInstalled": { + "description": "Snackbar - extension installed successfully", + "placeholders": { + "extensionName": { + "type": "String" + } + } + }, + "snackbarExtensionUpdated": "{extensionName} diperbarui.", + "@snackbarExtensionUpdated": { + "description": "Snackbar - extension updated successfully", + "placeholders": { + "extensionName": { + "type": "String" + } + } + }, + "snackbarFailedToInstall": "Gagal memasang ekstensi", + "@snackbarFailedToInstall": { + "description": "Snackbar - extension install error" + }, + "snackbarFailedToUpdate": "Gagal memperbarui ekstensi", + "@snackbarFailedToUpdate": { + "description": "Snackbar - extension update error" + }, + "errorRateLimited": "Dibatasi", + "@errorRateLimited": { + "description": "Error title - too many requests" + }, + "errorRateLimitedMessage": "Terlalu banyak permintaan. Harap tunggu sebentar sebelum mencari lagi.", + "@errorRateLimitedMessage": { + "description": "Error message - rate limit explanation" + }, + "errorNoTracksFound": "Tidak ada lagu ditemukan", + "@errorNoTracksFound": { + "description": "Error - search returned no results" + }, + "errorMissingExtensionSource": "Tidak dapat memuat {item}: sumber ekstensi tidak ada", + "@errorMissingExtensionSource": { + "description": "Error - extension source not available", + "placeholders": { + "item": { + "type": "String" + } + } + }, + "actionPause": "Jeda", + "@actionPause": { + "description": "Action button - pause download" + }, + "actionResume": "Lanjutkan", + "@actionResume": { + "description": "Action button - resume download" + }, + "actionCancel": "Batal", + "@actionCancel": { + "description": "Action button - cancel operation" + }, + "actionSelectAll": "Pilih Semua", + "@actionSelectAll": { + "description": "Action button - select all items" + }, + "actionDeselect": "Batal Pilih", + "@actionDeselect": { + "description": "Action button - deselect all" + }, + "actionRemoveCredentials": "Hapus Kredensial", + "@actionRemoveCredentials": { + "description": "Action button - delete Spotify credentials" + }, + "actionSaveCredentials": "Simpan Kredensial", + "@actionSaveCredentials": { + "description": "Action button - save Spotify credentials" + }, + "selectionSelected": "{count} dipilih", + "@selectionSelected": { + "description": "Selection count indicator", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "selectionAllSelected": "Semua lagu dipilih", + "@selectionAllSelected": { + "description": "Status - all items selected" + }, + "selectionSelectToDelete": "Pilih lagu untuk dihapus", + "@selectionSelectToDelete": { + "description": "Placeholder when nothing selected" + }, + "progressFetchingMetadata": "Mengambil metadata... {current}/{total}", + "@progressFetchingMetadata": { + "description": "Progress indicator - loading track info", + "placeholders": { + "current": { + "type": "int" + }, + "total": { + "type": "int" + } + } + }, + "progressReadingCsv": "Membaca CSV...", + "@progressReadingCsv": { + "description": "Progress indicator - parsing CSV file" + }, + "searchSongs": "Lagu", + "@searchSongs": { + "description": "Search result category - songs" + }, + "searchArtists": "Artis", + "@searchArtists": { + "description": "Search result category - artists" + }, + "searchAlbums": "Album", + "@searchAlbums": { + "description": "Search result category - albums" + }, + "searchPlaylists": "Playlist", + "@searchPlaylists": { + "description": "Search result category - playlists" + }, + "tooltipPlay": "Putar", + "@tooltipPlay": { + "description": "Tooltip - play button" + }, + "filenameFormat": "Format Nama File", + "@filenameFormat": { + "description": "Setting title - filename pattern" }, "filenameShowAdvancedTags": "Tampilkan tag lanjutan", "@filenameShowAdvancedTags": { @@ -1540,2341 +999,2107 @@ "@filenameShowAdvancedTagsDescription": { "description": "Description for advanced filename tag toggle" }, - "folderOrganization": "Organisasi Folder", - "@folderOrganization": { - "description": "Setting title - folder structure" - }, - "folderOrganizationNone": "Tidak ada", - "@folderOrganizationNone": { - "description": "Folder option - flat structure" - }, - "folderOrganizationByArtist": "Berdasarkan Artis", - "@folderOrganizationByArtist": { - "description": "Folder option - artist folders" - }, - "folderOrganizationByAlbum": "Berdasarkan Album", - "@folderOrganizationByAlbum": { - "description": "Folder option - album folders" - }, - "folderOrganizationByArtistAlbum": "Berdasarkan Artis & Album", - "@folderOrganizationByArtistAlbum": { - "description": "Folder option - nested folders" - }, - "folderOrganizationDescription": "Atur file yang diunduh ke dalam folder", - "@folderOrganizationDescription": { - "description": "Folder organization sheet description" - }, - "folderOrganizationNoneSubtitle": "Semua file di folder unduhan", - "@folderOrganizationNoneSubtitle": { - "description": "Subtitle for no organization option" - }, - "folderOrganizationByArtistSubtitle": "Folder terpisah untuk setiap artis", - "@folderOrganizationByArtistSubtitle": { - "description": "Subtitle for artist folder option" - }, - "folderOrganizationByAlbumSubtitle": "Folder terpisah untuk setiap album", - "@folderOrganizationByAlbumSubtitle": { - "description": "Subtitle for album folder option" - }, - "folderOrganizationByArtistAlbumSubtitle": "Folder bersarang untuk artis dan album", - "@folderOrganizationByArtistAlbumSubtitle": { - "description": "Subtitle for nested folder option" - }, - "updateAvailable": "Pembaruan Tersedia", - "@updateAvailable": { - "description": "Update dialog title" - }, - "updateNewVersion": "Versi {version} tersedia", - "@updateNewVersion": { - "description": "Update available message", - "placeholders": { - "version": { - "type": "String" - } - } - }, - "updateDownload": "Unduh", - "@updateDownload": { - "description": "Update button - download update" - }, - "updateLater": "Nanti", - "@updateLater": { - "description": "Update button - dismiss" - }, - "updateChangelog": "Log Perubahan", - "@updateChangelog": { - "description": "Link to changelog" - }, - "updateStartingDownload": "Memulai unduhan...", - "@updateStartingDownload": { - "description": "Update status - initializing" - }, - "updateDownloadFailed": "Unduhan gagal", - "@updateDownloadFailed": { - "description": "Update error title" - }, - "updateFailedMessage": "Gagal mengunduh pembaruan", - "@updateFailedMessage": { - "description": "Update error message" - }, - "updateNewVersionReady": "Versi baru sudah siap", - "@updateNewVersionReady": { - "description": "Update subtitle" - }, - "updateCurrent": "Saat ini", - "@updateCurrent": { - "description": "Label for current version" - }, - "updateNew": "Baru", - "@updateNew": { - "description": "Label for new version" - }, - "updateDownloading": "Mengunduh...", - "@updateDownloading": { - "description": "Update status - downloading" - }, - "updateWhatsNew": "Yang Baru", - "@updateWhatsNew": { - "description": "Changelog section title" - }, - "updateDownloadInstall": "Unduh & Pasang", - "@updateDownloadInstall": { - "description": "Update button - download and install" - }, - "updateDontRemind": "Jangan ingatkan", - "@updateDontRemind": { - "description": "Update button - skip this version" - }, - "providerPriority": "Prioritas Provider", - "@providerPriority": { - "description": "Setting title - download provider order" - }, - "providerPrioritySubtitle": "Seret untuk mengatur ulang provider unduhan", - "@providerPrioritySubtitle": { - "description": "Subtitle for provider priority" - }, - "providerPriorityTitle": "Prioritas Provider", - "@providerPriorityTitle": { - "description": "Provider priority page title" - }, - "providerPriorityDescription": "Seret untuk mengatur ulang urutan provider unduhan. Aplikasi akan mencoba provider dari atas ke bawah saat mengunduh lagu.", - "@providerPriorityDescription": { - "description": "Provider priority page description" - }, - "providerPriorityInfo": "Jika lagu tidak tersedia di provider pertama, aplikasi akan otomatis mencoba yang berikutnya.", - "@providerPriorityInfo": { - "description": "Info tip about fallback behavior" - }, - "providerBuiltIn": "Bawaan", - "@providerBuiltIn": { - "description": "Label for built-in providers (Tidal/Qobuz/Amazon)" - }, - "providerExtension": "Ekstensi", - "@providerExtension": { - "description": "Label for extension-provided providers" - }, - "metadataProviderPriority": "Prioritas Provider Metadata", - "@metadataProviderPriority": { - "description": "Setting title - metadata provider order" - }, - "metadataProviderPrioritySubtitle": "Urutan yang digunakan saat mengambil metadata lagu", - "@metadataProviderPrioritySubtitle": { - "description": "Subtitle for metadata priority" - }, - "metadataProviderPriorityTitle": "Prioritas Metadata", - "@metadataProviderPriorityTitle": { - "description": "Metadata priority page title" - }, - "metadataProviderPriorityDescription": "Seret untuk mengatur ulang urutan provider metadata. Aplikasi akan mencoba provider dari atas ke bawah saat mencari lagu dan mengambil metadata.", - "@metadataProviderPriorityDescription": { - "description": "Metadata priority page description" - }, - "metadataProviderPriorityInfo": "Deezer tidak memiliki batas rate dan direkomendasikan sebagai utama. Spotify mungkin membatasi rate setelah banyak permintaan.", - "@metadataProviderPriorityInfo": { - "description": "Info tip about rate limits" - }, - "metadataNoRateLimits": "Tidak ada batas rate", - "@metadataNoRateLimits": { - "description": "Deezer provider description" - }, - "metadataMayRateLimit": "Mungkin dibatasi rate", - "@metadataMayRateLimit": { - "description": "Spotify provider description" - }, - "logTitle": "Log", - "@logTitle": { - "description": "Logs screen title" - }, - "logCopy": "Salin Log", - "@logCopy": { - "description": "Action - copy logs to clipboard" - }, - "logClear": "Hapus Log", - "@logClear": { - "description": "Action - delete all logs" - }, - "logShare": "Bagikan Log", - "@logShare": { - "description": "Action - share logs file" - }, - "logEmpty": "Belum ada log", - "@logEmpty": { - "description": "Empty state title" - }, - "logCopied": "Log disalin ke clipboard", - "@logCopied": { - "description": "Snackbar - logs copied" - }, - "logSearchHint": "Cari log...", - "@logSearchHint": { - "description": "Log search placeholder" - }, - "logFilterLevel": "Level", - "@logFilterLevel": { - "description": "Filter by log level" - }, - "logFilterSection": "Filter", - "@logFilterSection": { - "description": "Filter section title" - }, - "logShareLogs": "Bagikan log", - "@logShareLogs": { - "description": "Share button tooltip" - }, - "logClearLogs": "Hapus log", - "@logClearLogs": { - "description": "Clear button tooltip" - }, - "logClearLogsTitle": "Hapus Log", - "@logClearLogsTitle": { - "description": "Clear logs dialog title" - }, - "logClearLogsMessage": "Apakah Anda yakin ingin menghapus semua log?", - "@logClearLogsMessage": { - "description": "Clear logs confirmation message" - }, - "logIspBlocking": "PEMBLOKIRAN ISP TERDETEKSI", - "@logIspBlocking": { - "description": "Error category - ISP blocking" - }, - "logRateLimited": "DIBATASI", - "@logRateLimited": { - "description": "Error category - rate limiting" - }, - "logNetworkError": "ERROR JARINGAN", - "@logNetworkError": { - "description": "Error category - network issues" - }, - "logTrackNotFound": "LAGU TIDAK DITEMUKAN", - "@logTrackNotFound": { - "description": "Error category - missing tracks" - }, - "logFilterBySeverity": "Filter log berdasarkan tingkat keparahan", - "@logFilterBySeverity": { - "description": "Filter dialog title" - }, - "logNoLogsYet": "Belum ada log", - "@logNoLogsYet": { - "description": "Empty state title" - }, - "logNoLogsYetSubtitle": "Log akan muncul di sini saat Anda menggunakan aplikasi", - "@logNoLogsYetSubtitle": { - "description": "Empty state subtitle" - }, - "logIssueSummary": "Ringkasan Masalah", - "@logIssueSummary": { - "description": "Section header for error summary" - }, - "logIspBlockingDescription": "ISP Anda mungkin memblokir akses ke layanan unduhan", - "@logIspBlockingDescription": { - "description": "ISP blocking explanation" - }, - "logIspBlockingSuggestion": "Coba gunakan VPN atau ubah DNS ke 1.1.1.1 atau 8.8.8.8", - "@logIspBlockingSuggestion": { - "description": "ISP blocking fix suggestion" - }, - "logRateLimitedDescription": "Terlalu banyak permintaan ke layanan", - "@logRateLimitedDescription": { - "description": "Rate limit explanation" - }, - "logRateLimitedSuggestion": "Tunggu beberapa menit sebelum mencoba lagi", - "@logRateLimitedSuggestion": { - "description": "Rate limit fix suggestion" - }, - "logNetworkErrorDescription": "Masalah koneksi terdeteksi", - "@logNetworkErrorDescription": { - "description": "Network error explanation" - }, - "logNetworkErrorSuggestion": "Periksa koneksi internet Anda", - "@logNetworkErrorSuggestion": { - "description": "Network error fix suggestion" - }, - "logTrackNotFoundDescription": "Beberapa lagu tidak dapat ditemukan di layanan unduhan", - "@logTrackNotFoundDescription": { - "description": "Track not found explanation" - }, - "logTrackNotFoundSuggestion": "Lagu mungkin tidak tersedia dalam kualitas lossless", - "@logTrackNotFoundSuggestion": { - "description": "Track not found explanation" - }, - "logTotalErrors": "Total error: {count}", - "@logTotalErrors": { - "description": "Error count display", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "logAffected": "Terpengaruh: {domains}", - "@logAffected": { - "description": "Affected domains display", - "placeholders": { - "domains": { - "type": "String" - } - } - }, - "logEntriesFiltered": "Entri ({count} difilter)", - "@logEntriesFiltered": { - "description": "Log count with filter active", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "logEntries": "Entri ({count})", - "@logEntries": { - "description": "Total log count", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "credentialsTitle": "Kredensial Spotify", - "@credentialsTitle": { - "description": "Credentials dialog title" - }, - "credentialsDescription": "Masukkan Client ID dan Secret Anda untuk menggunakan kuota aplikasi Spotify Anda sendiri.", - "@credentialsDescription": { - "description": "Credentials dialog explanation" - }, - "credentialsClientId": "Client ID", - "@credentialsClientId": { - "description": "Client ID field label - DO NOT TRANSLATE" - }, - "credentialsClientIdHint": "Tempel Client ID", - "@credentialsClientIdHint": { - "description": "Client ID placeholder" - }, - "credentialsClientSecret": "Client Secret", - "@credentialsClientSecret": { - "description": "Client Secret field label - DO NOT TRANSLATE" - }, - "credentialsClientSecretHint": "Tempel Client Secret", - "@credentialsClientSecretHint": { - "description": "Client Secret placeholder" - }, - "channelStable": "Stabil", - "@channelStable": { - "description": "Update channel - stable releases" - }, - "channelPreview": "Preview", - "@channelPreview": { - "description": "Update channel - beta/preview releases" - }, - "sectionSearchSource": "Sumber Pencarian", - "@sectionSearchSource": { - "description": "Settings section header" - }, - "sectionDownload": "Unduhan", - "@sectionDownload": { - "description": "Settings section header" - }, - "sectionPerformance": "Performa", - "@sectionPerformance": { - "description": "Settings section header" - }, - "sectionApp": "Aplikasi", - "@sectionApp": { - "description": "Settings section header" - }, - "sectionData": "Data", - "@sectionData": { - "description": "Settings section header" - }, - "sectionDebug": "Debug", - "@sectionDebug": { - "description": "Settings section header" - }, - "sectionService": "Layanan", - "@sectionService": { - "description": "Settings section header" - }, - "sectionAudioQuality": "Kualitas Audio", - "@sectionAudioQuality": { - "description": "Settings section header" - }, - "sectionFileSettings": "Pengaturan File", - "@sectionFileSettings": { - "description": "Settings section header" - }, - "sectionLyrics": "Lyrics", - "@sectionLyrics": { - "description": "Settings section header" - }, - "lyricsMode": "Lyrics Mode", - "@lyricsMode": { - "description": "Setting - how to save lyrics" - }, - "lyricsModeDescription": "Choose how lyrics are saved with your downloads", - "@lyricsModeDescription": { - "description": "Lyrics mode picker description" - }, - "lyricsModeEmbed": "Embed in file", - "@lyricsModeEmbed": { - "description": "Lyrics mode option - embed in audio file" - }, - "lyricsModeEmbedSubtitle": "Lyrics stored inside FLAC metadata", - "@lyricsModeEmbedSubtitle": { - "description": "Subtitle for embed option" - }, - "lyricsModeExternal": "External .lrc file", - "@lyricsModeExternal": { - "description": "Lyrics mode option - separate LRC file" - }, - "lyricsModeExternalSubtitle": "Separate .lrc file for players like Samsung Music", - "@lyricsModeExternalSubtitle": { - "description": "Subtitle for external option" - }, - "lyricsModeBoth": "Both", - "@lyricsModeBoth": { - "description": "Lyrics mode option - embed and external" - }, - "lyricsModeBothSubtitle": "Embed and save .lrc file", - "@lyricsModeBothSubtitle": { - "description": "Subtitle for both option" - }, - "sectionColor": "Warna", - "@sectionColor": { - "description": "Settings section header" - }, - "sectionTheme": "Tema", - "@sectionTheme": { - "description": "Settings section header" - }, - "sectionLayout": "Tata Letak", - "@sectionLayout": { - "description": "Settings section header" - }, - "sectionLanguage": "Bahasa", - "@sectionLanguage": { - "description": "Settings section header for language" - }, - "appearanceLanguage": "Bahasa Aplikasi", - "@appearanceLanguage": { - "description": "Language setting title" - }, - "appearanceLanguageSubtitle": "Pilih bahasa yang kamu inginkan", - "@appearanceLanguageSubtitle": { - "description": "Language setting subtitle" - }, - "settingsAppearanceSubtitle": "Tema, warna, tampilan", - "@settingsAppearanceSubtitle": { - "description": "Appearance settings description" - }, - "settingsDownloadSubtitle": "Layanan, kualitas, format nama file", - "@settingsDownloadSubtitle": { - "description": "Download settings description" - }, - "settingsOptionsSubtitle": "Fallback, lirik, cover art, pembaruan", - "@settingsOptionsSubtitle": { - "description": "Options settings description" - }, - "settingsExtensionsSubtitle": "Kelola provider unduhan", - "@settingsExtensionsSubtitle": { - "description": "Extensions settings description" - }, - "settingsLogsSubtitle": "Lihat log aplikasi untuk debugging", - "@settingsLogsSubtitle": { - "description": "Logs settings description" - }, - "loadingSharedLink": "Memuat link yang dibagikan...", - "@loadingSharedLink": { - "description": "Status when opening shared URL" - }, - "pressBackAgainToExit": "Tekan kembali sekali lagi untuk keluar", - "@pressBackAgainToExit": { - "description": "Exit confirmation message" - }, - "tracksHeader": "Lagu", - "@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}}", - "@tracksCount": { - "description": "Track count display", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "trackCopyFilePath": "Salin lokasi file", - "@trackCopyFilePath": { - "description": "Action - copy file path" - }, - "trackRemoveFromDevice": "Hapus dari perangkat", - "@trackRemoveFromDevice": { - "description": "Action - delete downloaded file" - }, - "trackLoadLyrics": "Muat Lirik", - "@trackLoadLyrics": { - "description": "Action - fetch lyrics" - }, - "trackMetadata": "Metadata", - "@trackMetadata": { - "description": "Tab title - track metadata" - }, - "trackFileInfo": "Info File", - "@trackFileInfo": { - "description": "Tab title - file information" - }, - "trackLyrics": "Lirik", - "@trackLyrics": { - "description": "Tab title - lyrics" - }, - "trackFileNotFound": "File tidak ditemukan", - "@trackFileNotFound": { - "description": "Error - file doesn't exist" - }, - "trackOpenInDeezer": "Buka di Deezer", - "@trackOpenInDeezer": { - "description": "Action - open track in Deezer app" - }, - "trackOpenInSpotify": "Buka di Spotify", - "@trackOpenInSpotify": { - "description": "Action - open track in Spotify app" - }, - "trackTrackName": "Nama lagu", - "@trackTrackName": { - "description": "Metadata label - track title" - }, - "trackArtist": "Artis", - "@trackArtist": { - "description": "Metadata label - artist name" - }, - "trackAlbumArtist": "Artis album", - "@trackAlbumArtist": { - "description": "Metadata label - album artist" - }, - "trackAlbum": "Album", - "@trackAlbum": { - "description": "Metadata label - album name" - }, - "trackTrackNumber": "Nomor lagu", - "@trackTrackNumber": { - "description": "Metadata label - track number" - }, - "trackDiscNumber": "Nomor disc", - "@trackDiscNumber": { - "description": "Metadata label - disc number" - }, - "trackDuration": "Durasi", - "@trackDuration": { - "description": "Metadata label - track length" - }, - "trackAudioQuality": "Kualitas audio", - "@trackAudioQuality": { - "description": "Metadata label - audio quality" - }, - "trackReleaseDate": "Tanggal rilis", - "@trackReleaseDate": { - "description": "Metadata label - release date" - }, - "trackGenre": "Genre", - "@trackGenre": { - "description": "Metadata label - music genre" - }, - "trackLabel": "Label", - "@trackLabel": { - "description": "Metadata label - record label" - }, - "trackCopyright": "Copyright", - "@trackCopyright": { - "description": "Metadata label - copyright information" - }, - "trackDownloaded": "Diunduh", - "@trackDownloaded": { - "description": "Metadata label - download date" - }, - "trackCopyLyrics": "Salin lirik", - "@trackCopyLyrics": { - "description": "Action - copy lyrics to clipboard" - }, - "trackLyricsNotAvailable": "Lirik tidak tersedia untuk lagu ini", - "@trackLyricsNotAvailable": { - "description": "Message when lyrics not found" - }, - "trackLyricsTimeout": "Permintaan timeout. Coba lagi nanti.", - "@trackLyricsTimeout": { - "description": "Message when lyrics request times out" - }, - "trackLyricsLoadFailed": "Gagal memuat lirik", - "@trackLyricsLoadFailed": { - "description": "Message when lyrics loading fails" - }, - "trackEmbedLyrics": "Embed Lyrics", - "@trackEmbedLyrics": { - "description": "Action - embed lyrics into audio file" - }, - "trackLyricsEmbedded": "Lyrics embedded successfully", - "@trackLyricsEmbedded": { - "description": "Snackbar - lyrics saved to file" - }, - "trackInstrumental": "Instrumental track", - "@trackInstrumental": { - "description": "Message when track is instrumental (no lyrics)" - }, - "trackCopiedToClipboard": "Disalin ke clipboard", - "@trackCopiedToClipboard": { - "description": "Snackbar - content copied" - }, - "trackDeleteConfirmTitle": "Hapus dari perangkat?", - "@trackDeleteConfirmTitle": { - "description": "Delete confirmation title" - }, - "trackDeleteConfirmMessage": "Ini akan menghapus file unduhan secara permanen dan menghapusnya dari riwayat Anda.", - "@trackDeleteConfirmMessage": { - "description": "Delete confirmation message" - }, - "trackCannotOpen": "Tidak dapat membuka: {message}", - "@trackCannotOpen": { - "description": "Error opening file", - "placeholders": { - "message": { - "type": "String" - } - } - }, - "dateToday": "Hari ini", - "@dateToday": { - "description": "Relative date - today" - }, - "dateYesterday": "Kemarin", - "@dateYesterday": { - "description": "Relative date - yesterday" - }, - "dateDaysAgo": "{count} hari lalu", - "@dateDaysAgo": { - "description": "Relative date - days ago", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "dateWeeksAgo": "{count} minggu lalu", - "@dateWeeksAgo": { - "description": "Relative date - weeks ago", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "dateMonthsAgo": "{count} bulan lalu", - "@dateMonthsAgo": { - "description": "Relative date - months ago", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "concurrentSequential": "Berurutan", - "@concurrentSequential": { - "description": "Download mode - one at a time" - }, - "concurrentParallel2": "2 Paralel", - "@concurrentParallel2": { - "description": "Download mode - 2 simultaneous" - }, - "concurrentParallel3": "3 Paralel", - "@concurrentParallel3": { - "description": "Download mode - 3 simultaneous" - }, - "tapToSeeError": "Ketuk untuk melihat detail error", - "@tapToSeeError": { - "description": "Tooltip for failed download" - }, - "storeFilterAll": "Semua", - "@storeFilterAll": { - "description": "Store filter - all extensions" - }, - "storeFilterMetadata": "Metadata", - "@storeFilterMetadata": { - "description": "Store filter - metadata providers" - }, - "storeFilterDownload": "Unduhan", - "@storeFilterDownload": { - "description": "Store filter - download providers" - }, - "storeFilterUtility": "Utilitas", - "@storeFilterUtility": { - "description": "Store filter - utility extensions" - }, - "storeFilterLyrics": "Lirik", - "@storeFilterLyrics": { - "description": "Store filter - lyrics providers" - }, - "storeFilterIntegration": "Integrasi", - "@storeFilterIntegration": { - "description": "Store filter - integrations" - }, - "storeClearFilters": "Hapus filter", - "@storeClearFilters": { - "description": "Button to clear all filters" - }, - "storeNoResults": "Tidak ada ekstensi ditemukan", - "@storeNoResults": { - "description": "Empty state when no extensions match filters" - }, - "extensionProviderPriority": "Prioritas Provider", - "@extensionProviderPriority": { - "description": "Extension capability - provider priority" - }, - "extensionInstallButton": "Pasang Ekstensi", - "@extensionInstallButton": { - "description": "Button to install extension" - }, - "extensionDefaultProvider": "Default (Deezer/Spotify)", - "@extensionDefaultProvider": { - "description": "Default search provider option" - }, - "extensionDefaultProviderSubtitle": "Gunakan pencarian bawaan", - "@extensionDefaultProviderSubtitle": { - "description": "Subtitle for default provider" - }, - "extensionAuthor": "Pembuat", - "@extensionAuthor": { - "description": "Extension detail - author" - }, - "extensionId": "ID", - "@extensionId": { - "description": "Extension detail - unique ID" - }, - "extensionError": "Error", - "@extensionError": { - "description": "Extension detail - error message" - }, - "extensionCapabilities": "Kemampuan", - "@extensionCapabilities": { - "description": "Section header - extension features" - }, - "extensionMetadataProvider": "Provider Metadata", - "@extensionMetadataProvider": { - "description": "Capability - provides metadata" - }, - "extensionDownloadProvider": "Provider Unduhan", - "@extensionDownloadProvider": { - "description": "Capability - provides downloads" - }, - "extensionLyricsProvider": "Provider Lirik", - "@extensionLyricsProvider": { - "description": "Capability - provides lyrics" - }, - "extensionUrlHandler": "Penanganan URL", - "@extensionUrlHandler": { - "description": "Capability - handles URLs" - }, - "extensionQualityOptions": "Opsi Kualitas", - "@extensionQualityOptions": { - "description": "Capability - quality selection" - }, - "extensionPostProcessingHooks": "Hook Pasca-Pemrosesan", - "@extensionPostProcessingHooks": { - "description": "Capability - post-processing" - }, - "extensionPermissions": "Izin", - "@extensionPermissions": { - "description": "Section header - required permissions" - }, - "extensionSettings": "Pengaturan", - "@extensionSettings": { - "description": "Section header - extension settings" - }, - "extensionRemoveButton": "Hapus Ekstensi", - "@extensionRemoveButton": { - "description": "Button to uninstall extension" - }, - "extensionUpdated": "Diperbarui", - "@extensionUpdated": { - "description": "Extension detail - last update" - }, - "extensionMinAppVersion": "Versi App Minimum", - "@extensionMinAppVersion": { - "description": "Extension detail - minimum app version" - }, - "extensionCustomTrackMatching": "Pencocokan Lagu Kustom", - "@extensionCustomTrackMatching": { - "description": "Capability - custom track matching algorithm" - }, - "extensionPostProcessing": "Pasca-Pemrosesan", - "@extensionPostProcessing": { - "description": "Capability - post-download processing" - }, - "extensionHooksAvailable": "{count} hook tersedia", - "@extensionHooksAvailable": { - "description": "Post-processing hooks count", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "extensionPatternsCount": "{count} pola", - "@extensionPatternsCount": { - "description": "URL patterns count", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "extensionStrategy": "Strategi: {strategy}", - "@extensionStrategy": { - "description": "Track matching strategy name", - "placeholders": { - "strategy": { - "type": "String" - } - } - }, - "extensionsProviderPrioritySection": "Prioritas Provider", - "@extensionsProviderPrioritySection": { - "description": "Section header - provider priority" - }, - "extensionsInstalledSection": "Ekstensi Terpasang", - "@extensionsInstalledSection": { - "description": "Section header - installed extensions" - }, - "extensionsNoExtensions": "Tidak ada ekstensi terpasang", - "@extensionsNoExtensions": { - "description": "Empty state - no extensions" - }, - "extensionsNoExtensionsSubtitle": "Pasang file .spotiflac-ext untuk menambahkan provider baru", - "@extensionsNoExtensionsSubtitle": { - "description": "Empty state subtitle" - }, - "extensionsInstallButton": "Pasang Ekstensi", - "@extensionsInstallButton": { - "description": "Button to install extension from file" - }, - "extensionsInfoTip": "Ekstensi dapat menambahkan provider metadata dan unduhan baru. Hanya pasang ekstensi dari sumber terpercaya.", - "@extensionsInfoTip": { - "description": "Security warning about extensions" - }, - "extensionsInstalledSuccess": "Ekstensi berhasil dipasang", - "@extensionsInstalledSuccess": { - "description": "Success message after install" - }, - "extensionsDownloadPriority": "Prioritas Unduhan", - "@extensionsDownloadPriority": { - "description": "Setting - download provider order" - }, - "extensionsDownloadPrioritySubtitle": "Atur urutan layanan unduhan", - "@extensionsDownloadPrioritySubtitle": { - "description": "Subtitle for download priority" - }, - "extensionsNoDownloadProvider": "Tidak ada ekstensi dengan provider unduhan", - "@extensionsNoDownloadProvider": { - "description": "Empty state - no download providers" - }, - "extensionsMetadataPriority": "Prioritas Metadata", - "@extensionsMetadataPriority": { - "description": "Setting - metadata provider order" - }, - "extensionsMetadataPrioritySubtitle": "Atur urutan sumber pencarian & metadata", - "@extensionsMetadataPrioritySubtitle": { - "description": "Subtitle for metadata priority" - }, - "extensionsNoMetadataProvider": "Tidak ada ekstensi dengan provider metadata", - "@extensionsNoMetadataProvider": { - "description": "Empty state - no metadata providers" - }, - "extensionsSearchProvider": "Provider Pencarian", - "@extensionsSearchProvider": { - "description": "Setting - search provider selection" - }, - "extensionsNoCustomSearch": "Tidak ada ekstensi dengan pencarian kustom", - "@extensionsNoCustomSearch": { - "description": "Empty state - no search providers" - }, - "extensionsSearchProviderDescription": "Pilih layanan yang digunakan untuk mencari lagu", - "@extensionsSearchProviderDescription": { - "description": "Search provider setting description" - }, - "extensionsCustomSearch": "Pencarian kustom", - "@extensionsCustomSearch": { - "description": "Label for custom search provider" - }, - "extensionsErrorLoading": "Error memuat ekstensi", - "@extensionsErrorLoading": { - "description": "Error message when extension fails to load" - }, - "qualityFlacLossless": "FLAC Lossless", - "@qualityFlacLossless": { - "description": "Quality option - CD quality FLAC" - }, - "qualityFlacLosslessSubtitle": "16-bit / 44.1kHz", - "@qualityFlacLosslessSubtitle": { - "description": "Technical spec for lossless" - }, - "qualityHiResFlac": "Hi-Res FLAC", - "@qualityHiResFlac": { - "description": "Quality option - high resolution FLAC" - }, - "qualityHiResFlacSubtitle": "24-bit / hingga 96kHz", - "@qualityHiResFlacSubtitle": { - "description": "Technical spec for hi-res" - }, - "qualityHiResFlacMax": "Hi-Res FLAC Max", - "@qualityHiResFlacMax": { - "description": "Quality option - maximum resolution FLAC" - }, - "qualityHiResFlacMaxSubtitle": "24-bit / hingga 192kHz", - "@qualityHiResFlacMaxSubtitle": { - "description": "Technical spec for hi-res max" - }, - "qualityLossy": "Lossy", - "@qualityLossy": { - "description": "Quality option - lossy format (MP3/Opus)" - }, - "qualityLossyMp3Subtitle": "MP3 320kbps (converted from FLAC)", - "@qualityLossyMp3Subtitle": { - "description": "Technical spec for lossy MP3" - }, - "qualityLossyOpusSubtitle": "Opus 128kbps (converted from FLAC)", - "@qualityLossyOpusSubtitle": { - "description": "Technical spec for lossy Opus" - }, - "enableLossyOption": "Enable Lossy Option", - "@enableLossyOption": { - "description": "Setting - enable lossy quality option" - }, - "enableLossyOptionSubtitleOn": "Lossy quality option is available", - "@enableLossyOptionSubtitleOn": { - "description": "Subtitle when lossy is enabled" - }, - "enableLossyOptionSubtitleOff": "Downloads FLAC then converts to lossy format", - "@enableLossyOptionSubtitleOff": { - "description": "Subtitle when lossy is disabled" - }, - "lossyFormat": "Lossy Format", - "@lossyFormat": { - "description": "Setting - choose lossy format" - }, - "lossyFormatDescription": "Choose the lossy format for conversion", - "@lossyFormatDescription": { - "description": "Description for lossy format picker" - }, - "lossyFormatMp3Subtitle": "320kbps, best compatibility", - "@lossyFormatMp3Subtitle": { - "description": "MP3 format description" - }, - "lossyFormatOpusSubtitle": "128kbps, better quality at smaller size", - "@lossyFormatOpusSubtitle": { - "description": "Opus format description" - }, - "qualityNote": "Kualitas sebenarnya tergantung ketersediaan lagu dari layanan", - "@qualityNote": { - "description": "Note about quality availability" - }, - "youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.", - "@youtubeQualityNote": { - "description": "Note for YouTube service explaining lossy-only quality" - }, - "downloadAskBeforeDownload": "Tanya Sebelum Unduh", - "@downloadAskBeforeDownload": { - "description": "Setting - show quality picker" - }, - "downloadDirectory": "Direktori Unduhan", - "@downloadDirectory": { - "description": "Setting - download folder" - }, - "downloadSeparateSinglesFolder": "Folder Singles Terpisah", - "@downloadSeparateSinglesFolder": { - "description": "Setting - separate folder for singles" - }, - "downloadAlbumFolderStructure": "Struktur Folder Album", - "@downloadAlbumFolderStructure": { - "description": "Setting - album folder organization" - }, - "downloadUseAlbumArtistForFolders": "Use Album Artist for folders", - "@downloadUseAlbumArtistForFolders": { - "description": "Setting - choose whether artist folders use Album Artist or Track Artist" - }, - "downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available", - "@downloadUseAlbumArtistForFoldersAlbumSubtitle": { - "description": "Subtitle when Album Artist is used for folder naming" - }, - "downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only", - "@downloadUseAlbumArtistForFoldersTrackSubtitle": { - "description": "Subtitle when Track Artist is used for folder naming" - }, - "downloadUsePrimaryArtistOnly": "Primary artist only for folders", - "@downloadUsePrimaryArtistOnly": { - "description": "Setting - strip featured artists from folder name" - }, - "downloadUsePrimaryArtistOnlyEnabled": "Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)", - "@downloadUsePrimaryArtistOnlyEnabled": { - "description": "Subtitle when primary artist only is enabled" - }, - "downloadUsePrimaryArtistOnlyDisabled": "Full artist string used for folder name", - "@downloadUsePrimaryArtistOnlyDisabled": { - "description": "Subtitle when primary artist only is disabled" - }, - "downloadSaveFormat": "Simpan Format", - "@downloadSaveFormat": { - "description": "Setting - output file format" - }, - "downloadSelectService": "Pilih Layanan", - "@downloadSelectService": { - "description": "Dialog title - choose download service" - }, - "downloadSelectQuality": "Pilih Kualitas", - "@downloadSelectQuality": { - "description": "Dialog title - choose audio quality" - }, - "downloadFrom": "Unduh Dari", - "@downloadFrom": { - "description": "Label - download source" - }, - "downloadDefaultQualityLabel": "Kualitas Default", - "@downloadDefaultQualityLabel": { - "description": "Label - default quality setting" - }, - "downloadBestAvailable": "Terbaik tersedia", - "@downloadBestAvailable": { - "description": "Quality option - highest available" - }, - "folderNone": "Tidak ada", - "@folderNone": { - "description": "Folder option - no organization" - }, - "folderNoneSubtitle": "Simpan semua file langsung ke folder unduhan", - "@folderNoneSubtitle": { - "description": "Subtitle for no folder organization" - }, - "folderArtist": "Artis", - "@folderArtist": { - "description": "Folder option - by artist" - }, - "folderArtistSubtitle": "Nama Artis/namafile", - "@folderArtistSubtitle": { - "description": "Folder structure example" - }, - "folderAlbum": "Album", - "@folderAlbum": { - "description": "Folder option - by album" - }, - "folderAlbumSubtitle": "Nama Album/namafile", - "@folderAlbumSubtitle": { - "description": "Folder structure example" - }, - "folderArtistAlbum": "Artis/Album", - "@folderArtistAlbum": { - "description": "Folder option - nested" - }, - "folderArtistAlbumSubtitle": "Nama Artis/Nama Album/namafile", - "@folderArtistAlbumSubtitle": { - "description": "Folder structure example" - }, - "serviceTidal": "Tidal", - "@serviceTidal": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceQobuz": "Qobuz", - "@serviceQobuz": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceAmazon": "Amazon", - "@serviceAmazon": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceDeezer": "Deezer", - "@serviceDeezer": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceSpotify": "Spotify", - "@serviceSpotify": { - "description": "Service name - DO NOT TRANSLATE" - }, - "appearanceAmoledDark": "AMOLED Gelap", - "@appearanceAmoledDark": { - "description": "Theme option - pure black" - }, - "appearanceAmoledDarkSubtitle": "Latar belakang hitam murni", - "@appearanceAmoledDarkSubtitle": { - "description": "Subtitle for AMOLED dark" - }, - "appearanceChooseAccentColor": "Pilih Warna Aksen", - "@appearanceChooseAccentColor": { - "description": "Color picker dialog title" - }, - "appearanceChooseTheme": "Mode Tema", - "@appearanceChooseTheme": { - "description": "Theme picker dialog title" - }, - "queueTitle": "Antrian Unduhan", - "@queueTitle": { - "description": "Queue screen title" - }, - "queueClearAll": "Hapus Semua", - "@queueClearAll": { - "description": "Button - clear all queue items" - }, - "queueClearAllMessage": "Apakah Anda yakin ingin menghapus semua unduhan?", - "@queueClearAllMessage": { - "description": "Clear queue confirmation" - }, - "queueExportFailed": "Export", - "@queueExportFailed": { - "description": "Button - export failed downloads to TXT" - }, - "queueExportFailedSuccess": "Failed downloads exported to TXT file", - "@queueExportFailedSuccess": { - "description": "Success message after exporting failed downloads" - }, - "queueExportFailedClear": "Clear Failed", - "@queueExportFailedClear": { - "description": "Action to clear failed downloads after export" - }, - "queueExportFailedError": "Failed to export downloads", - "@queueExportFailedError": { - "description": "Error message when export fails" - }, - "settingsAutoExportFailed": "Auto-export failed downloads", - "@settingsAutoExportFailed": { - "description": "Setting toggle for auto-export" - }, - "settingsAutoExportFailedSubtitle": "Save failed downloads to TXT file automatically", - "@settingsAutoExportFailedSubtitle": { - "description": "Subtitle for auto-export setting" - }, - "settingsDownloadNetwork": "Download Network", - "@settingsDownloadNetwork": { - "description": "Setting for network type preference" - }, - "settingsDownloadNetworkAny": "WiFi + Mobile Data", - "@settingsDownloadNetworkAny": { - "description": "Network option - use any connection" - }, - "settingsDownloadNetworkWifiOnly": "WiFi Only", - "@settingsDownloadNetworkWifiOnly": { - "description": "Network option - only use WiFi" - }, - "settingsDownloadNetworkSubtitle": "Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.", - "@settingsDownloadNetworkSubtitle": { - "description": "Subtitle explaining network preference" - }, - "queueEmpty": "Tidak ada unduhan dalam antrian", - "@queueEmpty": { - "description": "Empty queue state title" - }, - "queueEmptySubtitle": "Tambahkan lagu dari layar beranda", - "@queueEmptySubtitle": { - "description": "Empty queue state subtitle" - }, - "queueClearCompleted": "Hapus yang selesai", - "@queueClearCompleted": { - "description": "Button - clear finished downloads" - }, - "queueDownloadFailed": "Unduhan Gagal", - "@queueDownloadFailed": { - "description": "Error dialog title" - }, - "queueTrackLabel": "Lagu:", - "@queueTrackLabel": { - "description": "Label in error dialog" - }, - "queueArtistLabel": "Artis:", - "@queueArtistLabel": { - "description": "Label in error dialog" - }, - "queueErrorLabel": "Error:", - "@queueErrorLabel": { - "description": "Label in error dialog" - }, - "queueUnknownError": "Error tidak diketahui", - "@queueUnknownError": { - "description": "Fallback error message" - }, - "albumFolderArtistAlbum": "Artis / Album", - "@albumFolderArtistAlbum": { - "description": "Album folder option" - }, - "albumFolderArtistAlbumSubtitle": "Albums/Nama Artis/Nama Album/", - "@albumFolderArtistAlbumSubtitle": { - "description": "Folder structure example" - }, - "albumFolderArtistYearAlbum": "Artis / [Tahun] Album", - "@albumFolderArtistYearAlbum": { - "description": "Album folder option with year" - }, - "albumFolderArtistYearAlbumSubtitle": "Albums/Nama Artis/[2005] Nama Album/", - "@albumFolderArtistYearAlbumSubtitle": { - "description": "Folder structure example" - }, - "albumFolderAlbumOnly": "Album Saja", - "@albumFolderAlbumOnly": { - "description": "Album folder option" - }, - "albumFolderAlbumOnlySubtitle": "Albums/Nama Album/", - "@albumFolderAlbumOnlySubtitle": { - "description": "Folder structure example" - }, - "albumFolderYearAlbum": "[Tahun] Album", - "@albumFolderYearAlbum": { - "description": "Album folder option with year" - }, - "albumFolderYearAlbumSubtitle": "Albums/[2005] Nama Album/", - "@albumFolderYearAlbumSubtitle": { - "description": "Folder structure example" - }, - "albumFolderArtistAlbumSingles": "Artist / Album + Singles", - "@albumFolderArtistAlbumSingles": { - "description": "Album folder option with singles inside artist" - }, - "albumFolderArtistAlbumSinglesSubtitle": "Artist/Album/ and Artist/Singles/", - "@albumFolderArtistAlbumSinglesSubtitle": { - "description": "Folder structure example" - }, - "downloadedAlbumDeleteSelected": "Hapus yang Dipilih", - "@downloadedAlbumDeleteSelected": { - "description": "Button - delete selected tracks" - }, - "downloadedAlbumDeleteMessage": "Hapus {count} {count, plural, =1{lagu} other{lagu}} dari album ini?\n\nIni juga akan menghapus file dari penyimpanan.", - "@downloadedAlbumDeleteMessage": { - "description": "Delete confirmation with count", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "downloadedAlbumTracksHeader": "Lagu", - "@downloadedAlbumTracksHeader": { - "description": "Section header for tracks" - }, - "downloadedAlbumDownloadedCount": "{count} diunduh", - "@downloadedAlbumDownloadedCount": { - "description": "Downloaded tracks count badge", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "downloadedAlbumSelectedCount": "{count} dipilih", - "@downloadedAlbumSelectedCount": { - "description": "Selection count indicator", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "downloadedAlbumAllSelected": "Semua lagu dipilih", - "@downloadedAlbumAllSelected": { - "description": "Status - all items selected" - }, - "downloadedAlbumTapToSelect": "Ketuk lagu untuk memilih", - "@downloadedAlbumTapToSelect": { - "description": "Selection hint" - }, - "downloadedAlbumDeleteCount": "Hapus {count} {count, plural, =1{lagu} other{lagu}}", - "@downloadedAlbumDeleteCount": { - "description": "Delete button text with count", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "downloadedAlbumSelectToDelete": "Pilih lagu untuk dihapus", - "@downloadedAlbumSelectToDelete": { - "description": "Placeholder when nothing selected" - }, - "downloadedAlbumDiscHeader": "Disc {discNumber}", - "@downloadedAlbumDiscHeader": { - "description": "Header for disc separator in multi-disc albums", - "placeholders": { - "discNumber": { - "type": "int", - "example": "1" - } - } - }, - "utilityFunctions": "Fungsi Utilitas", - "@utilityFunctions": { - "description": "Extension capability - utility functions" - }, - "recentTypeArtist": "Artis", - "@recentTypeArtist": { - "description": "Recent access item type - artist" - }, - "recentTypeAlbum": "Album", - "@recentTypeAlbum": { - "description": "Recent access item type - album" - }, - "recentTypeSong": "Lagu", - "@recentTypeSong": { - "description": "Recent access item type - song/track" - }, - "recentTypePlaylist": "Playlist", - "@recentTypePlaylist": { - "description": "Recent access item type - playlist" - }, - "recentEmpty": "No recent items yet", - "@recentEmpty": { - "description": "Empty state text for recent access list" - }, - "recentShowAllDownloads": "Show All Downloads", - "@recentShowAllDownloads": { - "description": "Button label to unhide hidden downloads in recent access" - }, - "recentPlaylistInfo": "Playlist: {name}", - "@recentPlaylistInfo": { - "description": "Snackbar message when tapping playlist in recent access", - "placeholders": { - "name": { - "type": "String", - "description": "Playlist name" - } - } - }, - "errorGeneric": "Error: {message}", - "@errorGeneric": { - "description": "Generic error message format", - "placeholders": { - "message": { - "type": "String", - "description": "Error message" - } - } - }, - "discographyDownload": "Download Discography", - "@discographyDownload": { - "description": "Button - download artist discography" - }, - "discographyDownloadAll": "Unduh Semua", - "@discographyDownloadAll": { - "description": "Option - download entire discography" - }, - "discographyDownloadAllSubtitle": "{count} tracks from {albumCount} releases", - "@discographyDownloadAllSubtitle": { - "description": "Subtitle showing total tracks and albums", - "placeholders": { - "count": { - "type": "int" - }, - "albumCount": { - "type": "int" - } - } - }, - "discographyAlbumsOnly": "Albums Only", - "@discographyAlbumsOnly": { - "description": "Option - download only albums" - }, - "discographyAlbumsOnlySubtitle": "{count} tracks from {albumCount} albums", - "@discographyAlbumsOnlySubtitle": { - "description": "Subtitle showing album tracks count", - "placeholders": { - "count": { - "type": "int" - }, - "albumCount": { - "type": "int" - } - } - }, - "discographySinglesOnly": "Singles & EPs Only", - "@discographySinglesOnly": { - "description": "Option - download only singles" - }, - "discographySinglesOnlySubtitle": "{count} tracks from {albumCount} singles", - "@discographySinglesOnlySubtitle": { - "description": "Subtitle showing singles tracks count", - "placeholders": { - "count": { - "type": "int" - }, - "albumCount": { - "type": "int" - } - } - }, - "discographySelectAlbums": "Select Albums...", - "@discographySelectAlbums": { - "description": "Option - manually select albums to download" - }, - "discographySelectAlbumsSubtitle": "Choose specific albums or singles", - "@discographySelectAlbumsSubtitle": { - "description": "Subtitle for select albums option" - }, - "discographyFetchingTracks": "Fetching tracks...", - "@discographyFetchingTracks": { - "description": "Progress - fetching album tracks" - }, - "discographyFetchingAlbum": "Fetching {current} of {total}...", - "@discographyFetchingAlbum": { - "description": "Progress - fetching specific album", - "placeholders": { - "current": { - "type": "int" - }, - "total": { - "type": "int" - } - } - }, - "discographySelectedCount": "{count} selected", - "@discographySelectedCount": { - "description": "Selection count badge", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "discographyDownloadSelected": "Download Selected", - "@discographyDownloadSelected": { - "description": "Button - download selected albums" - }, - "discographyAddedToQueue": "Added {count} tracks to queue", - "@discographyAddedToQueue": { - "description": "Snackbar - tracks added from discography", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "discographySkippedDownloaded": "{added} added, {skipped} already downloaded", - "@discographySkippedDownloaded": { - "description": "Snackbar - with skipped tracks count", - "placeholders": { - "added": { - "type": "int" - }, - "skipped": { - "type": "int" - } - } - }, - "discographyNoAlbums": "No albums available", - "@discographyNoAlbums": { - "description": "Error - no albums found for artist" - }, - "discographyFailedToFetch": "Failed to fetch some albums", - "@discographyFailedToFetch": { - "description": "Error - some albums failed to load" - }, - "sectionStorageAccess": "Storage Access", - "@sectionStorageAccess": { - "description": "Section header for storage access settings" - }, - "allFilesAccess": "All Files Access", - "@allFilesAccess": { - "description": "Toggle for MANAGE_EXTERNAL_STORAGE permission" - }, - "allFilesAccessEnabledSubtitle": "Can write to any folder", - "@allFilesAccessEnabledSubtitle": { - "description": "Subtitle when all files access is enabled" - }, - "allFilesAccessDisabledSubtitle": "Limited to media folders only", - "@allFilesAccessDisabledSubtitle": { - "description": "Subtitle when all files access is disabled" - }, - "allFilesAccessDescription": "Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.", - "@allFilesAccessDescription": { - "description": "Description explaining when to enable all files access" - }, - "allFilesAccessDeniedMessage": "Permission was denied. Please enable 'All files access' manually in system settings.", - "@allFilesAccessDeniedMessage": { - "description": "Message when permission is permanently denied" - }, - "allFilesAccessDisabledMessage": "All Files Access disabled. The app will use limited storage access.", - "@allFilesAccessDisabledMessage": { - "description": "Snackbar message when user disables all files access" - }, - "settingsLocalLibrary": "Local Library", - "@settingsLocalLibrary": { - "description": "Settings menu item - local library" - }, - "settingsLocalLibrarySubtitle": "Scan music & detect duplicates", - "@settingsLocalLibrarySubtitle": { - "description": "Subtitle for local library settings" - }, - "settingsCache": "Storage & Cache", - "@settingsCache": { - "description": "Settings menu item - cache management" - }, - "settingsCacheSubtitle": "View size and clear cached data", - "@settingsCacheSubtitle": { - "description": "Subtitle for cache management menu" - }, - "libraryTitle": "Local Library", - "@libraryTitle": { - "description": "Library settings page title" - }, - "libraryStatus": "Library Status", - "@libraryStatus": { - "description": "Section header for library status" - }, - "libraryScanSettings": "Scan Settings", - "@libraryScanSettings": { - "description": "Section header for scan settings" - }, - "libraryEnableLocalLibrary": "Enable Local Library", - "@libraryEnableLocalLibrary": { - "description": "Toggle to enable library scanning" - }, - "libraryEnableLocalLibrarySubtitle": "Scan and track your existing music", - "@libraryEnableLocalLibrarySubtitle": { - "description": "Subtitle for enable toggle" - }, - "libraryFolder": "Library Folder", - "@libraryFolder": { - "description": "Folder selection setting" - }, - "libraryFolderHint": "Tap to select folder", - "@libraryFolderHint": { - "description": "Placeholder when no folder selected" - }, - "libraryShowDuplicateIndicator": "Show Duplicate Indicator", - "@libraryShowDuplicateIndicator": { - "description": "Toggle for duplicate indicator in search" - }, - "libraryShowDuplicateIndicatorSubtitle": "Show when searching for existing tracks", - "@libraryShowDuplicateIndicatorSubtitle": { - "description": "Subtitle for duplicate indicator toggle" - }, - "libraryActions": "Actions", - "@libraryActions": { - "description": "Section header for library actions" - }, - "libraryScan": "Scan Library", - "@libraryScan": { - "description": "Button to start library scan" - }, - "libraryScanSubtitle": "Scan for audio files", - "@libraryScanSubtitle": { - "description": "Subtitle for scan button" - }, - "libraryScanSelectFolderFirst": "Select a folder first", - "@libraryScanSelectFolderFirst": { - "description": "Message when trying to scan without folder" - }, - "libraryCleanupMissingFiles": "Cleanup Missing Files", - "@libraryCleanupMissingFiles": { - "description": "Button to remove entries for missing files" - }, - "libraryCleanupMissingFilesSubtitle": "Remove entries for files that no longer exist", - "@libraryCleanupMissingFilesSubtitle": { - "description": "Subtitle for cleanup button" - }, - "libraryClear": "Clear Library", - "@libraryClear": { - "description": "Button to clear all library entries" - }, - "libraryClearSubtitle": "Remove all scanned tracks", - "@libraryClearSubtitle": { - "description": "Subtitle for clear button" - }, - "libraryClearConfirmTitle": "Clear Library", - "@libraryClearConfirmTitle": { - "description": "Dialog title for clear confirmation" - }, - "libraryClearConfirmMessage": "This will remove all scanned tracks from your library. Your actual music files will not be deleted.", - "@libraryClearConfirmMessage": { - "description": "Dialog message for clear confirmation" - }, - "libraryAbout": "About Local Library", - "@libraryAbout": { - "description": "Section header for about info" - }, - "libraryAboutDescription": "Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.", - "@libraryAboutDescription": { - "description": "Description of local library feature" - }, - "libraryTracksCount": "{count} tracks", - "@libraryTracksCount": { - "description": "Track count in library", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "libraryLastScanned": "Last scanned: {time}", - "@libraryLastScanned": { - "description": "Last scan time display", - "placeholders": { - "time": { - "type": "String" - } - } - }, - "libraryLastScannedNever": "Never", - "@libraryLastScannedNever": { - "description": "Shown when library has never been scanned" - }, - "libraryScanning": "Scanning...", - "@libraryScanning": { - "description": "Status during scan" - }, - "libraryScanProgress": "{progress}% of {total} files", - "@libraryScanProgress": { - "description": "Scan progress display", - "placeholders": { - "progress": { - "type": "String" - }, - "total": { - "type": "int" - } - } - }, - "libraryInLibrary": "In Library", - "@libraryInLibrary": { - "description": "Badge shown on tracks that exist in local library" - }, - "libraryRemovedMissingFiles": "Removed {count} missing files from library", - "@libraryRemovedMissingFiles": { - "description": "Snackbar after cleanup", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "libraryCleared": "Library cleared", - "@libraryCleared": { - "description": "Snackbar after clearing library" - }, - "libraryStorageAccessRequired": "Storage Access Required", - "@libraryStorageAccessRequired": { - "description": "Dialog title for storage permission" - }, - "libraryStorageAccessMessage": "SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.", - "@libraryStorageAccessMessage": { - "description": "Dialog message for storage permission" - }, - "libraryFolderNotExist": "Selected folder does not exist", - "@libraryFolderNotExist": { - "description": "Error when folder doesn't exist" - }, - "librarySourceDownloaded": "Downloaded", - "@librarySourceDownloaded": { - "description": "Badge for tracks downloaded via SpotiFLAC" - }, - "librarySourceLocal": "Local", - "@librarySourceLocal": { - "description": "Badge for tracks from local library scan" - }, - "libraryFilterAll": "All", - "@libraryFilterAll": { - "description": "Filter chip - show all library items" - }, - "libraryFilterDownloaded": "Downloaded", - "@libraryFilterDownloaded": { - "description": "Filter chip - show only downloaded items" - }, - "libraryFilterLocal": "Local", - "@libraryFilterLocal": { - "description": "Filter chip - show only local library items" - }, - "libraryFilterTitle": "Filters", - "@libraryFilterTitle": { - "description": "Filter bottom sheet title" - }, - "libraryFilterReset": "Reset", - "@libraryFilterReset": { - "description": "Reset all filters button" - }, - "libraryFilterApply": "Apply", - "@libraryFilterApply": { - "description": "Apply filters button" - }, - "libraryFilterSource": "Source", - "@libraryFilterSource": { - "description": "Filter section - source type" - }, - "libraryFilterQuality": "Quality", - "@libraryFilterQuality": { - "description": "Filter section - audio quality" - }, - "libraryFilterQualityHiRes": "Hi-Res (24bit)", - "@libraryFilterQualityHiRes": { - "description": "Filter option - high resolution audio" - }, - "libraryFilterQualityCD": "CD (16bit)", - "@libraryFilterQualityCD": { - "description": "Filter option - CD quality audio" - }, - "libraryFilterQualityLossy": "Lossy", - "@libraryFilterQualityLossy": { - "description": "Filter option - lossy compressed audio" - }, - "libraryFilterFormat": "Format", - "@libraryFilterFormat": { - "description": "Filter section - file format" - }, - "libraryFilterDate": "Date Added", - "@libraryFilterDate": { - "description": "Filter section - date range" - }, - "libraryFilterDateToday": "Today", - "@libraryFilterDateToday": { - "description": "Filter option - today only" - }, - "libraryFilterDateWeek": "This Week", - "@libraryFilterDateWeek": { - "description": "Filter option - this week" - }, - "libraryFilterDateMonth": "This Month", - "@libraryFilterDateMonth": { - "description": "Filter option - this month" - }, - "libraryFilterDateYear": "This Year", - "@libraryFilterDateYear": { - "description": "Filter option - this year" - }, - "libraryFilterSort": "Sort", - "@libraryFilterSort": { - "description": "Filter section - sort order" - }, - "libraryFilterSortLatest": "Latest", - "@libraryFilterSortLatest": { - "description": "Sort option - newest first" - }, - "libraryFilterSortOldest": "Oldest", - "@libraryFilterSortOldest": { - "description": "Sort option - oldest first" - }, - "libraryFilterActive": "{count} filter(s) active", - "@libraryFilterActive": { - "description": "Badge showing number of active filters", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "timeJustNow": "Just now", - "@timeJustNow": { - "description": "Relative time - less than a minute ago" - }, - "timeMinutesAgo": "{count, plural, =1{1 minute ago} other{{count} minutes ago}}", - "@timeMinutesAgo": { - "description": "Relative time - minutes ago", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "timeHoursAgo": "{count, plural, =1{1 hour ago} other{{count} hours ago}}", - "@timeHoursAgo": { - "description": "Relative time - hours ago", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "storageSwitchTitle": "Switch Storage Mode", - "@storageSwitchTitle": { - "description": "Dialog title when switching storage mode" - }, - "storageSwitchToSafTitle": "Switch to SAF Storage?", - "@storageSwitchToSafTitle": { - "description": "Dialog title when switching to SAF" - }, - "storageSwitchToAppTitle": "Switch to App Storage?", - "@storageSwitchToAppTitle": { - "description": "Dialog title when switching to app storage" - }, - "storageSwitchToSafMessage": "Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.", - "@storageSwitchToSafMessage": { - "description": "Explanation when switching to SAF" - }, - "storageSwitchToAppMessage": "Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.", - "@storageSwitchToAppMessage": { - "description": "Explanation when switching to app storage" - }, - "storageSwitchExistingDownloads": "Existing Downloads", - "@storageSwitchExistingDownloads": { - "description": "Section header for existing downloads info" - }, - "storageSwitchExistingDownloadsInfo": "{count} tracks in {mode} storage", - "@storageSwitchExistingDownloadsInfo": { - "description": "Info about existing downloads count", - "placeholders": { - "count": { - "type": "int" - }, - "mode": { - "type": "String" - } - } - }, - "storageSwitchNewDownloads": "New Downloads", - "@storageSwitchNewDownloads": { - "description": "Section header for new downloads info" - }, - "storageSwitchNewDownloadsLocation": "Will be saved to: {location}", - "@storageSwitchNewDownloadsLocation": { - "description": "Shows where new downloads will go", - "placeholders": { - "location": { - "type": "String" - } - } - }, - "storageSwitchContinue": "Continue", - "@storageSwitchContinue": { - "description": "Button to proceed with storage switch" - }, - "storageSwitchSelectFolder": "Select SAF Folder", - "@storageSwitchSelectFolder": { - "description": "Button to select SAF folder" - }, - "storageAppStorage": "App Storage", - "@storageAppStorage": { - "description": "Label for app storage mode" - }, - "storageSafStorage": "SAF Storage", - "@storageSafStorage": { - "description": "Label for SAF storage mode" - }, - "storageModeBadge": "Storage: {mode}", - "@storageModeBadge": { - "description": "Badge showing storage mode for a track", - "placeholders": { - "mode": { - "type": "String" - } - } - }, - "storageStatsTitle": "Storage Statistics", - "@storageStatsTitle": { - "description": "Section title for storage stats" - }, - "storageStatsAppCount": "{count} tracks in App Storage", - "@storageStatsAppCount": { - "description": "Count of tracks in app storage", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "storageStatsSafCount": "{count} tracks in SAF Storage", - "@storageStatsSafCount": { - "description": "Count of tracks in SAF storage", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "storageModeInfo": "Your files are stored in multiple locations", - "@storageModeInfo": { - "description": "Info when user has files in both storage modes" - }, - "tutorialWelcomeTitle": "Welcome to SpotiFLAC!", - "@tutorialWelcomeTitle": { - "description": "Tutorial welcome page title" - }, - "tutorialWelcomeDesc": "Let's learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.", - "@tutorialWelcomeDesc": { - "description": "Tutorial welcome page description" - }, - "tutorialWelcomeTip1": "Download music from Spotify, Deezer, or paste any supported URL", - "@tutorialWelcomeTip1": { - "description": "Tutorial welcome tip 1" - }, - "tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music", - "@tutorialWelcomeTip2": { - "description": "Tutorial welcome tip 2" - }, - "tutorialWelcomeTip3": "Automatic metadata, cover art, and lyrics embedding", - "@tutorialWelcomeTip3": { - "description": "Tutorial welcome tip 3" - }, - "tutorialSearchTitle": "Finding Music", - "@tutorialSearchTitle": { - "description": "Tutorial search page title" - }, - "tutorialSearchDesc": "There are two easy ways to find music you want to download.", - "@tutorialSearchDesc": { - "description": "Tutorial search page description" - }, - "tutorialSearchTip1": "Paste a Spotify or Deezer URL directly in the search box", - "@tutorialSearchTip1": { - "description": "Tutorial search tip 1" - }, - "tutorialSearchTip2": "Or type the song name, artist, or album to search", - "@tutorialSearchTip2": { - "description": "Tutorial search tip 2" - }, - "tutorialSearchTip3": "Supports tracks, albums, playlists, and artist pages", - "@tutorialSearchTip3": { - "description": "Tutorial search tip 3" - }, - "tutorialDownloadTitle": "Downloading Music", - "@tutorialDownloadTitle": { - "description": "Tutorial download page title" - }, - "tutorialDownloadDesc": "Downloading music is simple and fast. Here's how it works.", - "@tutorialDownloadDesc": { - "description": "Tutorial download page description" - }, - "tutorialDownloadTip1": "Tap the download button next to any track to start downloading", - "@tutorialDownloadTip1": { - "description": "Tutorial download tip 1" - }, - "tutorialDownloadTip2": "Choose your preferred quality (FLAC, Hi-Res, or MP3)", - "@tutorialDownloadTip2": { - "description": "Tutorial download tip 2" - }, - "tutorialDownloadTip3": "Download entire albums or playlists with one tap", - "@tutorialDownloadTip3": { - "description": "Tutorial download tip 3" - }, - "tutorialLibraryTitle": "Your Library", - "@tutorialLibraryTitle": { - "description": "Tutorial library page title" - }, - "tutorialLibraryDesc": "All your downloaded music is organized in the Library tab.", - "@tutorialLibraryDesc": { - "description": "Tutorial library page description" - }, - "tutorialLibraryTip1": "View download progress and queue in the Library tab", - "@tutorialLibraryTip1": { - "description": "Tutorial library tip 1" - }, - "tutorialLibraryTip2": "Tap any track to play it with your music player", - "@tutorialLibraryTip2": { - "description": "Tutorial library tip 2" - }, - "tutorialLibraryTip3": "Switch between list and grid view for better browsing", - "@tutorialLibraryTip3": { - "description": "Tutorial library tip 3" - }, - "tutorialExtensionsTitle": "Extensions", - "@tutorialExtensionsTitle": { - "description": "Tutorial extensions page title" - }, - "tutorialExtensionsDesc": "Extend the app's capabilities with community extensions.", - "@tutorialExtensionsDesc": { - "description": "Tutorial extensions page description" - }, - "tutorialExtensionsTip1": "Browse the Store tab to discover useful extensions", - "@tutorialExtensionsTip1": { - "description": "Tutorial extensions tip 1" - }, - "tutorialExtensionsTip2": "Add new download providers or search sources", - "@tutorialExtensionsTip2": { - "description": "Tutorial extensions tip 2" - }, - "tutorialExtensionsTip3": "Get lyrics, enhanced metadata, and more features", - "@tutorialExtensionsTip3": { - "description": "Tutorial extensions tip 3" - }, - "tutorialSettingsTitle": "Customize Your Experience", - "@tutorialSettingsTitle": { - "description": "Tutorial settings page title" - }, - "tutorialSettingsDesc": "Personalize the app in Settings to match your preferences.", - "@tutorialSettingsDesc": { - "description": "Tutorial settings page description" - }, - "tutorialSettingsTip1": "Change download location and folder organization", - "@tutorialSettingsTip1": { - "description": "Tutorial settings tip 1" - }, - "tutorialSettingsTip2": "Set default audio quality and format preferences", - "@tutorialSettingsTip2": { - "description": "Tutorial settings tip 2" - }, - "tutorialSettingsTip3": "Customize app theme and appearance", - "@tutorialSettingsTip3": { - "description": "Tutorial settings tip 3" - }, - "tutorialReadyMessage": "You're all set! Start downloading your favorite music now.", - "@tutorialReadyMessage": { - "description": "Tutorial completion message" - }, - "tutorialExample": "EXAMPLE", - "@tutorialExample": { - "description": "Example label in tutorial" - }, - "libraryForceFullScan": "Force Full Scan", - "@libraryForceFullScan": { - "description": "Button to force a complete rescan of library" - }, - "libraryForceFullScanSubtitle": "Rescan all files, ignoring cache", - "@libraryForceFullScanSubtitle": { - "description": "Subtitle for force full scan button" - }, - "cleanupOrphanedDownloads": "Cleanup Orphaned Downloads", - "@cleanupOrphanedDownloads": { - "description": "Button to remove history entries for deleted files" - }, - "cleanupOrphanedDownloadsSubtitle": "Remove history entries for files that no longer exist", - "@cleanupOrphanedDownloadsSubtitle": { - "description": "Subtitle for orphaned cleanup button" - }, - "cleanupOrphanedDownloadsResult": "Removed {count} orphaned entries from history", - "@cleanupOrphanedDownloadsResult": { - "description": "Snackbar after orphan cleanup", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "cleanupOrphanedDownloadsNone": "No orphaned entries found", - "@cleanupOrphanedDownloadsNone": { - "description": "Snackbar when no orphans found" - }, - "cacheTitle": "Storage & Cache", - "@cacheTitle": { - "description": "Cache management page title" - }, - "cacheSummaryTitle": "Cache overview", - "@cacheSummaryTitle": { - "description": "Heading for cache summary card" - }, - "cacheSummarySubtitle": "Clearing cache will not remove downloaded music files.", - "@cacheSummarySubtitle": { - "description": "Helper text for cache summary card" - }, - "cacheEstimatedTotal": "Estimated cache usage: {size}", - "@cacheEstimatedTotal": { - "description": "Total cache size shown in summary", - "placeholders": { - "size": { - "type": "String" - } - } - }, - "cacheSectionStorage": "Cached Data", - "@cacheSectionStorage": { - "description": "Section header for cache entries" - }, - "cacheSectionMaintenance": "Maintenance", - "@cacheSectionMaintenance": { - "description": "Section header for cleanup actions" - }, - "cacheAppDirectory": "App cache directory", - "@cacheAppDirectory": { - "description": "Cache item title for app cache directory" - }, - "cacheAppDirectoryDesc": "HTTP responses, WebView data, and other temporary app data.", - "@cacheAppDirectoryDesc": { - "description": "Description of what app cache directory contains" - }, - "cacheTempDirectory": "Temporary directory", - "@cacheTempDirectory": { - "description": "Cache item title for temporary files directory" - }, - "cacheTempDirectoryDesc": "Temporary files from downloads and audio conversion.", - "@cacheTempDirectoryDesc": { - "description": "Description of what temporary directory contains" - }, - "cacheCoverImage": "Cover image cache", - "@cacheCoverImage": { - "description": "Cache item title for persistent cover images" - }, - "cacheCoverImageDesc": "Downloaded album and track cover art. Will re-download when viewed.", - "@cacheCoverImageDesc": { - "description": "Description of what cover image cache contains" - }, - "cacheLibraryCover": "Library cover cache", - "@cacheLibraryCover": { - "description": "Cache item title for local library cover art images" - }, - "cacheLibraryCoverDesc": "Cover art extracted from local music files. Will re-extract on next scan.", - "@cacheLibraryCoverDesc": { - "description": "Description of what library cover cache contains" - }, - "cacheExploreFeed": "Explore feed cache", - "@cacheExploreFeed": { - "description": "Cache item title for explore home feed cache" - }, - "cacheExploreFeedDesc": "Explore tab content (new releases, trending). Will refresh on next visit.", - "@cacheExploreFeedDesc": { - "description": "Description of what explore feed cache contains" - }, - "cacheTrackLookup": "Track lookup cache", - "@cacheTrackLookup": { - "description": "Cache item title for track ID lookup cache" - }, - "cacheTrackLookupDesc": "Spotify/Deezer track ID lookups. Clearing may slow next few searches.", - "@cacheTrackLookupDesc": { - "description": "Description of what track lookup cache contains" - }, - "cacheCleanupUnusedDesc": "Remove orphaned download history and library entries for missing files.", - "@cacheCleanupUnusedDesc": { - "description": "Description of what cleanup unused data does" - }, - "cacheNoData": "No cached data", - "@cacheNoData": { - "description": "Label when cache category has no data" - }, - "cacheSizeWithFiles": "{size} in {count} files", - "@cacheSizeWithFiles": { - "description": "Cache size and file count", - "placeholders": { - "size": { - "type": "String" - }, - "count": { - "type": "int" - } - } - }, - "cacheSizeOnly": "{size}", - "@cacheSizeOnly": { - "description": "Cache size only", - "placeholders": { - "size": { - "type": "String" - } - } - }, - "cacheEntries": "{count} entries", - "@cacheEntries": { - "description": "Track cache entry count", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "cacheClearSuccess": "Cleared: {target}", - "@cacheClearSuccess": { - "description": "Snackbar after clearing selected cache", - "placeholders": { - "target": { - "type": "String" - } - } - }, - "cacheClearConfirmTitle": "Clear cache?", - "@cacheClearConfirmTitle": { - "description": "Dialog title before clearing one cache category" - }, - "cacheClearConfirmMessage": "This will clear cached data for {target}. Downloaded music files will not be deleted.", - "@cacheClearConfirmMessage": { - "description": "Dialog message before clearing selected cache", - "placeholders": { - "target": { - "type": "String" - } - } - }, - "cacheClearAllConfirmTitle": "Clear all cache?", - "@cacheClearAllConfirmTitle": { - "description": "Dialog title before clearing all caches" - }, - "cacheClearAllConfirmMessage": "This will clear all cache categories on this page. Downloaded music files will not be deleted.", - "@cacheClearAllConfirmMessage": { - "description": "Dialog message before clearing all caches" - }, - "cacheClearAll": "Clear all cache", - "@cacheClearAll": { - "description": "Button label to clear all caches" - }, - "cacheCleanupUnused": "Cleanup unused data", - "@cacheCleanupUnused": { - "description": "Action title for cleaning unused entries" - }, - "cacheCleanupUnusedSubtitle": "Remove orphaned download history and missing library entries", - "@cacheCleanupUnusedSubtitle": { - "description": "Subtitle for cleanup unused data action" - }, - "cacheCleanupResult": "Cleanup completed: {downloadCount} orphaned downloads, {libraryCount} missing library entries", - "@cacheCleanupResult": { - "description": "Snackbar after unused data cleanup", - "placeholders": { - "downloadCount": { - "type": "int" - }, - "libraryCount": { - "type": "int" - } - } - }, - "cacheRefreshStats": "Refresh stats", - "@cacheRefreshStats": { - "description": "Button label to refresh cache statistics" - }, - "trackSaveCoverArt": "Save Cover Art", - "@trackSaveCoverArt": { - "description": "Menu action - save album cover art as file" - }, - "trackSaveCoverArtSubtitle": "Save album art as .jpg file", - "@trackSaveCoverArtSubtitle": { - "description": "Subtitle for save cover art action" - }, - "trackSaveLyrics": "Save Lyrics (.lrc)", - "@trackSaveLyrics": { - "description": "Menu action - save lyrics as .lrc file" - }, - "trackSaveLyricsSubtitle": "Fetch and save lyrics as .lrc file", - "@trackSaveLyricsSubtitle": { - "description": "Subtitle for save lyrics action" - }, - "trackSaveLyricsProgress": "Saving lyrics...", - "@trackSaveLyricsProgress": { - "description": "Snackbar while saving lyrics to file" - }, - "trackReEnrich": "Re-enrich Metadata", - "@trackReEnrich": { - "description": "Menu action - re-embed metadata into audio file" - }, - "trackReEnrichSubtitle": "Re-embed metadata without re-downloading", - "@trackReEnrichSubtitle": { - "description": "Subtitle for re-enrich metadata action" - }, - "trackReEnrichOnlineSubtitle": "Search metadata online and embed into file", - "@trackReEnrichOnlineSubtitle": { - "description": "Subtitle for re-enrich metadata action for local items" - }, - "trackEditMetadata": "Edit Metadata", - "@trackEditMetadata": { - "description": "Menu action - edit embedded metadata" - }, - "trackCoverSaved": "Cover art saved to {fileName}", - "@trackCoverSaved": { - "description": "Snackbar after cover art saved", - "placeholders": { - "fileName": { - "type": "String" - } - } - }, - "trackCoverNoSource": "No cover art source available", - "@trackCoverNoSource": { - "description": "Snackbar when no cover art URL or embedded cover" - }, - "trackLyricsSaved": "Lyrics saved to {fileName}", - "@trackLyricsSaved": { - "description": "Snackbar after lyrics saved", - "placeholders": { - "fileName": { - "type": "String" - } - } - }, - "trackReEnrichProgress": "Re-enriching metadata...", - "@trackReEnrichProgress": { - "description": "Snackbar while re-enriching metadata" - }, - "trackReEnrichSearching": "Searching metadata online...", - "@trackReEnrichSearching": { - "description": "Snackbar while searching metadata from internet for local items" - }, - "trackReEnrichSuccess": "Metadata re-enriched successfully", - "@trackReEnrichSuccess": { - "description": "Snackbar after successful re-enrichment" - }, - "trackReEnrichFfmpegFailed": "FFmpeg metadata embed failed", - "@trackReEnrichFfmpegFailed": { - "description": "Snackbar when FFmpeg embed fails for MP3/Opus" - }, - "trackSaveFailed": "Failed: {error}", - "@trackSaveFailed": { - "description": "Snackbar when save operation fails", - "placeholders": { - "error": { - "type": "String" - } - } - }, - "trackConvertFormat": "Convert Format", - "@trackConvertFormat": { - "description": "Menu item - convert audio format" - }, - "trackConvertFormatSubtitle": "Convert to MP3 or Opus", - "@trackConvertFormatSubtitle": { - "description": "Subtitle for convert format menu item" - }, - "trackConvertTitle": "Convert Audio", - "@trackConvertTitle": { - "description": "Title of convert bottom sheet" - }, - "trackConvertTargetFormat": "Target Format", - "@trackConvertTargetFormat": { - "description": "Label for format selection" - }, - "trackConvertBitrate": "Bitrate", - "@trackConvertBitrate": { - "description": "Label for bitrate selection" - }, - "trackConvertConfirmTitle": "Confirm Conversion", - "@trackConvertConfirmTitle": { - "description": "Confirmation dialog title" - }, - "trackConvertConfirmMessage": "Convert from {sourceFormat} to {targetFormat} at {bitrate}?\n\nThe original file will be deleted after conversion.", - "@trackConvertConfirmMessage": { - "description": "Confirmation dialog message", - "placeholders": { - "sourceFormat": { - "type": "String" - }, - "targetFormat": { - "type": "String" - }, - "bitrate": { - "type": "String" - } - } - }, - "trackConvertConverting": "Converting audio...", - "@trackConvertConverting": { - "description": "Snackbar while converting" - }, - "trackConvertSuccess": "Converted to {format} successfully", - "@trackConvertSuccess": { - "description": "Snackbar after successful conversion", - "placeholders": { - "format": { - "type": "String" - } - } - }, - "trackConvertFailed": "Conversion failed", - "@trackConvertFailed": { - "description": "Snackbar when conversion fails" - } + "folderOrganizationNone": "Tidak ada", + "@folderOrganizationNone": { + "description": "Folder option - flat structure" + }, + "folderOrganizationByArtist": "Berdasarkan Artis", + "@folderOrganizationByArtist": { + "description": "Folder option - artist folders" + }, + "folderOrganizationByAlbum": "Berdasarkan Album", + "@folderOrganizationByAlbum": { + "description": "Folder option - album folders" + }, + "folderOrganizationByArtistAlbum": "Berdasarkan Artis & Album", + "@folderOrganizationByArtistAlbum": { + "description": "Folder option - nested folders" + }, + "folderOrganizationDescription": "Atur file yang diunduh ke dalam folder", + "@folderOrganizationDescription": { + "description": "Folder organization sheet description" + }, + "folderOrganizationNoneSubtitle": "Semua file di folder unduhan", + "@folderOrganizationNoneSubtitle": { + "description": "Subtitle for no organization option" + }, + "folderOrganizationByArtistSubtitle": "Folder terpisah untuk setiap artis", + "@folderOrganizationByArtistSubtitle": { + "description": "Subtitle for artist folder option" + }, + "folderOrganizationByAlbumSubtitle": "Folder terpisah untuk setiap album", + "@folderOrganizationByAlbumSubtitle": { + "description": "Subtitle for album folder option" + }, + "folderOrganizationByArtistAlbumSubtitle": "Folder bersarang untuk artis dan album", + "@folderOrganizationByArtistAlbumSubtitle": { + "description": "Subtitle for nested folder option" + }, + "updateAvailable": "Pembaruan Tersedia", + "@updateAvailable": { + "description": "Update dialog title" + }, + "updateLater": "Nanti", + "@updateLater": { + "description": "Update button - dismiss" + }, + "updateStartingDownload": "Memulai unduhan...", + "@updateStartingDownload": { + "description": "Update status - initializing" + }, + "updateDownloadFailed": "Unduhan gagal", + "@updateDownloadFailed": { + "description": "Update error title" + }, + "updateFailedMessage": "Gagal mengunduh pembaruan", + "@updateFailedMessage": { + "description": "Update error message" + }, + "updateNewVersionReady": "Versi baru sudah siap", + "@updateNewVersionReady": { + "description": "Update subtitle" + }, + "updateCurrent": "Saat ini", + "@updateCurrent": { + "description": "Label for current version" + }, + "updateNew": "Baru", + "@updateNew": { + "description": "Label for new version" + }, + "updateDownloading": "Mengunduh...", + "@updateDownloading": { + "description": "Update status - downloading" + }, + "updateWhatsNew": "Yang Baru", + "@updateWhatsNew": { + "description": "Changelog section title" + }, + "updateDownloadInstall": "Unduh & Pasang", + "@updateDownloadInstall": { + "description": "Update button - download and install" + }, + "updateDontRemind": "Jangan ingatkan", + "@updateDontRemind": { + "description": "Update button - skip this version" + }, + "providerPriorityTitle": "Prioritas Provider", + "@providerPriorityTitle": { + "description": "Provider priority page title" + }, + "providerPriorityDescription": "Seret untuk mengatur ulang urutan provider unduhan. Aplikasi akan mencoba provider dari atas ke bawah saat mengunduh lagu.", + "@providerPriorityDescription": { + "description": "Provider priority page description" + }, + "providerPriorityInfo": "Jika lagu tidak tersedia di provider pertama, aplikasi akan otomatis mencoba yang berikutnya.", + "@providerPriorityInfo": { + "description": "Info tip about fallback behavior" + }, + "providerBuiltIn": "Bawaan", + "@providerBuiltIn": { + "description": "Label for built-in providers (Tidal/Qobuz/Amazon)" + }, + "providerExtension": "Ekstensi", + "@providerExtension": { + "description": "Label for extension-provided providers" + }, + "metadataProviderPriorityTitle": "Prioritas Metadata", + "@metadataProviderPriorityTitle": { + "description": "Metadata priority page title" + }, + "metadataProviderPriorityDescription": "Seret untuk mengatur ulang urutan provider metadata. Aplikasi akan mencoba provider dari atas ke bawah saat mencari lagu dan mengambil metadata.", + "@metadataProviderPriorityDescription": { + "description": "Metadata priority page description" + }, + "metadataProviderPriorityInfo": "Deezer tidak memiliki batas rate dan direkomendasikan sebagai utama. Spotify mungkin membatasi rate setelah banyak permintaan.", + "@metadataProviderPriorityInfo": { + "description": "Info tip about rate limits" + }, + "metadataNoRateLimits": "Tidak ada batas rate", + "@metadataNoRateLimits": { + "description": "Deezer provider description" + }, + "metadataMayRateLimit": "Mungkin dibatasi rate", + "@metadataMayRateLimit": { + "description": "Spotify provider description" + }, + "logTitle": "Log", + "@logTitle": { + "description": "Logs screen title" + }, + "logCopied": "Log disalin ke clipboard", + "@logCopied": { + "description": "Snackbar - logs copied" + }, + "logSearchHint": "Cari log...", + "@logSearchHint": { + "description": "Log search placeholder" + }, + "logFilterLevel": "Level", + "@logFilterLevel": { + "description": "Filter by log level" + }, + "logFilterSection": "Filter", + "@logFilterSection": { + "description": "Filter section title" + }, + "logShareLogs": "Bagikan log", + "@logShareLogs": { + "description": "Share button tooltip" + }, + "logClearLogs": "Hapus log", + "@logClearLogs": { + "description": "Clear button tooltip" + }, + "logClearLogsTitle": "Hapus Log", + "@logClearLogsTitle": { + "description": "Clear logs dialog title" + }, + "logClearLogsMessage": "Apakah Anda yakin ingin menghapus semua log?", + "@logClearLogsMessage": { + "description": "Clear logs confirmation message" + }, + "logFilterBySeverity": "Filter log berdasarkan tingkat keparahan", + "@logFilterBySeverity": { + "description": "Filter dialog title" + }, + "logNoLogsYet": "Belum ada log", + "@logNoLogsYet": { + "description": "Empty state title" + }, + "logNoLogsYetSubtitle": "Log akan muncul di sini saat Anda menggunakan aplikasi", + "@logNoLogsYetSubtitle": { + "description": "Empty state subtitle" + }, + "logEntriesFiltered": "Entri ({count} difilter)", + "@logEntriesFiltered": { + "description": "Log count with filter active", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "logEntries": "Entri ({count})", + "@logEntries": { + "description": "Total log count", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "credentialsTitle": "Kredensial Spotify", + "@credentialsTitle": { + "description": "Credentials dialog title" + }, + "credentialsDescription": "Masukkan Client ID dan Secret Anda untuk menggunakan kuota aplikasi Spotify Anda sendiri.", + "@credentialsDescription": { + "description": "Credentials dialog explanation" + }, + "credentialsClientId": "Client ID", + "@credentialsClientId": { + "description": "Client ID field label - DO NOT TRANSLATE" + }, + "credentialsClientIdHint": "Tempel Client ID", + "@credentialsClientIdHint": { + "description": "Client ID placeholder" + }, + "credentialsClientSecret": "Client Secret", + "@credentialsClientSecret": { + "description": "Client Secret field label - DO NOT TRANSLATE" + }, + "credentialsClientSecretHint": "Tempel Client Secret", + "@credentialsClientSecretHint": { + "description": "Client Secret placeholder" + }, + "channelStable": "Stabil", + "@channelStable": { + "description": "Update channel - stable releases" + }, + "channelPreview": "Preview", + "@channelPreview": { + "description": "Update channel - beta/preview releases" + }, + "sectionSearchSource": "Sumber Pencarian", + "@sectionSearchSource": { + "description": "Settings section header" + }, + "sectionDownload": "Unduhan", + "@sectionDownload": { + "description": "Settings section header" + }, + "sectionPerformance": "Performa", + "@sectionPerformance": { + "description": "Settings section header" + }, + "sectionApp": "Aplikasi", + "@sectionApp": { + "description": "Settings section header" + }, + "sectionData": "Data", + "@sectionData": { + "description": "Settings section header" + }, + "sectionDebug": "Debug", + "@sectionDebug": { + "description": "Settings section header" + }, + "sectionService": "Layanan", + "@sectionService": { + "description": "Settings section header" + }, + "sectionAudioQuality": "Kualitas Audio", + "@sectionAudioQuality": { + "description": "Settings section header" + }, + "sectionFileSettings": "Pengaturan File", + "@sectionFileSettings": { + "description": "Settings section header" + }, + "sectionLyrics": "Lyrics", + "@sectionLyrics": { + "description": "Settings section header" + }, + "lyricsMode": "Lyrics Mode", + "@lyricsMode": { + "description": "Setting - how to save lyrics" + }, + "lyricsModeDescription": "Choose how lyrics are saved with your downloads", + "@lyricsModeDescription": { + "description": "Lyrics mode picker description" + }, + "lyricsModeEmbed": "Embed in file", + "@lyricsModeEmbed": { + "description": "Lyrics mode option - embed in audio file" + }, + "lyricsModeEmbedSubtitle": "Lyrics stored inside FLAC metadata", + "@lyricsModeEmbedSubtitle": { + "description": "Subtitle for embed option" + }, + "lyricsModeExternal": "External .lrc file", + "@lyricsModeExternal": { + "description": "Lyrics mode option - separate LRC file" + }, + "lyricsModeExternalSubtitle": "Separate .lrc file for players like Samsung Music", + "@lyricsModeExternalSubtitle": { + "description": "Subtitle for external option" + }, + "lyricsModeBoth": "Both", + "@lyricsModeBoth": { + "description": "Lyrics mode option - embed and external" + }, + "lyricsModeBothSubtitle": "Embed and save .lrc file", + "@lyricsModeBothSubtitle": { + "description": "Subtitle for both option" + }, + "sectionColor": "Warna", + "@sectionColor": { + "description": "Settings section header" + }, + "sectionTheme": "Tema", + "@sectionTheme": { + "description": "Settings section header" + }, + "sectionLayout": "Tata Letak", + "@sectionLayout": { + "description": "Settings section header" + }, + "sectionLanguage": "Bahasa", + "@sectionLanguage": { + "description": "Settings section header for language" + }, + "appearanceLanguage": "Bahasa Aplikasi", + "@appearanceLanguage": { + "description": "Language setting title" + }, + "settingsAppearanceSubtitle": "Tema, warna, tampilan", + "@settingsAppearanceSubtitle": { + "description": "Appearance settings description" + }, + "settingsDownloadSubtitle": "Layanan, kualitas, format nama file", + "@settingsDownloadSubtitle": { + "description": "Download settings description" + }, + "settingsOptionsSubtitle": "Fallback, lirik, cover art, pembaruan", + "@settingsOptionsSubtitle": { + "description": "Options settings description" + }, + "settingsExtensionsSubtitle": "Kelola provider unduhan", + "@settingsExtensionsSubtitle": { + "description": "Extensions settings description" + }, + "settingsLogsSubtitle": "Lihat log aplikasi untuk debugging", + "@settingsLogsSubtitle": { + "description": "Logs settings description" + }, + "loadingSharedLink": "Memuat link yang dibagikan...", + "@loadingSharedLink": { + "description": "Status when opening shared URL" + }, + "pressBackAgainToExit": "Tekan kembali sekali lagi untuk keluar", + "@pressBackAgainToExit": { + "description": "Exit confirmation message" + }, + "downloadAllCount": "Unduh Semua ({count})", + "@downloadAllCount": { + "description": "Download all button with count", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "tracksCount": "{count, plural, =1{1 lagu} other{{count} lagu}}", + "@tracksCount": { + "description": "Track count display", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trackCopyFilePath": "Salin lokasi file", + "@trackCopyFilePath": { + "description": "Action - copy file path" + }, + "trackRemoveFromDevice": "Hapus dari perangkat", + "@trackRemoveFromDevice": { + "description": "Action - delete downloaded file" + }, + "trackLoadLyrics": "Muat Lirik", + "@trackLoadLyrics": { + "description": "Action - fetch lyrics" + }, + "trackMetadata": "Metadata", + "@trackMetadata": { + "description": "Tab title - track metadata" + }, + "trackFileInfo": "Info File", + "@trackFileInfo": { + "description": "Tab title - file information" + }, + "trackLyrics": "Lirik", + "@trackLyrics": { + "description": "Tab title - lyrics" + }, + "trackFileNotFound": "File tidak ditemukan", + "@trackFileNotFound": { + "description": "Error - file doesn't exist" + }, + "trackOpenInDeezer": "Buka di Deezer", + "@trackOpenInDeezer": { + "description": "Action - open track in Deezer app" + }, + "trackOpenInSpotify": "Buka di Spotify", + "@trackOpenInSpotify": { + "description": "Action - open track in Spotify app" + }, + "trackTrackName": "Nama lagu", + "@trackTrackName": { + "description": "Metadata label - track title" + }, + "trackArtist": "Artis", + "@trackArtist": { + "description": "Metadata label - artist name" + }, + "trackAlbumArtist": "Artis album", + "@trackAlbumArtist": { + "description": "Metadata label - album artist" + }, + "trackAlbum": "Album", + "@trackAlbum": { + "description": "Metadata label - album name" + }, + "trackTrackNumber": "Nomor lagu", + "@trackTrackNumber": { + "description": "Metadata label - track number" + }, + "trackDiscNumber": "Nomor disc", + "@trackDiscNumber": { + "description": "Metadata label - disc number" + }, + "trackDuration": "Durasi", + "@trackDuration": { + "description": "Metadata label - track length" + }, + "trackAudioQuality": "Kualitas audio", + "@trackAudioQuality": { + "description": "Metadata label - audio quality" + }, + "trackReleaseDate": "Tanggal rilis", + "@trackReleaseDate": { + "description": "Metadata label - release date" + }, + "trackGenre": "Genre", + "@trackGenre": { + "description": "Metadata label - music genre" + }, + "trackLabel": "Label", + "@trackLabel": { + "description": "Metadata label - record label" + }, + "trackCopyright": "Copyright", + "@trackCopyright": { + "description": "Metadata label - copyright information" + }, + "trackDownloaded": "Diunduh", + "@trackDownloaded": { + "description": "Metadata label - download date" + }, + "trackCopyLyrics": "Salin lirik", + "@trackCopyLyrics": { + "description": "Action - copy lyrics to clipboard" + }, + "trackLyricsNotAvailable": "Lirik tidak tersedia untuk lagu ini", + "@trackLyricsNotAvailable": { + "description": "Message when lyrics not found" + }, + "trackLyricsTimeout": "Permintaan timeout. Coba lagi nanti.", + "@trackLyricsTimeout": { + "description": "Message when lyrics request times out" + }, + "trackLyricsLoadFailed": "Gagal memuat lirik", + "@trackLyricsLoadFailed": { + "description": "Message when lyrics loading fails" + }, + "trackEmbedLyrics": "Embed Lyrics", + "@trackEmbedLyrics": { + "description": "Action - embed lyrics into audio file" + }, + "trackLyricsEmbedded": "Lyrics embedded successfully", + "@trackLyricsEmbedded": { + "description": "Snackbar - lyrics saved to file" + }, + "trackInstrumental": "Instrumental track", + "@trackInstrumental": { + "description": "Message when track is instrumental (no lyrics)" + }, + "trackCopiedToClipboard": "Disalin ke clipboard", + "@trackCopiedToClipboard": { + "description": "Snackbar - content copied" + }, + "trackDeleteConfirmTitle": "Hapus dari perangkat?", + "@trackDeleteConfirmTitle": { + "description": "Delete confirmation title" + }, + "trackDeleteConfirmMessage": "Ini akan menghapus file unduhan secara permanen dan menghapusnya dari riwayat Anda.", + "@trackDeleteConfirmMessage": { + "description": "Delete confirmation message" + }, + "dateToday": "Hari ini", + "@dateToday": { + "description": "Relative date - today" + }, + "dateYesterday": "Kemarin", + "@dateYesterday": { + "description": "Relative date - yesterday" + }, + "dateDaysAgo": "{count} hari lalu", + "@dateDaysAgo": { + "description": "Relative date - days ago", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dateWeeksAgo": "{count} minggu lalu", + "@dateWeeksAgo": { + "description": "Relative date - weeks ago", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dateMonthsAgo": "{count} bulan lalu", + "@dateMonthsAgo": { + "description": "Relative date - months ago", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "storeFilterAll": "Semua", + "@storeFilterAll": { + "description": "Store filter - all extensions" + }, + "storeFilterMetadata": "Metadata", + "@storeFilterMetadata": { + "description": "Store filter - metadata providers" + }, + "storeFilterDownload": "Unduhan", + "@storeFilterDownload": { + "description": "Store filter - download providers" + }, + "storeFilterUtility": "Utilitas", + "@storeFilterUtility": { + "description": "Store filter - utility extensions" + }, + "storeFilterLyrics": "Lirik", + "@storeFilterLyrics": { + "description": "Store filter - lyrics providers" + }, + "storeFilterIntegration": "Integrasi", + "@storeFilterIntegration": { + "description": "Store filter - integrations" + }, + "storeClearFilters": "Hapus filter", + "@storeClearFilters": { + "description": "Button to clear all filters" + }, + "extensionDefaultProvider": "Default (Deezer/Spotify)", + "@extensionDefaultProvider": { + "description": "Default search provider option" + }, + "extensionDefaultProviderSubtitle": "Gunakan pencarian bawaan", + "@extensionDefaultProviderSubtitle": { + "description": "Subtitle for default provider" + }, + "extensionAuthor": "Pembuat", + "@extensionAuthor": { + "description": "Extension detail - author" + }, + "extensionId": "ID", + "@extensionId": { + "description": "Extension detail - unique ID" + }, + "extensionError": "Error", + "@extensionError": { + "description": "Extension detail - error message" + }, + "extensionCapabilities": "Kemampuan", + "@extensionCapabilities": { + "description": "Section header - extension features" + }, + "extensionMetadataProvider": "Provider Metadata", + "@extensionMetadataProvider": { + "description": "Capability - provides metadata" + }, + "extensionDownloadProvider": "Provider Unduhan", + "@extensionDownloadProvider": { + "description": "Capability - provides downloads" + }, + "extensionLyricsProvider": "Provider Lirik", + "@extensionLyricsProvider": { + "description": "Capability - provides lyrics" + }, + "extensionUrlHandler": "Penanganan URL", + "@extensionUrlHandler": { + "description": "Capability - handles URLs" + }, + "extensionQualityOptions": "Opsi Kualitas", + "@extensionQualityOptions": { + "description": "Capability - quality selection" + }, + "extensionPostProcessingHooks": "Hook Pasca-Pemrosesan", + "@extensionPostProcessingHooks": { + "description": "Capability - post-processing" + }, + "extensionPermissions": "Izin", + "@extensionPermissions": { + "description": "Section header - required permissions" + }, + "extensionSettings": "Pengaturan", + "@extensionSettings": { + "description": "Section header - extension settings" + }, + "extensionRemoveButton": "Hapus Ekstensi", + "@extensionRemoveButton": { + "description": "Button to uninstall extension" + }, + "extensionUpdated": "Diperbarui", + "@extensionUpdated": { + "description": "Extension detail - last update" + }, + "extensionMinAppVersion": "Versi App Minimum", + "@extensionMinAppVersion": { + "description": "Extension detail - minimum app version" + }, + "extensionCustomTrackMatching": "Pencocokan Lagu Kustom", + "@extensionCustomTrackMatching": { + "description": "Capability - custom track matching algorithm" + }, + "extensionPostProcessing": "Pasca-Pemrosesan", + "@extensionPostProcessing": { + "description": "Capability - post-download processing" + }, + "extensionHooksAvailable": "{count} hook tersedia", + "@extensionHooksAvailable": { + "description": "Post-processing hooks count", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "extensionPatternsCount": "{count} pola", + "@extensionPatternsCount": { + "description": "URL patterns count", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "extensionStrategy": "Strategi: {strategy}", + "@extensionStrategy": { + "description": "Track matching strategy name", + "placeholders": { + "strategy": { + "type": "String" + } + } + }, + "extensionsProviderPrioritySection": "Prioritas Provider", + "@extensionsProviderPrioritySection": { + "description": "Section header - provider priority" + }, + "extensionsInstalledSection": "Ekstensi Terpasang", + "@extensionsInstalledSection": { + "description": "Section header - installed extensions" + }, + "extensionsNoExtensions": "Tidak ada ekstensi terpasang", + "@extensionsNoExtensions": { + "description": "Empty state - no extensions" + }, + "extensionsNoExtensionsSubtitle": "Pasang file .spotiflac-ext untuk menambahkan provider baru", + "@extensionsNoExtensionsSubtitle": { + "description": "Empty state subtitle" + }, + "extensionsInstallButton": "Pasang Ekstensi", + "@extensionsInstallButton": { + "description": "Button to install extension from file" + }, + "extensionsInfoTip": "Ekstensi dapat menambahkan provider metadata dan unduhan baru. Hanya pasang ekstensi dari sumber terpercaya.", + "@extensionsInfoTip": { + "description": "Security warning about extensions" + }, + "extensionsInstalledSuccess": "Ekstensi berhasil dipasang", + "@extensionsInstalledSuccess": { + "description": "Success message after install" + }, + "extensionsDownloadPriority": "Prioritas Unduhan", + "@extensionsDownloadPriority": { + "description": "Setting - download provider order" + }, + "extensionsDownloadPrioritySubtitle": "Atur urutan layanan unduhan", + "@extensionsDownloadPrioritySubtitle": { + "description": "Subtitle for download priority" + }, + "extensionsNoDownloadProvider": "Tidak ada ekstensi dengan provider unduhan", + "@extensionsNoDownloadProvider": { + "description": "Empty state - no download providers" + }, + "extensionsMetadataPriority": "Prioritas Metadata", + "@extensionsMetadataPriority": { + "description": "Setting - metadata provider order" + }, + "extensionsMetadataPrioritySubtitle": "Atur urutan sumber pencarian & metadata", + "@extensionsMetadataPrioritySubtitle": { + "description": "Subtitle for metadata priority" + }, + "extensionsNoMetadataProvider": "Tidak ada ekstensi dengan provider metadata", + "@extensionsNoMetadataProvider": { + "description": "Empty state - no metadata providers" + }, + "extensionsSearchProvider": "Provider Pencarian", + "@extensionsSearchProvider": { + "description": "Setting - search provider selection" + }, + "extensionsNoCustomSearch": "Tidak ada ekstensi dengan pencarian kustom", + "@extensionsNoCustomSearch": { + "description": "Empty state - no search providers" + }, + "extensionsSearchProviderDescription": "Pilih layanan yang digunakan untuk mencari lagu", + "@extensionsSearchProviderDescription": { + "description": "Search provider setting description" + }, + "extensionsCustomSearch": "Pencarian kustom", + "@extensionsCustomSearch": { + "description": "Label for custom search provider" + }, + "extensionsErrorLoading": "Error memuat ekstensi", + "@extensionsErrorLoading": { + "description": "Error message when extension fails to load" + }, + "qualityFlacLossless": "FLAC Lossless", + "@qualityFlacLossless": { + "description": "Quality option - CD quality FLAC" + }, + "qualityFlacLosslessSubtitle": "16-bit / 44.1kHz", + "@qualityFlacLosslessSubtitle": { + "description": "Technical spec for lossless" + }, + "qualityHiResFlac": "Hi-Res FLAC", + "@qualityHiResFlac": { + "description": "Quality option - high resolution FLAC" + }, + "qualityHiResFlacSubtitle": "24-bit / hingga 96kHz", + "@qualityHiResFlacSubtitle": { + "description": "Technical spec for hi-res" + }, + "qualityHiResFlacMax": "Hi-Res FLAC Max", + "@qualityHiResFlacMax": { + "description": "Quality option - maximum resolution FLAC" + }, + "qualityHiResFlacMaxSubtitle": "24-bit / hingga 192kHz", + "@qualityHiResFlacMaxSubtitle": { + "description": "Technical spec for hi-res max" + }, + "qualityNote": "Kualitas sebenarnya tergantung ketersediaan lagu dari layanan", + "@qualityNote": { + "description": "Note about quality availability" + }, + "youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.", + "@youtubeQualityNote": { + "description": "Note for YouTube service explaining lossy-only quality" + }, + "youtubeOpusBitrateTitle": "Bitrate Opus YouTube", + "@youtubeOpusBitrateTitle": { + "description": "Title for YouTube Opus bitrate setting" + }, + "youtubeMp3BitrateTitle": "Bitrate MP3 YouTube", + "@youtubeMp3BitrateTitle": { + "description": "Title for YouTube MP3 bitrate setting" + }, + "downloadAskBeforeDownload": "Tanya Sebelum Unduh", + "@downloadAskBeforeDownload": { + "description": "Setting - show quality picker" + }, + "downloadDirectory": "Direktori Unduhan", + "@downloadDirectory": { + "description": "Setting - download folder" + }, + "downloadSeparateSinglesFolder": "Folder Singles Terpisah", + "@downloadSeparateSinglesFolder": { + "description": "Setting - separate folder for singles" + }, + "downloadAlbumFolderStructure": "Struktur Folder Album", + "@downloadAlbumFolderStructure": { + "description": "Setting - album folder organization" + }, + "downloadUseAlbumArtistForFolders": "Use Album Artist for folders", + "@downloadUseAlbumArtistForFolders": { + "description": "Setting - choose whether artist folders use Album Artist or Track Artist" + }, + "downloadUsePrimaryArtistOnly": "Primary artist only for folders", + "@downloadUsePrimaryArtistOnly": { + "description": "Setting - strip featured artists from folder name" + }, + "downloadUsePrimaryArtistOnlyEnabled": "Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)", + "@downloadUsePrimaryArtistOnlyEnabled": { + "description": "Subtitle when primary artist only is enabled" + }, + "downloadUsePrimaryArtistOnlyDisabled": "Full artist string used for folder name", + "@downloadUsePrimaryArtistOnlyDisabled": { + "description": "Subtitle when primary artist only is disabled" + }, + "downloadSelectQuality": "Pilih Kualitas", + "@downloadSelectQuality": { + "description": "Dialog title - choose audio quality" + }, + "downloadFrom": "Unduh Dari", + "@downloadFrom": { + "description": "Label - download source" + }, + "appearanceAmoledDark": "AMOLED Gelap", + "@appearanceAmoledDark": { + "description": "Theme option - pure black" + }, + "appearanceAmoledDarkSubtitle": "Latar belakang hitam murni", + "@appearanceAmoledDarkSubtitle": { + "description": "Subtitle for AMOLED dark" + }, + "queueClearAll": "Hapus Semua", + "@queueClearAll": { + "description": "Button - clear all queue items" + }, + "queueClearAllMessage": "Apakah Anda yakin ingin menghapus semua unduhan?", + "@queueClearAllMessage": { + "description": "Clear queue confirmation" + }, + "settingsAutoExportFailed": "Auto-export failed downloads", + "@settingsAutoExportFailed": { + "description": "Setting toggle for auto-export" + }, + "settingsAutoExportFailedSubtitle": "Save failed downloads to TXT file automatically", + "@settingsAutoExportFailedSubtitle": { + "description": "Subtitle for auto-export setting" + }, + "settingsDownloadNetwork": "Download Network", + "@settingsDownloadNetwork": { + "description": "Setting for network type preference" + }, + "settingsDownloadNetworkAny": "WiFi + Mobile Data", + "@settingsDownloadNetworkAny": { + "description": "Network option - use any connection" + }, + "settingsDownloadNetworkWifiOnly": "WiFi Only", + "@settingsDownloadNetworkWifiOnly": { + "description": "Network option - only use WiFi" + }, + "settingsDownloadNetworkSubtitle": "Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.", + "@settingsDownloadNetworkSubtitle": { + "description": "Subtitle explaining network preference" + }, + "albumFolderArtistAlbum": "Artis / Album", + "@albumFolderArtistAlbum": { + "description": "Album folder option" + }, + "albumFolderArtistAlbumSubtitle": "Albums/Nama Artis/Nama Album/", + "@albumFolderArtistAlbumSubtitle": { + "description": "Folder structure example" + }, + "albumFolderArtistYearAlbum": "Artis / [Tahun] Album", + "@albumFolderArtistYearAlbum": { + "description": "Album folder option with year" + }, + "albumFolderArtistYearAlbumSubtitle": "Albums/Nama Artis/[2005] Nama Album/", + "@albumFolderArtistYearAlbumSubtitle": { + "description": "Folder structure example" + }, + "albumFolderAlbumOnly": "Album Saja", + "@albumFolderAlbumOnly": { + "description": "Album folder option" + }, + "albumFolderAlbumOnlySubtitle": "Albums/Nama Album/", + "@albumFolderAlbumOnlySubtitle": { + "description": "Folder structure example" + }, + "albumFolderYearAlbum": "[Tahun] Album", + "@albumFolderYearAlbum": { + "description": "Album folder option with year" + }, + "albumFolderYearAlbumSubtitle": "Albums/[2005] Nama Album/", + "@albumFolderYearAlbumSubtitle": { + "description": "Folder structure example" + }, + "albumFolderArtistAlbumSingles": "Artist / Album + Singles", + "@albumFolderArtistAlbumSingles": { + "description": "Album folder option with singles inside artist" + }, + "albumFolderArtistAlbumSinglesSubtitle": "Artist/Album/ and Artist/Singles/", + "@albumFolderArtistAlbumSinglesSubtitle": { + "description": "Folder structure example" + }, + "downloadedAlbumDeleteSelected": "Hapus yang Dipilih", + "@downloadedAlbumDeleteSelected": { + "description": "Button - delete selected tracks" + }, + "downloadedAlbumDeleteMessage": "Hapus {count} {count, plural, =1{lagu} other{lagu}} dari album ini?\n\nIni juga akan menghapus file dari penyimpanan.", + "@downloadedAlbumDeleteMessage": { + "description": "Delete confirmation with count", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedAlbumSelectedCount": "{count} dipilih", + "@downloadedAlbumSelectedCount": { + "description": "Selection count indicator", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedAlbumAllSelected": "Semua lagu dipilih", + "@downloadedAlbumAllSelected": { + "description": "Status - all items selected" + }, + "downloadedAlbumTapToSelect": "Ketuk lagu untuk memilih", + "@downloadedAlbumTapToSelect": { + "description": "Selection hint" + }, + "downloadedAlbumDeleteCount": "Hapus {count} {count, plural, =1{lagu} other{lagu}}", + "@downloadedAlbumDeleteCount": { + "description": "Delete button text with count", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedAlbumSelectToDelete": "Pilih lagu untuk dihapus", + "@downloadedAlbumSelectToDelete": { + "description": "Placeholder when nothing selected" + }, + "downloadedAlbumDiscHeader": "Disc {discNumber}", + "@downloadedAlbumDiscHeader": { + "description": "Header for disc separator in multi-disc albums", + "placeholders": { + "discNumber": { + "type": "int", + "example": "1" + } + } + }, + "recentTypeArtist": "Artis", + "@recentTypeArtist": { + "description": "Recent access item type - artist" + }, + "recentTypeAlbum": "Album", + "@recentTypeAlbum": { + "description": "Recent access item type - album" + }, + "recentTypeSong": "Lagu", + "@recentTypeSong": { + "description": "Recent access item type - song/track" + }, + "recentTypePlaylist": "Playlist", + "@recentTypePlaylist": { + "description": "Recent access item type - playlist" + }, + "recentEmpty": "No recent items yet", + "@recentEmpty": { + "description": "Empty state text for recent access list" + }, + "recentShowAllDownloads": "Show All Downloads", + "@recentShowAllDownloads": { + "description": "Button label to unhide hidden downloads in recent access" + }, + "recentPlaylistInfo": "Playlist: {name}", + "@recentPlaylistInfo": { + "description": "Snackbar message when tapping playlist in recent access", + "placeholders": { + "name": { + "type": "String", + "description": "Playlist name" + } + } + }, + "discographyDownload": "Download Discography", + "@discographyDownload": { + "description": "Button - download artist discography" + }, + "discographyDownloadAll": "Unduh Semua", + "@discographyDownloadAll": { + "description": "Option - download entire discography" + }, + "discographyDownloadAllSubtitle": "{count} tracks from {albumCount} releases", + "@discographyDownloadAllSubtitle": { + "description": "Subtitle showing total tracks and albums", + "placeholders": { + "count": { + "type": "int" + }, + "albumCount": { + "type": "int" + } + } + }, + "discographyAlbumsOnly": "Albums Only", + "@discographyAlbumsOnly": { + "description": "Option - download only albums" + }, + "discographyAlbumsOnlySubtitle": "{count} tracks from {albumCount} albums", + "@discographyAlbumsOnlySubtitle": { + "description": "Subtitle showing album tracks count", + "placeholders": { + "count": { + "type": "int" + }, + "albumCount": { + "type": "int" + } + } + }, + "discographySinglesOnly": "Singles & EPs Only", + "@discographySinglesOnly": { + "description": "Option - download only singles" + }, + "discographySinglesOnlySubtitle": "{count} tracks from {albumCount} singles", + "@discographySinglesOnlySubtitle": { + "description": "Subtitle showing singles tracks count", + "placeholders": { + "count": { + "type": "int" + }, + "albumCount": { + "type": "int" + } + } + }, + "discographySelectAlbums": "Select Albums...", + "@discographySelectAlbums": { + "description": "Option - manually select albums to download" + }, + "discographySelectAlbumsSubtitle": "Choose specific albums or singles", + "@discographySelectAlbumsSubtitle": { + "description": "Subtitle for select albums option" + }, + "discographyFetchingTracks": "Fetching tracks...", + "@discographyFetchingTracks": { + "description": "Progress - fetching album tracks" + }, + "discographyFetchingAlbum": "Fetching {current} of {total}...", + "@discographyFetchingAlbum": { + "description": "Progress - fetching specific album", + "placeholders": { + "current": { + "type": "int" + }, + "total": { + "type": "int" + } + } + }, + "discographySelectedCount": "{count} selected", + "@discographySelectedCount": { + "description": "Selection count badge", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "discographyDownloadSelected": "Download Selected", + "@discographyDownloadSelected": { + "description": "Button - download selected albums" + }, + "discographyAddedToQueue": "Added {count} tracks to queue", + "@discographyAddedToQueue": { + "description": "Snackbar - tracks added from discography", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "discographySkippedDownloaded": "{added} added, {skipped} already downloaded", + "@discographySkippedDownloaded": { + "description": "Snackbar - with skipped tracks count", + "placeholders": { + "added": { + "type": "int" + }, + "skipped": { + "type": "int" + } + } + }, + "discographyNoAlbums": "No albums available", + "@discographyNoAlbums": { + "description": "Error - no albums found for artist" + }, + "discographyFailedToFetch": "Failed to fetch some albums", + "@discographyFailedToFetch": { + "description": "Error - some albums failed to load" + }, + "sectionStorageAccess": "Storage Access", + "@sectionStorageAccess": { + "description": "Section header for storage access settings" + }, + "allFilesAccess": "All Files Access", + "@allFilesAccess": { + "description": "Toggle for MANAGE_EXTERNAL_STORAGE permission" + }, + "allFilesAccessEnabledSubtitle": "Can write to any folder", + "@allFilesAccessEnabledSubtitle": { + "description": "Subtitle when all files access is enabled" + }, + "allFilesAccessDisabledSubtitle": "Limited to media folders only", + "@allFilesAccessDisabledSubtitle": { + "description": "Subtitle when all files access is disabled" + }, + "allFilesAccessDescription": "Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.", + "@allFilesAccessDescription": { + "description": "Description explaining when to enable all files access" + }, + "allFilesAccessDeniedMessage": "Permission was denied. Please enable 'All files access' manually in system settings.", + "@allFilesAccessDeniedMessage": { + "description": "Message when permission is permanently denied" + }, + "allFilesAccessDisabledMessage": "All Files Access disabled. The app will use limited storage access.", + "@allFilesAccessDisabledMessage": { + "description": "Snackbar message when user disables all files access" + }, + "settingsLocalLibrary": "Local Library", + "@settingsLocalLibrary": { + "description": "Settings menu item - local library" + }, + "settingsLocalLibrarySubtitle": "Scan music & detect duplicates", + "@settingsLocalLibrarySubtitle": { + "description": "Subtitle for local library settings" + }, + "settingsCache": "Storage & Cache", + "@settingsCache": { + "description": "Settings menu item - cache management" + }, + "settingsCacheSubtitle": "View size and clear cached data", + "@settingsCacheSubtitle": { + "description": "Subtitle for cache management menu" + }, + "libraryTitle": "Local Library", + "@libraryTitle": { + "description": "Library settings page title" + }, + "libraryScanSettings": "Scan Settings", + "@libraryScanSettings": { + "description": "Section header for scan settings" + }, + "libraryEnableLocalLibrary": "Enable Local Library", + "@libraryEnableLocalLibrary": { + "description": "Toggle to enable library scanning" + }, + "libraryEnableLocalLibrarySubtitle": "Scan and track your existing music", + "@libraryEnableLocalLibrarySubtitle": { + "description": "Subtitle for enable toggle" + }, + "libraryFolder": "Library Folder", + "@libraryFolder": { + "description": "Folder selection setting" + }, + "libraryFolderHint": "Tap to select folder", + "@libraryFolderHint": { + "description": "Placeholder when no folder selected" + }, + "libraryShowDuplicateIndicator": "Show Duplicate Indicator", + "@libraryShowDuplicateIndicator": { + "description": "Toggle for duplicate indicator in search" + }, + "libraryShowDuplicateIndicatorSubtitle": "Show when searching for existing tracks", + "@libraryShowDuplicateIndicatorSubtitle": { + "description": "Subtitle for duplicate indicator toggle" + }, + "libraryActions": "Actions", + "@libraryActions": { + "description": "Section header for library actions" + }, + "libraryScan": "Scan Library", + "@libraryScan": { + "description": "Button to start library scan" + }, + "libraryScanSubtitle": "Scan for audio files", + "@libraryScanSubtitle": { + "description": "Subtitle for scan button" + }, + "libraryScanSelectFolderFirst": "Select a folder first", + "@libraryScanSelectFolderFirst": { + "description": "Message when trying to scan without folder" + }, + "libraryCleanupMissingFiles": "Cleanup Missing Files", + "@libraryCleanupMissingFiles": { + "description": "Button to remove entries for missing files" + }, + "libraryCleanupMissingFilesSubtitle": "Remove entries for files that no longer exist", + "@libraryCleanupMissingFilesSubtitle": { + "description": "Subtitle for cleanup button" + }, + "libraryClear": "Clear Library", + "@libraryClear": { + "description": "Button to clear all library entries" + }, + "libraryClearSubtitle": "Remove all scanned tracks", + "@libraryClearSubtitle": { + "description": "Subtitle for clear button" + }, + "libraryClearConfirmTitle": "Clear Library", + "@libraryClearConfirmTitle": { + "description": "Dialog title for clear confirmation" + }, + "libraryClearConfirmMessage": "This will remove all scanned tracks from your library. Your actual music files will not be deleted.", + "@libraryClearConfirmMessage": { + "description": "Dialog message for clear confirmation" + }, + "libraryAbout": "About Local Library", + "@libraryAbout": { + "description": "Section header for about info" + }, + "libraryAboutDescription": "Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.", + "@libraryAboutDescription": { + "description": "Description of local library feature" + }, + "libraryTracksUnit": "{count, plural, =1{trek} other{trek}}", + "@libraryTracksUnit": { + "description": "Unit label for tracks count (without the number itself)", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "libraryLastScanned": "Last scanned: {time}", + "@libraryLastScanned": { + "description": "Last scan time display", + "placeholders": { + "time": { + "type": "String" + } + } + }, + "libraryLastScannedNever": "Never", + "@libraryLastScannedNever": { + "description": "Shown when library has never been scanned" + }, + "libraryScanning": "Scanning...", + "@libraryScanning": { + "description": "Status during scan" + }, + "libraryScanProgress": "{progress}% of {total} files", + "@libraryScanProgress": { + "description": "Scan progress display", + "placeholders": { + "progress": { + "type": "String" + }, + "total": { + "type": "int" + } + } + }, + "libraryInLibrary": "In Library", + "@libraryInLibrary": { + "description": "Badge shown on tracks that exist in local library" + }, + "libraryRemovedMissingFiles": "Removed {count} missing files from library", + "@libraryRemovedMissingFiles": { + "description": "Snackbar after cleanup", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "libraryCleared": "Library cleared", + "@libraryCleared": { + "description": "Snackbar after clearing library" + }, + "libraryStorageAccessRequired": "Storage Access Required", + "@libraryStorageAccessRequired": { + "description": "Dialog title for storage permission" + }, + "libraryStorageAccessMessage": "SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.", + "@libraryStorageAccessMessage": { + "description": "Dialog message for storage permission" + }, + "libraryFolderNotExist": "Selected folder does not exist", + "@libraryFolderNotExist": { + "description": "Error when folder doesn't exist" + }, + "librarySourceDownloaded": "Downloaded", + "@librarySourceDownloaded": { + "description": "Badge for tracks downloaded via SpotiFLAC" + }, + "librarySourceLocal": "Local", + "@librarySourceLocal": { + "description": "Badge for tracks from local library scan" + }, + "libraryFilterAll": "All", + "@libraryFilterAll": { + "description": "Filter chip - show all library items" + }, + "libraryFilterDownloaded": "Downloaded", + "@libraryFilterDownloaded": { + "description": "Filter chip - show only downloaded items" + }, + "libraryFilterLocal": "Local", + "@libraryFilterLocal": { + "description": "Filter chip - show only local library items" + }, + "libraryFilterTitle": "Filters", + "@libraryFilterTitle": { + "description": "Filter bottom sheet title" + }, + "libraryFilterReset": "Reset", + "@libraryFilterReset": { + "description": "Reset all filters button" + }, + "libraryFilterApply": "Apply", + "@libraryFilterApply": { + "description": "Apply filters button" + }, + "libraryFilterSource": "Source", + "@libraryFilterSource": { + "description": "Filter section - source type" + }, + "libraryFilterQuality": "Quality", + "@libraryFilterQuality": { + "description": "Filter section - audio quality" + }, + "libraryFilterQualityHiRes": "Hi-Res (24bit)", + "@libraryFilterQualityHiRes": { + "description": "Filter option - high resolution audio" + }, + "libraryFilterQualityCD": "CD (16bit)", + "@libraryFilterQualityCD": { + "description": "Filter option - CD quality audio" + }, + "libraryFilterQualityLossy": "Lossy", + "@libraryFilterQualityLossy": { + "description": "Filter option - lossy compressed audio" + }, + "libraryFilterFormat": "Format", + "@libraryFilterFormat": { + "description": "Filter section - file format" + }, + "libraryFilterSort": "Sort", + "@libraryFilterSort": { + "description": "Filter section - sort order" + }, + "libraryFilterSortLatest": "Latest", + "@libraryFilterSortLatest": { + "description": "Sort option - newest first" + }, + "libraryFilterSortOldest": "Oldest", + "@libraryFilterSortOldest": { + "description": "Sort option - oldest first" + }, + "timeJustNow": "Just now", + "@timeJustNow": { + "description": "Relative time - less than a minute ago" + }, + "timeMinutesAgo": "{count, plural, =1{1 minute ago} other{{count} minutes ago}}", + "@timeMinutesAgo": { + "description": "Relative time - minutes ago", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "timeHoursAgo": "{count, plural, =1{1 hour ago} other{{count} hours ago}}", + "@timeHoursAgo": { + "description": "Relative time - hours ago", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "tutorialWelcomeTitle": "Welcome to SpotiFLAC!", + "@tutorialWelcomeTitle": { + "description": "Tutorial welcome page title" + }, + "tutorialWelcomeDesc": "Let's learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.", + "@tutorialWelcomeDesc": { + "description": "Tutorial welcome page description" + }, + "tutorialWelcomeTip1": "Download music from Spotify, Deezer, or paste any supported URL", + "@tutorialWelcomeTip1": { + "description": "Tutorial welcome tip 1" + }, + "tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music", + "@tutorialWelcomeTip2": { + "description": "Tutorial welcome tip 2" + }, + "tutorialWelcomeTip3": "Automatic metadata, cover art, and lyrics embedding", + "@tutorialWelcomeTip3": { + "description": "Tutorial welcome tip 3" + }, + "tutorialSearchTitle": "Finding Music", + "@tutorialSearchTitle": { + "description": "Tutorial search page title" + }, + "tutorialSearchDesc": "There are two easy ways to find music you want to download.", + "@tutorialSearchDesc": { + "description": "Tutorial search page description" + }, + "tutorialDownloadTitle": "Downloading Music", + "@tutorialDownloadTitle": { + "description": "Tutorial download page title" + }, + "tutorialDownloadDesc": "Downloading music is simple and fast. Here's how it works.", + "@tutorialDownloadDesc": { + "description": "Tutorial download page description" + }, + "tutorialLibraryTitle": "Your Library", + "@tutorialLibraryTitle": { + "description": "Tutorial library page title" + }, + "tutorialLibraryDesc": "All your downloaded music is organized in the Library tab.", + "@tutorialLibraryDesc": { + "description": "Tutorial library page description" + }, + "tutorialLibraryTip1": "View download progress and queue in the Library tab", + "@tutorialLibraryTip1": { + "description": "Tutorial library tip 1" + }, + "tutorialLibraryTip2": "Tap any track to play it with your music player", + "@tutorialLibraryTip2": { + "description": "Tutorial library tip 2" + }, + "tutorialLibraryTip3": "Switch between list and grid view for better browsing", + "@tutorialLibraryTip3": { + "description": "Tutorial library tip 3" + }, + "tutorialExtensionsTitle": "Extensions", + "@tutorialExtensionsTitle": { + "description": "Tutorial extensions page title" + }, + "tutorialExtensionsDesc": "Extend the app's capabilities with community extensions.", + "@tutorialExtensionsDesc": { + "description": "Tutorial extensions page description" + }, + "tutorialExtensionsTip1": "Browse the Store tab to discover useful extensions", + "@tutorialExtensionsTip1": { + "description": "Tutorial extensions tip 1" + }, + "tutorialExtensionsTip2": "Add new download providers or search sources", + "@tutorialExtensionsTip2": { + "description": "Tutorial extensions tip 2" + }, + "tutorialExtensionsTip3": "Get lyrics, enhanced metadata, and more features", + "@tutorialExtensionsTip3": { + "description": "Tutorial extensions tip 3" + }, + "tutorialSettingsTitle": "Customize Your Experience", + "@tutorialSettingsTitle": { + "description": "Tutorial settings page title" + }, + "tutorialSettingsDesc": "Personalize the app in Settings to match your preferences.", + "@tutorialSettingsDesc": { + "description": "Tutorial settings page description" + }, + "tutorialSettingsTip1": "Change download location and folder organization", + "@tutorialSettingsTip1": { + "description": "Tutorial settings tip 1" + }, + "tutorialSettingsTip2": "Set default audio quality and format preferences", + "@tutorialSettingsTip2": { + "description": "Tutorial settings tip 2" + }, + "tutorialSettingsTip3": "Customize app theme and appearance", + "@tutorialSettingsTip3": { + "description": "Tutorial settings tip 3" + }, + "tutorialReadyMessage": "You're all set! Start downloading your favorite music now.", + "@tutorialReadyMessage": { + "description": "Tutorial completion message" + }, + "libraryForceFullScan": "Force Full Scan", + "@libraryForceFullScan": { + "description": "Button to force a complete rescan of library" + }, + "libraryForceFullScanSubtitle": "Rescan all files, ignoring cache", + "@libraryForceFullScanSubtitle": { + "description": "Subtitle for force full scan button" + }, + "cleanupOrphanedDownloads": "Cleanup Orphaned Downloads", + "@cleanupOrphanedDownloads": { + "description": "Button to remove history entries for deleted files" + }, + "cleanupOrphanedDownloadsSubtitle": "Remove history entries for files that no longer exist", + "@cleanupOrphanedDownloadsSubtitle": { + "description": "Subtitle for orphaned cleanup button" + }, + "cleanupOrphanedDownloadsResult": "Removed {count} orphaned entries from history", + "@cleanupOrphanedDownloadsResult": { + "description": "Snackbar after orphan cleanup", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "cleanupOrphanedDownloadsNone": "No orphaned entries found", + "@cleanupOrphanedDownloadsNone": { + "description": "Snackbar when no orphans found" + }, + "cacheTitle": "Storage & Cache", + "@cacheTitle": { + "description": "Cache management page title" + }, + "cacheSummaryTitle": "Cache overview", + "@cacheSummaryTitle": { + "description": "Heading for cache summary card" + }, + "cacheSummarySubtitle": "Clearing cache will not remove downloaded music files.", + "@cacheSummarySubtitle": { + "description": "Helper text for cache summary card" + }, + "cacheEstimatedTotal": "Estimated cache usage: {size}", + "@cacheEstimatedTotal": { + "description": "Total cache size shown in summary", + "placeholders": { + "size": { + "type": "String" + } + } + }, + "cacheSectionStorage": "Cached Data", + "@cacheSectionStorage": { + "description": "Section header for cache entries" + }, + "cacheSectionMaintenance": "Maintenance", + "@cacheSectionMaintenance": { + "description": "Section header for cleanup actions" + }, + "cacheAppDirectory": "App cache directory", + "@cacheAppDirectory": { + "description": "Cache item title for app cache directory" + }, + "cacheAppDirectoryDesc": "HTTP responses, WebView data, and other temporary app data.", + "@cacheAppDirectoryDesc": { + "description": "Description of what app cache directory contains" + }, + "cacheTempDirectory": "Temporary directory", + "@cacheTempDirectory": { + "description": "Cache item title for temporary files directory" + }, + "cacheTempDirectoryDesc": "Temporary files from downloads and audio conversion.", + "@cacheTempDirectoryDesc": { + "description": "Description of what temporary directory contains" + }, + "cacheCoverImage": "Cover image cache", + "@cacheCoverImage": { + "description": "Cache item title for persistent cover images" + }, + "cacheCoverImageDesc": "Downloaded album and track cover art. Will re-download when viewed.", + "@cacheCoverImageDesc": { + "description": "Description of what cover image cache contains" + }, + "cacheLibraryCover": "Library cover cache", + "@cacheLibraryCover": { + "description": "Cache item title for local library cover art images" + }, + "cacheLibraryCoverDesc": "Cover art extracted from local music files. Will re-extract on next scan.", + "@cacheLibraryCoverDesc": { + "description": "Description of what library cover cache contains" + }, + "cacheExploreFeed": "Explore feed cache", + "@cacheExploreFeed": { + "description": "Cache item title for explore home feed cache" + }, + "cacheExploreFeedDesc": "Explore tab content (new releases, trending). Will refresh on next visit.", + "@cacheExploreFeedDesc": { + "description": "Description of what explore feed cache contains" + }, + "cacheTrackLookup": "Track lookup cache", + "@cacheTrackLookup": { + "description": "Cache item title for track ID lookup cache" + }, + "cacheTrackLookupDesc": "Spotify/Deezer track ID lookups. Clearing may slow next few searches.", + "@cacheTrackLookupDesc": { + "description": "Description of what track lookup cache contains" + }, + "cacheCleanupUnusedDesc": "Remove orphaned download history and library entries for missing files.", + "@cacheCleanupUnusedDesc": { + "description": "Description of what cleanup unused data does" + }, + "cacheNoData": "No cached data", + "@cacheNoData": { + "description": "Label when cache category has no data" + }, + "cacheSizeWithFiles": "{size} in {count} files", + "@cacheSizeWithFiles": { + "description": "Cache size and file count", + "placeholders": { + "size": { + "type": "String" + }, + "count": { + "type": "int" + } + } + }, + "cacheSizeOnly": "{size}", + "@cacheSizeOnly": { + "description": "Cache size only", + "placeholders": { + "size": { + "type": "String" + } + } + }, + "cacheEntries": "{count} entries", + "@cacheEntries": { + "description": "Track cache entry count", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "cacheClearSuccess": "Cleared: {target}", + "@cacheClearSuccess": { + "description": "Snackbar after clearing selected cache", + "placeholders": { + "target": { + "type": "String" + } + } + }, + "cacheClearConfirmTitle": "Clear cache?", + "@cacheClearConfirmTitle": { + "description": "Dialog title before clearing one cache category" + }, + "cacheClearConfirmMessage": "This will clear cached data for {target}. Downloaded music files will not be deleted.", + "@cacheClearConfirmMessage": { + "description": "Dialog message before clearing selected cache", + "placeholders": { + "target": { + "type": "String" + } + } + }, + "cacheClearAllConfirmTitle": "Clear all cache?", + "@cacheClearAllConfirmTitle": { + "description": "Dialog title before clearing all caches" + }, + "cacheClearAllConfirmMessage": "This will clear all cache categories on this page. Downloaded music files will not be deleted.", + "@cacheClearAllConfirmMessage": { + "description": "Dialog message before clearing all caches" + }, + "cacheClearAll": "Clear all cache", + "@cacheClearAll": { + "description": "Button label to clear all caches" + }, + "cacheCleanupUnused": "Cleanup unused data", + "@cacheCleanupUnused": { + "description": "Action title for cleaning unused entries" + }, + "cacheCleanupUnusedSubtitle": "Remove orphaned download history and missing library entries", + "@cacheCleanupUnusedSubtitle": { + "description": "Subtitle for cleanup unused data action" + }, + "cacheCleanupResult": "Cleanup completed: {downloadCount} orphaned downloads, {libraryCount} missing library entries", + "@cacheCleanupResult": { + "description": "Snackbar after unused data cleanup", + "placeholders": { + "downloadCount": { + "type": "int" + }, + "libraryCount": { + "type": "int" + } + } + }, + "cacheRefreshStats": "Refresh stats", + "@cacheRefreshStats": { + "description": "Button label to refresh cache statistics" + }, + "trackSaveCoverArt": "Save Cover Art", + "@trackSaveCoverArt": { + "description": "Menu action - save album cover art as file" + }, + "trackSaveCoverArtSubtitle": "Save album art as .jpg file", + "@trackSaveCoverArtSubtitle": { + "description": "Subtitle for save cover art action" + }, + "trackSaveLyrics": "Save Lyrics (.lrc)", + "@trackSaveLyrics": { + "description": "Menu action - save lyrics as .lrc file" + }, + "trackSaveLyricsSubtitle": "Fetch and save lyrics as .lrc file", + "@trackSaveLyricsSubtitle": { + "description": "Subtitle for save lyrics action" + }, + "trackSaveLyricsProgress": "Saving lyrics...", + "@trackSaveLyricsProgress": { + "description": "Snackbar while saving lyrics to file" + }, + "trackReEnrich": "Re-enrich", + "@trackReEnrich": { + "description": "Menu action - re-embed metadata into audio file" + }, + "trackReEnrichOnlineSubtitle": "Search metadata online and embed into file", + "@trackReEnrichOnlineSubtitle": { + "description": "Subtitle for re-enrich metadata action for local items" + }, + "trackEditMetadata": "Edit Metadata", + "@trackEditMetadata": { + "description": "Menu action - edit embedded metadata" + }, + "trackCoverSaved": "Cover art saved to {fileName}", + "@trackCoverSaved": { + "description": "Snackbar after cover art saved", + "placeholders": { + "fileName": { + "type": "String" + } + } + }, + "trackCoverNoSource": "No cover art source available", + "@trackCoverNoSource": { + "description": "Snackbar when no cover art URL or embedded cover" + }, + "trackLyricsSaved": "Lyrics saved to {fileName}", + "@trackLyricsSaved": { + "description": "Snackbar after lyrics saved", + "placeholders": { + "fileName": { + "type": "String" + } + } + }, + "trackReEnrichProgress": "Re-enriching metadata...", + "@trackReEnrichProgress": { + "description": "Snackbar while re-enriching metadata" + }, + "trackReEnrichSearching": "Searching metadata online...", + "@trackReEnrichSearching": { + "description": "Snackbar while searching metadata from internet for local items" + }, + "trackReEnrichSuccess": "Metadata re-enriched successfully", + "@trackReEnrichSuccess": { + "description": "Snackbar after successful re-enrichment" + }, + "trackReEnrichFfmpegFailed": "FFmpeg metadata embed failed", + "@trackReEnrichFfmpegFailed": { + "description": "Snackbar when FFmpeg embed fails for MP3/Opus" + }, + "trackSaveFailed": "Failed: {error}", + "@trackSaveFailed": { + "description": "Snackbar when save operation fails", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "trackConvertFormat": "Convert Format", + "@trackConvertFormat": { + "description": "Menu item - convert audio format" + }, + "trackConvertFormatSubtitle": "Convert to MP3 or Opus", + "@trackConvertFormatSubtitle": { + "description": "Subtitle for convert format menu item" + }, + "trackConvertTitle": "Convert Audio", + "@trackConvertTitle": { + "description": "Title of convert bottom sheet" + }, + "trackConvertTargetFormat": "Target Format", + "@trackConvertTargetFormat": { + "description": "Label for format selection" + }, + "trackConvertBitrate": "Bitrate", + "@trackConvertBitrate": { + "description": "Label for bitrate selection" + }, + "trackConvertConfirmTitle": "Confirm Conversion", + "@trackConvertConfirmTitle": { + "description": "Confirmation dialog title" + }, + "trackConvertConfirmMessage": "Convert from {sourceFormat} to {targetFormat} at {bitrate}?\n\nThe original file will be deleted after conversion.", + "@trackConvertConfirmMessage": { + "description": "Confirmation dialog message", + "placeholders": { + "sourceFormat": { + "type": "String" + }, + "targetFormat": { + "type": "String" + }, + "bitrate": { + "type": "String" + } + } + }, + "trackConvertConverting": "Converting audio...", + "@trackConvertConverting": { + "description": "Snackbar while converting" + }, + "trackConvertSuccess": "Converted to {format} successfully", + "@trackConvertSuccess": { + "description": "Snackbar after successful conversion", + "placeholders": { + "format": { + "type": "String" + } + } + }, + "trackConvertFailed": "Conversion failed", + "@trackConvertFailed": { + "description": "Snackbar when conversion fails" + }, + "actionCreate": "Buat", + "@actionCreate": { + "description": "Generic action button - create" + }, + "collectionFoldersTitle": "Folder saya", + "@collectionFoldersTitle": { + "description": "Library section title for custom folders" + }, + "collectionWishlist": "Wishlist", + "@collectionWishlist": { + "description": "Custom folder for saved tracks to download later" + }, + "collectionLoved": "Loved", + "@collectionLoved": { + "description": "Custom folder for favorite tracks" + }, + "collectionPlaylists": "Playlist", + "@collectionPlaylists": { + "description": "Custom user playlists folder" + }, + "collectionPlaylist": "Playlist", + "@collectionPlaylist": { + "description": "Single playlist label" + }, + "collectionAddToPlaylist": "Tambahkan ke playlist", + "@collectionAddToPlaylist": { + "description": "Action to add a track to user playlist" + }, + "collectionCreatePlaylist": "Buat playlist", + "@collectionCreatePlaylist": { + "description": "Action to create a new playlist" + }, + "collectionNoPlaylistsYet": "Belum ada playlist", + "@collectionNoPlaylistsYet": { + "description": "Empty state title when user has no playlists" + }, + "collectionNoPlaylistsSubtitle": "Buat playlist untuk mulai mengategorikan lagu", + "@collectionNoPlaylistsSubtitle": { + "description": "Empty state subtitle when user has no playlists" + }, + "collectionPlaylistTracks": "{count, plural, =1{1 lagu} other{{count} lagu}}", + "@collectionPlaylistTracks": { + "description": "Track count label for custom playlists", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "collectionAddedToPlaylist": "Ditambahkan ke \"{playlistName}\"", + "@collectionAddedToPlaylist": { + "description": "Snackbar after adding track to playlist", + "placeholders": { + "playlistName": { + "type": "String" + } + } + }, + "collectionAlreadyInPlaylist": "Sudah ada di \"{playlistName}\"", + "@collectionAlreadyInPlaylist": { + "description": "Snackbar when track already exists in playlist", + "placeholders": { + "playlistName": { + "type": "String" + } + } + }, + "collectionPlaylistCreated": "Playlist berhasil dibuat", + "@collectionPlaylistCreated": { + "description": "Snackbar after creating playlist" + }, + "collectionPlaylistNameHint": "Nama playlist", + "@collectionPlaylistNameHint": { + "description": "Hint text for playlist name input" + }, + "collectionPlaylistNameRequired": "Nama playlist wajib diisi", + "@collectionPlaylistNameRequired": { + "description": "Validation error for empty playlist name" + }, + "collectionRenamePlaylist": "Ubah nama playlist", + "@collectionRenamePlaylist": { + "description": "Action to rename playlist" + }, + "collectionDeletePlaylist": "Hapus playlist", + "@collectionDeletePlaylist": { + "description": "Action to delete playlist" + }, + "collectionDeletePlaylistMessage": "Hapus \"{playlistName}\" beserta semua lagunya?", + "@collectionDeletePlaylistMessage": { + "description": "Confirmation message for deleting playlist", + "placeholders": { + "playlistName": { + "type": "String" + } + } + }, + "collectionPlaylistDeleted": "Playlist dihapus", + "@collectionPlaylistDeleted": { + "description": "Snackbar after deleting playlist" + }, + "collectionPlaylistRenamed": "Nama playlist diperbarui", + "@collectionPlaylistRenamed": { + "description": "Snackbar after renaming playlist" + }, + "collectionWishlistEmptyTitle": "Wishlist masih kosong", + "@collectionWishlistEmptyTitle": { + "description": "Wishlist empty state title" + }, + "collectionWishlistEmptySubtitle": "Tap + di lagu untuk menyimpan yang ingin diunduh nanti", + "@collectionWishlistEmptySubtitle": { + "description": "Wishlist empty state subtitle" + }, + "collectionLovedEmptyTitle": "Folder Loved masih kosong", + "@collectionLovedEmptyTitle": { + "description": "Loved empty state title" + }, + "collectionLovedEmptySubtitle": "Tap love di lagu untuk menyimpan favoritmu", + "@collectionLovedEmptySubtitle": { + "description": "Loved empty state subtitle" + }, + "collectionPlaylistEmptyTitle": "Playlist masih kosong", + "@collectionPlaylistEmptyTitle": { + "description": "Playlist empty state title" + }, + "collectionPlaylistEmptySubtitle": "Tekan lama tombol + pada lagu untuk menambahkannya ke sini", + "@collectionPlaylistEmptySubtitle": { + "description": "Playlist empty state subtitle" + }, + "collectionRemoveFromPlaylist": "Hapus dari playlist", + "@collectionRemoveFromPlaylist": { + "description": "Tooltip for removing track from playlist" + }, + "collectionRemoveFromFolder": "Hapus dari folder", + "@collectionRemoveFromFolder": { + "description": "Tooltip for removing track from wishlist/loved folder" + }, + "collectionRemoved": "\"{trackName}\" dihapus", + "@collectionRemoved": { + "description": "Snackbar after removing a track from a collection", + "placeholders": { + "trackName": { + "type": "String" + } + } + }, + "collectionAddedToLoved": "\"{trackName}\" ditambahkan ke Loved", + "@collectionAddedToLoved": { + "description": "Snackbar after adding track to loved folder", + "placeholders": { + "trackName": { + "type": "String" + } + } + }, + "collectionRemovedFromLoved": "\"{trackName}\" dihapus dari Loved", + "@collectionRemovedFromLoved": { + "description": "Snackbar after removing track from loved folder", + "placeholders": { + "trackName": { + "type": "String" + } + } + }, + "collectionAddedToWishlist": "\"{trackName}\" ditambahkan ke Wishlist", + "@collectionAddedToWishlist": { + "description": "Snackbar after adding track to wishlist", + "placeholders": { + "trackName": { + "type": "String" + } + } + }, + "collectionRemovedFromWishlist": "\"{trackName}\" dihapus dari Wishlist", + "@collectionRemovedFromWishlist": { + "description": "Snackbar after removing track from wishlist", + "placeholders": { + "trackName": { + "type": "String" + } + } + }, + "trackOptionAddToLoved": "Tambahkan ke Loved", + "@trackOptionAddToLoved": { + "description": "Bottom sheet action label - add track to loved folder" + }, + "trackOptionRemoveFromLoved": "Hapus dari Loved", + "@trackOptionRemoveFromLoved": { + "description": "Bottom sheet action label - remove track from loved folder" + }, + "trackOptionAddToWishlist": "Tambahkan ke Wishlist", + "@trackOptionAddToWishlist": { + "description": "Bottom sheet action label - add track to wishlist" + }, + "trackOptionRemoveFromWishlist": "Hapus dari Wishlist", + "@trackOptionRemoveFromWishlist": { + "description": "Bottom sheet action label - remove track from wishlist" + }, + "collectionPlaylistChangeCover": "Ubah gambar sampul", + "@collectionPlaylistChangeCover": { + "description": "Bottom sheet action to pick a custom cover image for a playlist" + }, + "collectionPlaylistRemoveCover": "Hapus gambar sampul", + "@collectionPlaylistRemoveCover": { + "description": "Bottom sheet action to remove custom cover image from a playlist" + }, + "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" + } + } + }, + "downloadedAlbumDownloadedCount": "{count} diunduh", + "@downloadedAlbumDownloadedCount": { + "description": "Downloaded tracks count badge", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available", + "@downloadUseAlbumArtistForFoldersAlbumSubtitle": { + "description": "Subtitle when Album Artist is used for folder naming" + }, + "downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only", + "@downloadUseAlbumArtistForFoldersTrackSubtitle": { + "description": "Subtitle when Track Artist is used for folder naming" + } } diff --git a/lib/l10n/arb/app_ja.arb b/lib/l10n/arb/app_ja.arb index fd193b3c..b23b0de3 100644 --- a/lib/l10n/arb/app_ja.arb +++ b/lib/l10n/arb/app_ja.arb @@ -5,10 +5,6 @@ "@appName": { "description": "App name - DO NOT TRANSLATE" }, - "appDescription": "Tidal、Qobuz、Amazon Music から Spotify のトラックをロスレス品質でダウンロードします。", - "@appDescription": { - "description": "App description shown in about page" - }, "navHome": "ホーム", "@navHome": { "description": "Bottom navigation - Home tab" @@ -17,10 +13,6 @@ "@navLibrary": { "description": "Bottom navigation - Library tab" }, - "navHistory": "履歴", - "@navHistory": { - "description": "Bottom navigation - History tab (legacy)" - }, "navSettings": "設定", "@navSettings": { "description": "Bottom navigation - Settings tab" @@ -33,20 +25,6 @@ "@homeTitle": { "description": "Home screen title" }, - "homeSearchHint": "Spotify の URL を貼り付けまたは検索...", - "@homeSearchHint": { - "description": "Placeholder text in search box" - }, - "homeSearchHintExtension": "{extensionName} で検索...", - "@homeSearchHintExtension": { - "description": "Placeholder when extension search is active", - "placeholders": { - "extensionName": { - "type": "String", - "description": "Name of the active extension" - } - } - }, "homeSubtitle": "Spotify のリンクを貼り付けるか、名前で検索します", "@homeSubtitle": { "description": "Subtitle shown below search box" @@ -59,24 +37,6 @@ "@homeRecent": { "description": "Section header for recent searches" }, - "historyTitle": "履歴", - "@historyTitle": { - "description": "History screen title" - }, - "historyDownloading": "ダウンロード中 ({count})", - "@historyDownloading": { - "description": "Tab showing active downloads count", - "placeholders": { - "count": { - "type": "int", - "description": "Number of active downloads" - } - } - }, - "historyDownloaded": "ダウンロード済み", - "@historyDownloaded": { - "description": "Tab showing completed downloads" - }, "historyFilterAll": "すべて", "@historyFilterAll": { "description": "Filter chip - show all items" @@ -89,48 +49,6 @@ "@historyFilterSingles": { "description": "Filter chip - show singles only" }, - "historyTracksCount": "{count, plural, =1{1 個のトラック} other{{count} 個のトラック}}", - "@historyTracksCount": { - "description": "Track count with plural form", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "historyAlbumsCount": "{count, plural, =1{1 個のアルバム} other{{count} 個のアルバム}}", - "@historyAlbumsCount": { - "description": "Album count with plural form", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "historyNoDownloads": "ダウンロード履歴はありません", - "@historyNoDownloads": { - "description": "Empty state title" - }, - "historyNoDownloadsSubtitle": "ダウンロードしたトラックはここに表示されます", - "@historyNoDownloadsSubtitle": { - "description": "Empty state subtitle" - }, - "historyNoAlbums": "アルバムのダウンロードはありません", - "@historyNoAlbums": { - "description": "Empty state when filtering albums" - }, - "historyNoAlbumsSubtitle": "Download multiple tracks from an album to see them here", - "@historyNoAlbumsSubtitle": { - "description": "Empty state subtitle for albums filter" - }, - "historyNoSingles": "シングルのダウンロードはありません", - "@historyNoSingles": { - "description": "Empty state when filtering singles" - }, - "historyNoSinglesSubtitle": "Single track downloads will appear here", - "@historyNoSinglesSubtitle": { - "description": "Empty state subtitle for singles filter" - }, "historySearchHint": "検索履歴...", "@historySearchHint": { "description": "Search bar placeholder in history" @@ -163,34 +81,6 @@ "@downloadTitle": { "description": "Download settings page title" }, - "downloadLocation": "ダウンロード先", - "@downloadLocation": { - "description": "Setting for download folder" - }, - "downloadLocationSubtitle": "ファイルの保存先を選択", - "@downloadLocationSubtitle": { - "description": "Subtitle for download location" - }, - "downloadLocationDefault": "デフォルトの場所", - "@downloadLocationDefault": { - "description": "Shown when using default folder" - }, - "downloadDefaultService": "デフォルトのサービス", - "@downloadDefaultService": { - "description": "Setting for preferred download service (Tidal/Qobuz/Amazon)" - }, - "downloadDefaultServiceSubtitle": "ダウンロードに使用したサービス", - "@downloadDefaultServiceSubtitle": { - "description": "Subtitle for default service" - }, - "downloadDefaultQuality": "デフォルトの品質", - "@downloadDefaultQuality": { - "description": "Setting for audio quality" - }, - "downloadAskQuality": "ダウンロード前に品質を確認する", - "@downloadAskQuality": { - "description": "Toggle to show quality picker" - }, "downloadAskQualitySubtitle": "Show quality picker for each download", "@downloadAskQualitySubtitle": { "description": "Subtitle for ask quality toggle" @@ -203,38 +93,10 @@ "@downloadFolderOrganization": { "description": "Setting for folder structure" }, - "downloadSeparateSingles": "シングルを分割", - "@downloadSeparateSingles": { - "description": "Toggle to separate single tracks" - }, - "downloadSeparateSinglesSubtitle": "Put single tracks in a separate folder", - "@downloadSeparateSinglesSubtitle": { - "description": "Subtitle for separate singles toggle" - }, - "qualityBest": "おすすめ", - "@qualityBest": { - "description": "Audio quality option - highest available" - }, - "qualityFlac": "FLAC", - "@qualityFlac": { - "description": "Audio quality option - FLAC lossless" - }, - "quality320": "320 kbps", - "@quality320": { - "description": "Audio quality option - 320kbps MP3" - }, - "quality128": "128 kbps", - "@quality128": { - "description": "Audio quality option - 128kbps MP3" - }, "appearanceTitle": "外観", "@appearanceTitle": { "description": "Appearance settings page title" }, - "appearanceTheme": "テーマ", - "@appearanceTheme": { - "description": "Theme mode setting" - }, "appearanceThemeSystem": "システム", "@appearanceThemeSystem": { "description": "Follow system theme" @@ -255,10 +117,6 @@ "@appearanceDynamicColorSubtitle": { "description": "Subtitle for dynamic color" }, - "appearanceAccentColor": "アクセントカラー", - "@appearanceAccentColor": { - "description": "Custom accent color picker" - }, "appearanceHistoryView": "履歴の表示", "@appearanceHistoryView": { "description": "Layout style for history" @@ -275,10 +133,6 @@ "@optionsTitle": { "description": "Options settings page title" }, - "optionsSearchSource": "検索ソース", - "@optionsSearchSource": { - "description": "Section for search provider settings" - }, "optionsPrimaryProvider": "プライマリーのプロバイダー", "@optionsPrimaryProvider": { "description": "Main search provider setting" @@ -438,22 +292,6 @@ "@extensionsTitle": { "description": "Extensions page title" }, - "extensionsInstalled": "インストール済みの拡張", - "@extensionsInstalled": { - "description": "Section header for installed extensions" - }, - "extensionsNone": "拡張はインストールされていません", - "@extensionsNone": { - "description": "Empty state title" - }, - "extensionsNoneSubtitle": "ストアタブから拡張をインストール", - "@extensionsNoneSubtitle": { - "description": "Empty state subtitle" - }, - "extensionsEnabled": "有効", - "@extensionsEnabled": { - "description": "Extension status - active" - }, "extensionsDisabled": "無効", "@extensionsDisabled": { "description": "Extension status - inactive" @@ -480,10 +318,6 @@ "@extensionsUninstall": { "description": "Uninstall extension button" }, - "extensionsSetAsSearch": "検索のプロバイダーを設定", - "@extensionsSetAsSearch": { - "description": "Use extension for search" - }, "storeTitle": "拡張ストア", "@storeTitle": { "description": "Store screen title" @@ -580,10 +414,6 @@ "@aboutSocial": { "description": "Section for social links" }, - "aboutSupport": "サポート", - "@aboutSupport": { - "description": "Section for support/donation links" - }, "aboutApp": "アプリ", "@aboutApp": { "description": "Section for app info" @@ -604,14 +434,6 @@ "@aboutSjdonadoDesc": { "description": "Credit description for sjdonado" }, - "aboutDoubleDouble": "DoubleDouble", - "@aboutDoubleDouble": { - "description": "Name of Amazon API service - DO NOT TRANSLATE" - }, - "aboutDoubleDoubleDesc": "Amazing API for Amazon Music downloads. Thank you for making it free!", - "@aboutDoubleDoubleDesc": { - "description": "Credit for DoubleDouble API" - }, "aboutDabMusic": "DAB Music", "@aboutDabMusic": { "description": "Name of Qobuz API service - DO NOT TRANSLATE" @@ -632,35 +454,6 @@ "@aboutAppDescription": { "description": "App description in header card" }, - "albumTitle": "アルバム", - "@albumTitle": { - "description": "Album screen title" - }, - "albumTracks": "{count, plural, =1{1 個のトラック} other{{count} 個のトラック}}", - "@albumTracks": { - "description": "Album track count", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "albumDownloadAll": "すべてダウンロード", - "@albumDownloadAll": { - "description": "Button to download all tracks" - }, - "albumDownloadRemaining": "Download Remaining", - "@albumDownloadRemaining": { - "description": "Button to download remaining tracks" - }, - "playlistTitle": "プレイリスト", - "@playlistTitle": { - "description": "Playlist screen title" - }, - "artistTitle": "アーティスト", - "@artistTitle": { - "description": "Artist screen title" - }, "artistAlbums": "アルバム", "@artistAlbums": { "description": "Section header for artist albums" @@ -673,15 +466,6 @@ "@artistCompilations": { "description": "Section header for compilations" }, - "artistReleases": "{count, plural, =1{1 個のリリース} other{{count} 個のリリース}}", - "@artistReleases": { - "description": "Artist release count", - "placeholders": { - "count": { - "type": "int" - } - } - }, "artistPopular": "人気", "@artistPopular": { "description": "Section header for popular/top tracks" @@ -696,34 +480,6 @@ } } }, - "trackMetadataTitle": "トラック情報", - "@trackMetadataTitle": { - "description": "Track metadata screen title" - }, - "trackMetadataArtist": "アーティスト", - "@trackMetadataArtist": { - "description": "Metadata field - artist name" - }, - "trackMetadataAlbum": "アルバム", - "@trackMetadataAlbum": { - "description": "Metadata field - album name" - }, - "trackMetadataDuration": "再生時間", - "@trackMetadataDuration": { - "description": "Metadata field - track length" - }, - "trackMetadataQuality": "品質", - "@trackMetadataQuality": { - "description": "Metadata field - audio quality" - }, - "trackMetadataPath": "ファイルパス", - "@trackMetadataPath": { - "description": "Metadata field - file location" - }, - "trackMetadataDownloadedAt": "ダウンロード済み", - "@trackMetadataDownloadedAt": { - "description": "Metadata field - download date" - }, "trackMetadataService": "サービス", "@trackMetadataService": { "description": "Metadata field - download service used" @@ -740,54 +496,10 @@ "@trackMetadataDelete": { "description": "Action button - delete track" }, - "trackMetadataRedownload": "再ダウンロード", - "@trackMetadataRedownload": { - "description": "Action button - download again" - }, - "trackMetadataOpenFolder": "フォルダを開く", - "@trackMetadataOpenFolder": { - "description": "Action button - open containing folder" - }, - "setupTitle": "SpotiFLAC へようこそ", - "@setupTitle": { - "description": "Setup wizard title" - }, - "setupSubtitle": "Let's get you started", - "@setupSubtitle": { - "description": "Setup wizard subtitle" - }, - "setupStoragePermission": "ストレージの権限", - "@setupStoragePermission": { - "description": "Storage permission step title" - }, - "setupStoragePermissionSubtitle": "ダウンロードしたファイルを保存するために必要です", - "@setupStoragePermissionSubtitle": { - "description": "Explanation for storage permission" - }, - "setupStoragePermissionGranted": "権限を許可しました", - "@setupStoragePermissionGranted": { - "description": "Status when permission granted" - }, - "setupStoragePermissionDenied": "権限が拒否されました", - "@setupStoragePermissionDenied": { - "description": "Status when permission denied" - }, "setupGrantPermission": "権限を許可", "@setupGrantPermission": { "description": "Button to request permission" }, - "setupDownloadLocation": "ダウンロード先", - "@setupDownloadLocation": { - "description": "Download folder step title" - }, - "setupChooseFolder": "フォルダを選択", - "@setupChooseFolder": { - "description": "Button to pick folder" - }, - "setupContinue": "続行", - "@setupContinue": { - "description": "Continue to next step button" - }, "setupSkip": "今はスキップ", "@setupSkip": { "description": "Skip current step button" @@ -796,10 +508,6 @@ "@setupStorageAccessRequired": { "description": "Title when storage access needed" }, - "setupStorageAccessMessage": "SpotiFLAC needs \"All files access\" permission to save music files to your chosen folder.", - "@setupStorageAccessMessage": { - "description": "Explanation for storage access" - }, "setupStorageAccessMessageAndroid11": "Android 11+ requires \"All files access\" permission to save files to your chosen download folder.", "@setupStorageAccessMessageAndroid11": { "description": "Android 11+ specific explanation" @@ -831,10 +539,6 @@ } } }, - "setupSelectDownloadFolder": "ダウンロードフォルダを選択", - "@setupSelectDownloadFolder": { - "description": "Folder selection step title" - }, "setupUseDefaultFolder": "デフォルトのフォルダを使用しますか?", "@setupUseDefaultFolder": { "description": "Dialog title for default folder" @@ -883,26 +587,6 @@ "@setupDownloadInFlac": { "description": "App tagline in setup" }, - "setupStepStorage": "ストレージ", - "@setupStepStorage": { - "description": "Setup step indicator - storage" - }, - "setupStepNotification": "通知", - "@setupStepNotification": { - "description": "Setup step indicator - notification" - }, - "setupStepFolder": "フォルダ", - "@setupStepFolder": { - "description": "Setup step indicator - folder" - }, - "setupStepSpotify": "Spotify", - "@setupStepSpotify": { - "description": "Setup step indicator - Spotify API" - }, - "setupStepPermission": "権限", - "@setupStepPermission": { - "description": "Setup step indicator - permission" - }, "setupStorageGranted": "ストレージの権限が許可されました!", "@setupStorageGranted": { "description": "Success message for storage permission" @@ -923,14 +607,6 @@ "@setupNotificationEnable": { "description": "Button to enable notifications" }, - "setupNotificationDescription": "Get notified when downloads complete or require attention.", - "@setupNotificationDescription": { - "description": "Explanation for notifications" - }, - "setupFolderSelected": "ダウンロードフォルダが選択済みです!", - "@setupFolderSelected": { - "description": "Success message for folder selection" - }, "setupFolderChoose": "ダウンロードフォルダを選択", "@setupFolderChoose": { "description": "Button to choose folder" @@ -939,58 +615,14 @@ "@setupFolderDescription": { "description": "Explanation for folder selection" }, - "setupChangeFolder": "フォルダを変更", - "@setupChangeFolder": { - "description": "Button to change selected folder" - }, "setupSelectFolder": "フォルダを選択", "@setupSelectFolder": { "description": "Button to select folder" }, - "setupSpotifyApiOptional": "Spotify API (任意)", - "@setupSpotifyApiOptional": { - "description": "Spotify API step title" - }, - "setupSpotifyApiDescription": "Add your Spotify API credentials for better search results and access to Spotify-exclusive content.", - "@setupSpotifyApiDescription": { - "description": "Explanation for Spotify API" - }, - "setupUseSpotifyApi": "Spotify API を使用する", - "@setupUseSpotifyApi": { - "description": "Toggle to enable Spotify API" - }, - "setupEnterCredentialsBelow": "以下に認証情報を入力してください", - "@setupEnterCredentialsBelow": { - "description": "Prompt to enter credentials" - }, - "setupUsingDeezer": "Deezer を使用中 (アカウントは不要です)", - "@setupUsingDeezer": { - "description": "Status when using Deezer" - }, - "setupEnterClientId": "Spotify クライアント ID を入力", - "@setupEnterClientId": { - "description": "Placeholder for client ID field" - }, - "setupEnterClientSecret": "Spotify クライアントシークレットを入力", - "@setupEnterClientSecret": { - "description": "Placeholder for client secret field" - }, - "setupGetFreeCredentials": "Spotify 開発者ダッシュボードから無料の API 認証情報を取得します。", - "@setupGetFreeCredentials": { - "description": "Info about getting Spotify credentials" - }, "setupEnableNotifications": "通知を有効化する", "@setupEnableNotifications": { "description": "Button to enable notifications" }, - "setupProceedToNextStep": "You can now proceed to the next step.", - "@setupProceedToNextStep": { - "description": "Message after completing a step" - }, - "setupNotificationProgressDescription": "You will receive download progress notifications.", - "@setupNotificationProgressDescription": { - "description": "Info about notification usage" - }, "setupNotificationBackgroundDescription": "Get notified about download progress and completion. This helps you track downloads when the app is in background.", "@setupNotificationBackgroundDescription": { "description": "Detailed notification explanation" @@ -999,10 +631,6 @@ "@setupSkipForNow": { "description": "Skip button text" }, - "setupBack": "戻る", - "@setupBack": { - "description": "Back button text" - }, "setupNext": "次へ", "@setupNext": { "description": "Next button text" @@ -1011,26 +639,14 @@ "@setupGetStarted": { "description": "Final setup button" }, - "setupSkipAndStart": "スキップと開始", - "@setupSkipAndStart": { - "description": "Skip setup and start app" - }, "setupAllowAccessToManageFiles": "Please enable \"Allow access to manage all files\" in the next screen.", "@setupAllowAccessToManageFiles": { "description": "Instruction for file access permission" }, - "setupGetCredentialsFromSpotify": "developer.spotify.com から認証情報を取得します", - "@setupGetCredentialsFromSpotify": { - "description": "Link text for Spotify developer portal" - }, "dialogCancel": "キャンセル", "@dialogCancel": { "description": "Dialog button - cancel action" }, - "dialogOk": "OK", - "@dialogOk": { - "description": "Dialog button - confirm/acknowledge" - }, "dialogSave": "保存", "@dialogSave": { "description": "Dialog button - save changes" @@ -1043,26 +659,10 @@ "@dialogRetry": { "description": "Dialog button - retry action" }, - "dialogClose": "閉じる", - "@dialogClose": { - "description": "Dialog button - close dialog" - }, - "dialogYes": "はい", - "@dialogYes": { - "description": "Dialog button - confirm yes" - }, - "dialogNo": "いいえ", - "@dialogNo": { - "description": "Dialog button - confirm no" - }, "dialogClear": "消去", "@dialogClear": { "description": "Dialog button - clear items" }, - "dialogConfirm": "続行", - "@dialogConfirm": { - "description": "Dialog button - confirm action" - }, "dialogDone": "完了", "@dialogDone": { "description": "Dialog button - action completed" @@ -1091,34 +691,10 @@ "@dialogUnsavedChanges": { "description": "Dialog message - unsaved changes" }, - "dialogDownloadFailed": "ダウンロードに失敗しました", - "@dialogDownloadFailed": { - "description": "Dialog title - download error" - }, - "dialogTrackLabel": "トラック:", - "@dialogTrackLabel": { - "description": "Label for track name in error dialog" - }, - "dialogArtistLabel": "アーティスト:", - "@dialogArtistLabel": { - "description": "Label for artist name in error dialog" - }, - "dialogErrorLabel": "エラー:", - "@dialogErrorLabel": { - "description": "Label for error message" - }, "dialogClearAll": "すべて消去", "@dialogClearAll": { "description": "Dialog title - clear all items" }, - "dialogClearAllDownloads": "Are you sure you want to clear all downloads?", - "@dialogClearAllDownloads": { - "description": "Dialog message - clear downloads confirmation" - }, - "dialogRemoveFromDevice": "デバイスから削除しますか?", - "@dialogRemoveFromDevice": { - "description": "Dialog title - delete file confirmation" - }, "dialogRemoveExtension": "拡張を削除", "@dialogRemoveExtension": { "description": "Dialog title - uninstall extension" @@ -1257,15 +833,6 @@ "@snackbarViewQueue": { "description": "Snackbar action - view download queue" }, - "snackbarFailedToLoad": "読み込みに失敗: {error}", - "@snackbarFailedToLoad": { - "description": "Snackbar - loading error", - "placeholders": { - "error": { - "type": "String" - } - } - }, "snackbarUrlCopied": "{platform} の URL をクリップボードにコピーしました", "@snackbarUrlCopied": { "description": "Snackbar - URL copied", @@ -1326,16 +893,6 @@ "@errorRateLimitedMessage": { "description": "Error message - rate limit explanation" }, - "errorFailedToLoad": "Failed to load {item}", - "@errorFailedToLoad": { - "description": "Error message - loading failed", - "placeholders": { - "item": { - "type": "String", - "description": "Item that failed to load (album/playlist/etc)" - } - } - }, "errorNoTracksFound": "トラックがありません", "@errorNoTracksFound": { "description": "Error - search returned no results" @@ -1349,34 +906,6 @@ } } }, - "statusQueued": "キュー済み", - "@statusQueued": { - "description": "Download status - waiting in queue" - }, - "statusDownloading": "ダウンロード中", - "@statusDownloading": { - "description": "Download status - in progress" - }, - "statusFinalizing": "終了処理中", - "@statusFinalizing": { - "description": "Download status - writing metadata" - }, - "statusCompleted": "完了しました", - "@statusCompleted": { - "description": "Download status - finished" - }, - "statusFailed": "失敗しました", - "@statusFailed": { - "description": "Download status - error occurred" - }, - "statusSkipped": "スキップしました", - "@statusSkipped": { - "description": "Download status - already exists" - }, - "statusPaused": "一時停止中", - "@statusPaused": { - "description": "Download status - paused" - }, "actionPause": "一時停止", "@actionPause": { "description": "Action button - pause download" @@ -1389,14 +918,6 @@ "@actionCancel": { "description": "Action button - cancel operation" }, - "actionStop": "停止", - "@actionStop": { - "description": "Action button - stop operation" - }, - "actionSelect": "選択", - "@actionSelect": { - "description": "Action button - enter selection mode" - }, "actionSelectAll": "すべて選択", "@actionSelectAll": { "description": "Action button - select all items" @@ -1405,14 +926,6 @@ "@actionDeselect": { "description": "Action button - deselect all" }, - "actionPaste": "貼り付け", - "@actionPaste": { - "description": "Action button - paste from clipboard" - }, - "actionImportCsv": "CSV をインポート", - "@actionImportCsv": { - "description": "Action button - import CSV file" - }, "actionRemoveCredentials": "認証情報を削除", "@actionRemoveCredentials": { "description": "Action button - delete Spotify credentials" @@ -1434,19 +947,6 @@ "@selectionAllSelected": { "description": "Status - all items selected" }, - "selectionTapToSelect": "トラックをタップで選択", - "@selectionTapToSelect": { - "description": "Hint - how to select items" - }, - "selectionDeleteTracks": "{count} {count, plural, =1{個のトラック} other{個のトラック}}を削除", - "@selectionDeleteTracks": { - "description": "Delete button with count", - "placeholders": { - "count": { - "type": "int" - } - } - }, "selectionSelectToDelete": "トラックを選択で削除", "@selectionSelectToDelete": { "description": "Placeholder when nothing selected" @@ -1487,55 +987,10 @@ "@tooltipPlay": { "description": "Tooltip - play button" }, - "tooltipCancel": "キャンセル", - "@tooltipCancel": { - "description": "Tooltip - cancel button" - }, - "tooltipStop": "停止", - "@tooltipStop": { - "description": "Tooltip - stop button" - }, - "tooltipRetry": "再試行", - "@tooltipRetry": { - "description": "Tooltip - retry button" - }, - "tooltipRemove": "削除", - "@tooltipRemove": { - "description": "Tooltip - remove button" - }, - "tooltipClear": "消去", - "@tooltipClear": { - "description": "Tooltip - clear button" - }, - "tooltipPaste": "貼り付け", - "@tooltipPaste": { - "description": "Tooltip - paste button" - }, "filenameFormat": "ファイル名の形式", "@filenameFormat": { "description": "Setting title - filename pattern" }, - "filenameFormatPreview": "プレビュー: {preview}", - "@filenameFormatPreview": { - "description": "Preview of filename pattern", - "placeholders": { - "preview": { - "type": "String" - } - } - }, - "filenameAvailablePlaceholders": "利用可能なプレースホルダー:", - "@filenameAvailablePlaceholders": { - "description": "Label for placeholder list" - }, - "filenameHint": "{artist} - {title}", - "@filenameHint": { - "description": "Default filename format hint" - }, - "folderOrganization": "フォルダ構成", - "@folderOrganization": { - "description": "Setting title - folder structure" - }, "folderOrganizationNone": "構成がありません", "@folderOrganizationNone": { "description": "Folder option - flat structure" @@ -1576,27 +1031,10 @@ "@updateAvailable": { "description": "Update dialog title" }, - "updateNewVersion": "バージョン {version} が利用可能です", - "@updateNewVersion": { - "description": "Update available message", - "placeholders": { - "version": { - "type": "String" - } - } - }, - "updateDownload": "ダウンロード", - "@updateDownload": { - "description": "Update button - download update" - }, "updateLater": "後で", "@updateLater": { "description": "Update button - dismiss" }, - "updateChangelog": "更新履歴", - "@updateChangelog": { - "description": "Link to changelog" - }, "updateStartingDownload": "ダウンロードを開始中...", "@updateStartingDownload": { "description": "Update status - initializing" @@ -1637,14 +1075,6 @@ "@updateDontRemind": { "description": "Update button - skip this version" }, - "providerPriority": "プロバイダーの優先度", - "@providerPriority": { - "description": "Setting title - download provider order" - }, - "providerPrioritySubtitle": "ドラッグでダウンロードプロバイダーを並べ替え", - "@providerPrioritySubtitle": { - "description": "Subtitle for provider priority" - }, "providerPriorityTitle": "プロバイダーの優先度", "@providerPriorityTitle": { "description": "Provider priority page title" @@ -1665,14 +1095,6 @@ "@providerExtension": { "description": "Label for extension-provided providers" }, - "metadataProviderPriority": "メタデータプロバイダーの優先度", - "@metadataProviderPriority": { - "description": "Setting title - metadata provider order" - }, - "metadataProviderPrioritySubtitle": "Order used when fetching track metadata", - "@metadataProviderPrioritySubtitle": { - "description": "Subtitle for metadata priority" - }, "metadataProviderPriorityTitle": "メタデータの優先度", "@metadataProviderPriorityTitle": { "description": "Metadata priority page title" @@ -1697,22 +1119,6 @@ "@logTitle": { "description": "Logs screen title" }, - "logCopy": "ログをコピー", - "@logCopy": { - "description": "Action - copy logs to clipboard" - }, - "logClear": "ログを消去", - "@logClear": { - "description": "Action - delete all logs" - }, - "logShare": "ログを共有", - "@logShare": { - "description": "Action - share logs file" - }, - "logEmpty": "まだログはありません", - "@logEmpty": { - "description": "Empty state title" - }, "logCopied": "ログをクリップボードにコピーしました", "@logCopied": { "description": "Snackbar - logs copied" @@ -1745,22 +1151,6 @@ "@logClearLogsMessage": { "description": "Clear logs confirmation message" }, - "logIspBlocking": "ISP のブロックを検出しました", - "@logIspBlocking": { - "description": "Error category - ISP blocking" - }, - "logRateLimited": "レート制限", - "@logRateLimited": { - "description": "Error category - rate limiting" - }, - "logNetworkError": "ネットワークエラー", - "@logNetworkError": { - "description": "Error category - network issues" - }, - "logTrackNotFound": "トラックがありません", - "@logTrackNotFound": { - "description": "Error category - missing tracks" - }, "logFilterBySeverity": "Filter logs by severity", "@logFilterBySeverity": { "description": "Filter dialog title" @@ -1773,60 +1163,6 @@ "@logNoLogsYetSubtitle": { "description": "Empty state subtitle" }, - "logIssueSummary": "問題の概要", - "@logIssueSummary": { - "description": "Section header for error summary" - }, - "logIspBlockingDescription": "ISP がダウンロードサービスのアクセスをブロックしている可能性があります", - "@logIspBlockingDescription": { - "description": "ISP blocking explanation" - }, - "logIspBlockingSuggestion": "VPN を使用するか DNS を 1.1.1.1 または 8.8.8.8 に変更をお試しください", - "@logIspBlockingSuggestion": { - "description": "ISP blocking fix suggestion" - }, - "logRateLimitedDescription": "サービスへのリクエストが多すぎます", - "@logRateLimitedDescription": { - "description": "Rate limit explanation" - }, - "logRateLimitedSuggestion": "Wait a few minutes before trying again", - "@logRateLimitedSuggestion": { - "description": "Rate limit fix suggestion" - }, - "logNetworkErrorDescription": "接続の問題が検出されました", - "@logNetworkErrorDescription": { - "description": "Network error explanation" - }, - "logNetworkErrorSuggestion": "インターネット接続を確認してください", - "@logNetworkErrorSuggestion": { - "description": "Network error fix suggestion" - }, - "logTrackNotFoundDescription": "Some tracks could not be found on download services", - "@logTrackNotFoundDescription": { - "description": "Track not found explanation" - }, - "logTrackNotFoundSuggestion": "The track may not be available in lossless quality", - "@logTrackNotFoundSuggestion": { - "description": "Track not found explanation" - }, - "logTotalErrors": "エラーの合計: {count}", - "@logTotalErrors": { - "description": "Error count display", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "logAffected": "影響: {domains}", - "@logAffected": { - "description": "Affected domains display", - "placeholders": { - "domains": { - "type": "String" - } - } - }, "logEntriesFiltered": "エントリー ({count} 個をフィルター済み)", "@logEntriesFiltered": { "description": "Log count with filter active", @@ -1969,10 +1305,6 @@ "@appearanceLanguage": { "description": "Language setting title" }, - "appearanceLanguageSubtitle": "お好みの言語を選択してください", - "@appearanceLanguageSubtitle": { - "description": "Language setting subtitle" - }, "settingsAppearanceSubtitle": "テーマ、カラー、画面", "@settingsAppearanceSubtitle": { "description": "Appearance settings description" @@ -2001,10 +1333,6 @@ "@pressBackAgainToExit": { "description": "Exit confirmation message" }, - "tracksHeader": "トラック", - "@tracksHeader": { - "description": "Section header for track list" - }, "downloadAllCount": "すべてダウンロード ({count})", "@downloadAllCount": { "description": "Download all button with count", @@ -2151,15 +1479,6 @@ "@trackDeleteConfirmMessage": { "description": "Delete confirmation message" }, - "trackCannotOpen": "開けません: {message}", - "@trackCannotOpen": { - "description": "Error opening file", - "placeholders": { - "message": { - "type": "String" - } - } - }, "dateToday": "今日", "@dateToday": { "description": "Relative date - today" @@ -2195,22 +1514,6 @@ } } }, - "concurrentSequential": "Sequential", - "@concurrentSequential": { - "description": "Download mode - one at a time" - }, - "concurrentParallel2": "2 並列", - "@concurrentParallel2": { - "description": "Download mode - 2 simultaneous" - }, - "concurrentParallel3": "3 並列", - "@concurrentParallel3": { - "description": "Download mode - 3 simultaneous" - }, - "tapToSeeError": "タップでエラーの詳細を表示", - "@tapToSeeError": { - "description": "Tooltip for failed download" - }, "storeFilterAll": "すべて", "@storeFilterAll": { "description": "Store filter - all extensions" @@ -2239,18 +1542,6 @@ "@storeClearFilters": { "description": "Button to clear all filters" }, - "storeNoResults": "拡張がありません", - "@storeNoResults": { - "description": "Empty state when no extensions match filters" - }, - "extensionProviderPriority": "プロバイダーの優先度", - "@extensionProviderPriority": { - "description": "Extension capability - provider priority" - }, - "extensionInstallButton": "拡張をインストール", - "@extensionInstallButton": { - "description": "Button to install extension" - }, "extensionDefaultProvider": "デフォルト (Deezer/Spotify)", "@extensionDefaultProvider": { "description": "Default search provider option" @@ -2450,46 +1741,6 @@ "@qualityHiResFlacMaxSubtitle": { "description": "Technical spec for hi-res max" }, - "qualityLossy": "Lossy", - "@qualityLossy": { - "description": "Quality option - lossy format (MP3/Opus)" - }, - "qualityLossyMp3Subtitle": "MP3 320kbps (converted from FLAC)", - "@qualityLossyMp3Subtitle": { - "description": "Technical spec for lossy MP3" - }, - "qualityLossyOpusSubtitle": "Opus 128kbps (converted from FLAC)", - "@qualityLossyOpusSubtitle": { - "description": "Technical spec for lossy Opus" - }, - "enableLossyOption": "Enable Lossy Option", - "@enableLossyOption": { - "description": "Setting - enable lossy quality option" - }, - "enableLossyOptionSubtitleOn": "Lossy quality option is available", - "@enableLossyOptionSubtitleOn": { - "description": "Subtitle when lossy is enabled" - }, - "enableLossyOptionSubtitleOff": "Downloads FLAC then converts to lossy format", - "@enableLossyOptionSubtitleOff": { - "description": "Subtitle when lossy is disabled" - }, - "lossyFormat": "Lossy Format", - "@lossyFormat": { - "description": "Setting - choose lossy format" - }, - "lossyFormatDescription": "Choose the lossy format for conversion", - "@lossyFormatDescription": { - "description": "Description for lossy format picker" - }, - "lossyFormatMp3Subtitle": "320kbps, best compatibility", - "@lossyFormatMp3Subtitle": { - "description": "MP3 format description" - }, - "lossyFormatOpusSubtitle": "128kbps, better quality at smaller size", - "@lossyFormatOpusSubtitle": { - "description": "Opus format description" - }, "qualityNote": "実際の品質はサービスからのトラックの可用性に依存します", "@qualityNote": { "description": "Note about quality availability" @@ -2518,14 +1769,6 @@ "@downloadUseAlbumArtistForFolders": { "description": "Setting - choose whether artist folders use Album Artist or Track Artist" }, - "downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available", - "@downloadUseAlbumArtistForFoldersAlbumSubtitle": { - "description": "Subtitle when Album Artist is used for folder naming" - }, - "downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only", - "@downloadUseAlbumArtistForFoldersTrackSubtitle": { - "description": "Subtitle when Track Artist is used for folder naming" - }, "downloadUsePrimaryArtistOnly": "Primary artist only for folders", "@downloadUsePrimaryArtistOnly": { "description": "Setting - strip featured artists from folder name" @@ -2538,14 +1781,6 @@ "@downloadUsePrimaryArtistOnlyDisabled": { "description": "Subtitle when primary artist only is disabled" }, - "downloadSaveFormat": "形式を保存", - "@downloadSaveFormat": { - "description": "Setting - output file format" - }, - "downloadSelectService": "サービスを選択", - "@downloadSelectService": { - "description": "Dialog title - choose download service" - }, "downloadSelectQuality": "品質を選択", "@downloadSelectQuality": { "description": "Dialog title - choose audio quality" @@ -2554,66 +1789,6 @@ "@downloadFrom": { "description": "Label - download source" }, - "downloadDefaultQualityLabel": "デフォルトの品質", - "@downloadDefaultQualityLabel": { - "description": "Label - default quality setting" - }, - "downloadBestAvailable": "おすすめ", - "@downloadBestAvailable": { - "description": "Quality option - highest available" - }, - "folderNone": "なし", - "@folderNone": { - "description": "Folder option - no organization" - }, - "folderNoneSubtitle": "すべてのファイルをダウンロードフォルダに保存します", - "@folderNoneSubtitle": { - "description": "Subtitle for no folder organization" - }, - "folderArtist": "アーティスト", - "@folderArtist": { - "description": "Folder option - by artist" - }, - "folderArtistSubtitle": "アーティスト名/ファイル名", - "@folderArtistSubtitle": { - "description": "Folder structure example" - }, - "folderAlbum": "アルバム", - "@folderAlbum": { - "description": "Folder option - by album" - }, - "folderAlbumSubtitle": "アルバム名/ファイル名", - "@folderAlbumSubtitle": { - "description": "Folder structure example" - }, - "folderArtistAlbum": "アーティスト/アルバム", - "@folderArtistAlbum": { - "description": "Folder option - nested" - }, - "folderArtistAlbumSubtitle": "アーティスト名/アルバム名/ファイル名", - "@folderArtistAlbumSubtitle": { - "description": "Folder structure example" - }, - "serviceTidal": "Tidal", - "@serviceTidal": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceQobuz": "Qobuz", - "@serviceQobuz": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceAmazon": "Amazon", - "@serviceAmazon": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceDeezer": "Deezer", - "@serviceDeezer": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceSpotify": "Spotify", - "@serviceSpotify": { - "description": "Service name - DO NOT TRANSLATE" - }, "appearanceAmoledDark": "AMOLED ダーク", "@appearanceAmoledDark": { "description": "Theme option - pure black" @@ -2622,18 +1797,6 @@ "@appearanceAmoledDarkSubtitle": { "description": "Subtitle for AMOLED dark" }, - "appearanceChooseAccentColor": "アクセントカラーを選択", - "@appearanceChooseAccentColor": { - "description": "Color picker dialog title" - }, - "appearanceChooseTheme": "テーマモード", - "@appearanceChooseTheme": { - "description": "Theme picker dialog title" - }, - "queueTitle": "ダウンロードキュー", - "@queueTitle": { - "description": "Queue screen title" - }, "queueClearAll": "すべて消去", "@queueClearAll": { "description": "Button - clear all queue items" @@ -2642,22 +1805,6 @@ "@queueClearAllMessage": { "description": "Clear queue confirmation" }, - "queueExportFailed": "Export", - "@queueExportFailed": { - "description": "Button - export failed downloads to TXT" - }, - "queueExportFailedSuccess": "Failed downloads exported to TXT file", - "@queueExportFailedSuccess": { - "description": "Success message after exporting failed downloads" - }, - "queueExportFailedClear": "Clear Failed", - "@queueExportFailedClear": { - "description": "Action to clear failed downloads after export" - }, - "queueExportFailedError": "Failed to export downloads", - "@queueExportFailedError": { - "description": "Error message when export fails" - }, "settingsAutoExportFailed": "Auto-export failed downloads", "@settingsAutoExportFailed": { "description": "Setting toggle for auto-export" @@ -2682,38 +1829,6 @@ "@settingsDownloadNetworkSubtitle": { "description": "Subtitle explaining network preference" }, - "queueEmpty": "キューにダウンロードがありません", - "@queueEmpty": { - "description": "Empty queue state title" - }, - "queueEmptySubtitle": "ホーム画面からトラックを追加", - "@queueEmptySubtitle": { - "description": "Empty queue state subtitle" - }, - "queueClearCompleted": "完了済みを消去", - "@queueClearCompleted": { - "description": "Button - clear finished downloads" - }, - "queueDownloadFailed": "ダウンロードに失敗しました", - "@queueDownloadFailed": { - "description": "Error dialog title" - }, - "queueTrackLabel": "トラック:", - "@queueTrackLabel": { - "description": "Label in error dialog" - }, - "queueArtistLabel": "アーティスト:", - "@queueArtistLabel": { - "description": "Label in error dialog" - }, - "queueErrorLabel": "エラー:", - "@queueErrorLabel": { - "description": "Label in error dialog" - }, - "queueUnknownError": "不明なエラー", - "@queueUnknownError": { - "description": "Fallback error message" - }, "albumFolderArtistAlbum": "アーティスト / アルバム", "@albumFolderArtistAlbum": { "description": "Album folder option" @@ -2767,19 +1882,6 @@ } } }, - "downloadedAlbumTracksHeader": "トラック", - "@downloadedAlbumTracksHeader": { - "description": "Section header for tracks" - }, - "downloadedAlbumDownloadedCount": "{count} 個をダウンロード済み", - "@downloadedAlbumDownloadedCount": { - "description": "Downloaded tracks count badge", - "placeholders": { - "count": { - "type": "int" - } - } - }, "downloadedAlbumSelectedCount": "{count} 個を選択済み", "@downloadedAlbumSelectedCount": { "description": "Selection count indicator", @@ -2820,10 +1922,6 @@ } } }, - "utilityFunctions": "ユーティリティ機能", - "@utilityFunctions": { - "description": "Extension capability - utility functions" - }, "recentTypeArtist": "アーティスト", "@recentTypeArtist": { "description": "Recent access item type - artist" @@ -2858,16 +1956,6 @@ } } }, - "errorGeneric": "エラー: {message}", - "@errorGeneric": { - "description": "Generic error message format", - "placeholders": { - "message": { - "type": "String", - "description": "Error message" - } - } - }, "discographyDownload": "ディスコグラフィをダウンロード", "@discographyDownload": { "description": "Button - download artist discography" @@ -3034,10 +2122,6 @@ "@libraryTitle": { "description": "Library settings page title" }, - "libraryStatus": "Library Status", - "@libraryStatus": { - "description": "Section header for library status" - }, "libraryScanSettings": "Scan Settings", "@libraryScanSettings": { "description": "Section header for scan settings" @@ -3114,15 +2198,6 @@ "@libraryAboutDescription": { "description": "Description of local library feature" }, - "libraryTracksCount": "{count} tracks", - "@libraryTracksCount": { - "description": "Track count in library", - "placeholders": { - "count": { - "type": "int" - } - } - }, "libraryLastScanned": "Last scanned: {time}", "@libraryLastScanned": { "description": "Last scan time display", @@ -3237,26 +2312,6 @@ "@libraryFilterFormat": { "description": "Filter section - file format" }, - "libraryFilterDate": "Date Added", - "@libraryFilterDate": { - "description": "Filter section - date range" - }, - "libraryFilterDateToday": "Today", - "@libraryFilterDateToday": { - "description": "Filter option - today only" - }, - "libraryFilterDateWeek": "This Week", - "@libraryFilterDateWeek": { - "description": "Filter option - this week" - }, - "libraryFilterDateMonth": "This Month", - "@libraryFilterDateMonth": { - "description": "Filter option - this month" - }, - "libraryFilterDateYear": "This Year", - "@libraryFilterDateYear": { - "description": "Filter option - this year" - }, "libraryFilterSort": "Sort", "@libraryFilterSort": { "description": "Filter section - sort order" @@ -3269,15 +2324,6 @@ "@libraryFilterSortOldest": { "description": "Sort option - oldest first" }, - "libraryFilterActive": "{count} filter(s) active", - "@libraryFilterActive": { - "description": "Badge showing number of active filters", - "placeholders": { - "count": { - "type": "int" - } - } - }, "timeJustNow": "Just now", "@timeJustNow": { "description": "Relative time - less than a minute ago" @@ -3300,106 +2346,6 @@ } } }, - "storageSwitchTitle": "Switch Storage Mode", - "@storageSwitchTitle": { - "description": "Dialog title when switching storage mode" - }, - "storageSwitchToSafTitle": "Switch to SAF Storage?", - "@storageSwitchToSafTitle": { - "description": "Dialog title when switching to SAF" - }, - "storageSwitchToAppTitle": "Switch to App Storage?", - "@storageSwitchToAppTitle": { - "description": "Dialog title when switching to app storage" - }, - "storageSwitchToSafMessage": "Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.", - "@storageSwitchToSafMessage": { - "description": "Explanation when switching to SAF" - }, - "storageSwitchToAppMessage": "Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.", - "@storageSwitchToAppMessage": { - "description": "Explanation when switching to app storage" - }, - "storageSwitchExistingDownloads": "Existing Downloads", - "@storageSwitchExistingDownloads": { - "description": "Section header for existing downloads info" - }, - "storageSwitchExistingDownloadsInfo": "{count} tracks in {mode} storage", - "@storageSwitchExistingDownloadsInfo": { - "description": "Info about existing downloads count", - "placeholders": { - "count": { - "type": "int" - }, - "mode": { - "type": "String" - } - } - }, - "storageSwitchNewDownloads": "New Downloads", - "@storageSwitchNewDownloads": { - "description": "Section header for new downloads info" - }, - "storageSwitchNewDownloadsLocation": "Will be saved to: {location}", - "@storageSwitchNewDownloadsLocation": { - "description": "Shows where new downloads will go", - "placeholders": { - "location": { - "type": "String" - } - } - }, - "storageSwitchContinue": "Continue", - "@storageSwitchContinue": { - "description": "Button to proceed with storage switch" - }, - "storageSwitchSelectFolder": "Select SAF Folder", - "@storageSwitchSelectFolder": { - "description": "Button to select SAF folder" - }, - "storageAppStorage": "App Storage", - "@storageAppStorage": { - "description": "Label for app storage mode" - }, - "storageSafStorage": "SAF Storage", - "@storageSafStorage": { - "description": "Label for SAF storage mode" - }, - "storageModeBadge": "Storage: {mode}", - "@storageModeBadge": { - "description": "Badge showing storage mode for a track", - "placeholders": { - "mode": { - "type": "String" - } - } - }, - "storageStatsTitle": "Storage Statistics", - "@storageStatsTitle": { - "description": "Section title for storage stats" - }, - "storageStatsAppCount": "{count} tracks in App Storage", - "@storageStatsAppCount": { - "description": "Count of tracks in app storage", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "storageStatsSafCount": "{count} tracks in SAF Storage", - "@storageStatsSafCount": { - "description": "Count of tracks in SAF storage", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "storageModeInfo": "Your files are stored in multiple locations", - "@storageModeInfo": { - "description": "Info when user has files in both storage modes" - }, "tutorialWelcomeTitle": "Welcome to SpotiFLAC!", "@tutorialWelcomeTitle": { "description": "Tutorial welcome page title" @@ -3428,18 +2374,6 @@ "@tutorialSearchDesc": { "description": "Tutorial search page description" }, - "tutorialSearchTip1": "Paste a Spotify or Deezer URL directly in the search box", - "@tutorialSearchTip1": { - "description": "Tutorial search tip 1" - }, - "tutorialSearchTip2": "Or type the song name, artist, or album to search", - "@tutorialSearchTip2": { - "description": "Tutorial search tip 2" - }, - "tutorialSearchTip3": "Supports tracks, albums, playlists, and artist pages", - "@tutorialSearchTip3": { - "description": "Tutorial search tip 3" - }, "tutorialDownloadTitle": "Downloading Music", "@tutorialDownloadTitle": { "description": "Tutorial download page title" @@ -3448,18 +2382,6 @@ "@tutorialDownloadDesc": { "description": "Tutorial download page description" }, - "tutorialDownloadTip1": "Tap the download button next to any track to start downloading", - "@tutorialDownloadTip1": { - "description": "Tutorial download tip 1" - }, - "tutorialDownloadTip2": "Choose your preferred quality (FLAC, Hi-Res, or MP3)", - "@tutorialDownloadTip2": { - "description": "Tutorial download tip 2" - }, - "tutorialDownloadTip3": "Download entire albums or playlists with one tap", - "@tutorialDownloadTip3": { - "description": "Tutorial download tip 3" - }, "tutorialLibraryTitle": "Your Library", "@tutorialLibraryTitle": { "description": "Tutorial library page title" @@ -3524,10 +2446,6 @@ "@tutorialReadyMessage": { "description": "Tutorial completion message" }, - "tutorialExample": "EXAMPLE", - "@tutorialExample": { - "description": "Example label in tutorial" - }, "libraryForceFullScan": "Force Full Scan", "@libraryForceFullScan": { "description": "Button to force a complete rescan of library" @@ -3750,14 +2668,10 @@ "@trackSaveLyricsProgress": { "description": "Snackbar while saving lyrics to file" }, - "trackReEnrich": "Re-enrich Metadata", + "trackReEnrich": "Re-enrich", "@trackReEnrich": { "description": "Menu action - re-embed metadata into audio file" }, - "trackReEnrichSubtitle": "Re-embed metadata without re-downloading", - "@trackReEnrichSubtitle": { - "description": "Subtitle for re-enrich metadata action" - }, "trackReEnrichOnlineSubtitle": "Search metadata online and embed into file", "@trackReEnrichOnlineSubtitle": { "description": "Subtitle for re-enrich metadata action for local items" @@ -3868,5 +2782,22 @@ "trackConvertFailed": "Conversion failed", "@trackConvertFailed": { "description": "Snackbar when conversion fails" + }, + "downloadedAlbumDownloadedCount": "{count} 個をダウンロード済み", + "@downloadedAlbumDownloadedCount": { + "description": "Downloaded tracks count badge", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available", + "@downloadUseAlbumArtistForFoldersAlbumSubtitle": { + "description": "Subtitle when Album Artist is used for folder naming" + }, + "downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only", + "@downloadUseAlbumArtistForFoldersTrackSubtitle": { + "description": "Subtitle when Track Artist is used for folder naming" } -} \ No newline at end of file +} diff --git a/lib/l10n/arb/app_ko.arb b/lib/l10n/arb/app_ko.arb index 9d982e1a..22b488a3 100644 --- a/lib/l10n/arb/app_ko.arb +++ b/lib/l10n/arb/app_ko.arb @@ -5,10 +5,6 @@ "@appName": { "description": "App name - DO NOT TRANSLATE" }, - "appDescription": "Spotify 트랙을 Tidal, Qobuz, Amazon Music에서 무손실 음질로 다운로드하세요.", - "@appDescription": { - "description": "App description shown in about page" - }, "navHome": "Home", "@navHome": { "description": "Bottom navigation - Home tab" @@ -17,10 +13,6 @@ "@navLibrary": { "description": "Bottom navigation - Library tab" }, - "navHistory": "History", - "@navHistory": { - "description": "Bottom navigation - History tab (legacy)" - }, "navSettings": "Settings", "@navSettings": { "description": "Bottom navigation - Settings tab" @@ -33,20 +25,6 @@ "@homeTitle": { "description": "Home screen title" }, - "homeSearchHint": "Spotify URL을 붙여 넣거나 검색", - "@homeSearchHint": { - "description": "Placeholder text in search box" - }, - "homeSearchHintExtension": "{extensionName}에서 검색", - "@homeSearchHintExtension": { - "description": "Placeholder when extension search is active", - "placeholders": { - "extensionName": { - "type": "String", - "description": "Name of the active extension" - } - } - }, "homeSubtitle": "Spotify URL을 붙여 넣거나 검색", "@homeSubtitle": { "description": "Subtitle shown below search box" @@ -59,24 +37,6 @@ "@homeRecent": { "description": "Section header for recent searches" }, - "historyTitle": "기록", - "@historyTitle": { - "description": "History screen title" - }, - "historyDownloading": "다운로드 중... {count}", - "@historyDownloading": { - "description": "Tab showing active downloads count", - "placeholders": { - "count": { - "type": "int", - "description": "Number of active downloads" - } - } - }, - "historyDownloaded": "다운로드 목록", - "@historyDownloaded": { - "description": "Tab showing completed downloads" - }, "historyFilterAll": "All", "@historyFilterAll": { "description": "Filter chip - show all items" @@ -89,48 +49,6 @@ "@historyFilterSingles": { "description": "Filter chip - show singles only" }, - "historyTracksCount": "{count, plural,=1{1 track}other{{count}tracks}}", - "@historyTracksCount": { - "description": "Track count with plural form", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "historyAlbumsCount": "{count, plural,=1{1 album}other{{count} albums}}", - "@historyAlbumsCount": { - "description": "Album count with plural form", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "historyNoDownloads": "No download history", - "@historyNoDownloads": { - "description": "Empty state title" - }, - "historyNoDownloadsSubtitle": "Downloaded tracks will appear here", - "@historyNoDownloadsSubtitle": { - "description": "Empty state subtitle" - }, - "historyNoAlbums": "No album downloads", - "@historyNoAlbums": { - "description": "Empty state when filtering albums" - }, - "historyNoAlbumsSubtitle": "Download multiple tracks from an album to see them here", - "@historyNoAlbumsSubtitle": { - "description": "Empty state subtitle for albums filter" - }, - "historyNoSingles": "No single downloads", - "@historyNoSingles": { - "description": "Empty state when filtering singles" - }, - "historyNoSinglesSubtitle": "Single track downloads will appear here", - "@historyNoSinglesSubtitle": { - "description": "Empty state subtitle for singles filter" - }, "historySearchHint": "Search history...", "@historySearchHint": { "description": "Search bar placeholder in history" @@ -163,34 +81,6 @@ "@downloadTitle": { "description": "Download settings page title" }, - "downloadLocation": "Download Location", - "@downloadLocation": { - "description": "Setting for download folder" - }, - "downloadLocationSubtitle": "Choose where to save files", - "@downloadLocationSubtitle": { - "description": "Subtitle for download location" - }, - "downloadLocationDefault": "Default location", - "@downloadLocationDefault": { - "description": "Shown when using default folder" - }, - "downloadDefaultService": "Default Service", - "@downloadDefaultService": { - "description": "Setting for preferred download service (Tidal/Qobuz/Amazon)" - }, - "downloadDefaultServiceSubtitle": "Service used for downloads", - "@downloadDefaultServiceSubtitle": { - "description": "Subtitle for default service" - }, - "downloadDefaultQuality": "Default Quality", - "@downloadDefaultQuality": { - "description": "Setting for audio quality" - }, - "downloadAskQuality": "Ask Quality Before Download", - "@downloadAskQuality": { - "description": "Toggle to show quality picker" - }, "downloadAskQualitySubtitle": "Show quality picker for each download", "@downloadAskQualitySubtitle": { "description": "Subtitle for ask quality toggle" @@ -203,38 +93,10 @@ "@downloadFolderOrganization": { "description": "Setting for folder structure" }, - "downloadSeparateSingles": "Separate Singles", - "@downloadSeparateSingles": { - "description": "Toggle to separate single tracks" - }, - "downloadSeparateSinglesSubtitle": "Put single tracks in a separate folder", - "@downloadSeparateSinglesSubtitle": { - "description": "Subtitle for separate singles toggle" - }, - "qualityBest": "Best Available", - "@qualityBest": { - "description": "Audio quality option - highest available" - }, - "qualityFlac": "FLAC", - "@qualityFlac": { - "description": "Audio quality option - FLAC lossless" - }, - "quality320": "320 kbps", - "@quality320": { - "description": "Audio quality option - 320kbps MP3" - }, - "quality128": "128 kbps", - "@quality128": { - "description": "Audio quality option - 128kbps MP3" - }, "appearanceTitle": "Appearance", "@appearanceTitle": { "description": "Appearance settings page title" }, - "appearanceTheme": "Theme", - "@appearanceTheme": { - "description": "Theme mode setting" - }, "appearanceThemeSystem": "System", "@appearanceThemeSystem": { "description": "Follow system theme" @@ -255,10 +117,6 @@ "@appearanceDynamicColorSubtitle": { "description": "Subtitle for dynamic color" }, - "appearanceAccentColor": "Accent Color", - "@appearanceAccentColor": { - "description": "Custom accent color picker" - }, "appearanceHistoryView": "History View", "@appearanceHistoryView": { "description": "Layout style for history" @@ -275,10 +133,6 @@ "@optionsTitle": { "description": "Options settings page title" }, - "optionsSearchSource": "Search Source", - "@optionsSearchSource": { - "description": "Section for search provider settings" - }, "optionsPrimaryProvider": "Primary Provider", "@optionsPrimaryProvider": { "description": "Main search provider setting" @@ -438,22 +292,6 @@ "@extensionsTitle": { "description": "Extensions page title" }, - "extensionsInstalled": "Installed Extensions", - "@extensionsInstalled": { - "description": "Section header for installed extensions" - }, - "extensionsNone": "No extensions installed", - "@extensionsNone": { - "description": "Empty state title" - }, - "extensionsNoneSubtitle": "Install extensions from the Store tab", - "@extensionsNoneSubtitle": { - "description": "Empty state subtitle" - }, - "extensionsEnabled": "Enabled", - "@extensionsEnabled": { - "description": "Extension status - active" - }, "extensionsDisabled": "Disabled", "@extensionsDisabled": { "description": "Extension status - inactive" @@ -480,10 +318,6 @@ "@extensionsUninstall": { "description": "Uninstall extension button" }, - "extensionsSetAsSearch": "Set as Search Provider", - "@extensionsSetAsSearch": { - "description": "Use extension for search" - }, "storeTitle": "Extension Store", "@storeTitle": { "description": "Store screen title" @@ -580,10 +414,6 @@ "@aboutSocial": { "description": "Section for social links" }, - "aboutSupport": "Support", - "@aboutSupport": { - "description": "Section for support/donation links" - }, "aboutApp": "App", "@aboutApp": { "description": "Section for app info" @@ -604,14 +434,6 @@ "@aboutSjdonadoDesc": { "description": "Credit description for sjdonado" }, - "aboutDoubleDouble": "DoubleDouble", - "@aboutDoubleDouble": { - "description": "Name of Amazon API service - DO NOT TRANSLATE" - }, - "aboutDoubleDoubleDesc": "Amazing API for Amazon Music downloads. Thank you for making it free!", - "@aboutDoubleDoubleDesc": { - "description": "Credit for DoubleDouble API" - }, "aboutDabMusic": "DAB Music", "@aboutDabMusic": { "description": "Name of Qobuz API service - DO NOT TRANSLATE" @@ -632,35 +454,6 @@ "@aboutAppDescription": { "description": "App description in header card" }, - "albumTitle": "Album", - "@albumTitle": { - "description": "Album screen title" - }, - "albumTracks": "{count, plural, =1{1 track} other{{count} tracks}}", - "@albumTracks": { - "description": "Album track count", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "albumDownloadAll": "Download All", - "@albumDownloadAll": { - "description": "Button to download all tracks" - }, - "albumDownloadRemaining": "Download Remaining", - "@albumDownloadRemaining": { - "description": "Button to download remaining tracks" - }, - "playlistTitle": "Playlist", - "@playlistTitle": { - "description": "Playlist screen title" - }, - "artistTitle": "Artist", - "@artistTitle": { - "description": "Artist screen title" - }, "artistAlbums": "Albums", "@artistAlbums": { "description": "Section header for artist albums" @@ -673,15 +466,6 @@ "@artistCompilations": { "description": "Section header for compilations" }, - "artistReleases": "{count, plural, =1{1 release} other{{count} releases}}", - "@artistReleases": { - "description": "Artist release count", - "placeholders": { - "count": { - "type": "int" - } - } - }, "artistPopular": "Popular", "@artistPopular": { "description": "Section header for popular/top tracks" @@ -696,34 +480,6 @@ } } }, - "trackMetadataTitle": "Track Info", - "@trackMetadataTitle": { - "description": "Track metadata screen title" - }, - "trackMetadataArtist": "Artist", - "@trackMetadataArtist": { - "description": "Metadata field - artist name" - }, - "trackMetadataAlbum": "Album", - "@trackMetadataAlbum": { - "description": "Metadata field - album name" - }, - "trackMetadataDuration": "Duration", - "@trackMetadataDuration": { - "description": "Metadata field - track length" - }, - "trackMetadataQuality": "Quality", - "@trackMetadataQuality": { - "description": "Metadata field - audio quality" - }, - "trackMetadataPath": "File Path", - "@trackMetadataPath": { - "description": "Metadata field - file location" - }, - "trackMetadataDownloadedAt": "Downloaded", - "@trackMetadataDownloadedAt": { - "description": "Metadata field - download date" - }, "trackMetadataService": "Service", "@trackMetadataService": { "description": "Metadata field - download service used" @@ -740,54 +496,10 @@ "@trackMetadataDelete": { "description": "Action button - delete track" }, - "trackMetadataRedownload": "Re-download", - "@trackMetadataRedownload": { - "description": "Action button - download again" - }, - "trackMetadataOpenFolder": "Open Folder", - "@trackMetadataOpenFolder": { - "description": "Action button - open containing folder" - }, - "setupTitle": "Welcome to SpotiFLAC", - "@setupTitle": { - "description": "Setup wizard title" - }, - "setupSubtitle": "Let's get you started", - "@setupSubtitle": { - "description": "Setup wizard subtitle" - }, - "setupStoragePermission": "Storage Permission", - "@setupStoragePermission": { - "description": "Storage permission step title" - }, - "setupStoragePermissionSubtitle": "Required to save downloaded files", - "@setupStoragePermissionSubtitle": { - "description": "Explanation for storage permission" - }, - "setupStoragePermissionGranted": "Permission granted", - "@setupStoragePermissionGranted": { - "description": "Status when permission granted" - }, - "setupStoragePermissionDenied": "Permission denied", - "@setupStoragePermissionDenied": { - "description": "Status when permission denied" - }, "setupGrantPermission": "Grant Permission", "@setupGrantPermission": { "description": "Button to request permission" }, - "setupDownloadLocation": "Download Location", - "@setupDownloadLocation": { - "description": "Download folder step title" - }, - "setupChooseFolder": "Choose Folder", - "@setupChooseFolder": { - "description": "Button to pick folder" - }, - "setupContinue": "Continue", - "@setupContinue": { - "description": "Continue to next step button" - }, "setupSkip": "Skip for now", "@setupSkip": { "description": "Skip current step button" @@ -796,10 +508,6 @@ "@setupStorageAccessRequired": { "description": "Title when storage access needed" }, - "setupStorageAccessMessage": "SpotiFLAC needs \"All files access\" permission to save music files to your chosen folder.", - "@setupStorageAccessMessage": { - "description": "Explanation for storage access" - }, "setupStorageAccessMessageAndroid11": "Android 11+ requires \"All files access\" permission to save files to your chosen download folder.", "@setupStorageAccessMessageAndroid11": { "description": "Android 11+ specific explanation" @@ -831,10 +539,6 @@ } } }, - "setupSelectDownloadFolder": "Select Download Folder", - "@setupSelectDownloadFolder": { - "description": "Folder selection step title" - }, "setupUseDefaultFolder": "Use Default Folder?", "@setupUseDefaultFolder": { "description": "Dialog title for default folder" @@ -883,26 +587,6 @@ "@setupDownloadInFlac": { "description": "App tagline in setup" }, - "setupStepStorage": "Storage", - "@setupStepStorage": { - "description": "Setup step indicator - storage" - }, - "setupStepNotification": "Notification", - "@setupStepNotification": { - "description": "Setup step indicator - notification" - }, - "setupStepFolder": "Folder", - "@setupStepFolder": { - "description": "Setup step indicator - folder" - }, - "setupStepSpotify": "Spotify", - "@setupStepSpotify": { - "description": "Setup step indicator - Spotify API" - }, - "setupStepPermission": "Permission", - "@setupStepPermission": { - "description": "Setup step indicator - permission" - }, "setupStorageGranted": "Storage Permission Granted!", "@setupStorageGranted": { "description": "Success message for storage permission" @@ -923,14 +607,6 @@ "@setupNotificationEnable": { "description": "Button to enable notifications" }, - "setupNotificationDescription": "Get notified when downloads complete or require attention.", - "@setupNotificationDescription": { - "description": "Explanation for notifications" - }, - "setupFolderSelected": "Download Folder Selected!", - "@setupFolderSelected": { - "description": "Success message for folder selection" - }, "setupFolderChoose": "Choose Download Folder", "@setupFolderChoose": { "description": "Button to choose folder" @@ -939,58 +615,14 @@ "@setupFolderDescription": { "description": "Explanation for folder selection" }, - "setupChangeFolder": "Change Folder", - "@setupChangeFolder": { - "description": "Button to change selected folder" - }, "setupSelectFolder": "Select Folder", "@setupSelectFolder": { "description": "Button to select folder" }, - "setupSpotifyApiOptional": "Spotify API (Optional)", - "@setupSpotifyApiOptional": { - "description": "Spotify API step title" - }, - "setupSpotifyApiDescription": "Add your Spotify API credentials for better search results and access to Spotify-exclusive content.", - "@setupSpotifyApiDescription": { - "description": "Explanation for Spotify API" - }, - "setupUseSpotifyApi": "Use Spotify API", - "@setupUseSpotifyApi": { - "description": "Toggle to enable Spotify API" - }, - "setupEnterCredentialsBelow": "Enter your credentials below", - "@setupEnterCredentialsBelow": { - "description": "Prompt to enter credentials" - }, - "setupUsingDeezer": "Using Deezer (no account needed)", - "@setupUsingDeezer": { - "description": "Status when using Deezer" - }, - "setupEnterClientId": "Enter Spotify Client ID", - "@setupEnterClientId": { - "description": "Placeholder for client ID field" - }, - "setupEnterClientSecret": "Enter Spotify Client Secret", - "@setupEnterClientSecret": { - "description": "Placeholder for client secret field" - }, - "setupGetFreeCredentials": "Get your free API credentials from the Spotify Developer Dashboard.", - "@setupGetFreeCredentials": { - "description": "Info about getting Spotify credentials" - }, "setupEnableNotifications": "Enable Notifications", "@setupEnableNotifications": { "description": "Button to enable notifications" }, - "setupProceedToNextStep": "You can now proceed to the next step.", - "@setupProceedToNextStep": { - "description": "Message after completing a step" - }, - "setupNotificationProgressDescription": "You will receive download progress notifications.", - "@setupNotificationProgressDescription": { - "description": "Info about notification usage" - }, "setupNotificationBackgroundDescription": "Get notified about download progress and completion. This helps you track downloads when the app is in background.", "@setupNotificationBackgroundDescription": { "description": "Detailed notification explanation" @@ -999,10 +631,6 @@ "@setupSkipForNow": { "description": "Skip button text" }, - "setupBack": "Back", - "@setupBack": { - "description": "Back button text" - }, "setupNext": "Next", "@setupNext": { "description": "Next button text" @@ -1011,26 +639,14 @@ "@setupGetStarted": { "description": "Final setup button" }, - "setupSkipAndStart": "Skip & Start", - "@setupSkipAndStart": { - "description": "Skip setup and start app" - }, "setupAllowAccessToManageFiles": "Please enable \"Allow access to manage all files\" in the next screen.", "@setupAllowAccessToManageFiles": { "description": "Instruction for file access permission" }, - "setupGetCredentialsFromSpotify": "Get credentials from developer.spotify.com", - "@setupGetCredentialsFromSpotify": { - "description": "Link text for Spotify developer portal" - }, "dialogCancel": "Cancel", "@dialogCancel": { "description": "Dialog button - cancel action" }, - "dialogOk": "OK", - "@dialogOk": { - "description": "Dialog button - confirm/acknowledge" - }, "dialogSave": "Save", "@dialogSave": { "description": "Dialog button - save changes" @@ -1043,26 +659,10 @@ "@dialogRetry": { "description": "Dialog button - retry action" }, - "dialogClose": "Close", - "@dialogClose": { - "description": "Dialog button - close dialog" - }, - "dialogYes": "Yes", - "@dialogYes": { - "description": "Dialog button - confirm yes" - }, - "dialogNo": "No", - "@dialogNo": { - "description": "Dialog button - confirm no" - }, "dialogClear": "Clear", "@dialogClear": { "description": "Dialog button - clear items" }, - "dialogConfirm": "Confirm", - "@dialogConfirm": { - "description": "Dialog button - confirm action" - }, "dialogDone": "Done", "@dialogDone": { "description": "Dialog button - action completed" @@ -1091,34 +691,10 @@ "@dialogUnsavedChanges": { "description": "Dialog message - unsaved changes" }, - "dialogDownloadFailed": "Download Failed", - "@dialogDownloadFailed": { - "description": "Dialog title - download error" - }, - "dialogTrackLabel": "Track:", - "@dialogTrackLabel": { - "description": "Label for track name in error dialog" - }, - "dialogArtistLabel": "Artist:", - "@dialogArtistLabel": { - "description": "Label for artist name in error dialog" - }, - "dialogErrorLabel": "Error:", - "@dialogErrorLabel": { - "description": "Label for error message" - }, "dialogClearAll": "Clear All", "@dialogClearAll": { "description": "Dialog title - clear all items" }, - "dialogClearAllDownloads": "Are you sure you want to clear all downloads?", - "@dialogClearAllDownloads": { - "description": "Dialog message - clear downloads confirmation" - }, - "dialogRemoveFromDevice": "Remove from device?", - "@dialogRemoveFromDevice": { - "description": "Dialog title - delete file confirmation" - }, "dialogRemoveExtension": "Remove Extension", "@dialogRemoveExtension": { "description": "Dialog title - uninstall extension" @@ -1257,15 +833,6 @@ "@snackbarViewQueue": { "description": "Snackbar action - view download queue" }, - "snackbarFailedToLoad": "Failed to load: {error}", - "@snackbarFailedToLoad": { - "description": "Snackbar - loading error", - "placeholders": { - "error": { - "type": "String" - } - } - }, "snackbarUrlCopied": "{platform} URL copied to clipboard", "@snackbarUrlCopied": { "description": "Snackbar - URL copied", @@ -1326,16 +893,6 @@ "@errorRateLimitedMessage": { "description": "Error message - rate limit explanation" }, - "errorFailedToLoad": "Failed to load {item}", - "@errorFailedToLoad": { - "description": "Error message - loading failed", - "placeholders": { - "item": { - "type": "String", - "description": "Item that failed to load (album/playlist/etc)" - } - } - }, "errorNoTracksFound": "No tracks found", "@errorNoTracksFound": { "description": "Error - search returned no results" @@ -1349,34 +906,6 @@ } } }, - "statusQueued": "Queued", - "@statusQueued": { - "description": "Download status - waiting in queue" - }, - "statusDownloading": "Downloading", - "@statusDownloading": { - "description": "Download status - in progress" - }, - "statusFinalizing": "Finalizing", - "@statusFinalizing": { - "description": "Download status - writing metadata" - }, - "statusCompleted": "Completed", - "@statusCompleted": { - "description": "Download status - finished" - }, - "statusFailed": "Failed", - "@statusFailed": { - "description": "Download status - error occurred" - }, - "statusSkipped": "Skipped", - "@statusSkipped": { - "description": "Download status - already exists" - }, - "statusPaused": "Paused", - "@statusPaused": { - "description": "Download status - paused" - }, "actionPause": "Pause", "@actionPause": { "description": "Action button - pause download" @@ -1389,14 +918,6 @@ "@actionCancel": { "description": "Action button - cancel operation" }, - "actionStop": "Stop", - "@actionStop": { - "description": "Action button - stop operation" - }, - "actionSelect": "Select", - "@actionSelect": { - "description": "Action button - enter selection mode" - }, "actionSelectAll": "Select All", "@actionSelectAll": { "description": "Action button - select all items" @@ -1405,14 +926,6 @@ "@actionDeselect": { "description": "Action button - deselect all" }, - "actionPaste": "Paste", - "@actionPaste": { - "description": "Action button - paste from clipboard" - }, - "actionImportCsv": "Import CSV", - "@actionImportCsv": { - "description": "Action button - import CSV file" - }, "actionRemoveCredentials": "Remove Credentials", "@actionRemoveCredentials": { "description": "Action button - delete Spotify credentials" @@ -1434,19 +947,6 @@ "@selectionAllSelected": { "description": "Status - all items selected" }, - "selectionTapToSelect": "Tap tracks to select", - "@selectionTapToSelect": { - "description": "Hint - how to select items" - }, - "selectionDeleteTracks": "Delete {count} {count, plural, =1{track} other{tracks}}", - "@selectionDeleteTracks": { - "description": "Delete button with count", - "placeholders": { - "count": { - "type": "int" - } - } - }, "selectionSelectToDelete": "Select tracks to delete", "@selectionSelectToDelete": { "description": "Placeholder when nothing selected" @@ -1487,55 +987,10 @@ "@tooltipPlay": { "description": "Tooltip - play button" }, - "tooltipCancel": "Cancel", - "@tooltipCancel": { - "description": "Tooltip - cancel button" - }, - "tooltipStop": "Stop", - "@tooltipStop": { - "description": "Tooltip - stop button" - }, - "tooltipRetry": "Retry", - "@tooltipRetry": { - "description": "Tooltip - retry button" - }, - "tooltipRemove": "Remove", - "@tooltipRemove": { - "description": "Tooltip - remove button" - }, - "tooltipClear": "Clear", - "@tooltipClear": { - "description": "Tooltip - clear button" - }, - "tooltipPaste": "Paste", - "@tooltipPaste": { - "description": "Tooltip - paste button" - }, "filenameFormat": "Filename Format", "@filenameFormat": { "description": "Setting title - filename pattern" }, - "filenameFormatPreview": "Preview: {preview}", - "@filenameFormatPreview": { - "description": "Preview of filename pattern", - "placeholders": { - "preview": { - "type": "String" - } - } - }, - "filenameAvailablePlaceholders": "Available placeholders:", - "@filenameAvailablePlaceholders": { - "description": "Label for placeholder list" - }, - "filenameHint": "{artist} - {title}", - "@filenameHint": { - "description": "Default filename format hint" - }, - "folderOrganization": "Folder Organization", - "@folderOrganization": { - "description": "Setting title - folder structure" - }, "folderOrganizationNone": "No organization", "@folderOrganizationNone": { "description": "Folder option - flat structure" @@ -1576,27 +1031,10 @@ "@updateAvailable": { "description": "Update dialog title" }, - "updateNewVersion": "Version {version} is available", - "@updateNewVersion": { - "description": "Update available message", - "placeholders": { - "version": { - "type": "String" - } - } - }, - "updateDownload": "Download", - "@updateDownload": { - "description": "Update button - download update" - }, "updateLater": "Later", "@updateLater": { "description": "Update button - dismiss" }, - "updateChangelog": "Changelog", - "@updateChangelog": { - "description": "Link to changelog" - }, "updateStartingDownload": "Starting download...", "@updateStartingDownload": { "description": "Update status - initializing" @@ -1637,14 +1075,6 @@ "@updateDontRemind": { "description": "Update button - skip this version" }, - "providerPriority": "Provider Priority", - "@providerPriority": { - "description": "Setting title - download provider order" - }, - "providerPrioritySubtitle": "Drag to reorder download providers", - "@providerPrioritySubtitle": { - "description": "Subtitle for provider priority" - }, "providerPriorityTitle": "Provider Priority", "@providerPriorityTitle": { "description": "Provider priority page title" @@ -1665,14 +1095,6 @@ "@providerExtension": { "description": "Label for extension-provided providers" }, - "metadataProviderPriority": "Metadata Provider Priority", - "@metadataProviderPriority": { - "description": "Setting title - metadata provider order" - }, - "metadataProviderPrioritySubtitle": "Order used when fetching track metadata", - "@metadataProviderPrioritySubtitle": { - "description": "Subtitle for metadata priority" - }, "metadataProviderPriorityTitle": "Metadata Priority", "@metadataProviderPriorityTitle": { "description": "Metadata priority page title" @@ -1697,22 +1119,6 @@ "@logTitle": { "description": "Logs screen title" }, - "logCopy": "Copy Logs", - "@logCopy": { - "description": "Action - copy logs to clipboard" - }, - "logClear": "Clear Logs", - "@logClear": { - "description": "Action - delete all logs" - }, - "logShare": "Share Logs", - "@logShare": { - "description": "Action - share logs file" - }, - "logEmpty": "No logs yet", - "@logEmpty": { - "description": "Empty state title" - }, "logCopied": "Logs copied to clipboard", "@logCopied": { "description": "Snackbar - logs copied" @@ -1745,22 +1151,6 @@ "@logClearLogsMessage": { "description": "Clear logs confirmation message" }, - "logIspBlocking": "ISP BLOCKING DETECTED", - "@logIspBlocking": { - "description": "Error category - ISP blocking" - }, - "logRateLimited": "RATE LIMITED", - "@logRateLimited": { - "description": "Error category - rate limiting" - }, - "logNetworkError": "NETWORK ERROR", - "@logNetworkError": { - "description": "Error category - network issues" - }, - "logTrackNotFound": "TRACK NOT FOUND", - "@logTrackNotFound": { - "description": "Error category - missing tracks" - }, "logFilterBySeverity": "Filter logs by severity", "@logFilterBySeverity": { "description": "Filter dialog title" @@ -1773,60 +1163,6 @@ "@logNoLogsYetSubtitle": { "description": "Empty state subtitle" }, - "logIssueSummary": "Issue Summary", - "@logIssueSummary": { - "description": "Section header for error summary" - }, - "logIspBlockingDescription": "Your ISP may be blocking access to download services", - "@logIspBlockingDescription": { - "description": "ISP blocking explanation" - }, - "logIspBlockingSuggestion": "Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8", - "@logIspBlockingSuggestion": { - "description": "ISP blocking fix suggestion" - }, - "logRateLimitedDescription": "Too many requests to the service", - "@logRateLimitedDescription": { - "description": "Rate limit explanation" - }, - "logRateLimitedSuggestion": "Wait a few minutes before trying again", - "@logRateLimitedSuggestion": { - "description": "Rate limit fix suggestion" - }, - "logNetworkErrorDescription": "Connection issues detected", - "@logNetworkErrorDescription": { - "description": "Network error explanation" - }, - "logNetworkErrorSuggestion": "Check your internet connection", - "@logNetworkErrorSuggestion": { - "description": "Network error fix suggestion" - }, - "logTrackNotFoundDescription": "Some tracks could not be found on download services", - "@logTrackNotFoundDescription": { - "description": "Track not found explanation" - }, - "logTrackNotFoundSuggestion": "The track may not be available in lossless quality", - "@logTrackNotFoundSuggestion": { - "description": "Track not found explanation" - }, - "logTotalErrors": "Total errors: {count}", - "@logTotalErrors": { - "description": "Error count display", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "logAffected": "Affected: {domains}", - "@logAffected": { - "description": "Affected domains display", - "placeholders": { - "domains": { - "type": "String" - } - } - }, "logEntriesFiltered": "Entries ({count} filtered)", "@logEntriesFiltered": { "description": "Log count with filter active", @@ -1969,10 +1305,6 @@ "@appearanceLanguage": { "description": "Language setting title" }, - "appearanceLanguageSubtitle": "Choose your preferred language", - "@appearanceLanguageSubtitle": { - "description": "Language setting subtitle" - }, "settingsAppearanceSubtitle": "Theme, colors, display", "@settingsAppearanceSubtitle": { "description": "Appearance settings description" @@ -2001,10 +1333,6 @@ "@pressBackAgainToExit": { "description": "Exit confirmation message" }, - "tracksHeader": "Tracks", - "@tracksHeader": { - "description": "Section header for track list" - }, "downloadAllCount": "Download All ({count})", "@downloadAllCount": { "description": "Download all button with count", @@ -2151,15 +1479,6 @@ "@trackDeleteConfirmMessage": { "description": "Delete confirmation message" }, - "trackCannotOpen": "Cannot open: {message}", - "@trackCannotOpen": { - "description": "Error opening file", - "placeholders": { - "message": { - "type": "String" - } - } - }, "dateToday": "Today", "@dateToday": { "description": "Relative date - today" @@ -2195,22 +1514,6 @@ } } }, - "concurrentSequential": "Sequential", - "@concurrentSequential": { - "description": "Download mode - one at a time" - }, - "concurrentParallel2": "2 Parallel", - "@concurrentParallel2": { - "description": "Download mode - 2 simultaneous" - }, - "concurrentParallel3": "3 Parallel", - "@concurrentParallel3": { - "description": "Download mode - 3 simultaneous" - }, - "tapToSeeError": "Tap to see error details", - "@tapToSeeError": { - "description": "Tooltip for failed download" - }, "storeFilterAll": "All", "@storeFilterAll": { "description": "Store filter - all extensions" @@ -2239,18 +1542,6 @@ "@storeClearFilters": { "description": "Button to clear all filters" }, - "storeNoResults": "No extensions found", - "@storeNoResults": { - "description": "Empty state when no extensions match filters" - }, - "extensionProviderPriority": "Provider Priority", - "@extensionProviderPriority": { - "description": "Extension capability - provider priority" - }, - "extensionInstallButton": "Install Extension", - "@extensionInstallButton": { - "description": "Button to install extension" - }, "extensionDefaultProvider": "Default (Deezer/Spotify)", "@extensionDefaultProvider": { "description": "Default search provider option" @@ -2450,46 +1741,6 @@ "@qualityHiResFlacMaxSubtitle": { "description": "Technical spec for hi-res max" }, - "qualityLossy": "Lossy", - "@qualityLossy": { - "description": "Quality option - lossy format (MP3/Opus)" - }, - "qualityLossyMp3Subtitle": "MP3 320kbps (converted from FLAC)", - "@qualityLossyMp3Subtitle": { - "description": "Technical spec for lossy MP3" - }, - "qualityLossyOpusSubtitle": "Opus 128kbps (converted from FLAC)", - "@qualityLossyOpusSubtitle": { - "description": "Technical spec for lossy Opus" - }, - "enableLossyOption": "Enable Lossy Option", - "@enableLossyOption": { - "description": "Setting - enable lossy quality option" - }, - "enableLossyOptionSubtitleOn": "Lossy quality option is available", - "@enableLossyOptionSubtitleOn": { - "description": "Subtitle when lossy is enabled" - }, - "enableLossyOptionSubtitleOff": "Downloads FLAC then converts to lossy format", - "@enableLossyOptionSubtitleOff": { - "description": "Subtitle when lossy is disabled" - }, - "lossyFormat": "Lossy Format", - "@lossyFormat": { - "description": "Setting - choose lossy format" - }, - "lossyFormatDescription": "Choose the lossy format for conversion", - "@lossyFormatDescription": { - "description": "Description for lossy format picker" - }, - "lossyFormatMp3Subtitle": "320kbps, best compatibility", - "@lossyFormatMp3Subtitle": { - "description": "MP3 format description" - }, - "lossyFormatOpusSubtitle": "128kbps, better quality at smaller size", - "@lossyFormatOpusSubtitle": { - "description": "Opus format description" - }, "qualityNote": "Actual quality depends on track availability from the service", "@qualityNote": { "description": "Note about quality availability" @@ -2518,14 +1769,6 @@ "@downloadUseAlbumArtistForFolders": { "description": "Setting - choose whether artist folders use Album Artist or Track Artist" }, - "downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available", - "@downloadUseAlbumArtistForFoldersAlbumSubtitle": { - "description": "Subtitle when Album Artist is used for folder naming" - }, - "downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only", - "@downloadUseAlbumArtistForFoldersTrackSubtitle": { - "description": "Subtitle when Track Artist is used for folder naming" - }, "downloadUsePrimaryArtistOnly": "Primary artist only for folders", "@downloadUsePrimaryArtistOnly": { "description": "Setting - strip featured artists from folder name" @@ -2538,14 +1781,6 @@ "@downloadUsePrimaryArtistOnlyDisabled": { "description": "Subtitle when primary artist only is disabled" }, - "downloadSaveFormat": "Save Format", - "@downloadSaveFormat": { - "description": "Setting - output file format" - }, - "downloadSelectService": "Select Service", - "@downloadSelectService": { - "description": "Dialog title - choose download service" - }, "downloadSelectQuality": "Select Quality", "@downloadSelectQuality": { "description": "Dialog title - choose audio quality" @@ -2554,66 +1789,6 @@ "@downloadFrom": { "description": "Label - download source" }, - "downloadDefaultQualityLabel": "Default Quality", - "@downloadDefaultQualityLabel": { - "description": "Label - default quality setting" - }, - "downloadBestAvailable": "Best available", - "@downloadBestAvailable": { - "description": "Quality option - highest available" - }, - "folderNone": "None", - "@folderNone": { - "description": "Folder option - no organization" - }, - "folderNoneSubtitle": "Save all files directly to download folder", - "@folderNoneSubtitle": { - "description": "Subtitle for no folder organization" - }, - "folderArtist": "Artist", - "@folderArtist": { - "description": "Folder option - by artist" - }, - "folderArtistSubtitle": "Artist Name/filename", - "@folderArtistSubtitle": { - "description": "Folder structure example" - }, - "folderAlbum": "Album", - "@folderAlbum": { - "description": "Folder option - by album" - }, - "folderAlbumSubtitle": "Album Name/filename", - "@folderAlbumSubtitle": { - "description": "Folder structure example" - }, - "folderArtistAlbum": "Artist/Album", - "@folderArtistAlbum": { - "description": "Folder option - nested" - }, - "folderArtistAlbumSubtitle": "Artist Name/Album Name/filename", - "@folderArtistAlbumSubtitle": { - "description": "Folder structure example" - }, - "serviceTidal": "Tidal", - "@serviceTidal": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceQobuz": "Qobuz", - "@serviceQobuz": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceAmazon": "Amazon", - "@serviceAmazon": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceDeezer": "Deezer", - "@serviceDeezer": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceSpotify": "Spotify", - "@serviceSpotify": { - "description": "Service name - DO NOT TRANSLATE" - }, "appearanceAmoledDark": "AMOLED Dark", "@appearanceAmoledDark": { "description": "Theme option - pure black" @@ -2622,18 +1797,6 @@ "@appearanceAmoledDarkSubtitle": { "description": "Subtitle for AMOLED dark" }, - "appearanceChooseAccentColor": "Choose Accent Color", - "@appearanceChooseAccentColor": { - "description": "Color picker dialog title" - }, - "appearanceChooseTheme": "Theme Mode", - "@appearanceChooseTheme": { - "description": "Theme picker dialog title" - }, - "queueTitle": "Download Queue", - "@queueTitle": { - "description": "Queue screen title" - }, "queueClearAll": "Clear All", "@queueClearAll": { "description": "Button - clear all queue items" @@ -2642,22 +1805,6 @@ "@queueClearAllMessage": { "description": "Clear queue confirmation" }, - "queueExportFailed": "Export", - "@queueExportFailed": { - "description": "Button - export failed downloads to TXT" - }, - "queueExportFailedSuccess": "Failed downloads exported to TXT file", - "@queueExportFailedSuccess": { - "description": "Success message after exporting failed downloads" - }, - "queueExportFailedClear": "Clear Failed", - "@queueExportFailedClear": { - "description": "Action to clear failed downloads after export" - }, - "queueExportFailedError": "Failed to export downloads", - "@queueExportFailedError": { - "description": "Error message when export fails" - }, "settingsAutoExportFailed": "Auto-export failed downloads", "@settingsAutoExportFailed": { "description": "Setting toggle for auto-export" @@ -2682,38 +1829,6 @@ "@settingsDownloadNetworkSubtitle": { "description": "Subtitle explaining network preference" }, - "queueEmpty": "No downloads in queue", - "@queueEmpty": { - "description": "Empty queue state title" - }, - "queueEmptySubtitle": "Add tracks from the home screen", - "@queueEmptySubtitle": { - "description": "Empty queue state subtitle" - }, - "queueClearCompleted": "Clear completed", - "@queueClearCompleted": { - "description": "Button - clear finished downloads" - }, - "queueDownloadFailed": "Download Failed", - "@queueDownloadFailed": { - "description": "Error dialog title" - }, - "queueTrackLabel": "Track:", - "@queueTrackLabel": { - "description": "Label in error dialog" - }, - "queueArtistLabel": "Artist:", - "@queueArtistLabel": { - "description": "Label in error dialog" - }, - "queueErrorLabel": "Error:", - "@queueErrorLabel": { - "description": "Label in error dialog" - }, - "queueUnknownError": "Unknown error", - "@queueUnknownError": { - "description": "Fallback error message" - }, "albumFolderArtistAlbum": "Artist / Album", "@albumFolderArtistAlbum": { "description": "Album folder option" @@ -2767,19 +1882,6 @@ } } }, - "downloadedAlbumTracksHeader": "Tracks", - "@downloadedAlbumTracksHeader": { - "description": "Section header for tracks" - }, - "downloadedAlbumDownloadedCount": "{count} downloaded", - "@downloadedAlbumDownloadedCount": { - "description": "Downloaded tracks count badge", - "placeholders": { - "count": { - "type": "int" - } - } - }, "downloadedAlbumSelectedCount": "{count} selected", "@downloadedAlbumSelectedCount": { "description": "Selection count indicator", @@ -2820,10 +1922,6 @@ } } }, - "utilityFunctions": "Utility Functions", - "@utilityFunctions": { - "description": "Extension capability - utility functions" - }, "recentTypeArtist": "Artist", "@recentTypeArtist": { "description": "Recent access item type - artist" @@ -2858,16 +1956,6 @@ } } }, - "errorGeneric": "Error: {message}", - "@errorGeneric": { - "description": "Generic error message format", - "placeholders": { - "message": { - "type": "String", - "description": "Error message" - } - } - }, "discographyDownload": "Download Discography", "@discographyDownload": { "description": "Button - download artist discography" @@ -3034,10 +2122,6 @@ "@libraryTitle": { "description": "Library settings page title" }, - "libraryStatus": "Library Status", - "@libraryStatus": { - "description": "Section header for library status" - }, "libraryScanSettings": "Scan Settings", "@libraryScanSettings": { "description": "Section header for scan settings" @@ -3114,15 +2198,6 @@ "@libraryAboutDescription": { "description": "Description of local library feature" }, - "libraryTracksCount": "{count} tracks", - "@libraryTracksCount": { - "description": "Track count in library", - "placeholders": { - "count": { - "type": "int" - } - } - }, "libraryLastScanned": "Last scanned: {time}", "@libraryLastScanned": { "description": "Last scan time display", @@ -3237,26 +2312,6 @@ "@libraryFilterFormat": { "description": "Filter section - file format" }, - "libraryFilterDate": "Date Added", - "@libraryFilterDate": { - "description": "Filter section - date range" - }, - "libraryFilterDateToday": "Today", - "@libraryFilterDateToday": { - "description": "Filter option - today only" - }, - "libraryFilterDateWeek": "This Week", - "@libraryFilterDateWeek": { - "description": "Filter option - this week" - }, - "libraryFilterDateMonth": "This Month", - "@libraryFilterDateMonth": { - "description": "Filter option - this month" - }, - "libraryFilterDateYear": "This Year", - "@libraryFilterDateYear": { - "description": "Filter option - this year" - }, "libraryFilterSort": "Sort", "@libraryFilterSort": { "description": "Filter section - sort order" @@ -3269,15 +2324,6 @@ "@libraryFilterSortOldest": { "description": "Sort option - oldest first" }, - "libraryFilterActive": "{count} filter(s) active", - "@libraryFilterActive": { - "description": "Badge showing number of active filters", - "placeholders": { - "count": { - "type": "int" - } - } - }, "timeJustNow": "Just now", "@timeJustNow": { "description": "Relative time - less than a minute ago" @@ -3300,106 +2346,6 @@ } } }, - "storageSwitchTitle": "Switch Storage Mode", - "@storageSwitchTitle": { - "description": "Dialog title when switching storage mode" - }, - "storageSwitchToSafTitle": "Switch to SAF Storage?", - "@storageSwitchToSafTitle": { - "description": "Dialog title when switching to SAF" - }, - "storageSwitchToAppTitle": "Switch to App Storage?", - "@storageSwitchToAppTitle": { - "description": "Dialog title when switching to app storage" - }, - "storageSwitchToSafMessage": "Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.", - "@storageSwitchToSafMessage": { - "description": "Explanation when switching to SAF" - }, - "storageSwitchToAppMessage": "Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.", - "@storageSwitchToAppMessage": { - "description": "Explanation when switching to app storage" - }, - "storageSwitchExistingDownloads": "Existing Downloads", - "@storageSwitchExistingDownloads": { - "description": "Section header for existing downloads info" - }, - "storageSwitchExistingDownloadsInfo": "{count} tracks in {mode} storage", - "@storageSwitchExistingDownloadsInfo": { - "description": "Info about existing downloads count", - "placeholders": { - "count": { - "type": "int" - }, - "mode": { - "type": "String" - } - } - }, - "storageSwitchNewDownloads": "New Downloads", - "@storageSwitchNewDownloads": { - "description": "Section header for new downloads info" - }, - "storageSwitchNewDownloadsLocation": "Will be saved to: {location}", - "@storageSwitchNewDownloadsLocation": { - "description": "Shows where new downloads will go", - "placeholders": { - "location": { - "type": "String" - } - } - }, - "storageSwitchContinue": "Continue", - "@storageSwitchContinue": { - "description": "Button to proceed with storage switch" - }, - "storageSwitchSelectFolder": "Select SAF Folder", - "@storageSwitchSelectFolder": { - "description": "Button to select SAF folder" - }, - "storageAppStorage": "App Storage", - "@storageAppStorage": { - "description": "Label for app storage mode" - }, - "storageSafStorage": "SAF Storage", - "@storageSafStorage": { - "description": "Label for SAF storage mode" - }, - "storageModeBadge": "Storage: {mode}", - "@storageModeBadge": { - "description": "Badge showing storage mode for a track", - "placeholders": { - "mode": { - "type": "String" - } - } - }, - "storageStatsTitle": "Storage Statistics", - "@storageStatsTitle": { - "description": "Section title for storage stats" - }, - "storageStatsAppCount": "{count} tracks in App Storage", - "@storageStatsAppCount": { - "description": "Count of tracks in app storage", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "storageStatsSafCount": "{count} tracks in SAF Storage", - "@storageStatsSafCount": { - "description": "Count of tracks in SAF storage", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "storageModeInfo": "Your files are stored in multiple locations", - "@storageModeInfo": { - "description": "Info when user has files in both storage modes" - }, "tutorialWelcomeTitle": "Welcome to SpotiFLAC!", "@tutorialWelcomeTitle": { "description": "Tutorial welcome page title" @@ -3428,18 +2374,6 @@ "@tutorialSearchDesc": { "description": "Tutorial search page description" }, - "tutorialSearchTip1": "Paste a Spotify or Deezer URL directly in the search box", - "@tutorialSearchTip1": { - "description": "Tutorial search tip 1" - }, - "tutorialSearchTip2": "Or type the song name, artist, or album to search", - "@tutorialSearchTip2": { - "description": "Tutorial search tip 2" - }, - "tutorialSearchTip3": "Supports tracks, albums, playlists, and artist pages", - "@tutorialSearchTip3": { - "description": "Tutorial search tip 3" - }, "tutorialDownloadTitle": "Downloading Music", "@tutorialDownloadTitle": { "description": "Tutorial download page title" @@ -3448,18 +2382,6 @@ "@tutorialDownloadDesc": { "description": "Tutorial download page description" }, - "tutorialDownloadTip1": "Tap the download button next to any track to start downloading", - "@tutorialDownloadTip1": { - "description": "Tutorial download tip 1" - }, - "tutorialDownloadTip2": "Choose your preferred quality (FLAC, Hi-Res, or MP3)", - "@tutorialDownloadTip2": { - "description": "Tutorial download tip 2" - }, - "tutorialDownloadTip3": "Download entire albums or playlists with one tap", - "@tutorialDownloadTip3": { - "description": "Tutorial download tip 3" - }, "tutorialLibraryTitle": "Your Library", "@tutorialLibraryTitle": { "description": "Tutorial library page title" @@ -3524,10 +2446,6 @@ "@tutorialReadyMessage": { "description": "Tutorial completion message" }, - "tutorialExample": "EXAMPLE", - "@tutorialExample": { - "description": "Example label in tutorial" - }, "libraryForceFullScan": "Force Full Scan", "@libraryForceFullScan": { "description": "Button to force a complete rescan of library" @@ -3750,14 +2668,10 @@ "@trackSaveLyricsProgress": { "description": "Snackbar while saving lyrics to file" }, - "trackReEnrich": "Re-enrich Metadata", + "trackReEnrich": "Re-enrich", "@trackReEnrich": { "description": "Menu action - re-embed metadata into audio file" }, - "trackReEnrichSubtitle": "Re-embed metadata without re-downloading", - "@trackReEnrichSubtitle": { - "description": "Subtitle for re-enrich metadata action" - }, "trackReEnrichOnlineSubtitle": "Search metadata online and embed into file", "@trackReEnrichOnlineSubtitle": { "description": "Subtitle for re-enrich metadata action for local items" @@ -3868,5 +2782,22 @@ "trackConvertFailed": "Conversion failed", "@trackConvertFailed": { "description": "Snackbar when conversion fails" + }, + "downloadedAlbumDownloadedCount": "{count} downloaded", + "@downloadedAlbumDownloadedCount": { + "description": "Downloaded tracks count badge", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available", + "@downloadUseAlbumArtistForFoldersAlbumSubtitle": { + "description": "Subtitle when Album Artist is used for folder naming" + }, + "downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only", + "@downloadUseAlbumArtistForFoldersTrackSubtitle": { + "description": "Subtitle when Track Artist is used for folder naming" } -} \ No newline at end of file +} diff --git a/lib/l10n/arb/app_nl.arb b/lib/l10n/arb/app_nl.arb index 8bae60f8..1fd0bec6 100644 --- a/lib/l10n/arb/app_nl.arb +++ b/lib/l10n/arb/app_nl.arb @@ -5,10 +5,6 @@ "@appName": { "description": "App name - DO NOT TRANSLATE" }, - "appDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.", - "@appDescription": { - "description": "App description shown in about page" - }, "navHome": "Home", "@navHome": { "description": "Bottom navigation - Home tab" @@ -17,10 +13,6 @@ "@navLibrary": { "description": "Bottom navigation - Library tab" }, - "navHistory": "History", - "@navHistory": { - "description": "Bottom navigation - History tab (legacy)" - }, "navSettings": "Settings", "@navSettings": { "description": "Bottom navigation - Settings tab" @@ -33,20 +25,6 @@ "@homeTitle": { "description": "Home screen title" }, - "homeSearchHint": "Paste Spotify URL or search...", - "@homeSearchHint": { - "description": "Placeholder text in search box" - }, - "homeSearchHintExtension": "Search with {extensionName}...", - "@homeSearchHintExtension": { - "description": "Placeholder when extension search is active", - "placeholders": { - "extensionName": { - "type": "String", - "description": "Name of the active extension" - } - } - }, "homeSubtitle": "Paste a Spotify link or search by name", "@homeSubtitle": { "description": "Subtitle shown below search box" @@ -59,24 +37,6 @@ "@homeRecent": { "description": "Section header for recent searches" }, - "historyTitle": "History", - "@historyTitle": { - "description": "History screen title" - }, - "historyDownloading": "Downloading ({count})", - "@historyDownloading": { - "description": "Tab showing active downloads count", - "placeholders": { - "count": { - "type": "int", - "description": "Number of active downloads" - } - } - }, - "historyDownloaded": "Downloaded", - "@historyDownloaded": { - "description": "Tab showing completed downloads" - }, "historyFilterAll": "All", "@historyFilterAll": { "description": "Filter chip - show all items" @@ -89,48 +49,6 @@ "@historyFilterSingles": { "description": "Filter chip - show singles only" }, - "historyTracksCount": "{count, plural, =1{1 track} other{{count} tracks}}", - "@historyTracksCount": { - "description": "Track count with plural form", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "historyAlbumsCount": "{count, plural, =1{1 album} other{{count} albums}}", - "@historyAlbumsCount": { - "description": "Album count with plural form", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "historyNoDownloads": "No download history", - "@historyNoDownloads": { - "description": "Empty state title" - }, - "historyNoDownloadsSubtitle": "Downloaded tracks will appear here", - "@historyNoDownloadsSubtitle": { - "description": "Empty state subtitle" - }, - "historyNoAlbums": "No album downloads", - "@historyNoAlbums": { - "description": "Empty state when filtering albums" - }, - "historyNoAlbumsSubtitle": "Download multiple tracks from an album to see them here", - "@historyNoAlbumsSubtitle": { - "description": "Empty state subtitle for albums filter" - }, - "historyNoSingles": "No single downloads", - "@historyNoSingles": { - "description": "Empty state when filtering singles" - }, - "historyNoSinglesSubtitle": "Single track downloads will appear here", - "@historyNoSinglesSubtitle": { - "description": "Empty state subtitle for singles filter" - }, "historySearchHint": "Search history...", "@historySearchHint": { "description": "Search bar placeholder in history" @@ -163,34 +81,6 @@ "@downloadTitle": { "description": "Download settings page title" }, - "downloadLocation": "Download Location", - "@downloadLocation": { - "description": "Setting for download folder" - }, - "downloadLocationSubtitle": "Choose where to save files", - "@downloadLocationSubtitle": { - "description": "Subtitle for download location" - }, - "downloadLocationDefault": "Default location", - "@downloadLocationDefault": { - "description": "Shown when using default folder" - }, - "downloadDefaultService": "Default Service", - "@downloadDefaultService": { - "description": "Setting for preferred download service (Tidal/Qobuz/Amazon)" - }, - "downloadDefaultServiceSubtitle": "Service used for downloads", - "@downloadDefaultServiceSubtitle": { - "description": "Subtitle for default service" - }, - "downloadDefaultQuality": "Default Quality", - "@downloadDefaultQuality": { - "description": "Setting for audio quality" - }, - "downloadAskQuality": "Ask Quality Before Download", - "@downloadAskQuality": { - "description": "Toggle to show quality picker" - }, "downloadAskQualitySubtitle": "Show quality picker for each download", "@downloadAskQualitySubtitle": { "description": "Subtitle for ask quality toggle" @@ -203,38 +93,10 @@ "@downloadFolderOrganization": { "description": "Setting for folder structure" }, - "downloadSeparateSingles": "Separate Singles", - "@downloadSeparateSingles": { - "description": "Toggle to separate single tracks" - }, - "downloadSeparateSinglesSubtitle": "Put single tracks in a separate folder", - "@downloadSeparateSinglesSubtitle": { - "description": "Subtitle for separate singles toggle" - }, - "qualityBest": "Best Available", - "@qualityBest": { - "description": "Audio quality option - highest available" - }, - "qualityFlac": "FLAC", - "@qualityFlac": { - "description": "Audio quality option - FLAC lossless" - }, - "quality320": "320 kbps", - "@quality320": { - "description": "Audio quality option - 320kbps MP3" - }, - "quality128": "128 kbps", - "@quality128": { - "description": "Audio quality option - 128kbps MP3" - }, "appearanceTitle": "Appearance", "@appearanceTitle": { "description": "Appearance settings page title" }, - "appearanceTheme": "Theme", - "@appearanceTheme": { - "description": "Theme mode setting" - }, "appearanceThemeSystem": "System", "@appearanceThemeSystem": { "description": "Follow system theme" @@ -255,10 +117,6 @@ "@appearanceDynamicColorSubtitle": { "description": "Subtitle for dynamic color" }, - "appearanceAccentColor": "Accent Color", - "@appearanceAccentColor": { - "description": "Custom accent color picker" - }, "appearanceHistoryView": "History View", "@appearanceHistoryView": { "description": "Layout style for history" @@ -275,10 +133,6 @@ "@optionsTitle": { "description": "Options settings page title" }, - "optionsSearchSource": "Search Source", - "@optionsSearchSource": { - "description": "Section for search provider settings" - }, "optionsPrimaryProvider": "Primary Provider", "@optionsPrimaryProvider": { "description": "Main search provider setting" @@ -438,22 +292,6 @@ "@extensionsTitle": { "description": "Extensions page title" }, - "extensionsInstalled": "Installed Extensions", - "@extensionsInstalled": { - "description": "Section header for installed extensions" - }, - "extensionsNone": "No extensions installed", - "@extensionsNone": { - "description": "Empty state title" - }, - "extensionsNoneSubtitle": "Install extensions from the Store tab", - "@extensionsNoneSubtitle": { - "description": "Empty state subtitle" - }, - "extensionsEnabled": "Enabled", - "@extensionsEnabled": { - "description": "Extension status - active" - }, "extensionsDisabled": "Disabled", "@extensionsDisabled": { "description": "Extension status - inactive" @@ -480,10 +318,6 @@ "@extensionsUninstall": { "description": "Uninstall extension button" }, - "extensionsSetAsSearch": "Set as Search Provider", - "@extensionsSetAsSearch": { - "description": "Use extension for search" - }, "storeTitle": "Extension Store", "@storeTitle": { "description": "Store screen title" @@ -580,10 +414,6 @@ "@aboutSocial": { "description": "Section for social links" }, - "aboutSupport": "Support", - "@aboutSupport": { - "description": "Section for support/donation links" - }, "aboutApp": "App", "@aboutApp": { "description": "Section for app info" @@ -604,14 +434,6 @@ "@aboutSjdonadoDesc": { "description": "Credit description for sjdonado" }, - "aboutDoubleDouble": "DoubleDouble", - "@aboutDoubleDouble": { - "description": "Name of Amazon API service - DO NOT TRANSLATE" - }, - "aboutDoubleDoubleDesc": "Amazing API for Amazon Music downloads. Thank you for making it free!", - "@aboutDoubleDoubleDesc": { - "description": "Credit for DoubleDouble API" - }, "aboutDabMusic": "DAB Music", "@aboutDabMusic": { "description": "Name of Qobuz API service - DO NOT TRANSLATE" @@ -632,35 +454,6 @@ "@aboutAppDescription": { "description": "App description in header card" }, - "albumTitle": "Album", - "@albumTitle": { - "description": "Album screen title" - }, - "albumTracks": "{count, plural, =1{1 track} other{{count} tracks}}", - "@albumTracks": { - "description": "Album track count", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "albumDownloadAll": "Download All", - "@albumDownloadAll": { - "description": "Button to download all tracks" - }, - "albumDownloadRemaining": "Download Remaining", - "@albumDownloadRemaining": { - "description": "Button to download remaining tracks" - }, - "playlistTitle": "Playlist", - "@playlistTitle": { - "description": "Playlist screen title" - }, - "artistTitle": "Artist", - "@artistTitle": { - "description": "Artist screen title" - }, "artistAlbums": "Albums", "@artistAlbums": { "description": "Section header for artist albums" @@ -673,15 +466,6 @@ "@artistCompilations": { "description": "Section header for compilations" }, - "artistReleases": "{count, plural, =1{1 release} other{{count} releases}}", - "@artistReleases": { - "description": "Artist release count", - "placeholders": { - "count": { - "type": "int" - } - } - }, "artistPopular": "Popular", "@artistPopular": { "description": "Section header for popular/top tracks" @@ -696,34 +480,6 @@ } } }, - "trackMetadataTitle": "Track Info", - "@trackMetadataTitle": { - "description": "Track metadata screen title" - }, - "trackMetadataArtist": "Artist", - "@trackMetadataArtist": { - "description": "Metadata field - artist name" - }, - "trackMetadataAlbum": "Album", - "@trackMetadataAlbum": { - "description": "Metadata field - album name" - }, - "trackMetadataDuration": "Duration", - "@trackMetadataDuration": { - "description": "Metadata field - track length" - }, - "trackMetadataQuality": "Quality", - "@trackMetadataQuality": { - "description": "Metadata field - audio quality" - }, - "trackMetadataPath": "File Path", - "@trackMetadataPath": { - "description": "Metadata field - file location" - }, - "trackMetadataDownloadedAt": "Downloaded", - "@trackMetadataDownloadedAt": { - "description": "Metadata field - download date" - }, "trackMetadataService": "Service", "@trackMetadataService": { "description": "Metadata field - download service used" @@ -740,54 +496,10 @@ "@trackMetadataDelete": { "description": "Action button - delete track" }, - "trackMetadataRedownload": "Re-download", - "@trackMetadataRedownload": { - "description": "Action button - download again" - }, - "trackMetadataOpenFolder": "Open Folder", - "@trackMetadataOpenFolder": { - "description": "Action button - open containing folder" - }, - "setupTitle": "Welcome to SpotiFLAC", - "@setupTitle": { - "description": "Setup wizard title" - }, - "setupSubtitle": "Let's get you started", - "@setupSubtitle": { - "description": "Setup wizard subtitle" - }, - "setupStoragePermission": "Storage Permission", - "@setupStoragePermission": { - "description": "Storage permission step title" - }, - "setupStoragePermissionSubtitle": "Required to save downloaded files", - "@setupStoragePermissionSubtitle": { - "description": "Explanation for storage permission" - }, - "setupStoragePermissionGranted": "Permission granted", - "@setupStoragePermissionGranted": { - "description": "Status when permission granted" - }, - "setupStoragePermissionDenied": "Permission denied", - "@setupStoragePermissionDenied": { - "description": "Status when permission denied" - }, "setupGrantPermission": "Grant Permission", "@setupGrantPermission": { "description": "Button to request permission" }, - "setupDownloadLocation": "Download Location", - "@setupDownloadLocation": { - "description": "Download folder step title" - }, - "setupChooseFolder": "Choose Folder", - "@setupChooseFolder": { - "description": "Button to pick folder" - }, - "setupContinue": "Continue", - "@setupContinue": { - "description": "Continue to next step button" - }, "setupSkip": "Skip for now", "@setupSkip": { "description": "Skip current step button" @@ -796,10 +508,6 @@ "@setupStorageAccessRequired": { "description": "Title when storage access needed" }, - "setupStorageAccessMessage": "SpotiFLAC needs \"All files access\" permission to save music files to your chosen folder.", - "@setupStorageAccessMessage": { - "description": "Explanation for storage access" - }, "setupStorageAccessMessageAndroid11": "Android 11+ requires \"All files access\" permission to save files to your chosen download folder.", "@setupStorageAccessMessageAndroid11": { "description": "Android 11+ specific explanation" @@ -831,10 +539,6 @@ } } }, - "setupSelectDownloadFolder": "Select Download Folder", - "@setupSelectDownloadFolder": { - "description": "Folder selection step title" - }, "setupUseDefaultFolder": "Use Default Folder?", "@setupUseDefaultFolder": { "description": "Dialog title for default folder" @@ -883,26 +587,6 @@ "@setupDownloadInFlac": { "description": "App tagline in setup" }, - "setupStepStorage": "Storage", - "@setupStepStorage": { - "description": "Setup step indicator - storage" - }, - "setupStepNotification": "Notification", - "@setupStepNotification": { - "description": "Setup step indicator - notification" - }, - "setupStepFolder": "Folder", - "@setupStepFolder": { - "description": "Setup step indicator - folder" - }, - "setupStepSpotify": "Spotify", - "@setupStepSpotify": { - "description": "Setup step indicator - Spotify API" - }, - "setupStepPermission": "Permission", - "@setupStepPermission": { - "description": "Setup step indicator - permission" - }, "setupStorageGranted": "Storage Permission Granted!", "@setupStorageGranted": { "description": "Success message for storage permission" @@ -923,14 +607,6 @@ "@setupNotificationEnable": { "description": "Button to enable notifications" }, - "setupNotificationDescription": "Get notified when downloads complete or require attention.", - "@setupNotificationDescription": { - "description": "Explanation for notifications" - }, - "setupFolderSelected": "Download Folder Selected!", - "@setupFolderSelected": { - "description": "Success message for folder selection" - }, "setupFolderChoose": "Choose Download Folder", "@setupFolderChoose": { "description": "Button to choose folder" @@ -939,58 +615,14 @@ "@setupFolderDescription": { "description": "Explanation for folder selection" }, - "setupChangeFolder": "Change Folder", - "@setupChangeFolder": { - "description": "Button to change selected folder" - }, "setupSelectFolder": "Select Folder", "@setupSelectFolder": { "description": "Button to select folder" }, - "setupSpotifyApiOptional": "Spotify API (Optional)", - "@setupSpotifyApiOptional": { - "description": "Spotify API step title" - }, - "setupSpotifyApiDescription": "Add your Spotify API credentials for better search results and access to Spotify-exclusive content.", - "@setupSpotifyApiDescription": { - "description": "Explanation for Spotify API" - }, - "setupUseSpotifyApi": "Use Spotify API", - "@setupUseSpotifyApi": { - "description": "Toggle to enable Spotify API" - }, - "setupEnterCredentialsBelow": "Enter your credentials below", - "@setupEnterCredentialsBelow": { - "description": "Prompt to enter credentials" - }, - "setupUsingDeezer": "Using Deezer (no account needed)", - "@setupUsingDeezer": { - "description": "Status when using Deezer" - }, - "setupEnterClientId": "Enter Spotify Client ID", - "@setupEnterClientId": { - "description": "Placeholder for client ID field" - }, - "setupEnterClientSecret": "Enter Spotify Client Secret", - "@setupEnterClientSecret": { - "description": "Placeholder for client secret field" - }, - "setupGetFreeCredentials": "Get your free API credentials from the Spotify Developer Dashboard.", - "@setupGetFreeCredentials": { - "description": "Info about getting Spotify credentials" - }, "setupEnableNotifications": "Enable Notifications", "@setupEnableNotifications": { "description": "Button to enable notifications" }, - "setupProceedToNextStep": "You can now proceed to the next step.", - "@setupProceedToNextStep": { - "description": "Message after completing a step" - }, - "setupNotificationProgressDescription": "You will receive download progress notifications.", - "@setupNotificationProgressDescription": { - "description": "Info about notification usage" - }, "setupNotificationBackgroundDescription": "Get notified about download progress and completion. This helps you track downloads when the app is in background.", "@setupNotificationBackgroundDescription": { "description": "Detailed notification explanation" @@ -999,10 +631,6 @@ "@setupSkipForNow": { "description": "Skip button text" }, - "setupBack": "Back", - "@setupBack": { - "description": "Back button text" - }, "setupNext": "Next", "@setupNext": { "description": "Next button text" @@ -1011,26 +639,14 @@ "@setupGetStarted": { "description": "Final setup button" }, - "setupSkipAndStart": "Skip & Start", - "@setupSkipAndStart": { - "description": "Skip setup and start app" - }, "setupAllowAccessToManageFiles": "Please enable \"Allow access to manage all files\" in the next screen.", "@setupAllowAccessToManageFiles": { "description": "Instruction for file access permission" }, - "setupGetCredentialsFromSpotify": "Get credentials from developer.spotify.com", - "@setupGetCredentialsFromSpotify": { - "description": "Link text for Spotify developer portal" - }, "dialogCancel": "Cancel", "@dialogCancel": { "description": "Dialog button - cancel action" }, - "dialogOk": "OK", - "@dialogOk": { - "description": "Dialog button - confirm/acknowledge" - }, "dialogSave": "Save", "@dialogSave": { "description": "Dialog button - save changes" @@ -1043,26 +659,10 @@ "@dialogRetry": { "description": "Dialog button - retry action" }, - "dialogClose": "Close", - "@dialogClose": { - "description": "Dialog button - close dialog" - }, - "dialogYes": "Yes", - "@dialogYes": { - "description": "Dialog button - confirm yes" - }, - "dialogNo": "No", - "@dialogNo": { - "description": "Dialog button - confirm no" - }, "dialogClear": "Clear", "@dialogClear": { "description": "Dialog button - clear items" }, - "dialogConfirm": "Confirm", - "@dialogConfirm": { - "description": "Dialog button - confirm action" - }, "dialogDone": "Done", "@dialogDone": { "description": "Dialog button - action completed" @@ -1091,34 +691,10 @@ "@dialogUnsavedChanges": { "description": "Dialog message - unsaved changes" }, - "dialogDownloadFailed": "Download Failed", - "@dialogDownloadFailed": { - "description": "Dialog title - download error" - }, - "dialogTrackLabel": "Track:", - "@dialogTrackLabel": { - "description": "Label for track name in error dialog" - }, - "dialogArtistLabel": "Artist:", - "@dialogArtistLabel": { - "description": "Label for artist name in error dialog" - }, - "dialogErrorLabel": "Error:", - "@dialogErrorLabel": { - "description": "Label for error message" - }, "dialogClearAll": "Clear All", "@dialogClearAll": { "description": "Dialog title - clear all items" }, - "dialogClearAllDownloads": "Are you sure you want to clear all downloads?", - "@dialogClearAllDownloads": { - "description": "Dialog message - clear downloads confirmation" - }, - "dialogRemoveFromDevice": "Remove from device?", - "@dialogRemoveFromDevice": { - "description": "Dialog title - delete file confirmation" - }, "dialogRemoveExtension": "Remove Extension", "@dialogRemoveExtension": { "description": "Dialog title - uninstall extension" @@ -1257,15 +833,6 @@ "@snackbarViewQueue": { "description": "Snackbar action - view download queue" }, - "snackbarFailedToLoad": "Failed to load: {error}", - "@snackbarFailedToLoad": { - "description": "Snackbar - loading error", - "placeholders": { - "error": { - "type": "String" - } - } - }, "snackbarUrlCopied": "{platform} URL copied to clipboard", "@snackbarUrlCopied": { "description": "Snackbar - URL copied", @@ -1326,16 +893,6 @@ "@errorRateLimitedMessage": { "description": "Error message - rate limit explanation" }, - "errorFailedToLoad": "Failed to load {item}", - "@errorFailedToLoad": { - "description": "Error message - loading failed", - "placeholders": { - "item": { - "type": "String", - "description": "Item that failed to load (album/playlist/etc)" - } - } - }, "errorNoTracksFound": "No tracks found", "@errorNoTracksFound": { "description": "Error - search returned no results" @@ -1349,34 +906,6 @@ } } }, - "statusQueued": "Queued", - "@statusQueued": { - "description": "Download status - waiting in queue" - }, - "statusDownloading": "Downloading", - "@statusDownloading": { - "description": "Download status - in progress" - }, - "statusFinalizing": "Finalizing", - "@statusFinalizing": { - "description": "Download status - writing metadata" - }, - "statusCompleted": "Completed", - "@statusCompleted": { - "description": "Download status - finished" - }, - "statusFailed": "Failed", - "@statusFailed": { - "description": "Download status - error occurred" - }, - "statusSkipped": "Skipped", - "@statusSkipped": { - "description": "Download status - already exists" - }, - "statusPaused": "Paused", - "@statusPaused": { - "description": "Download status - paused" - }, "actionPause": "Pause", "@actionPause": { "description": "Action button - pause download" @@ -1389,14 +918,6 @@ "@actionCancel": { "description": "Action button - cancel operation" }, - "actionStop": "Stop", - "@actionStop": { - "description": "Action button - stop operation" - }, - "actionSelect": "Select", - "@actionSelect": { - "description": "Action button - enter selection mode" - }, "actionSelectAll": "Select All", "@actionSelectAll": { "description": "Action button - select all items" @@ -1405,14 +926,6 @@ "@actionDeselect": { "description": "Action button - deselect all" }, - "actionPaste": "Paste", - "@actionPaste": { - "description": "Action button - paste from clipboard" - }, - "actionImportCsv": "Import CSV", - "@actionImportCsv": { - "description": "Action button - import CSV file" - }, "actionRemoveCredentials": "Remove Credentials", "@actionRemoveCredentials": { "description": "Action button - delete Spotify credentials" @@ -1434,19 +947,6 @@ "@selectionAllSelected": { "description": "Status - all items selected" }, - "selectionTapToSelect": "Tap tracks to select", - "@selectionTapToSelect": { - "description": "Hint - how to select items" - }, - "selectionDeleteTracks": "Delete {count} {count, plural, =1{track} other{tracks}}", - "@selectionDeleteTracks": { - "description": "Delete button with count", - "placeholders": { - "count": { - "type": "int" - } - } - }, "selectionSelectToDelete": "Select tracks to delete", "@selectionSelectToDelete": { "description": "Placeholder when nothing selected" @@ -1487,55 +987,10 @@ "@tooltipPlay": { "description": "Tooltip - play button" }, - "tooltipCancel": "Cancel", - "@tooltipCancel": { - "description": "Tooltip - cancel button" - }, - "tooltipStop": "Stop", - "@tooltipStop": { - "description": "Tooltip - stop button" - }, - "tooltipRetry": "Retry", - "@tooltipRetry": { - "description": "Tooltip - retry button" - }, - "tooltipRemove": "Remove", - "@tooltipRemove": { - "description": "Tooltip - remove button" - }, - "tooltipClear": "Clear", - "@tooltipClear": { - "description": "Tooltip - clear button" - }, - "tooltipPaste": "Paste", - "@tooltipPaste": { - "description": "Tooltip - paste button" - }, "filenameFormat": "Filename Format", "@filenameFormat": { "description": "Setting title - filename pattern" }, - "filenameFormatPreview": "Preview: {preview}", - "@filenameFormatPreview": { - "description": "Preview of filename pattern", - "placeholders": { - "preview": { - "type": "String" - } - } - }, - "filenameAvailablePlaceholders": "Available placeholders:", - "@filenameAvailablePlaceholders": { - "description": "Label for placeholder list" - }, - "filenameHint": "{artist} - {title}", - "@filenameHint": { - "description": "Default filename format hint" - }, - "folderOrganization": "Folder Organization", - "@folderOrganization": { - "description": "Setting title - folder structure" - }, "folderOrganizationNone": "No organization", "@folderOrganizationNone": { "description": "Folder option - flat structure" @@ -1576,27 +1031,10 @@ "@updateAvailable": { "description": "Update dialog title" }, - "updateNewVersion": "Version {version} is available", - "@updateNewVersion": { - "description": "Update available message", - "placeholders": { - "version": { - "type": "String" - } - } - }, - "updateDownload": "Download", - "@updateDownload": { - "description": "Update button - download update" - }, "updateLater": "Later", "@updateLater": { "description": "Update button - dismiss" }, - "updateChangelog": "Changelog", - "@updateChangelog": { - "description": "Link to changelog" - }, "updateStartingDownload": "Starting download...", "@updateStartingDownload": { "description": "Update status - initializing" @@ -1637,14 +1075,6 @@ "@updateDontRemind": { "description": "Update button - skip this version" }, - "providerPriority": "Provider Priority", - "@providerPriority": { - "description": "Setting title - download provider order" - }, - "providerPrioritySubtitle": "Drag to reorder download providers", - "@providerPrioritySubtitle": { - "description": "Subtitle for provider priority" - }, "providerPriorityTitle": "Provider Priority", "@providerPriorityTitle": { "description": "Provider priority page title" @@ -1665,14 +1095,6 @@ "@providerExtension": { "description": "Label for extension-provided providers" }, - "metadataProviderPriority": "Metadata Provider Priority", - "@metadataProviderPriority": { - "description": "Setting title - metadata provider order" - }, - "metadataProviderPrioritySubtitle": "Order used when fetching track metadata", - "@metadataProviderPrioritySubtitle": { - "description": "Subtitle for metadata priority" - }, "metadataProviderPriorityTitle": "Metadata Priority", "@metadataProviderPriorityTitle": { "description": "Metadata priority page title" @@ -1697,22 +1119,6 @@ "@logTitle": { "description": "Logs screen title" }, - "logCopy": "Copy Logs", - "@logCopy": { - "description": "Action - copy logs to clipboard" - }, - "logClear": "Clear Logs", - "@logClear": { - "description": "Action - delete all logs" - }, - "logShare": "Share Logs", - "@logShare": { - "description": "Action - share logs file" - }, - "logEmpty": "No logs yet", - "@logEmpty": { - "description": "Empty state title" - }, "logCopied": "Logs copied to clipboard", "@logCopied": { "description": "Snackbar - logs copied" @@ -1745,22 +1151,6 @@ "@logClearLogsMessage": { "description": "Clear logs confirmation message" }, - "logIspBlocking": "ISP BLOCKING DETECTED", - "@logIspBlocking": { - "description": "Error category - ISP blocking" - }, - "logRateLimited": "RATE LIMITED", - "@logRateLimited": { - "description": "Error category - rate limiting" - }, - "logNetworkError": "NETWORK ERROR", - "@logNetworkError": { - "description": "Error category - network issues" - }, - "logTrackNotFound": "TRACK NOT FOUND", - "@logTrackNotFound": { - "description": "Error category - missing tracks" - }, "logFilterBySeverity": "Filter logs by severity", "@logFilterBySeverity": { "description": "Filter dialog title" @@ -1773,60 +1163,6 @@ "@logNoLogsYetSubtitle": { "description": "Empty state subtitle" }, - "logIssueSummary": "Issue Summary", - "@logIssueSummary": { - "description": "Section header for error summary" - }, - "logIspBlockingDescription": "Your ISP may be blocking access to download services", - "@logIspBlockingDescription": { - "description": "ISP blocking explanation" - }, - "logIspBlockingSuggestion": "Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8", - "@logIspBlockingSuggestion": { - "description": "ISP blocking fix suggestion" - }, - "logRateLimitedDescription": "Too many requests to the service", - "@logRateLimitedDescription": { - "description": "Rate limit explanation" - }, - "logRateLimitedSuggestion": "Wait a few minutes before trying again", - "@logRateLimitedSuggestion": { - "description": "Rate limit fix suggestion" - }, - "logNetworkErrorDescription": "Connection issues detected", - "@logNetworkErrorDescription": { - "description": "Network error explanation" - }, - "logNetworkErrorSuggestion": "Check your internet connection", - "@logNetworkErrorSuggestion": { - "description": "Network error fix suggestion" - }, - "logTrackNotFoundDescription": "Some tracks could not be found on download services", - "@logTrackNotFoundDescription": { - "description": "Track not found explanation" - }, - "logTrackNotFoundSuggestion": "The track may not be available in lossless quality", - "@logTrackNotFoundSuggestion": { - "description": "Track not found explanation" - }, - "logTotalErrors": "Total errors: {count}", - "@logTotalErrors": { - "description": "Error count display", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "logAffected": "Affected: {domains}", - "@logAffected": { - "description": "Affected domains display", - "placeholders": { - "domains": { - "type": "String" - } - } - }, "logEntriesFiltered": "Entries ({count} filtered)", "@logEntriesFiltered": { "description": "Log count with filter active", @@ -1969,10 +1305,6 @@ "@appearanceLanguage": { "description": "Language setting title" }, - "appearanceLanguageSubtitle": "Choose your preferred language", - "@appearanceLanguageSubtitle": { - "description": "Language setting subtitle" - }, "settingsAppearanceSubtitle": "Theme, colors, display", "@settingsAppearanceSubtitle": { "description": "Appearance settings description" @@ -2001,10 +1333,6 @@ "@pressBackAgainToExit": { "description": "Exit confirmation message" }, - "tracksHeader": "Tracks", - "@tracksHeader": { - "description": "Section header for track list" - }, "downloadAllCount": "Download All ({count})", "@downloadAllCount": { "description": "Download all button with count", @@ -2151,15 +1479,6 @@ "@trackDeleteConfirmMessage": { "description": "Delete confirmation message" }, - "trackCannotOpen": "Cannot open: {message}", - "@trackCannotOpen": { - "description": "Error opening file", - "placeholders": { - "message": { - "type": "String" - } - } - }, "dateToday": "Today", "@dateToday": { "description": "Relative date - today" @@ -2195,22 +1514,6 @@ } } }, - "concurrentSequential": "Sequential", - "@concurrentSequential": { - "description": "Download mode - one at a time" - }, - "concurrentParallel2": "2 Parallel", - "@concurrentParallel2": { - "description": "Download mode - 2 simultaneous" - }, - "concurrentParallel3": "3 Parallel", - "@concurrentParallel3": { - "description": "Download mode - 3 simultaneous" - }, - "tapToSeeError": "Tap to see error details", - "@tapToSeeError": { - "description": "Tooltip for failed download" - }, "storeFilterAll": "All", "@storeFilterAll": { "description": "Store filter - all extensions" @@ -2239,18 +1542,6 @@ "@storeClearFilters": { "description": "Button to clear all filters" }, - "storeNoResults": "No extensions found", - "@storeNoResults": { - "description": "Empty state when no extensions match filters" - }, - "extensionProviderPriority": "Provider Priority", - "@extensionProviderPriority": { - "description": "Extension capability - provider priority" - }, - "extensionInstallButton": "Install Extension", - "@extensionInstallButton": { - "description": "Button to install extension" - }, "extensionDefaultProvider": "Default (Deezer/Spotify)", "@extensionDefaultProvider": { "description": "Default search provider option" @@ -2450,46 +1741,6 @@ "@qualityHiResFlacMaxSubtitle": { "description": "Technical spec for hi-res max" }, - "qualityLossy": "Lossy", - "@qualityLossy": { - "description": "Quality option - lossy format (MP3/Opus)" - }, - "qualityLossyMp3Subtitle": "MP3 320kbps (converted from FLAC)", - "@qualityLossyMp3Subtitle": { - "description": "Technical spec for lossy MP3" - }, - "qualityLossyOpusSubtitle": "Opus 128kbps (converted from FLAC)", - "@qualityLossyOpusSubtitle": { - "description": "Technical spec for lossy Opus" - }, - "enableLossyOption": "Enable Lossy Option", - "@enableLossyOption": { - "description": "Setting - enable lossy quality option" - }, - "enableLossyOptionSubtitleOn": "Lossy quality option is available", - "@enableLossyOptionSubtitleOn": { - "description": "Subtitle when lossy is enabled" - }, - "enableLossyOptionSubtitleOff": "Downloads FLAC then converts to lossy format", - "@enableLossyOptionSubtitleOff": { - "description": "Subtitle when lossy is disabled" - }, - "lossyFormat": "Lossy Format", - "@lossyFormat": { - "description": "Setting - choose lossy format" - }, - "lossyFormatDescription": "Choose the lossy format for conversion", - "@lossyFormatDescription": { - "description": "Description for lossy format picker" - }, - "lossyFormatMp3Subtitle": "320kbps, best compatibility", - "@lossyFormatMp3Subtitle": { - "description": "MP3 format description" - }, - "lossyFormatOpusSubtitle": "128kbps, better quality at smaller size", - "@lossyFormatOpusSubtitle": { - "description": "Opus format description" - }, "qualityNote": "Actual quality depends on track availability from the service", "@qualityNote": { "description": "Note about quality availability" @@ -2518,14 +1769,6 @@ "@downloadUseAlbumArtistForFolders": { "description": "Setting - choose whether artist folders use Album Artist or Track Artist" }, - "downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available", - "@downloadUseAlbumArtistForFoldersAlbumSubtitle": { - "description": "Subtitle when Album Artist is used for folder naming" - }, - "downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only", - "@downloadUseAlbumArtistForFoldersTrackSubtitle": { - "description": "Subtitle when Track Artist is used for folder naming" - }, "downloadUsePrimaryArtistOnly": "Primary artist only for folders", "@downloadUsePrimaryArtistOnly": { "description": "Setting - strip featured artists from folder name" @@ -2538,14 +1781,6 @@ "@downloadUsePrimaryArtistOnlyDisabled": { "description": "Subtitle when primary artist only is disabled" }, - "downloadSaveFormat": "Save Format", - "@downloadSaveFormat": { - "description": "Setting - output file format" - }, - "downloadSelectService": "Select Service", - "@downloadSelectService": { - "description": "Dialog title - choose download service" - }, "downloadSelectQuality": "Select Quality", "@downloadSelectQuality": { "description": "Dialog title - choose audio quality" @@ -2554,66 +1789,6 @@ "@downloadFrom": { "description": "Label - download source" }, - "downloadDefaultQualityLabel": "Default Quality", - "@downloadDefaultQualityLabel": { - "description": "Label - default quality setting" - }, - "downloadBestAvailable": "Best available", - "@downloadBestAvailable": { - "description": "Quality option - highest available" - }, - "folderNone": "None", - "@folderNone": { - "description": "Folder option - no organization" - }, - "folderNoneSubtitle": "Save all files directly to download folder", - "@folderNoneSubtitle": { - "description": "Subtitle for no folder organization" - }, - "folderArtist": "Artist", - "@folderArtist": { - "description": "Folder option - by artist" - }, - "folderArtistSubtitle": "Artist Name/filename", - "@folderArtistSubtitle": { - "description": "Folder structure example" - }, - "folderAlbum": "Album", - "@folderAlbum": { - "description": "Folder option - by album" - }, - "folderAlbumSubtitle": "Album Name/filename", - "@folderAlbumSubtitle": { - "description": "Folder structure example" - }, - "folderArtistAlbum": "Artist/Album", - "@folderArtistAlbum": { - "description": "Folder option - nested" - }, - "folderArtistAlbumSubtitle": "Artist Name/Album Name/filename", - "@folderArtistAlbumSubtitle": { - "description": "Folder structure example" - }, - "serviceTidal": "Tidal", - "@serviceTidal": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceQobuz": "Qobuz", - "@serviceQobuz": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceAmazon": "Amazon", - "@serviceAmazon": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceDeezer": "Deezer", - "@serviceDeezer": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceSpotify": "Spotify", - "@serviceSpotify": { - "description": "Service name - DO NOT TRANSLATE" - }, "appearanceAmoledDark": "AMOLED Dark", "@appearanceAmoledDark": { "description": "Theme option - pure black" @@ -2622,18 +1797,6 @@ "@appearanceAmoledDarkSubtitle": { "description": "Subtitle for AMOLED dark" }, - "appearanceChooseAccentColor": "Choose Accent Color", - "@appearanceChooseAccentColor": { - "description": "Color picker dialog title" - }, - "appearanceChooseTheme": "Theme Mode", - "@appearanceChooseTheme": { - "description": "Theme picker dialog title" - }, - "queueTitle": "Download Queue", - "@queueTitle": { - "description": "Queue screen title" - }, "queueClearAll": "Clear All", "@queueClearAll": { "description": "Button - clear all queue items" @@ -2642,22 +1805,6 @@ "@queueClearAllMessage": { "description": "Clear queue confirmation" }, - "queueExportFailed": "Export", - "@queueExportFailed": { - "description": "Button - export failed downloads to TXT" - }, - "queueExportFailedSuccess": "Failed downloads exported to TXT file", - "@queueExportFailedSuccess": { - "description": "Success message after exporting failed downloads" - }, - "queueExportFailedClear": "Clear Failed", - "@queueExportFailedClear": { - "description": "Action to clear failed downloads after export" - }, - "queueExportFailedError": "Failed to export downloads", - "@queueExportFailedError": { - "description": "Error message when export fails" - }, "settingsAutoExportFailed": "Auto-export failed downloads", "@settingsAutoExportFailed": { "description": "Setting toggle for auto-export" @@ -2682,38 +1829,6 @@ "@settingsDownloadNetworkSubtitle": { "description": "Subtitle explaining network preference" }, - "queueEmpty": "No downloads in queue", - "@queueEmpty": { - "description": "Empty queue state title" - }, - "queueEmptySubtitle": "Add tracks from the home screen", - "@queueEmptySubtitle": { - "description": "Empty queue state subtitle" - }, - "queueClearCompleted": "Clear completed", - "@queueClearCompleted": { - "description": "Button - clear finished downloads" - }, - "queueDownloadFailed": "Download Failed", - "@queueDownloadFailed": { - "description": "Error dialog title" - }, - "queueTrackLabel": "Track:", - "@queueTrackLabel": { - "description": "Label in error dialog" - }, - "queueArtistLabel": "Artist:", - "@queueArtistLabel": { - "description": "Label in error dialog" - }, - "queueErrorLabel": "Error:", - "@queueErrorLabel": { - "description": "Label in error dialog" - }, - "queueUnknownError": "Unknown error", - "@queueUnknownError": { - "description": "Fallback error message" - }, "albumFolderArtistAlbum": "Artist / Album", "@albumFolderArtistAlbum": { "description": "Album folder option" @@ -2767,19 +1882,6 @@ } } }, - "downloadedAlbumTracksHeader": "Tracks", - "@downloadedAlbumTracksHeader": { - "description": "Section header for tracks" - }, - "downloadedAlbumDownloadedCount": "{count} downloaded", - "@downloadedAlbumDownloadedCount": { - "description": "Downloaded tracks count badge", - "placeholders": { - "count": { - "type": "int" - } - } - }, "downloadedAlbumSelectedCount": "{count} selected", "@downloadedAlbumSelectedCount": { "description": "Selection count indicator", @@ -2820,10 +1922,6 @@ } } }, - "utilityFunctions": "Utility Functions", - "@utilityFunctions": { - "description": "Extension capability - utility functions" - }, "recentTypeArtist": "Artist", "@recentTypeArtist": { "description": "Recent access item type - artist" @@ -2858,16 +1956,6 @@ } } }, - "errorGeneric": "Error: {message}", - "@errorGeneric": { - "description": "Generic error message format", - "placeholders": { - "message": { - "type": "String", - "description": "Error message" - } - } - }, "discographyDownload": "Download Discography", "@discographyDownload": { "description": "Button - download artist discography" @@ -3034,10 +2122,6 @@ "@libraryTitle": { "description": "Library settings page title" }, - "libraryStatus": "Library Status", - "@libraryStatus": { - "description": "Section header for library status" - }, "libraryScanSettings": "Scan Settings", "@libraryScanSettings": { "description": "Section header for scan settings" @@ -3114,15 +2198,6 @@ "@libraryAboutDescription": { "description": "Description of local library feature" }, - "libraryTracksCount": "{count} tracks", - "@libraryTracksCount": { - "description": "Track count in library", - "placeholders": { - "count": { - "type": "int" - } - } - }, "libraryLastScanned": "Last scanned: {time}", "@libraryLastScanned": { "description": "Last scan time display", @@ -3237,26 +2312,6 @@ "@libraryFilterFormat": { "description": "Filter section - file format" }, - "libraryFilterDate": "Date Added", - "@libraryFilterDate": { - "description": "Filter section - date range" - }, - "libraryFilterDateToday": "Today", - "@libraryFilterDateToday": { - "description": "Filter option - today only" - }, - "libraryFilterDateWeek": "This Week", - "@libraryFilterDateWeek": { - "description": "Filter option - this week" - }, - "libraryFilterDateMonth": "This Month", - "@libraryFilterDateMonth": { - "description": "Filter option - this month" - }, - "libraryFilterDateYear": "This Year", - "@libraryFilterDateYear": { - "description": "Filter option - this year" - }, "libraryFilterSort": "Sort", "@libraryFilterSort": { "description": "Filter section - sort order" @@ -3269,15 +2324,6 @@ "@libraryFilterSortOldest": { "description": "Sort option - oldest first" }, - "libraryFilterActive": "{count} filter(s) active", - "@libraryFilterActive": { - "description": "Badge showing number of active filters", - "placeholders": { - "count": { - "type": "int" - } - } - }, "timeJustNow": "Just now", "@timeJustNow": { "description": "Relative time - less than a minute ago" @@ -3300,106 +2346,6 @@ } } }, - "storageSwitchTitle": "Switch Storage Mode", - "@storageSwitchTitle": { - "description": "Dialog title when switching storage mode" - }, - "storageSwitchToSafTitle": "Switch to SAF Storage?", - "@storageSwitchToSafTitle": { - "description": "Dialog title when switching to SAF" - }, - "storageSwitchToAppTitle": "Switch to App Storage?", - "@storageSwitchToAppTitle": { - "description": "Dialog title when switching to app storage" - }, - "storageSwitchToSafMessage": "Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.", - "@storageSwitchToSafMessage": { - "description": "Explanation when switching to SAF" - }, - "storageSwitchToAppMessage": "Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.", - "@storageSwitchToAppMessage": { - "description": "Explanation when switching to app storage" - }, - "storageSwitchExistingDownloads": "Existing Downloads", - "@storageSwitchExistingDownloads": { - "description": "Section header for existing downloads info" - }, - "storageSwitchExistingDownloadsInfo": "{count} tracks in {mode} storage", - "@storageSwitchExistingDownloadsInfo": { - "description": "Info about existing downloads count", - "placeholders": { - "count": { - "type": "int" - }, - "mode": { - "type": "String" - } - } - }, - "storageSwitchNewDownloads": "New Downloads", - "@storageSwitchNewDownloads": { - "description": "Section header for new downloads info" - }, - "storageSwitchNewDownloadsLocation": "Will be saved to: {location}", - "@storageSwitchNewDownloadsLocation": { - "description": "Shows where new downloads will go", - "placeholders": { - "location": { - "type": "String" - } - } - }, - "storageSwitchContinue": "Continue", - "@storageSwitchContinue": { - "description": "Button to proceed with storage switch" - }, - "storageSwitchSelectFolder": "Select SAF Folder", - "@storageSwitchSelectFolder": { - "description": "Button to select SAF folder" - }, - "storageAppStorage": "App Storage", - "@storageAppStorage": { - "description": "Label for app storage mode" - }, - "storageSafStorage": "SAF Storage", - "@storageSafStorage": { - "description": "Label for SAF storage mode" - }, - "storageModeBadge": "Storage: {mode}", - "@storageModeBadge": { - "description": "Badge showing storage mode for a track", - "placeholders": { - "mode": { - "type": "String" - } - } - }, - "storageStatsTitle": "Storage Statistics", - "@storageStatsTitle": { - "description": "Section title for storage stats" - }, - "storageStatsAppCount": "{count} tracks in App Storage", - "@storageStatsAppCount": { - "description": "Count of tracks in app storage", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "storageStatsSafCount": "{count} tracks in SAF Storage", - "@storageStatsSafCount": { - "description": "Count of tracks in SAF storage", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "storageModeInfo": "Your files are stored in multiple locations", - "@storageModeInfo": { - "description": "Info when user has files in both storage modes" - }, "tutorialWelcomeTitle": "Welcome to SpotiFLAC!", "@tutorialWelcomeTitle": { "description": "Tutorial welcome page title" @@ -3428,18 +2374,6 @@ "@tutorialSearchDesc": { "description": "Tutorial search page description" }, - "tutorialSearchTip1": "Paste a Spotify or Deezer URL directly in the search box", - "@tutorialSearchTip1": { - "description": "Tutorial search tip 1" - }, - "tutorialSearchTip2": "Or type the song name, artist, or album to search", - "@tutorialSearchTip2": { - "description": "Tutorial search tip 2" - }, - "tutorialSearchTip3": "Supports tracks, albums, playlists, and artist pages", - "@tutorialSearchTip3": { - "description": "Tutorial search tip 3" - }, "tutorialDownloadTitle": "Downloading Music", "@tutorialDownloadTitle": { "description": "Tutorial download page title" @@ -3448,18 +2382,6 @@ "@tutorialDownloadDesc": { "description": "Tutorial download page description" }, - "tutorialDownloadTip1": "Tap the download button next to any track to start downloading", - "@tutorialDownloadTip1": { - "description": "Tutorial download tip 1" - }, - "tutorialDownloadTip2": "Choose your preferred quality (FLAC, Hi-Res, or MP3)", - "@tutorialDownloadTip2": { - "description": "Tutorial download tip 2" - }, - "tutorialDownloadTip3": "Download entire albums or playlists with one tap", - "@tutorialDownloadTip3": { - "description": "Tutorial download tip 3" - }, "tutorialLibraryTitle": "Your Library", "@tutorialLibraryTitle": { "description": "Tutorial library page title" @@ -3524,10 +2446,6 @@ "@tutorialReadyMessage": { "description": "Tutorial completion message" }, - "tutorialExample": "EXAMPLE", - "@tutorialExample": { - "description": "Example label in tutorial" - }, "libraryForceFullScan": "Force Full Scan", "@libraryForceFullScan": { "description": "Button to force a complete rescan of library" @@ -3750,14 +2668,10 @@ "@trackSaveLyricsProgress": { "description": "Snackbar while saving lyrics to file" }, - "trackReEnrich": "Re-enrich Metadata", + "trackReEnrich": "Re-enrich", "@trackReEnrich": { "description": "Menu action - re-embed metadata into audio file" }, - "trackReEnrichSubtitle": "Re-embed metadata without re-downloading", - "@trackReEnrichSubtitle": { - "description": "Subtitle for re-enrich metadata action" - }, "trackReEnrichOnlineSubtitle": "Search metadata online and embed into file", "@trackReEnrichOnlineSubtitle": { "description": "Subtitle for re-enrich metadata action for local items" @@ -3868,5 +2782,22 @@ "trackConvertFailed": "Conversion failed", "@trackConvertFailed": { "description": "Snackbar when conversion fails" + }, + "downloadedAlbumDownloadedCount": "{count} downloaded", + "@downloadedAlbumDownloadedCount": { + "description": "Downloaded tracks count badge", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available", + "@downloadUseAlbumArtistForFoldersAlbumSubtitle": { + "description": "Subtitle when Album Artist is used for folder naming" + }, + "downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only", + "@downloadUseAlbumArtistForFoldersTrackSubtitle": { + "description": "Subtitle when Track Artist is used for folder naming" } -} \ No newline at end of file +} diff --git a/lib/l10n/arb/app_pt.arb b/lib/l10n/arb/app_pt.arb index 654f6c08..503247dd 100644 --- a/lib/l10n/arb/app_pt.arb +++ b/lib/l10n/arb/app_pt.arb @@ -5,18 +5,10 @@ "@appName": { "description": "App name - DO NOT TRANSLATE" }, - "appDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.", - "@appDescription": { - "description": "App description shown in about page" - }, "navHome": "Home", "@navHome": { "description": "Bottom navigation - Home tab" }, - "navHistory": "History", - "@navHistory": { - "description": "Bottom navigation - History tab" - }, "navSettings": "Settings", "@navSettings": { "description": "Bottom navigation - Settings tab" @@ -29,20 +21,6 @@ "@homeTitle": { "description": "Home screen title" }, - "homeSearchHint": "Paste Spotify URL or search...", - "@homeSearchHint": { - "description": "Placeholder text in search box" - }, - "homeSearchHintExtension": "Search with {extensionName}...", - "@homeSearchHintExtension": { - "description": "Placeholder when extension search is active", - "placeholders": { - "extensionName": { - "type": "String", - "description": "Name of the active extension" - } - } - }, "homeSubtitle": "Paste a Spotify link or search by name", "@homeSubtitle": { "description": "Subtitle shown below search box" @@ -55,24 +33,6 @@ "@homeRecent": { "description": "Section header for recent searches" }, - "historyTitle": "History", - "@historyTitle": { - "description": "History screen title" - }, - "historyDownloading": "Downloading ({count})", - "@historyDownloading": { - "description": "Tab showing active downloads count", - "placeholders": { - "count": { - "type": "int", - "description": "Number of active downloads" - } - } - }, - "historyDownloaded": "Downloaded", - "@historyDownloaded": { - "description": "Tab showing completed downloads" - }, "historyFilterAll": "All", "@historyFilterAll": { "description": "Filter chip - show all items" @@ -85,48 +45,6 @@ "@historyFilterSingles": { "description": "Filter chip - show singles only" }, - "historyTracksCount": "{count, plural, =1{1 track} other{{count} tracks}}", - "@historyTracksCount": { - "description": "Track count with plural form", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "historyAlbumsCount": "{count, plural, =1{1 album} other{{count} albums}}", - "@historyAlbumsCount": { - "description": "Album count with plural form", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "historyNoDownloads": "No download history", - "@historyNoDownloads": { - "description": "Empty state title" - }, - "historyNoDownloadsSubtitle": "Downloaded tracks will appear here", - "@historyNoDownloadsSubtitle": { - "description": "Empty state subtitle" - }, - "historyNoAlbums": "No album downloads", - "@historyNoAlbums": { - "description": "Empty state when filtering albums" - }, - "historyNoAlbumsSubtitle": "Download multiple tracks from an album to see them here", - "@historyNoAlbumsSubtitle": { - "description": "Empty state subtitle for albums filter" - }, - "historyNoSingles": "No single downloads", - "@historyNoSingles": { - "description": "Empty state when filtering singles" - }, - "historyNoSinglesSubtitle": "Single track downloads will appear here", - "@historyNoSinglesSubtitle": { - "description": "Empty state subtitle for singles filter" - }, "settingsTitle": "Settings", "@settingsTitle": { "description": "Settings screen title" @@ -155,34 +73,6 @@ "@downloadTitle": { "description": "Download settings page title" }, - "downloadLocation": "Download Location", - "@downloadLocation": { - "description": "Setting for download folder" - }, - "downloadLocationSubtitle": "Choose where to save files", - "@downloadLocationSubtitle": { - "description": "Subtitle for download location" - }, - "downloadLocationDefault": "Default location", - "@downloadLocationDefault": { - "description": "Shown when using default folder" - }, - "downloadDefaultService": "Default Service", - "@downloadDefaultService": { - "description": "Setting for preferred download service (Tidal/Qobuz/Amazon)" - }, - "downloadDefaultServiceSubtitle": "Service used for downloads", - "@downloadDefaultServiceSubtitle": { - "description": "Subtitle for default service" - }, - "downloadDefaultQuality": "Default Quality", - "@downloadDefaultQuality": { - "description": "Setting for audio quality" - }, - "downloadAskQuality": "Ask Quality Before Download", - "@downloadAskQuality": { - "description": "Toggle to show quality picker" - }, "downloadAskQualitySubtitle": "Show quality picker for each download", "@downloadAskQualitySubtitle": { "description": "Subtitle for ask quality toggle" @@ -195,38 +85,10 @@ "@downloadFolderOrganization": { "description": "Setting for folder structure" }, - "downloadSeparateSingles": "Separate Singles", - "@downloadSeparateSingles": { - "description": "Toggle to separate single tracks" - }, - "downloadSeparateSinglesSubtitle": "Put single tracks in a separate folder", - "@downloadSeparateSinglesSubtitle": { - "description": "Subtitle for separate singles toggle" - }, - "qualityBest": "Best Available", - "@qualityBest": { - "description": "Audio quality option - highest available" - }, - "qualityFlac": "FLAC", - "@qualityFlac": { - "description": "Audio quality option - FLAC lossless" - }, - "quality320": "320 kbps", - "@quality320": { - "description": "Audio quality option - 320kbps MP3" - }, - "quality128": "128 kbps", - "@quality128": { - "description": "Audio quality option - 128kbps MP3" - }, "appearanceTitle": "Appearance", "@appearanceTitle": { "description": "Appearance settings page title" }, - "appearanceTheme": "Theme", - "@appearanceTheme": { - "description": "Theme mode setting" - }, "appearanceThemeSystem": "System", "@appearanceThemeSystem": { "description": "Follow system theme" @@ -247,10 +109,6 @@ "@appearanceDynamicColorSubtitle": { "description": "Subtitle for dynamic color" }, - "appearanceAccentColor": "Accent Color", - "@appearanceAccentColor": { - "description": "Custom accent color picker" - }, "appearanceHistoryView": "History View", "@appearanceHistoryView": { "description": "Layout style for history" @@ -267,10 +125,6 @@ "@optionsTitle": { "description": "Options settings page title" }, - "optionsSearchSource": "Search Source", - "@optionsSearchSource": { - "description": "Section for search provider settings" - }, "optionsPrimaryProvider": "Primary Provider", "@optionsPrimaryProvider": { "description": "Main search provider setting" @@ -426,22 +280,6 @@ "@extensionsTitle": { "description": "Extensions page title" }, - "extensionsInstalled": "Installed Extensions", - "@extensionsInstalled": { - "description": "Section header for installed extensions" - }, - "extensionsNone": "No extensions installed", - "@extensionsNone": { - "description": "Empty state title" - }, - "extensionsNoneSubtitle": "Install extensions from the Store tab", - "@extensionsNoneSubtitle": { - "description": "Empty state subtitle" - }, - "extensionsEnabled": "Enabled", - "@extensionsEnabled": { - "description": "Extension status - active" - }, "extensionsDisabled": "Disabled", "@extensionsDisabled": { "description": "Extension status - inactive" @@ -468,10 +306,6 @@ "@extensionsUninstall": { "description": "Uninstall extension button" }, - "extensionsSetAsSearch": "Set as Search Provider", - "@extensionsSetAsSearch": { - "description": "Use extension for search" - }, "storeTitle": "Extension Store", "@storeTitle": { "description": "Store screen title" @@ -544,10 +378,6 @@ "@aboutFeatureRequestSubtitle": { "description": "Subtitle for feature request" }, - "aboutSupport": "Support", - "@aboutSupport": { - "description": "Section for support/donation links" - }, "aboutApp": "App", "@aboutApp": { "description": "Section for app info" @@ -564,14 +394,6 @@ "@aboutSachinsenalDesc": { "description": "Credit description for sachinsenal0x64" }, - "aboutDoubleDouble": "DoubleDouble", - "@aboutDoubleDouble": { - "description": "Name of Amazon API service - DO NOT TRANSLATE" - }, - "aboutDoubleDoubleDesc": "Amazing API for Amazon Music downloads. Thank you for making it free!", - "@aboutDoubleDoubleDesc": { - "description": "Credit for DoubleDouble API" - }, "aboutDabMusic": "DAB Music", "@aboutDabMusic": { "description": "Name of Qobuz API service - DO NOT TRANSLATE" @@ -584,35 +406,6 @@ "@aboutAppDescription": { "description": "App description in header card" }, - "albumTitle": "Album", - "@albumTitle": { - "description": "Album screen title" - }, - "albumTracks": "{count, plural, =1{1 track} other{{count} tracks}}", - "@albumTracks": { - "description": "Album track count", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "albumDownloadAll": "Download All", - "@albumDownloadAll": { - "description": "Button to download all tracks" - }, - "albumDownloadRemaining": "Download Remaining", - "@albumDownloadRemaining": { - "description": "Button to download remaining tracks" - }, - "playlistTitle": "Playlist", - "@playlistTitle": { - "description": "Playlist screen title" - }, - "artistTitle": "Artist", - "@artistTitle": { - "description": "Artist screen title" - }, "artistAlbums": "Albums", "@artistAlbums": { "description": "Section header for artist albums" @@ -625,43 +418,6 @@ "@artistCompilations": { "description": "Section header for compilations" }, - "artistReleases": "{count, plural, =1{1 release} other{{count} releases}}", - "@artistReleases": { - "description": "Artist release count", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "trackMetadataTitle": "Track Info", - "@trackMetadataTitle": { - "description": "Track metadata screen title" - }, - "trackMetadataArtist": "Artist", - "@trackMetadataArtist": { - "description": "Metadata field - artist name" - }, - "trackMetadataAlbum": "Album", - "@trackMetadataAlbum": { - "description": "Metadata field - album name" - }, - "trackMetadataDuration": "Duration", - "@trackMetadataDuration": { - "description": "Metadata field - track length" - }, - "trackMetadataQuality": "Quality", - "@trackMetadataQuality": { - "description": "Metadata field - audio quality" - }, - "trackMetadataPath": "File Path", - "@trackMetadataPath": { - "description": "Metadata field - file location" - }, - "trackMetadataDownloadedAt": "Downloaded", - "@trackMetadataDownloadedAt": { - "description": "Metadata field - download date" - }, "trackMetadataService": "Service", "@trackMetadataService": { "description": "Metadata field - download service used" @@ -678,54 +434,10 @@ "@trackMetadataDelete": { "description": "Action button - delete track" }, - "trackMetadataRedownload": "Re-download", - "@trackMetadataRedownload": { - "description": "Action button - download again" - }, - "trackMetadataOpenFolder": "Open Folder", - "@trackMetadataOpenFolder": { - "description": "Action button - open containing folder" - }, - "setupTitle": "Welcome to SpotiFLAC", - "@setupTitle": { - "description": "Setup wizard title" - }, - "setupSubtitle": "Let's get you started", - "@setupSubtitle": { - "description": "Setup wizard subtitle" - }, - "setupStoragePermission": "Storage Permission", - "@setupStoragePermission": { - "description": "Storage permission step title" - }, - "setupStoragePermissionSubtitle": "Required to save downloaded files", - "@setupStoragePermissionSubtitle": { - "description": "Explanation for storage permission" - }, - "setupStoragePermissionGranted": "Permission granted", - "@setupStoragePermissionGranted": { - "description": "Status when permission granted" - }, - "setupStoragePermissionDenied": "Permission denied", - "@setupStoragePermissionDenied": { - "description": "Status when permission denied" - }, "setupGrantPermission": "Grant Permission", "@setupGrantPermission": { "description": "Button to request permission" }, - "setupDownloadLocation": "Download Location", - "@setupDownloadLocation": { - "description": "Download folder step title" - }, - "setupChooseFolder": "Choose Folder", - "@setupChooseFolder": { - "description": "Button to pick folder" - }, - "setupContinue": "Continue", - "@setupContinue": { - "description": "Continue to next step button" - }, "setupSkip": "Skip for now", "@setupSkip": { "description": "Skip current step button" @@ -734,10 +446,6 @@ "@setupStorageAccessRequired": { "description": "Title when storage access needed" }, - "setupStorageAccessMessage": "SpotiFLAC needs \"All files access\" permission to save music files to your chosen folder.", - "@setupStorageAccessMessage": { - "description": "Explanation for storage access" - }, "setupStorageAccessMessageAndroid11": "Android 11+ requires \"All files access\" permission to save files to your chosen download folder.", "@setupStorageAccessMessageAndroid11": { "description": "Android 11+ specific explanation" @@ -769,10 +477,6 @@ } } }, - "setupSelectDownloadFolder": "Select Download Folder", - "@setupSelectDownloadFolder": { - "description": "Folder selection step title" - }, "setupUseDefaultFolder": "Use Default Folder?", "@setupUseDefaultFolder": { "description": "Dialog title for default folder" @@ -817,26 +521,6 @@ "@setupDownloadInFlac": { "description": "App tagline in setup" }, - "setupStepStorage": "Storage", - "@setupStepStorage": { - "description": "Setup step indicator - storage" - }, - "setupStepNotification": "Notification", - "@setupStepNotification": { - "description": "Setup step indicator - notification" - }, - "setupStepFolder": "Folder", - "@setupStepFolder": { - "description": "Setup step indicator - folder" - }, - "setupStepSpotify": "Spotify", - "@setupStepSpotify": { - "description": "Setup step indicator - Spotify API" - }, - "setupStepPermission": "Permission", - "@setupStepPermission": { - "description": "Setup step indicator - permission" - }, "setupStorageGranted": "Storage Permission Granted!", "@setupStorageGranted": { "description": "Success message for storage permission" @@ -857,14 +541,6 @@ "@setupNotificationEnable": { "description": "Button to enable notifications" }, - "setupNotificationDescription": "Get notified when downloads complete or require attention.", - "@setupNotificationDescription": { - "description": "Explanation for notifications" - }, - "setupFolderSelected": "Download Folder Selected!", - "@setupFolderSelected": { - "description": "Success message for folder selection" - }, "setupFolderChoose": "Choose Download Folder", "@setupFolderChoose": { "description": "Button to choose folder" @@ -873,58 +549,14 @@ "@setupFolderDescription": { "description": "Explanation for folder selection" }, - "setupChangeFolder": "Change Folder", - "@setupChangeFolder": { - "description": "Button to change selected folder" - }, "setupSelectFolder": "Select Folder", "@setupSelectFolder": { "description": "Button to select folder" }, - "setupSpotifyApiOptional": "Spotify API (Optional)", - "@setupSpotifyApiOptional": { - "description": "Spotify API step title" - }, - "setupSpotifyApiDescription": "Add your Spotify API credentials for better search results and access to Spotify-exclusive content.", - "@setupSpotifyApiDescription": { - "description": "Explanation for Spotify API" - }, - "setupUseSpotifyApi": "Use Spotify API", - "@setupUseSpotifyApi": { - "description": "Toggle to enable Spotify API" - }, - "setupEnterCredentialsBelow": "Enter your credentials below", - "@setupEnterCredentialsBelow": { - "description": "Prompt to enter credentials" - }, - "setupUsingDeezer": "Using Deezer (no account needed)", - "@setupUsingDeezer": { - "description": "Status when using Deezer" - }, - "setupEnterClientId": "Enter Spotify Client ID", - "@setupEnterClientId": { - "description": "Placeholder for client ID field" - }, - "setupEnterClientSecret": "Enter Spotify Client Secret", - "@setupEnterClientSecret": { - "description": "Placeholder for client secret field" - }, - "setupGetFreeCredentials": "Get your free API credentials from the Spotify Developer Dashboard.", - "@setupGetFreeCredentials": { - "description": "Info about getting Spotify credentials" - }, "setupEnableNotifications": "Enable Notifications", "@setupEnableNotifications": { "description": "Button to enable notifications" }, - "setupProceedToNextStep": "You can now proceed to the next step.", - "@setupProceedToNextStep": { - "description": "Message after completing a step" - }, - "setupNotificationProgressDescription": "You will receive download progress notifications.", - "@setupNotificationProgressDescription": { - "description": "Info about notification usage" - }, "setupNotificationBackgroundDescription": "Get notified about download progress and completion. This helps you track downloads when the app is in background.", "@setupNotificationBackgroundDescription": { "description": "Detailed notification explanation" @@ -933,10 +565,6 @@ "@setupSkipForNow": { "description": "Skip button text" }, - "setupBack": "Back", - "@setupBack": { - "description": "Back button text" - }, "setupNext": "Next", "@setupNext": { "description": "Next button text" @@ -945,26 +573,14 @@ "@setupGetStarted": { "description": "Final setup button" }, - "setupSkipAndStart": "Skip & Start", - "@setupSkipAndStart": { - "description": "Skip setup and start app" - }, "setupAllowAccessToManageFiles": "Please enable \"Allow access to manage all files\" in the next screen.", "@setupAllowAccessToManageFiles": { "description": "Instruction for file access permission" }, - "setupGetCredentialsFromSpotify": "Get credentials from developer.spotify.com", - "@setupGetCredentialsFromSpotify": { - "description": "Link text for Spotify developer portal" - }, "dialogCancel": "Cancel", "@dialogCancel": { "description": "Dialog button - cancel action" }, - "dialogOk": "OK", - "@dialogOk": { - "description": "Dialog button - confirm/acknowledge" - }, "dialogSave": "Save", "@dialogSave": { "description": "Dialog button - save changes" @@ -977,26 +593,10 @@ "@dialogRetry": { "description": "Dialog button - retry action" }, - "dialogClose": "Close", - "@dialogClose": { - "description": "Dialog button - close dialog" - }, - "dialogYes": "Yes", - "@dialogYes": { - "description": "Dialog button - confirm yes" - }, - "dialogNo": "No", - "@dialogNo": { - "description": "Dialog button - confirm no" - }, "dialogClear": "Clear", "@dialogClear": { "description": "Dialog button - clear items" }, - "dialogConfirm": "Confirm", - "@dialogConfirm": { - "description": "Dialog button - confirm action" - }, "dialogDone": "Done", "@dialogDone": { "description": "Dialog button - action completed" @@ -1025,34 +625,10 @@ "@dialogUnsavedChanges": { "description": "Dialog message - unsaved changes" }, - "dialogDownloadFailed": "Download Failed", - "@dialogDownloadFailed": { - "description": "Dialog title - download error" - }, - "dialogTrackLabel": "Track:", - "@dialogTrackLabel": { - "description": "Label for track name in error dialog" - }, - "dialogArtistLabel": "Artist:", - "@dialogArtistLabel": { - "description": "Label for artist name in error dialog" - }, - "dialogErrorLabel": "Error:", - "@dialogErrorLabel": { - "description": "Label for error message" - }, "dialogClearAll": "Clear All", "@dialogClearAll": { "description": "Dialog title - clear all items" }, - "dialogClearAllDownloads": "Are you sure you want to clear all downloads?", - "@dialogClearAllDownloads": { - "description": "Dialog message - clear downloads confirmation" - }, - "dialogRemoveFromDevice": "Remove from device?", - "@dialogRemoveFromDevice": { - "description": "Dialog title - delete file confirmation" - }, "dialogRemoveExtension": "Remove Extension", "@dialogRemoveExtension": { "description": "Dialog title - uninstall extension" @@ -1173,15 +749,6 @@ "@snackbarViewQueue": { "description": "Snackbar action - view download queue" }, - "snackbarFailedToLoad": "Failed to load: {error}", - "@snackbarFailedToLoad": { - "description": "Snackbar - loading error", - "placeholders": { - "error": { - "type": "String" - } - } - }, "snackbarUrlCopied": "{platform} URL copied to clipboard", "@snackbarUrlCopied": { "description": "Snackbar - URL copied", @@ -1242,16 +809,6 @@ "@errorRateLimitedMessage": { "description": "Error message - rate limit explanation" }, - "errorFailedToLoad": "Failed to load {item}", - "@errorFailedToLoad": { - "description": "Error message - loading failed", - "placeholders": { - "item": { - "type": "String", - "description": "Item that failed to load (album/playlist/etc)" - } - } - }, "errorNoTracksFound": "No tracks found", "@errorNoTracksFound": { "description": "Error - search returned no results" @@ -1265,34 +822,6 @@ } } }, - "statusQueued": "Queued", - "@statusQueued": { - "description": "Download status - waiting in queue" - }, - "statusDownloading": "Downloading", - "@statusDownloading": { - "description": "Download status - in progress" - }, - "statusFinalizing": "Finalizing", - "@statusFinalizing": { - "description": "Download status - writing metadata" - }, - "statusCompleted": "Completed", - "@statusCompleted": { - "description": "Download status - finished" - }, - "statusFailed": "Failed", - "@statusFailed": { - "description": "Download status - error occurred" - }, - "statusSkipped": "Skipped", - "@statusSkipped": { - "description": "Download status - already exists" - }, - "statusPaused": "Paused", - "@statusPaused": { - "description": "Download status - paused" - }, "actionPause": "Pause", "@actionPause": { "description": "Action button - pause download" @@ -1305,14 +834,6 @@ "@actionCancel": { "description": "Action button - cancel operation" }, - "actionStop": "Stop", - "@actionStop": { - "description": "Action button - stop operation" - }, - "actionSelect": "Select", - "@actionSelect": { - "description": "Action button - enter selection mode" - }, "actionSelectAll": "Select All", "@actionSelectAll": { "description": "Action button - select all items" @@ -1321,14 +842,6 @@ "@actionDeselect": { "description": "Action button - deselect all" }, - "actionPaste": "Paste", - "@actionPaste": { - "description": "Action button - paste from clipboard" - }, - "actionImportCsv": "Import CSV", - "@actionImportCsv": { - "description": "Action button - import CSV file" - }, "actionRemoveCredentials": "Remove Credentials", "@actionRemoveCredentials": { "description": "Action button - delete Spotify credentials" @@ -1350,19 +863,6 @@ "@selectionAllSelected": { "description": "Status - all items selected" }, - "selectionTapToSelect": "Tap tracks to select", - "@selectionTapToSelect": { - "description": "Hint - how to select items" - }, - "selectionDeleteTracks": "Delete {count} {count, plural, =1{track} other{tracks}}", - "@selectionDeleteTracks": { - "description": "Delete button with count", - "placeholders": { - "count": { - "type": "int" - } - } - }, "selectionSelectToDelete": "Select tracks to delete", "@selectionSelectToDelete": { "description": "Placeholder when nothing selected" @@ -1403,55 +903,10 @@ "@tooltipPlay": { "description": "Tooltip - play button" }, - "tooltipCancel": "Cancel", - "@tooltipCancel": { - "description": "Tooltip - cancel button" - }, - "tooltipStop": "Stop", - "@tooltipStop": { - "description": "Tooltip - stop button" - }, - "tooltipRetry": "Retry", - "@tooltipRetry": { - "description": "Tooltip - retry button" - }, - "tooltipRemove": "Remove", - "@tooltipRemove": { - "description": "Tooltip - remove button" - }, - "tooltipClear": "Clear", - "@tooltipClear": { - "description": "Tooltip - clear button" - }, - "tooltipPaste": "Paste", - "@tooltipPaste": { - "description": "Tooltip - paste button" - }, "filenameFormat": "Filename Format", "@filenameFormat": { "description": "Setting title - filename pattern" }, - "filenameFormatPreview": "Preview: {preview}", - "@filenameFormatPreview": { - "description": "Preview of filename pattern", - "placeholders": { - "preview": { - "type": "String" - } - } - }, - "filenameAvailablePlaceholders": "Available placeholders:", - "@filenameAvailablePlaceholders": { - "description": "Label for placeholder list" - }, - "filenameHint": "{artist} - {title}", - "@filenameHint": { - "description": "Default filename format hint" - }, - "folderOrganization": "Folder Organization", - "@folderOrganization": { - "description": "Setting title - folder structure" - }, "folderOrganizationNone": "No organization", "@folderOrganizationNone": { "description": "Folder option - flat structure" @@ -1492,27 +947,10 @@ "@updateAvailable": { "description": "Update dialog title" }, - "updateNewVersion": "Version {version} is available", - "@updateNewVersion": { - "description": "Update available message", - "placeholders": { - "version": { - "type": "String" - } - } - }, - "updateDownload": "Download", - "@updateDownload": { - "description": "Update button - download update" - }, "updateLater": "Later", "@updateLater": { "description": "Update button - dismiss" }, - "updateChangelog": "Changelog", - "@updateChangelog": { - "description": "Link to changelog" - }, "updateStartingDownload": "Starting download...", "@updateStartingDownload": { "description": "Update status - initializing" @@ -1553,14 +991,6 @@ "@updateDontRemind": { "description": "Update button - skip this version" }, - "providerPriority": "Provider Priority", - "@providerPriority": { - "description": "Setting title - download provider order" - }, - "providerPrioritySubtitle": "Drag to reorder download providers", - "@providerPrioritySubtitle": { - "description": "Subtitle for provider priority" - }, "providerPriorityTitle": "Provider Priority", "@providerPriorityTitle": { "description": "Provider priority page title" @@ -1581,14 +1011,6 @@ "@providerExtension": { "description": "Label for extension-provided providers" }, - "metadataProviderPriority": "Metadata Provider Priority", - "@metadataProviderPriority": { - "description": "Setting title - metadata provider order" - }, - "metadataProviderPrioritySubtitle": "Order used when fetching track metadata", - "@metadataProviderPrioritySubtitle": { - "description": "Subtitle for metadata priority" - }, "metadataProviderPriorityTitle": "Metadata Priority", "@metadataProviderPriorityTitle": { "description": "Metadata priority page title" @@ -1613,22 +1035,6 @@ "@logTitle": { "description": "Logs screen title" }, - "logCopy": "Copy Logs", - "@logCopy": { - "description": "Action - copy logs to clipboard" - }, - "logClear": "Clear Logs", - "@logClear": { - "description": "Action - delete all logs" - }, - "logShare": "Share Logs", - "@logShare": { - "description": "Action - share logs file" - }, - "logEmpty": "No logs yet", - "@logEmpty": { - "description": "Empty state title" - }, "logCopied": "Logs copied to clipboard", "@logCopied": { "description": "Snackbar - logs copied" @@ -1661,22 +1067,6 @@ "@logClearLogsMessage": { "description": "Clear logs confirmation message" }, - "logIspBlocking": "ISP BLOCKING DETECTED", - "@logIspBlocking": { - "description": "Error category - ISP blocking" - }, - "logRateLimited": "RATE LIMITED", - "@logRateLimited": { - "description": "Error category - rate limiting" - }, - "logNetworkError": "NETWORK ERROR", - "@logNetworkError": { - "description": "Error category - network issues" - }, - "logTrackNotFound": "TRACK NOT FOUND", - "@logTrackNotFound": { - "description": "Error category - missing tracks" - }, "logFilterBySeverity": "Filter logs by severity", "@logFilterBySeverity": { "description": "Filter dialog title" @@ -1689,60 +1079,6 @@ "@logNoLogsYetSubtitle": { "description": "Empty state subtitle" }, - "logIssueSummary": "Issue Summary", - "@logIssueSummary": { - "description": "Section header for error summary" - }, - "logIspBlockingDescription": "Your ISP may be blocking access to download services", - "@logIspBlockingDescription": { - "description": "ISP blocking explanation" - }, - "logIspBlockingSuggestion": "Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8", - "@logIspBlockingSuggestion": { - "description": "ISP blocking fix suggestion" - }, - "logRateLimitedDescription": "Too many requests to the service", - "@logRateLimitedDescription": { - "description": "Rate limit explanation" - }, - "logRateLimitedSuggestion": "Wait a few minutes before trying again", - "@logRateLimitedSuggestion": { - "description": "Rate limit fix suggestion" - }, - "logNetworkErrorDescription": "Connection issues detected", - "@logNetworkErrorDescription": { - "description": "Network error explanation" - }, - "logNetworkErrorSuggestion": "Check your internet connection", - "@logNetworkErrorSuggestion": { - "description": "Network error fix suggestion" - }, - "logTrackNotFoundDescription": "Some tracks could not be found on download services", - "@logTrackNotFoundDescription": { - "description": "Track not found explanation" - }, - "logTrackNotFoundSuggestion": "The track may not be available in lossless quality", - "@logTrackNotFoundSuggestion": { - "description": "Track not found explanation" - }, - "logTotalErrors": "Total errors: {count}", - "@logTotalErrors": { - "description": "Error count display", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "logAffected": "Affected: {domains}", - "@logAffected": { - "description": "Affected domains display", - "placeholders": { - "domains": { - "type": "String" - } - } - }, "logEntriesFiltered": "Entries ({count} filtered)", "@logEntriesFiltered": { "description": "Log count with filter active", @@ -1849,22 +1185,6 @@ "@appearanceLanguage": { "description": "Setting title for language selection" }, - "appearanceLanguageSubtitle": "Choose your preferred language", - "@appearanceLanguageSubtitle": { - "description": "Subtitle for language setting" - }, - "languageSystem": "System Default", - "@languageSystem": { - "description": "Use device system language" - }, - "languageEnglish": "English", - "@languageEnglish": { - "description": "English language option" - }, - "languageIndonesian": "Bahasa Indonesia", - "@languageIndonesian": { - "description": "Indonesian language option" - }, "settingsAppearanceSubtitle": "Theme, colors, display", "@settingsAppearanceSubtitle": { "description": "Appearance settings description" @@ -1893,10 +1213,6 @@ "@pressBackAgainToExit": { "description": "Exit confirmation message" }, - "tracksHeader": "Tracks", - "@tracksHeader": { - "description": "Section header for track list" - }, "downloadAllCount": "Download All ({count})", "@downloadAllCount": { "description": "Download all button with count", @@ -2019,15 +1335,6 @@ "@trackDeleteConfirmMessage": { "description": "Delete confirmation message" }, - "trackCannotOpen": "Cannot open: {message}", - "@trackCannotOpen": { - "description": "Error opening file", - "placeholders": { - "message": { - "type": "String" - } - } - }, "dateToday": "Today", "@dateToday": { "description": "Relative date - today" @@ -2063,22 +1370,6 @@ } } }, - "concurrentSequential": "Sequential", - "@concurrentSequential": { - "description": "Download mode - one at a time" - }, - "concurrentParallel2": "2 Parallel", - "@concurrentParallel2": { - "description": "Download mode - 2 simultaneous" - }, - "concurrentParallel3": "3 Parallel", - "@concurrentParallel3": { - "description": "Download mode - 3 simultaneous" - }, - "tapToSeeError": "Tap to see error details", - "@tapToSeeError": { - "description": "Tooltip for failed download" - }, "storeFilterAll": "All", "@storeFilterAll": { "description": "Store filter - all extensions" @@ -2107,18 +1398,6 @@ "@storeClearFilters": { "description": "Button to clear all filters" }, - "storeNoResults": "No extensions found", - "@storeNoResults": { - "description": "Empty state when no extensions match filters" - }, - "extensionProviderPriority": "Provider Priority", - "@extensionProviderPriority": { - "description": "Extension capability - provider priority" - }, - "extensionInstallButton": "Install Extension", - "@extensionInstallButton": { - "description": "Button to install extension" - }, "extensionDefaultProvider": "Default (Deezer/Spotify)", "@extensionDefaultProvider": { "description": "Default search provider option" @@ -2338,14 +1617,6 @@ "@downloadAlbumFolderStructure": { "description": "Setting - album folder organization" }, - "downloadSaveFormat": "Save Format", - "@downloadSaveFormat": { - "description": "Setting - output file format" - }, - "downloadSelectService": "Select Service", - "@downloadSelectService": { - "description": "Dialog title - choose download service" - }, "downloadSelectQuality": "Select Quality", "@downloadSelectQuality": { "description": "Dialog title - choose audio quality" @@ -2354,66 +1625,6 @@ "@downloadFrom": { "description": "Label - download source" }, - "downloadDefaultQualityLabel": "Default Quality", - "@downloadDefaultQualityLabel": { - "description": "Label - default quality setting" - }, - "downloadBestAvailable": "Best available", - "@downloadBestAvailable": { - "description": "Quality option - highest available" - }, - "folderNone": "None", - "@folderNone": { - "description": "Folder option - no organization" - }, - "folderNoneSubtitle": "Save all files directly to download folder", - "@folderNoneSubtitle": { - "description": "Subtitle for no folder organization" - }, - "folderArtist": "Artist", - "@folderArtist": { - "description": "Folder option - by artist" - }, - "folderArtistSubtitle": "Artist Name/filename", - "@folderArtistSubtitle": { - "description": "Folder structure example" - }, - "folderAlbum": "Album", - "@folderAlbum": { - "description": "Folder option - by album" - }, - "folderAlbumSubtitle": "Album Name/filename", - "@folderAlbumSubtitle": { - "description": "Folder structure example" - }, - "folderArtistAlbum": "Artist/Album", - "@folderArtistAlbum": { - "description": "Folder option - nested" - }, - "folderArtistAlbumSubtitle": "Artist Name/Album Name/filename", - "@folderArtistAlbumSubtitle": { - "description": "Folder structure example" - }, - "serviceTidal": "Tidal", - "@serviceTidal": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceQobuz": "Qobuz", - "@serviceQobuz": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceAmazon": "Amazon", - "@serviceAmazon": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceDeezer": "Deezer", - "@serviceDeezer": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceSpotify": "Spotify", - "@serviceSpotify": { - "description": "Service name - DO NOT TRANSLATE" - }, "appearanceAmoledDark": "AMOLED Dark", "@appearanceAmoledDark": { "description": "Theme option - pure black" @@ -2422,18 +1633,6 @@ "@appearanceAmoledDarkSubtitle": { "description": "Subtitle for AMOLED dark" }, - "appearanceChooseAccentColor": "Choose Accent Color", - "@appearanceChooseAccentColor": { - "description": "Color picker dialog title" - }, - "appearanceChooseTheme": "Theme Mode", - "@appearanceChooseTheme": { - "description": "Theme picker dialog title" - }, - "queueTitle": "Download Queue", - "@queueTitle": { - "description": "Queue screen title" - }, "queueClearAll": "Clear All", "@queueClearAll": { "description": "Button - clear all queue items" @@ -2442,38 +1641,6 @@ "@queueClearAllMessage": { "description": "Clear queue confirmation" }, - "queueEmpty": "No downloads in queue", - "@queueEmpty": { - "description": "Empty queue state title" - }, - "queueEmptySubtitle": "Add tracks from the home screen", - "@queueEmptySubtitle": { - "description": "Empty queue state subtitle" - }, - "queueClearCompleted": "Clear completed", - "@queueClearCompleted": { - "description": "Button - clear finished downloads" - }, - "queueDownloadFailed": "Download Failed", - "@queueDownloadFailed": { - "description": "Error dialog title" - }, - "queueTrackLabel": "Track:", - "@queueTrackLabel": { - "description": "Label in error dialog" - }, - "queueArtistLabel": "Artist:", - "@queueArtistLabel": { - "description": "Label in error dialog" - }, - "queueErrorLabel": "Error:", - "@queueErrorLabel": { - "description": "Label in error dialog" - }, - "queueUnknownError": "Unknown error", - "@queueUnknownError": { - "description": "Fallback error message" - }, "albumFolderArtistAlbum": "Artist / Album", "@albumFolderArtistAlbum": { "description": "Album folder option" @@ -2519,19 +1686,6 @@ } } }, - "downloadedAlbumTracksHeader": "Tracks", - "@downloadedAlbumTracksHeader": { - "description": "Section header for tracks" - }, - "downloadedAlbumDownloadedCount": "{count} downloaded", - "@downloadedAlbumDownloadedCount": { - "description": "Downloaded tracks count badge", - "placeholders": { - "count": { - "type": "int" - } - } - }, "downloadedAlbumSelectedCount": "{count} selected", "@downloadedAlbumSelectedCount": { "description": "Selection count indicator", @@ -2562,8 +1716,13 @@ "@downloadedAlbumSelectToDelete": { "description": "Placeholder when nothing selected" }, - "utilityFunctions": "Utility Functions", - "@utilityFunctions": { - "description": "Extension capability - utility functions" + "downloadedAlbumDownloadedCount": "{count} downloaded", + "@downloadedAlbumDownloadedCount": { + "description": "Downloaded tracks count badge", + "placeholders": { + "count": { + "type": "int" + } + } } -} \ 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 2caeebcb..8d844396 100644 --- a/lib/l10n/arb/app_pt_PT.arb +++ b/lib/l10n/arb/app_pt_PT.arb @@ -5,10 +5,6 @@ "@appName": { "description": "App name - DO NOT TRANSLATE" }, - "appDescription": "Baixe faixas do Spotify em qualidade sem perdas de Tidal, Qobuz e Amazon Music.", - "@appDescription": { - "description": "App description shown in about page" - }, "navHome": "Início", "@navHome": { "description": "Bottom navigation - Home tab" @@ -17,10 +13,6 @@ "@navLibrary": { "description": "Bottom navigation - Library tab" }, - "navHistory": "Histórico", - "@navHistory": { - "description": "Bottom navigation - History tab (legacy)" - }, "navSettings": "Configurações", "@navSettings": { "description": "Bottom navigation - Settings tab" @@ -33,20 +25,6 @@ "@homeTitle": { "description": "Home screen title" }, - "homeSearchHint": "Pesquise ou cole a URL do Spotify...", - "@homeSearchHint": { - "description": "Placeholder text in search box" - }, - "homeSearchHintExtension": "Pesquisar com {extensionName}...", - "@homeSearchHintExtension": { - "description": "Placeholder when extension search is active", - "placeholders": { - "extensionName": { - "type": "String", - "description": "Name of the active extension" - } - } - }, "homeSubtitle": "Cole um link do Spotify ou procure por nome", "@homeSubtitle": { "description": "Subtitle shown below search box" @@ -59,24 +37,6 @@ "@homeRecent": { "description": "Section header for recent searches" }, - "historyTitle": "Histórico", - "@historyTitle": { - "description": "History screen title" - }, - "historyDownloading": "Baixando ({count})", - "@historyDownloading": { - "description": "Tab showing active downloads count", - "placeholders": { - "count": { - "type": "int", - "description": "Number of active downloads" - } - } - }, - "historyDownloaded": "Baixados", - "@historyDownloaded": { - "description": "Tab showing completed downloads" - }, "historyFilterAll": "Tudo", "@historyFilterAll": { "description": "Filter chip - show all items" @@ -89,48 +49,6 @@ "@historyFilterSingles": { "description": "Filter chip - show singles only" }, - "historyTracksCount": "{count, plural, one {1 faixa} other{{count} faixas}}", - "@historyTracksCount": { - "description": "Track count with plural form", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "historyAlbumsCount": "{count, plural, one {1 álbum} other{{count} álbuns}}", - "@historyAlbumsCount": { - "description": "Album count with plural form", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "historyNoDownloads": "Nenhum histórico de downloads", - "@historyNoDownloads": { - "description": "Empty state title" - }, - "historyNoDownloadsSubtitle": "As faixas baixadas aparecerão aqui", - "@historyNoDownloadsSubtitle": { - "description": "Empty state subtitle" - }, - "historyNoAlbums": "Sem álbuns baixados", - "@historyNoAlbums": { - "description": "Empty state when filtering albums" - }, - "historyNoAlbumsSubtitle": "Baixe várias faixas de um álbum para vê-las aqui", - "@historyNoAlbumsSubtitle": { - "description": "Empty state subtitle for albums filter" - }, - "historyNoSingles": "Sem singles baixados", - "@historyNoSingles": { - "description": "Empty state when filtering singles" - }, - "historyNoSinglesSubtitle": "Os downloads de faixa individuais aparecerão aqui", - "@historyNoSinglesSubtitle": { - "description": "Empty state subtitle for singles filter" - }, "historySearchHint": "Pesquisar histórico...", "@historySearchHint": { "description": "Search bar placeholder in history" @@ -163,34 +81,6 @@ "@downloadTitle": { "description": "Download settings page title" }, - "downloadLocation": "Local dos Downloads", - "@downloadLocation": { - "description": "Setting for download folder" - }, - "downloadLocationSubtitle": "Escolha onde salvar os arquivos", - "@downloadLocationSubtitle": { - "description": "Subtitle for download location" - }, - "downloadLocationDefault": "Local padrão", - "@downloadLocationDefault": { - "description": "Shown when using default folder" - }, - "downloadDefaultService": "Serviço Padrão", - "@downloadDefaultService": { - "description": "Setting for preferred download service (Tidal/Qobuz/Amazon)" - }, - "downloadDefaultServiceSubtitle": "Serviço usado para downloads", - "@downloadDefaultServiceSubtitle": { - "description": "Subtitle for default service" - }, - "downloadDefaultQuality": "Qualidade Predefinida", - "@downloadDefaultQuality": { - "description": "Setting for audio quality" - }, - "downloadAskQuality": "Perguntar qualidade antes de baixar", - "@downloadAskQuality": { - "description": "Toggle to show quality picker" - }, "downloadAskQualitySubtitle": "Mostrar seletor de qualidade para cada download", "@downloadAskQualitySubtitle": { "description": "Subtitle for ask quality toggle" @@ -203,38 +93,10 @@ "@downloadFolderOrganization": { "description": "Setting for folder structure" }, - "downloadSeparateSingles": "Separar Singles", - "@downloadSeparateSingles": { - "description": "Toggle to separate single tracks" - }, - "downloadSeparateSinglesSubtitle": "Colocar singles numa pasta separada", - "@downloadSeparateSinglesSubtitle": { - "description": "Subtitle for separate singles toggle" - }, - "qualityBest": "Melhor Disponível", - "@qualityBest": { - "description": "Audio quality option - highest available" - }, - "qualityFlac": "FLAC", - "@qualityFlac": { - "description": "Audio quality option - FLAC lossless" - }, - "quality320": "320 kbps", - "@quality320": { - "description": "Audio quality option - 320kbps MP3" - }, - "quality128": "128 kbps", - "@quality128": { - "description": "Audio quality option - 128kbps MP3" - }, "appearanceTitle": "Aparência", "@appearanceTitle": { "description": "Appearance settings page title" }, - "appearanceTheme": "Tema", - "@appearanceTheme": { - "description": "Theme mode setting" - }, "appearanceThemeSystem": "Sistema", "@appearanceThemeSystem": { "description": "Follow system theme" @@ -255,10 +117,6 @@ "@appearanceDynamicColorSubtitle": { "description": "Subtitle for dynamic color" }, - "appearanceAccentColor": "Cor de Destaque", - "@appearanceAccentColor": { - "description": "Custom accent color picker" - }, "appearanceHistoryView": "Visualização do Histórico", "@appearanceHistoryView": { "description": "Layout style for history" @@ -275,10 +133,6 @@ "@optionsTitle": { "description": "Options settings page title" }, - "optionsSearchSource": "Origem da Pesquisa", - "@optionsSearchSource": { - "description": "Section for search provider settings" - }, "optionsPrimaryProvider": "Provedor Primário", "@optionsPrimaryProvider": { "description": "Main search provider setting" @@ -438,22 +292,6 @@ "@extensionsTitle": { "description": "Extensions page title" }, - "extensionsInstalled": "Extensões Instaladas", - "@extensionsInstalled": { - "description": "Section header for installed extensions" - }, - "extensionsNone": "Nenhuma extensão instalada", - "@extensionsNone": { - "description": "Empty state title" - }, - "extensionsNoneSubtitle": "Instalar extensões a partir da aba Loja", - "@extensionsNoneSubtitle": { - "description": "Empty state subtitle" - }, - "extensionsEnabled": "Habilitado", - "@extensionsEnabled": { - "description": "Extension status - active" - }, "extensionsDisabled": "Desabilitado", "@extensionsDisabled": { "description": "Extension status - inactive" @@ -480,10 +318,6 @@ "@extensionsUninstall": { "description": "Uninstall extension button" }, - "extensionsSetAsSearch": "Definir como Provedor de Pesquisa", - "@extensionsSetAsSearch": { - "description": "Use extension for search" - }, "storeTitle": "Loja de Extensões", "@storeTitle": { "description": "Store screen title" @@ -580,10 +414,6 @@ "@aboutSocial": { "description": "Section for social links" }, - "aboutSupport": "Apoiar", - "@aboutSupport": { - "description": "Section for support/donation links" - }, "aboutApp": "Aplicativo", "@aboutApp": { "description": "Section for app info" @@ -604,14 +434,6 @@ "@aboutSjdonadoDesc": { "description": "Credit description for sjdonado" }, - "aboutDoubleDouble": "DoubleDouble", - "@aboutDoubleDouble": { - "description": "Name of Amazon API service - DO NOT TRANSLATE" - }, - "aboutDoubleDoubleDesc": "API incrível para downloads do Amazon Music. Obrigado por fazê-lo gratuitamente!", - "@aboutDoubleDoubleDesc": { - "description": "Credit for DoubleDouble API" - }, "aboutDabMusic": "DAB Music", "@aboutDabMusic": { "description": "Name of Qobuz API service - DO NOT TRANSLATE" @@ -632,35 +454,6 @@ "@aboutAppDescription": { "description": "App description in header card" }, - "albumTitle": "Álbum", - "@albumTitle": { - "description": "Album screen title" - }, - "albumTracks": "{count, plural, one {1 faixa} other{{count} faixas}}", - "@albumTracks": { - "description": "Album track count", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "albumDownloadAll": "Baixar Tudo", - "@albumDownloadAll": { - "description": "Button to download all tracks" - }, - "albumDownloadRemaining": "Downloads Restantes", - "@albumDownloadRemaining": { - "description": "Button to download remaining tracks" - }, - "playlistTitle": "Playlist", - "@playlistTitle": { - "description": "Playlist screen title" - }, - "artistTitle": "Artista", - "@artistTitle": { - "description": "Artist screen title" - }, "artistAlbums": "Álbuns", "@artistAlbums": { "description": "Section header for artist albums" @@ -673,15 +466,6 @@ "@artistCompilations": { "description": "Section header for compilations" }, - "artistReleases": "{count, plural, one {1 lançamento} other{{count} lançamentos}}", - "@artistReleases": { - "description": "Artist release count", - "placeholders": { - "count": { - "type": "int" - } - } - }, "artistPopular": "Populares", "@artistPopular": { "description": "Section header for popular/top tracks" @@ -696,34 +480,6 @@ } } }, - "trackMetadataTitle": "Informações da Faixa", - "@trackMetadataTitle": { - "description": "Track metadata screen title" - }, - "trackMetadataArtist": "Artista", - "@trackMetadataArtist": { - "description": "Metadata field - artist name" - }, - "trackMetadataAlbum": "Álbum", - "@trackMetadataAlbum": { - "description": "Metadata field - album name" - }, - "trackMetadataDuration": "Duração", - "@trackMetadataDuration": { - "description": "Metadata field - track length" - }, - "trackMetadataQuality": "Qualidade", - "@trackMetadataQuality": { - "description": "Metadata field - audio quality" - }, - "trackMetadataPath": "Caminho do Arquivo", - "@trackMetadataPath": { - "description": "Metadata field - file location" - }, - "trackMetadataDownloadedAt": "Baixado", - "@trackMetadataDownloadedAt": { - "description": "Metadata field - download date" - }, "trackMetadataService": "Serviço", "@trackMetadataService": { "description": "Metadata field - download service used" @@ -740,54 +496,10 @@ "@trackMetadataDelete": { "description": "Action button - delete track" }, - "trackMetadataRedownload": "Baixar Novamente", - "@trackMetadataRedownload": { - "description": "Action button - download again" - }, - "trackMetadataOpenFolder": "Abrir Pasta", - "@trackMetadataOpenFolder": { - "description": "Action button - open containing folder" - }, - "setupTitle": "Bem-vindo ao SpotiFLAC", - "@setupTitle": { - "description": "Setup wizard title" - }, - "setupSubtitle": "Vamos começar", - "@setupSubtitle": { - "description": "Setup wizard subtitle" - }, - "setupStoragePermission": "Permissão de Armazenamento", - "@setupStoragePermission": { - "description": "Storage permission step title" - }, - "setupStoragePermissionSubtitle": "Necessária para salvar arquivos baixados", - "@setupStoragePermissionSubtitle": { - "description": "Explanation for storage permission" - }, - "setupStoragePermissionGranted": "Permissão concedida", - "@setupStoragePermissionGranted": { - "description": "Status when permission granted" - }, - "setupStoragePermissionDenied": "Permissão negada", - "@setupStoragePermissionDenied": { - "description": "Status when permission denied" - }, "setupGrantPermission": "Conceder Permissão", "@setupGrantPermission": { "description": "Button to request permission" }, - "setupDownloadLocation": "Local do Download", - "@setupDownloadLocation": { - "description": "Download folder step title" - }, - "setupChooseFolder": "Selecionar Pasta", - "@setupChooseFolder": { - "description": "Button to pick folder" - }, - "setupContinue": "Continuar", - "@setupContinue": { - "description": "Continue to next step button" - }, "setupSkip": "Ignorar por enquanto", "@setupSkip": { "description": "Skip current step button" @@ -796,10 +508,6 @@ "@setupStorageAccessRequired": { "description": "Title when storage access needed" }, - "setupStorageAccessMessage": "O SpotiFLAC precisa da permissão \"Acesso a todos os arquivos\" para salvar arquivos de música na sua pasta escolhida.", - "@setupStorageAccessMessage": { - "description": "Explanation for storage access" - }, "setupStorageAccessMessageAndroid11": "O Android 11+ requer a permissão \"Acesso a Todos os Arquivos\" para salvar arquivos na pasta de download escolhida.", "@setupStorageAccessMessageAndroid11": { "description": "Android 11+ specific explanation" @@ -831,10 +539,6 @@ } } }, - "setupSelectDownloadFolder": "Escolher Pasta de Download", - "@setupSelectDownloadFolder": { - "description": "Folder selection step title" - }, "setupUseDefaultFolder": "Usar Pasta Padrão?", "@setupUseDefaultFolder": { "description": "Dialog title for default folder" @@ -883,26 +587,6 @@ "@setupDownloadInFlac": { "description": "App tagline in setup" }, - "setupStepStorage": "Armazenamento", - "@setupStepStorage": { - "description": "Setup step indicator - storage" - }, - "setupStepNotification": "Notificação", - "@setupStepNotification": { - "description": "Setup step indicator - notification" - }, - "setupStepFolder": "Pasta", - "@setupStepFolder": { - "description": "Setup step indicator - folder" - }, - "setupStepSpotify": "Spotify", - "@setupStepSpotify": { - "description": "Setup step indicator - Spotify API" - }, - "setupStepPermission": "Permissão", - "@setupStepPermission": { - "description": "Setup step indicator - permission" - }, "setupStorageGranted": "Permissão de Armazenamento Concedida!", "@setupStorageGranted": { "description": "Success message for storage permission" @@ -923,14 +607,6 @@ "@setupNotificationEnable": { "description": "Button to enable notifications" }, - "setupNotificationDescription": "Seja notificado quando os downloads completarem ou exigirem atenção.", - "@setupNotificationDescription": { - "description": "Explanation for notifications" - }, - "setupFolderSelected": "Pasta para Download Selecionada!", - "@setupFolderSelected": { - "description": "Success message for folder selection" - }, "setupFolderChoose": "Escolher Pasta de Download", "@setupFolderChoose": { "description": "Button to choose folder" @@ -939,58 +615,14 @@ "@setupFolderDescription": { "description": "Explanation for folder selection" }, - "setupChangeFolder": "Alterar Pasta", - "@setupChangeFolder": { - "description": "Button to change selected folder" - }, "setupSelectFolder": "Seleccionar Pasta", "@setupSelectFolder": { "description": "Button to select folder" }, - "setupSpotifyApiOptional": "API do Spotify (opcional)", - "@setupSpotifyApiOptional": { - "description": "Spotify API step title" - }, - "setupSpotifyApiDescription": "Adicione as suas credenciais da API do Spotify para obter melhores resultados de busca e acesso a conteúdo exclusivo do Spotify.", - "@setupSpotifyApiDescription": { - "description": "Explanation for Spotify API" - }, - "setupUseSpotifyApi": "Usar API do Spotify", - "@setupUseSpotifyApi": { - "description": "Toggle to enable Spotify API" - }, - "setupEnterCredentialsBelow": "Insira as suas credenciais abaixo", - "@setupEnterCredentialsBelow": { - "description": "Prompt to enter credentials" - }, - "setupUsingDeezer": "Usando o Deezer (nenhuma conta necessária)", - "@setupUsingDeezer": { - "description": "Status when using Deezer" - }, - "setupEnterClientId": "Insira o Spotify Client ID", - "@setupEnterClientId": { - "description": "Placeholder for client ID field" - }, - "setupEnterClientSecret": "Insira o Spotify Client Secret", - "@setupEnterClientSecret": { - "description": "Placeholder for client secret field" - }, - "setupGetFreeCredentials": "Receba as suas credenciais de API gratuitas na Spotify Developer Dashboard.", - "@setupGetFreeCredentials": { - "description": "Info about getting Spotify credentials" - }, "setupEnableNotifications": "Habilitar Notificações", "@setupEnableNotifications": { "description": "Button to enable notifications" }, - "setupProceedToNextStep": "Você já pode prosseguir para o próximo passo.", - "@setupProceedToNextStep": { - "description": "Message after completing a step" - }, - "setupNotificationProgressDescription": "Você receberá notificações de progresso dos downloads.", - "@setupNotificationProgressDescription": { - "description": "Info about notification usage" - }, "setupNotificationBackgroundDescription": "Seja notificado sobre o progresso e conclusão do download. Isso ajuda você a acompanhar os downloads quando o app estiver em segundo plano.", "@setupNotificationBackgroundDescription": { "description": "Detailed notification explanation" @@ -999,10 +631,6 @@ "@setupSkipForNow": { "description": "Skip button text" }, - "setupBack": "Voltar", - "@setupBack": { - "description": "Back button text" - }, "setupNext": "Próximo", "@setupNext": { "description": "Next button text" @@ -1011,26 +639,14 @@ "@setupGetStarted": { "description": "Final setup button" }, - "setupSkipAndStart": "Ignorar e Iniciar", - "@setupSkipAndStart": { - "description": "Skip setup and start app" - }, "setupAllowAccessToManageFiles": "Por favor, habilite \"Permitir acesso para gerenciar todos os arquivos\" na próxima tela.", "@setupAllowAccessToManageFiles": { "description": "Instruction for file access permission" }, - "setupGetCredentialsFromSpotify": "Obter credenciais do developer.spotify.com", - "@setupGetCredentialsFromSpotify": { - "description": "Link text for Spotify developer portal" - }, "dialogCancel": "Cancelar", "@dialogCancel": { "description": "Dialog button - cancel action" }, - "dialogOk": "OK", - "@dialogOk": { - "description": "Dialog button - confirm/acknowledge" - }, "dialogSave": "Salvar", "@dialogSave": { "description": "Dialog button - save changes" @@ -1043,26 +659,10 @@ "@dialogRetry": { "description": "Dialog button - retry action" }, - "dialogClose": "Fechar", - "@dialogClose": { - "description": "Dialog button - close dialog" - }, - "dialogYes": "Sim", - "@dialogYes": { - "description": "Dialog button - confirm yes" - }, - "dialogNo": "Não", - "@dialogNo": { - "description": "Dialog button - confirm no" - }, "dialogClear": "Limpar", "@dialogClear": { "description": "Dialog button - clear items" }, - "dialogConfirm": "Confirmar", - "@dialogConfirm": { - "description": "Dialog button - confirm action" - }, "dialogDone": "Concluído", "@dialogDone": { "description": "Dialog button - action completed" @@ -1091,34 +691,10 @@ "@dialogUnsavedChanges": { "description": "Dialog message - unsaved changes" }, - "dialogDownloadFailed": "Download Falhou", - "@dialogDownloadFailed": { - "description": "Dialog title - download error" - }, - "dialogTrackLabel": "Faixa:", - "@dialogTrackLabel": { - "description": "Label for track name in error dialog" - }, - "dialogArtistLabel": "Artista:", - "@dialogArtistLabel": { - "description": "Label for artist name in error dialog" - }, - "dialogErrorLabel": "Erro:", - "@dialogErrorLabel": { - "description": "Label for error message" - }, "dialogClearAll": "Limpar Tudo", "@dialogClearAll": { "description": "Dialog title - clear all items" }, - "dialogClearAllDownloads": "Você tem certeza que deseja limpar todos os downloads?", - "@dialogClearAllDownloads": { - "description": "Dialog message - clear downloads confirmation" - }, - "dialogRemoveFromDevice": "Remover do dispositivo?", - "@dialogRemoveFromDevice": { - "description": "Dialog title - delete file confirmation" - }, "dialogRemoveExtension": "Remover Extensão", "@dialogRemoveExtension": { "description": "Dialog title - uninstall extension" @@ -1257,15 +833,6 @@ "@snackbarViewQueue": { "description": "Snackbar action - view download queue" }, - "snackbarFailedToLoad": "Falha ao carregar: {error}", - "@snackbarFailedToLoad": { - "description": "Snackbar - loading error", - "placeholders": { - "error": { - "type": "String" - } - } - }, "snackbarUrlCopied": "URL do {platform} copiado para a área de transferência", "@snackbarUrlCopied": { "description": "Snackbar - URL copied", @@ -1326,16 +893,6 @@ "@errorRateLimitedMessage": { "description": "Error message - rate limit explanation" }, - "errorFailedToLoad": "Falha ao carregar {item}", - "@errorFailedToLoad": { - "description": "Error message - loading failed", - "placeholders": { - "item": { - "type": "String", - "description": "Item that failed to load (album/playlist/etc)" - } - } - }, "errorNoTracksFound": "Nenhuma faixa encontrada", "@errorNoTracksFound": { "description": "Error - search returned no results" @@ -1349,34 +906,6 @@ } } }, - "statusQueued": "Na Fila", - "@statusQueued": { - "description": "Download status - waiting in queue" - }, - "statusDownloading": "Baixando", - "@statusDownloading": { - "description": "Download status - in progress" - }, - "statusFinalizing": "Finalizando", - "@statusFinalizing": { - "description": "Download status - writing metadata" - }, - "statusCompleted": "Concluído", - "@statusCompleted": { - "description": "Download status - finished" - }, - "statusFailed": "Falhou", - "@statusFailed": { - "description": "Download status - error occurred" - }, - "statusSkipped": "Ignorado", - "@statusSkipped": { - "description": "Download status - already exists" - }, - "statusPaused": "Pausado", - "@statusPaused": { - "description": "Download status - paused" - }, "actionPause": "Pausar", "@actionPause": { "description": "Action button - pause download" @@ -1389,14 +918,6 @@ "@actionCancel": { "description": "Action button - cancel operation" }, - "actionStop": "Parar", - "@actionStop": { - "description": "Action button - stop operation" - }, - "actionSelect": "Selecionar", - "@actionSelect": { - "description": "Action button - enter selection mode" - }, "actionSelectAll": "Selecionar Tudo", "@actionSelectAll": { "description": "Action button - select all items" @@ -1405,14 +926,6 @@ "@actionDeselect": { "description": "Action button - deselect all" }, - "actionPaste": "Colar", - "@actionPaste": { - "description": "Action button - paste from clipboard" - }, - "actionImportCsv": "Importar CSV", - "@actionImportCsv": { - "description": "Action button - import CSV file" - }, "actionRemoveCredentials": "Remover Credenciais", "@actionRemoveCredentials": { "description": "Action button - delete Spotify credentials" @@ -1434,19 +947,6 @@ "@selectionAllSelected": { "description": "Status - all items selected" }, - "selectionTapToSelect": "Toque nas faixas para selecionar", - "@selectionTapToSelect": { - "description": "Hint - how to select items" - }, - "selectionDeleteTracks": "Apagar {count} {count, plural, one {faixa} other{faixas}}", - "@selectionDeleteTracks": { - "description": "Delete button with count", - "placeholders": { - "count": { - "type": "int" - } - } - }, "selectionSelectToDelete": "Selecione as faixas para apagar", "@selectionSelectToDelete": { "description": "Placeholder when nothing selected" @@ -1487,55 +987,10 @@ "@tooltipPlay": { "description": "Tooltip - play button" }, - "tooltipCancel": "Cancelar", - "@tooltipCancel": { - "description": "Tooltip - cancel button" - }, - "tooltipStop": "Parar", - "@tooltipStop": { - "description": "Tooltip - stop button" - }, - "tooltipRetry": "Tentar Novamente", - "@tooltipRetry": { - "description": "Tooltip - retry button" - }, - "tooltipRemove": "Remover", - "@tooltipRemove": { - "description": "Tooltip - remove button" - }, - "tooltipClear": "Limpar", - "@tooltipClear": { - "description": "Tooltip - clear button" - }, - "tooltipPaste": "Colar", - "@tooltipPaste": { - "description": "Tooltip - paste button" - }, "filenameFormat": "Formato do Nome do Arquivo", "@filenameFormat": { "description": "Setting title - filename pattern" }, - "filenameFormatPreview": "Prévia: {preview}", - "@filenameFormatPreview": { - "description": "Preview of filename pattern", - "placeholders": { - "preview": { - "type": "String" - } - } - }, - "filenameAvailablePlaceholders": "Substituições permitidas:", - "@filenameAvailablePlaceholders": { - "description": "Label for placeholder list" - }, - "filenameHint": "{artist} - {title}", - "@filenameHint": { - "description": "Default filename format hint" - }, - "folderOrganization": "Organização de Pastas", - "@folderOrganization": { - "description": "Setting title - folder structure" - }, "folderOrganizationNone": "Nenhuma organização", "@folderOrganizationNone": { "description": "Folder option - flat structure" @@ -1576,27 +1031,10 @@ "@updateAvailable": { "description": "Update dialog title" }, - "updateNewVersion": "A versão {version} está disponível", - "@updateNewVersion": { - "description": "Update available message", - "placeholders": { - "version": { - "type": "String" - } - } - }, - "updateDownload": "Baixar", - "@updateDownload": { - "description": "Update button - download update" - }, "updateLater": "Depois", "@updateLater": { "description": "Update button - dismiss" }, - "updateChangelog": "Lista de alterações", - "@updateChangelog": { - "description": "Link to changelog" - }, "updateStartingDownload": "Iniciando download...", "@updateStartingDownload": { "description": "Update status - initializing" @@ -1637,14 +1075,6 @@ "@updateDontRemind": { "description": "Update button - skip this version" }, - "providerPriority": "Prioridade de Provedor", - "@providerPriority": { - "description": "Setting title - download provider order" - }, - "providerPrioritySubtitle": "Arraste para reordenar os provedores de download", - "@providerPrioritySubtitle": { - "description": "Subtitle for provider priority" - }, "providerPriorityTitle": "Prioridade de Provedor", "@providerPriorityTitle": { "description": "Provider priority page title" @@ -1665,14 +1095,6 @@ "@providerExtension": { "description": "Label for extension-provided providers" }, - "metadataProviderPriority": "Prioridade de Provedor de Metadados", - "@metadataProviderPriority": { - "description": "Setting title - metadata provider order" - }, - "metadataProviderPrioritySubtitle": "Ordem usada para obter metadados de faixa", - "@metadataProviderPrioritySubtitle": { - "description": "Subtitle for metadata priority" - }, "metadataProviderPriorityTitle": "Prioridade de Metadados", "@metadataProviderPriorityTitle": { "description": "Metadata priority page title" @@ -1697,22 +1119,6 @@ "@logTitle": { "description": "Logs screen title" }, - "logCopy": "Copiar Registros", - "@logCopy": { - "description": "Action - copy logs to clipboard" - }, - "logClear": "Limpar Registros", - "@logClear": { - "description": "Action - delete all logs" - }, - "logShare": "Compartilhar Registros", - "@logShare": { - "description": "Action - share logs file" - }, - "logEmpty": "Ainda não há registros", - "@logEmpty": { - "description": "Empty state title" - }, "logCopied": "Registros copiados para área de transferência", "@logCopied": { "description": "Snackbar - logs copied" @@ -1745,22 +1151,6 @@ "@logClearLogsMessage": { "description": "Clear logs confirmation message" }, - "logIspBlocking": "BLOQUEIO DE ISP DETECTADO", - "@logIspBlocking": { - "description": "Error category - ISP blocking" - }, - "logRateLimited": "TAXA LIMITADA (RATELIMITED)", - "@logRateLimited": { - "description": "Error category - rate limiting" - }, - "logNetworkError": "ERRO DE REDE", - "@logNetworkError": { - "description": "Error category - network issues" - }, - "logTrackNotFound": "FAIXA NÃO ENCONTRADA", - "@logTrackNotFound": { - "description": "Error category - missing tracks" - }, "logFilterBySeverity": "Filtrar registros por gravidade", "@logFilterBySeverity": { "description": "Filter dialog title" @@ -1773,60 +1163,6 @@ "@logNoLogsYetSubtitle": { "description": "Empty state subtitle" }, - "logIssueSummary": "Resumo do Problemas", - "@logIssueSummary": { - "description": "Section header for error summary" - }, - "logIspBlockingDescription": "O seu provedor pode estar bloqueando o acesso aos serviços de download", - "@logIspBlockingDescription": { - "description": "ISP blocking explanation" - }, - "logIspBlockingSuggestion": "Tente usar uma VPN ou altere o DNS para 1.1.1 ou 8.8.8.8", - "@logIspBlockingSuggestion": { - "description": "ISP blocking fix suggestion" - }, - "logRateLimitedDescription": "Muitas solicitações ao serviço", - "@logRateLimitedDescription": { - "description": "Rate limit explanation" - }, - "logRateLimitedSuggestion": "Aguarde alguns minutos antes de tentar novamente", - "@logRateLimitedSuggestion": { - "description": "Rate limit fix suggestion" - }, - "logNetworkErrorDescription": "Problemas de conexão detectados", - "@logNetworkErrorDescription": { - "description": "Network error explanation" - }, - "logNetworkErrorSuggestion": "Verifique sua conexão de internet", - "@logNetworkErrorSuggestion": { - "description": "Network error fix suggestion" - }, - "logTrackNotFoundDescription": "Algumas faixas não foram encontradas nos serviços de download", - "@logTrackNotFoundDescription": { - "description": "Track not found explanation" - }, - "logTrackNotFoundSuggestion": "A faixa pode não estar disponível em qualidade sem perdas", - "@logTrackNotFoundSuggestion": { - "description": "Track not found explanation" - }, - "logTotalErrors": "Total de erros: {count}", - "@logTotalErrors": { - "description": "Error count display", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "logAffected": "Afetado(s): {domains}", - "@logAffected": { - "description": "Affected domains display", - "placeholders": { - "domains": { - "type": "String" - } - } - }, "logEntriesFiltered": "Entradas ({count} filtradas)", "@logEntriesFiltered": { "description": "Log count with filter active", @@ -1969,10 +1305,6 @@ "@appearanceLanguage": { "description": "Language setting title" }, - "appearanceLanguageSubtitle": "Escolha o seu idioma preferido", - "@appearanceLanguageSubtitle": { - "description": "Language setting subtitle" - }, "settingsAppearanceSubtitle": "Tema, cores, exibição", "@settingsAppearanceSubtitle": { "description": "Appearance settings description" @@ -2001,10 +1333,6 @@ "@pressBackAgainToExit": { "description": "Exit confirmation message" }, - "tracksHeader": "Faixas", - "@tracksHeader": { - "description": "Section header for track list" - }, "downloadAllCount": "Baixar Todos ({count})", "@downloadAllCount": { "description": "Download all button with count", @@ -2151,15 +1479,6 @@ "@trackDeleteConfirmMessage": { "description": "Delete confirmation message" }, - "trackCannotOpen": "Não foi possível abrir: {message}", - "@trackCannotOpen": { - "description": "Error opening file", - "placeholders": { - "message": { - "type": "String" - } - } - }, "dateToday": "Hoje", "@dateToday": { "description": "Relative date - today" @@ -2195,22 +1514,6 @@ } } }, - "concurrentSequential": "Sequencial", - "@concurrentSequential": { - "description": "Download mode - one at a time" - }, - "concurrentParallel2": "2 Paralelos", - "@concurrentParallel2": { - "description": "Download mode - 2 simultaneous" - }, - "concurrentParallel3": "3 Paralelos", - "@concurrentParallel3": { - "description": "Download mode - 3 simultaneous" - }, - "tapToSeeError": "Toque para ver os detalhes do erro", - "@tapToSeeError": { - "description": "Tooltip for failed download" - }, "storeFilterAll": "Tudo", "@storeFilterAll": { "description": "Store filter - all extensions" @@ -2239,18 +1542,6 @@ "@storeClearFilters": { "description": "Button to clear all filters" }, - "storeNoResults": "Nenhuma extensão encontrada", - "@storeNoResults": { - "description": "Empty state when no extensions match filters" - }, - "extensionProviderPriority": "Prioridade de Provedor", - "@extensionProviderPriority": { - "description": "Extension capability - provider priority" - }, - "extensionInstallButton": "Instalar Extensão", - "@extensionInstallButton": { - "description": "Button to install extension" - }, "extensionDefaultProvider": "Padrão (Deezer/Spotify)", "@extensionDefaultProvider": { "description": "Default search provider option" @@ -2450,46 +1741,6 @@ "@qualityHiResFlacMaxSubtitle": { "description": "Technical spec for hi-res max" }, - "qualityLossy": "Lossy", - "@qualityLossy": { - "description": "Quality option - lossy format (MP3/Opus)" - }, - "qualityLossyMp3Subtitle": "MP3 320kbps (converted from FLAC)", - "@qualityLossyMp3Subtitle": { - "description": "Technical spec for lossy MP3" - }, - "qualityLossyOpusSubtitle": "Opus 128kbps (converted from FLAC)", - "@qualityLossyOpusSubtitle": { - "description": "Technical spec for lossy Opus" - }, - "enableLossyOption": "Enable Lossy Option", - "@enableLossyOption": { - "description": "Setting - enable lossy quality option" - }, - "enableLossyOptionSubtitleOn": "Lossy quality option is available", - "@enableLossyOptionSubtitleOn": { - "description": "Subtitle when lossy is enabled" - }, - "enableLossyOptionSubtitleOff": "Downloads FLAC then converts to lossy format", - "@enableLossyOptionSubtitleOff": { - "description": "Subtitle when lossy is disabled" - }, - "lossyFormat": "Lossy Format", - "@lossyFormat": { - "description": "Setting - choose lossy format" - }, - "lossyFormatDescription": "Choose the lossy format for conversion", - "@lossyFormatDescription": { - "description": "Description for lossy format picker" - }, - "lossyFormatMp3Subtitle": "320kbps, best compatibility", - "@lossyFormatMp3Subtitle": { - "description": "MP3 format description" - }, - "lossyFormatOpusSubtitle": "128kbps, better quality at smaller size", - "@lossyFormatOpusSubtitle": { - "description": "Opus format description" - }, "qualityNote": "A qualidade real depende da faixa que estiver disponível no serviço", "@qualityNote": { "description": "Note about quality availability" @@ -2518,14 +1769,6 @@ "@downloadUseAlbumArtistForFolders": { "description": "Setting - choose whether artist folders use Album Artist or Track Artist" }, - "downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available", - "@downloadUseAlbumArtistForFoldersAlbumSubtitle": { - "description": "Subtitle when Album Artist is used for folder naming" - }, - "downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only", - "@downloadUseAlbumArtistForFoldersTrackSubtitle": { - "description": "Subtitle when Track Artist is used for folder naming" - }, "downloadUsePrimaryArtistOnly": "Primary artist only for folders", "@downloadUsePrimaryArtistOnly": { "description": "Setting - strip featured artists from folder name" @@ -2538,14 +1781,6 @@ "@downloadUsePrimaryArtistOnlyDisabled": { "description": "Subtitle when primary artist only is disabled" }, - "downloadSaveFormat": "Formato para Salvar", - "@downloadSaveFormat": { - "description": "Setting - output file format" - }, - "downloadSelectService": "Selecionar Serviço", - "@downloadSelectService": { - "description": "Dialog title - choose download service" - }, "downloadSelectQuality": "Selecionar Qualidade", "@downloadSelectQuality": { "description": "Dialog title - choose audio quality" @@ -2554,66 +1789,6 @@ "@downloadFrom": { "description": "Label - download source" }, - "downloadDefaultQualityLabel": "Qualidade Padrão", - "@downloadDefaultQualityLabel": { - "description": "Label - default quality setting" - }, - "downloadBestAvailable": "Melhor Disponível", - "@downloadBestAvailable": { - "description": "Quality option - highest available" - }, - "folderNone": "Nenhuma", - "@folderNone": { - "description": "Folder option - no organization" - }, - "folderNoneSubtitle": "Salvar todos os arquivos diretamente na pasta de download", - "@folderNoneSubtitle": { - "description": "Subtitle for no folder organization" - }, - "folderArtist": "Artista", - "@folderArtist": { - "description": "Folder option - by artist" - }, - "folderArtistSubtitle": "Nome do Artista/arquivo", - "@folderArtistSubtitle": { - "description": "Folder structure example" - }, - "folderAlbum": "Álbum", - "@folderAlbum": { - "description": "Folder option - by album" - }, - "folderAlbumSubtitle": "Nome do Álbum/arquivo", - "@folderAlbumSubtitle": { - "description": "Folder structure example" - }, - "folderArtistAlbum": "Artista/Álbum", - "@folderArtistAlbum": { - "description": "Folder option - nested" - }, - "folderArtistAlbumSubtitle": "Nome do Artista/Nome do Álbum/arquivo", - "@folderArtistAlbumSubtitle": { - "description": "Folder structure example" - }, - "serviceTidal": "Tidal", - "@serviceTidal": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceQobuz": "Qobuz", - "@serviceQobuz": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceAmazon": "Amazon", - "@serviceAmazon": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceDeezer": "Deezer", - "@serviceDeezer": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceSpotify": "Spotify", - "@serviceSpotify": { - "description": "Service name - DO NOT TRANSLATE" - }, "appearanceAmoledDark": "Escuro AMOLED", "@appearanceAmoledDark": { "description": "Theme option - pure black" @@ -2622,18 +1797,6 @@ "@appearanceAmoledDarkSubtitle": { "description": "Subtitle for AMOLED dark" }, - "appearanceChooseAccentColor": "Escolha a Cor de Destaque", - "@appearanceChooseAccentColor": { - "description": "Color picker dialog title" - }, - "appearanceChooseTheme": "Modo do Tema", - "@appearanceChooseTheme": { - "description": "Theme picker dialog title" - }, - "queueTitle": "Fila de Download", - "@queueTitle": { - "description": "Queue screen title" - }, "queueClearAll": "Limpar Tudo", "@queueClearAll": { "description": "Button - clear all queue items" @@ -2642,22 +1805,6 @@ "@queueClearAllMessage": { "description": "Clear queue confirmation" }, - "queueExportFailed": "Export", - "@queueExportFailed": { - "description": "Button - export failed downloads to TXT" - }, - "queueExportFailedSuccess": "Failed downloads exported to TXT file", - "@queueExportFailedSuccess": { - "description": "Success message after exporting failed downloads" - }, - "queueExportFailedClear": "Clear Failed", - "@queueExportFailedClear": { - "description": "Action to clear failed downloads after export" - }, - "queueExportFailedError": "Failed to export downloads", - "@queueExportFailedError": { - "description": "Error message when export fails" - }, "settingsAutoExportFailed": "Auto-export failed downloads", "@settingsAutoExportFailed": { "description": "Setting toggle for auto-export" @@ -2682,38 +1829,6 @@ "@settingsDownloadNetworkSubtitle": { "description": "Subtitle explaining network preference" }, - "queueEmpty": "Nenhum download na fila", - "@queueEmpty": { - "description": "Empty queue state title" - }, - "queueEmptySubtitle": "Adicione faixas a partir da tela inicial", - "@queueEmptySubtitle": { - "description": "Empty queue state subtitle" - }, - "queueClearCompleted": "Limpar concluídos", - "@queueClearCompleted": { - "description": "Button - clear finished downloads" - }, - "queueDownloadFailed": "Download Falhou", - "@queueDownloadFailed": { - "description": "Error dialog title" - }, - "queueTrackLabel": "Faixa:", - "@queueTrackLabel": { - "description": "Label in error dialog" - }, - "queueArtistLabel": "Artista:", - "@queueArtistLabel": { - "description": "Label in error dialog" - }, - "queueErrorLabel": "Erro:", - "@queueErrorLabel": { - "description": "Label in error dialog" - }, - "queueUnknownError": "Erro desconhecido", - "@queueUnknownError": { - "description": "Fallback error message" - }, "albumFolderArtistAlbum": "Artista / Álbum", "@albumFolderArtistAlbum": { "description": "Album folder option" @@ -2767,19 +1882,6 @@ } } }, - "downloadedAlbumTracksHeader": "Faixas", - "@downloadedAlbumTracksHeader": { - "description": "Section header for tracks" - }, - "downloadedAlbumDownloadedCount": "{count} baixado(s)", - "@downloadedAlbumDownloadedCount": { - "description": "Downloaded tracks count badge", - "placeholders": { - "count": { - "type": "int" - } - } - }, "downloadedAlbumSelectedCount": "{count} selecionado(s)", "@downloadedAlbumSelectedCount": { "description": "Selection count indicator", @@ -2820,10 +1922,6 @@ } } }, - "utilityFunctions": "Funções Utilitárias", - "@utilityFunctions": { - "description": "Extension capability - utility functions" - }, "recentTypeArtist": "Artista", "@recentTypeArtist": { "description": "Recent access item type - artist" @@ -2858,16 +1956,6 @@ } } }, - "errorGeneric": "Erro: {message}", - "@errorGeneric": { - "description": "Generic error message format", - "placeholders": { - "message": { - "type": "String", - "description": "Error message" - } - } - }, "discographyDownload": "Baixar Discografia", "@discographyDownload": { "description": "Button - download artist discography" @@ -3034,10 +2122,6 @@ "@libraryTitle": { "description": "Library settings page title" }, - "libraryStatus": "Library Status", - "@libraryStatus": { - "description": "Section header for library status" - }, "libraryScanSettings": "Scan Settings", "@libraryScanSettings": { "description": "Section header for scan settings" @@ -3114,15 +2198,6 @@ "@libraryAboutDescription": { "description": "Description of local library feature" }, - "libraryTracksCount": "{count} tracks", - "@libraryTracksCount": { - "description": "Track count in library", - "placeholders": { - "count": { - "type": "int" - } - } - }, "libraryLastScanned": "Last scanned: {time}", "@libraryLastScanned": { "description": "Last scan time display", @@ -3237,26 +2312,6 @@ "@libraryFilterFormat": { "description": "Filter section - file format" }, - "libraryFilterDate": "Date Added", - "@libraryFilterDate": { - "description": "Filter section - date range" - }, - "libraryFilterDateToday": "Today", - "@libraryFilterDateToday": { - "description": "Filter option - today only" - }, - "libraryFilterDateWeek": "This Week", - "@libraryFilterDateWeek": { - "description": "Filter option - this week" - }, - "libraryFilterDateMonth": "This Month", - "@libraryFilterDateMonth": { - "description": "Filter option - this month" - }, - "libraryFilterDateYear": "This Year", - "@libraryFilterDateYear": { - "description": "Filter option - this year" - }, "libraryFilterSort": "Sort", "@libraryFilterSort": { "description": "Filter section - sort order" @@ -3269,15 +2324,6 @@ "@libraryFilterSortOldest": { "description": "Sort option - oldest first" }, - "libraryFilterActive": "{count} filter(s) active", - "@libraryFilterActive": { - "description": "Badge showing number of active filters", - "placeholders": { - "count": { - "type": "int" - } - } - }, "timeJustNow": "Just now", "@timeJustNow": { "description": "Relative time - less than a minute ago" @@ -3300,106 +2346,6 @@ } } }, - "storageSwitchTitle": "Switch Storage Mode", - "@storageSwitchTitle": { - "description": "Dialog title when switching storage mode" - }, - "storageSwitchToSafTitle": "Switch to SAF Storage?", - "@storageSwitchToSafTitle": { - "description": "Dialog title when switching to SAF" - }, - "storageSwitchToAppTitle": "Switch to App Storage?", - "@storageSwitchToAppTitle": { - "description": "Dialog title when switching to app storage" - }, - "storageSwitchToSafMessage": "Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.", - "@storageSwitchToSafMessage": { - "description": "Explanation when switching to SAF" - }, - "storageSwitchToAppMessage": "Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.", - "@storageSwitchToAppMessage": { - "description": "Explanation when switching to app storage" - }, - "storageSwitchExistingDownloads": "Existing Downloads", - "@storageSwitchExistingDownloads": { - "description": "Section header for existing downloads info" - }, - "storageSwitchExistingDownloadsInfo": "{count} tracks in {mode} storage", - "@storageSwitchExistingDownloadsInfo": { - "description": "Info about existing downloads count", - "placeholders": { - "count": { - "type": "int" - }, - "mode": { - "type": "String" - } - } - }, - "storageSwitchNewDownloads": "New Downloads", - "@storageSwitchNewDownloads": { - "description": "Section header for new downloads info" - }, - "storageSwitchNewDownloadsLocation": "Will be saved to: {location}", - "@storageSwitchNewDownloadsLocation": { - "description": "Shows where new downloads will go", - "placeholders": { - "location": { - "type": "String" - } - } - }, - "storageSwitchContinue": "Continue", - "@storageSwitchContinue": { - "description": "Button to proceed with storage switch" - }, - "storageSwitchSelectFolder": "Select SAF Folder", - "@storageSwitchSelectFolder": { - "description": "Button to select SAF folder" - }, - "storageAppStorage": "App Storage", - "@storageAppStorage": { - "description": "Label for app storage mode" - }, - "storageSafStorage": "SAF Storage", - "@storageSafStorage": { - "description": "Label for SAF storage mode" - }, - "storageModeBadge": "Storage: {mode}", - "@storageModeBadge": { - "description": "Badge showing storage mode for a track", - "placeholders": { - "mode": { - "type": "String" - } - } - }, - "storageStatsTitle": "Storage Statistics", - "@storageStatsTitle": { - "description": "Section title for storage stats" - }, - "storageStatsAppCount": "{count} tracks in App Storage", - "@storageStatsAppCount": { - "description": "Count of tracks in app storage", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "storageStatsSafCount": "{count} tracks in SAF Storage", - "@storageStatsSafCount": { - "description": "Count of tracks in SAF storage", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "storageModeInfo": "Your files are stored in multiple locations", - "@storageModeInfo": { - "description": "Info when user has files in both storage modes" - }, "tutorialWelcomeTitle": "Welcome to SpotiFLAC!", "@tutorialWelcomeTitle": { "description": "Tutorial welcome page title" @@ -3428,18 +2374,6 @@ "@tutorialSearchDesc": { "description": "Tutorial search page description" }, - "tutorialSearchTip1": "Paste a Spotify or Deezer URL directly in the search box", - "@tutorialSearchTip1": { - "description": "Tutorial search tip 1" - }, - "tutorialSearchTip2": "Or type the song name, artist, or album to search", - "@tutorialSearchTip2": { - "description": "Tutorial search tip 2" - }, - "tutorialSearchTip3": "Supports tracks, albums, playlists, and artist pages", - "@tutorialSearchTip3": { - "description": "Tutorial search tip 3" - }, "tutorialDownloadTitle": "Downloading Music", "@tutorialDownloadTitle": { "description": "Tutorial download page title" @@ -3448,18 +2382,6 @@ "@tutorialDownloadDesc": { "description": "Tutorial download page description" }, - "tutorialDownloadTip1": "Tap the download button next to any track to start downloading", - "@tutorialDownloadTip1": { - "description": "Tutorial download tip 1" - }, - "tutorialDownloadTip2": "Choose your preferred quality (FLAC, Hi-Res, or MP3)", - "@tutorialDownloadTip2": { - "description": "Tutorial download tip 2" - }, - "tutorialDownloadTip3": "Download entire albums or playlists with one tap", - "@tutorialDownloadTip3": { - "description": "Tutorial download tip 3" - }, "tutorialLibraryTitle": "Your Library", "@tutorialLibraryTitle": { "description": "Tutorial library page title" @@ -3524,10 +2446,6 @@ "@tutorialReadyMessage": { "description": "Tutorial completion message" }, - "tutorialExample": "EXAMPLE", - "@tutorialExample": { - "description": "Example label in tutorial" - }, "libraryForceFullScan": "Force Full Scan", "@libraryForceFullScan": { "description": "Button to force a complete rescan of library" @@ -3750,14 +2668,10 @@ "@trackSaveLyricsProgress": { "description": "Snackbar while saving lyrics to file" }, - "trackReEnrich": "Re-enrich Metadata", + "trackReEnrich": "Re-enrich", "@trackReEnrich": { "description": "Menu action - re-embed metadata into audio file" }, - "trackReEnrichSubtitle": "Re-embed metadata without re-downloading", - "@trackReEnrichSubtitle": { - "description": "Subtitle for re-enrich metadata action" - }, "trackReEnrichOnlineSubtitle": "Search metadata online and embed into file", "@trackReEnrichOnlineSubtitle": { "description": "Subtitle for re-enrich metadata action for local items" @@ -3868,5 +2782,22 @@ "trackConvertFailed": "Conversion failed", "@trackConvertFailed": { "description": "Snackbar when conversion fails" + }, + "downloadedAlbumDownloadedCount": "{count} baixado(s)", + "@downloadedAlbumDownloadedCount": { + "description": "Downloaded tracks count badge", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available", + "@downloadUseAlbumArtistForFoldersAlbumSubtitle": { + "description": "Subtitle when Album Artist is used for folder naming" + }, + "downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only", + "@downloadUseAlbumArtistForFoldersTrackSubtitle": { + "description": "Subtitle when Track Artist is used for folder naming" } -} \ No newline at end of file +} diff --git a/lib/l10n/arb/app_ru.arb b/lib/l10n/arb/app_ru.arb index 2025e802..4ea4e618 100644 --- a/lib/l10n/arb/app_ru.arb +++ b/lib/l10n/arb/app_ru.arb @@ -5,10 +5,6 @@ "@appName": { "description": "App name - DO NOT TRANSLATE" }, - "appDescription": "Скачайте треки Spotify в Lossless качестве из Tidal, Qobuz и Amazon Music.", - "@appDescription": { - "description": "App description shown in about page" - }, "navHome": "Главная", "@navHome": { "description": "Bottom navigation - Home tab" @@ -17,10 +13,6 @@ "@navLibrary": { "description": "Bottom navigation - Library tab" }, - "navHistory": "История", - "@navHistory": { - "description": "Bottom navigation - History tab (legacy)" - }, "navSettings": "Настройки", "@navSettings": { "description": "Bottom navigation - Settings tab" @@ -33,20 +25,6 @@ "@homeTitle": { "description": "Home screen title" }, - "homeSearchHint": "Вставьте URL Spotify или выполните поиск...", - "@homeSearchHint": { - "description": "Placeholder text in search box" - }, - "homeSearchHintExtension": "Искать с помощью {extensionName}...", - "@homeSearchHintExtension": { - "description": "Placeholder when extension search is active", - "placeholders": { - "extensionName": { - "type": "String", - "description": "Name of the active extension" - } - } - }, "homeSubtitle": "Вставьте ссылку Spotify или ищите по названию", "@homeSubtitle": { "description": "Subtitle shown below search box" @@ -59,24 +37,6 @@ "@homeRecent": { "description": "Section header for recent searches" }, - "historyTitle": "История", - "@historyTitle": { - "description": "History screen title" - }, - "historyDownloading": "Скачивание ({count})", - "@historyDownloading": { - "description": "Tab showing active downloads count", - "placeholders": { - "count": { - "type": "int", - "description": "Number of active downloads" - } - } - }, - "historyDownloaded": "Скачано", - "@historyDownloaded": { - "description": "Tab showing completed downloads" - }, "historyFilterAll": "Все", "@historyFilterAll": { "description": "Filter chip - show all items" @@ -89,48 +49,6 @@ "@historyFilterSingles": { "description": "Filter chip - show singles only" }, - "historyTracksCount": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} other {{count} треков}}", - "@historyTracksCount": { - "description": "Track count with plural form", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "historyAlbumsCount": "{count, plural, one {{count} альбом} few {{count} альбома} many {{count} альбомов} other {{count} альбомов}}", - "@historyAlbumsCount": { - "description": "Album count with plural form", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "historyNoDownloads": "Нет истории скачиваний", - "@historyNoDownloads": { - "description": "Empty state title" - }, - "historyNoDownloadsSubtitle": "Скачанные треки появятся здесь", - "@historyNoDownloadsSubtitle": { - "description": "Empty state subtitle" - }, - "historyNoAlbums": "Нет скачанных альбомов", - "@historyNoAlbums": { - "description": "Empty state when filtering albums" - }, - "historyNoAlbumsSubtitle": "Скачайте несколько треков из альбома, чтобы увидеть их здесь", - "@historyNoAlbumsSubtitle": { - "description": "Empty state subtitle for albums filter" - }, - "historyNoSingles": "Нет скачанных синглов", - "@historyNoSingles": { - "description": "Empty state when filtering singles" - }, - "historyNoSinglesSubtitle": "Здесь будут отображаться загрузки синглов", - "@historyNoSinglesSubtitle": { - "description": "Empty state subtitle for singles filter" - }, "historySearchHint": "Поиск в истории...", "@historySearchHint": { "description": "Search bar placeholder in history" @@ -163,34 +81,6 @@ "@downloadTitle": { "description": "Download settings page title" }, - "downloadLocation": "Папка для скачивания", - "@downloadLocation": { - "description": "Setting for download folder" - }, - "downloadLocationSubtitle": "Выберите, куда сохранить файлы", - "@downloadLocationSubtitle": { - "description": "Subtitle for download location" - }, - "downloadLocationDefault": "Расположение по умолчанию", - "@downloadLocationDefault": { - "description": "Shown when using default folder" - }, - "downloadDefaultService": "Сервис по умолчанию", - "@downloadDefaultService": { - "description": "Setting for preferred download service (Tidal/Qobuz/Amazon)" - }, - "downloadDefaultServiceSubtitle": "Сервис, используемый для скачивания", - "@downloadDefaultServiceSubtitle": { - "description": "Subtitle for default service" - }, - "downloadDefaultQuality": "Качество по умолчанию", - "@downloadDefaultQuality": { - "description": "Setting for audio quality" - }, - "downloadAskQuality": "Спрашивать качество перед скачиванием", - "@downloadAskQuality": { - "description": "Toggle to show quality picker" - }, "downloadAskQualitySubtitle": "Показывать выбор качества для каждого скачивания", "@downloadAskQualitySubtitle": { "description": "Subtitle for ask quality toggle" @@ -203,38 +93,10 @@ "@downloadFolderOrganization": { "description": "Setting for folder structure" }, - "downloadSeparateSingles": "Разделять синглы", - "@downloadSeparateSingles": { - "description": "Toggle to separate single tracks" - }, - "downloadSeparateSinglesSubtitle": "Помещать синглы в отдельную папку", - "@downloadSeparateSinglesSubtitle": { - "description": "Subtitle for separate singles toggle" - }, - "qualityBest": "Лучшее из доступных", - "@qualityBest": { - "description": "Audio quality option - highest available" - }, - "qualityFlac": "FLAC", - "@qualityFlac": { - "description": "Audio quality option - FLAC lossless" - }, - "quality320": "320 кбит/с", - "@quality320": { - "description": "Audio quality option - 320kbps MP3" - }, - "quality128": "128 кбит/с", - "@quality128": { - "description": "Audio quality option - 128kbps MP3" - }, "appearanceTitle": "Внешний вид", "@appearanceTitle": { "description": "Appearance settings page title" }, - "appearanceTheme": "Тема", - "@appearanceTheme": { - "description": "Theme mode setting" - }, "appearanceThemeSystem": "Системная", "@appearanceThemeSystem": { "description": "Follow system theme" @@ -255,10 +117,6 @@ "@appearanceDynamicColorSubtitle": { "description": "Subtitle for dynamic color" }, - "appearanceAccentColor": "Акцентный цвет", - "@appearanceAccentColor": { - "description": "Custom accent color picker" - }, "appearanceHistoryView": "Отображение истории", "@appearanceHistoryView": { "description": "Layout style for history" @@ -275,10 +133,6 @@ "@optionsTitle": { "description": "Options settings page title" }, - "optionsSearchSource": "Поиск источника", - "@optionsSearchSource": { - "description": "Section for search provider settings" - }, "optionsPrimaryProvider": "Основной провайдер", "@optionsPrimaryProvider": { "description": "Main search provider setting" @@ -438,22 +292,6 @@ "@extensionsTitle": { "description": "Extensions page title" }, - "extensionsInstalled": "Установленные расширения", - "@extensionsInstalled": { - "description": "Section header for installed extensions" - }, - "extensionsNone": "Нет установленных расширений", - "@extensionsNone": { - "description": "Empty state title" - }, - "extensionsNoneSubtitle": "Установка расширений из вкладки Магазин", - "@extensionsNoneSubtitle": { - "description": "Empty state subtitle" - }, - "extensionsEnabled": "Включено", - "@extensionsEnabled": { - "description": "Extension status - active" - }, "extensionsDisabled": "Выключено", "@extensionsDisabled": { "description": "Extension status - inactive" @@ -480,10 +318,6 @@ "@extensionsUninstall": { "description": "Uninstall extension button" }, - "extensionsSetAsSearch": "Установить в качестве поисковой системы", - "@extensionsSetAsSearch": { - "description": "Use extension for search" - }, "storeTitle": "Магазин расширений", "@storeTitle": { "description": "Store screen title" @@ -580,10 +414,6 @@ "@aboutSocial": { "description": "Section for social links" }, - "aboutSupport": "Поддержка", - "@aboutSupport": { - "description": "Section for support/donation links" - }, "aboutApp": "Приложение", "@aboutApp": { "description": "Section for app info" @@ -604,14 +434,6 @@ "@aboutSjdonadoDesc": { "description": "Credit description for sjdonado" }, - "aboutDoubleDouble": "DoubleDouble", - "@aboutDoubleDouble": { - "description": "Name of Amazon API service - DO NOT TRANSLATE" - }, - "aboutDoubleDoubleDesc": "Удивительный API для загрузок Amazon Music. Спасибо за то, что сделали это бесплатно!", - "@aboutDoubleDoubleDesc": { - "description": "Credit for DoubleDouble API" - }, "aboutDabMusic": "DAB Music", "@aboutDabMusic": { "description": "Name of Qobuz API service - DO NOT TRANSLATE" @@ -632,35 +454,6 @@ "@aboutAppDescription": { "description": "App description in header card" }, - "albumTitle": "Альбом", - "@albumTitle": { - "description": "Album screen title" - }, - "albumTracks": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} other {{count} треков}}", - "@albumTracks": { - "description": "Album track count", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "albumDownloadAll": "Скачать всё", - "@albumDownloadAll": { - "description": "Button to download all tracks" - }, - "albumDownloadRemaining": "Скачать оставшиеся", - "@albumDownloadRemaining": { - "description": "Button to download remaining tracks" - }, - "playlistTitle": "Плейлист", - "@playlistTitle": { - "description": "Playlist screen title" - }, - "artistTitle": "Исполнитель", - "@artistTitle": { - "description": "Artist screen title" - }, "artistAlbums": "Альбомы", "@artistAlbums": { "description": "Section header for artist albums" @@ -673,15 +466,6 @@ "@artistCompilations": { "description": "Section header for compilations" }, - "artistReleases": "{count, plural, one {{count} релиз} few {{count} релиза} many {{count} релизов} other {{count} релизов}}", - "@artistReleases": { - "description": "Artist release count", - "placeholders": { - "count": { - "type": "int" - } - } - }, "artistPopular": "Популярное", "@artistPopular": { "description": "Section header for popular/top tracks" @@ -696,34 +480,6 @@ } } }, - "trackMetadataTitle": "Информация о треке", - "@trackMetadataTitle": { - "description": "Track metadata screen title" - }, - "trackMetadataArtist": "Исполнитель", - "@trackMetadataArtist": { - "description": "Metadata field - artist name" - }, - "trackMetadataAlbum": "Альбом", - "@trackMetadataAlbum": { - "description": "Metadata field - album name" - }, - "trackMetadataDuration": "Продолжительность", - "@trackMetadataDuration": { - "description": "Metadata field - track length" - }, - "trackMetadataQuality": "Качество", - "@trackMetadataQuality": { - "description": "Metadata field - audio quality" - }, - "trackMetadataPath": "Путь к файлу", - "@trackMetadataPath": { - "description": "Metadata field - file location" - }, - "trackMetadataDownloadedAt": "Скачано", - "@trackMetadataDownloadedAt": { - "description": "Metadata field - download date" - }, "trackMetadataService": "Сервис", "@trackMetadataService": { "description": "Metadata field - download service used" @@ -740,54 +496,10 @@ "@trackMetadataDelete": { "description": "Action button - delete track" }, - "trackMetadataRedownload": "Скачать снова", - "@trackMetadataRedownload": { - "description": "Action button - download again" - }, - "trackMetadataOpenFolder": "Открыть папку", - "@trackMetadataOpenFolder": { - "description": "Action button - open containing folder" - }, - "setupTitle": "Добро пожаловать в SpotiFLAC", - "@setupTitle": { - "description": "Setup wizard title" - }, - "setupSubtitle": "Давайте начнем", - "@setupSubtitle": { - "description": "Setup wizard subtitle" - }, - "setupStoragePermission": "Доступ к хранилищу", - "@setupStoragePermission": { - "description": "Storage permission step title" - }, - "setupStoragePermissionSubtitle": "Необходимо для сохранения загруженных файлов", - "@setupStoragePermissionSubtitle": { - "description": "Explanation for storage permission" - }, - "setupStoragePermissionGranted": "Разрешение предоставлено", - "@setupStoragePermissionGranted": { - "description": "Status when permission granted" - }, - "setupStoragePermissionDenied": "Разрешение не предоставлено", - "@setupStoragePermissionDenied": { - "description": "Status when permission denied" - }, "setupGrantPermission": "Предоставить разрешение", "@setupGrantPermission": { "description": "Button to request permission" }, - "setupDownloadLocation": "Папка для скачивания", - "@setupDownloadLocation": { - "description": "Download folder step title" - }, - "setupChooseFolder": "Выбрать папку", - "@setupChooseFolder": { - "description": "Button to pick folder" - }, - "setupContinue": "Продолжить", - "@setupContinue": { - "description": "Continue to next step button" - }, "setupSkip": "Пропустить", "@setupSkip": { "description": "Skip current step button" @@ -796,10 +508,6 @@ "@setupStorageAccessRequired": { "description": "Title when storage access needed" }, - "setupStorageAccessMessage": "SpotiFLAC требуется разрешение \"Доступ ко всем файлам\" для сохранения музыкальных файлов в выбранную папку.", - "@setupStorageAccessMessage": { - "description": "Explanation for storage access" - }, "setupStorageAccessMessageAndroid11": "Для Android 11+ требуется разрешение \"Доступ ко всем файлам\" для сохранения файлов в выбранную вами папку загрузки.", "@setupStorageAccessMessageAndroid11": { "description": "Android 11+ specific explanation" @@ -831,10 +539,6 @@ } } }, - "setupSelectDownloadFolder": "Выбрать папку для скачивания", - "@setupSelectDownloadFolder": { - "description": "Folder selection step title" - }, "setupUseDefaultFolder": "Использовать папку по умолчанию?", "@setupUseDefaultFolder": { "description": "Dialog title for default folder" @@ -883,26 +587,6 @@ "@setupDownloadInFlac": { "description": "App tagline in setup" }, - "setupStepStorage": "Хранилище", - "@setupStepStorage": { - "description": "Setup step indicator - storage" - }, - "setupStepNotification": "Уведомления", - "@setupStepNotification": { - "description": "Setup step indicator - notification" - }, - "setupStepFolder": "Папка", - "@setupStepFolder": { - "description": "Setup step indicator - folder" - }, - "setupStepSpotify": "Spotify", - "@setupStepSpotify": { - "description": "Setup step indicator - Spotify API" - }, - "setupStepPermission": "Разрешение", - "@setupStepPermission": { - "description": "Setup step indicator - permission" - }, "setupStorageGranted": "Доступ к хранилищу предоставлен!", "@setupStorageGranted": { "description": "Success message for storage permission" @@ -923,14 +607,6 @@ "@setupNotificationEnable": { "description": "Button to enable notifications" }, - "setupNotificationDescription": "Получайте уведомления о завершении загрузки или о необходимости привлечения внимания.", - "@setupNotificationDescription": { - "description": "Explanation for notifications" - }, - "setupFolderSelected": "Папка для загрузки выбрана!", - "@setupFolderSelected": { - "description": "Success message for folder selection" - }, "setupFolderChoose": "Выбрать папку для скачивания", "@setupFolderChoose": { "description": "Button to choose folder" @@ -939,58 +615,14 @@ "@setupFolderDescription": { "description": "Explanation for folder selection" }, - "setupChangeFolder": "Сменить папку", - "@setupChangeFolder": { - "description": "Button to change selected folder" - }, "setupSelectFolder": "Выбрать папку", "@setupSelectFolder": { "description": "Button to select folder" }, - "setupSpotifyApiOptional": "Spotify API (необязательно)", - "@setupSpotifyApiOptional": { - "description": "Spotify API step title" - }, - "setupSpotifyApiDescription": "Добавьте свои учётные данные Spotify для улучшения результатов поиска и доступа к эксклюзивному контенту Spotify.", - "@setupSpotifyApiDescription": { - "description": "Explanation for Spotify API" - }, - "setupUseSpotifyApi": "Использовать Spotify API", - "@setupUseSpotifyApi": { - "description": "Toggle to enable Spotify API" - }, - "setupEnterCredentialsBelow": "Введите ваши учётные данные ниже", - "@setupEnterCredentialsBelow": { - "description": "Prompt to enter credentials" - }, - "setupUsingDeezer": "Использование Deezer (аккаунт не требуется)", - "@setupUsingDeezer": { - "description": "Status when using Deezer" - }, - "setupEnterClientId": "Введите Client ID Spotify", - "@setupEnterClientId": { - "description": "Placeholder for client ID field" - }, - "setupEnterClientSecret": "Введите Spotify Client Secret", - "@setupEnterClientSecret": { - "description": "Placeholder for client secret field" - }, - "setupGetFreeCredentials": "Получите бесплатный API учётной записи на панели разработчика Spotify.", - "@setupGetFreeCredentials": { - "description": "Info about getting Spotify credentials" - }, "setupEnableNotifications": "Включить уведомления", "@setupEnableNotifications": { "description": "Button to enable notifications" }, - "setupProceedToNextStep": "Теперь вы можете перейти к следующему шагу.", - "@setupProceedToNextStep": { - "description": "Message after completing a step" - }, - "setupNotificationProgressDescription": "Вы будете получать уведомления о ходе загрузки.", - "@setupNotificationProgressDescription": { - "description": "Info about notification usage" - }, "setupNotificationBackgroundDescription": "Получайте уведомления о ходе и завершении загрузки. Это поможет вам отслеживать загрузки, когда приложение находится в фоновом режиме.", "@setupNotificationBackgroundDescription": { "description": "Detailed notification explanation" @@ -999,10 +631,6 @@ "@setupSkipForNow": { "description": "Skip button text" }, - "setupBack": "Назад", - "@setupBack": { - "description": "Back button text" - }, "setupNext": "Далее", "@setupNext": { "description": "Next button text" @@ -1011,26 +639,14 @@ "@setupGetStarted": { "description": "Final setup button" }, - "setupSkipAndStart": "Пропустить и начать", - "@setupSkipAndStart": { - "description": "Skip setup and start app" - }, "setupAllowAccessToManageFiles": "Пожалуйста, включите \"Разрешить доступ для управления всеми файлами\" на следующем экране.", "@setupAllowAccessToManageFiles": { "description": "Instruction for file access permission" }, - "setupGetCredentialsFromSpotify": "Получить учётные данные с developer.spotify.com", - "@setupGetCredentialsFromSpotify": { - "description": "Link text for Spotify developer portal" - }, "dialogCancel": "Отмена", "@dialogCancel": { "description": "Dialog button - cancel action" }, - "dialogOk": "ОК", - "@dialogOk": { - "description": "Dialog button - confirm/acknowledge" - }, "dialogSave": "Сохранить", "@dialogSave": { "description": "Dialog button - save changes" @@ -1043,26 +659,10 @@ "@dialogRetry": { "description": "Dialog button - retry action" }, - "dialogClose": "Закрыть", - "@dialogClose": { - "description": "Dialog button - close dialog" - }, - "dialogYes": "Да", - "@dialogYes": { - "description": "Dialog button - confirm yes" - }, - "dialogNo": "Нет", - "@dialogNo": { - "description": "Dialog button - confirm no" - }, "dialogClear": "Очистить", "@dialogClear": { "description": "Dialog button - clear items" }, - "dialogConfirm": "Подтвердить", - "@dialogConfirm": { - "description": "Dialog button - confirm action" - }, "dialogDone": "Готово", "@dialogDone": { "description": "Dialog button - action completed" @@ -1091,34 +691,10 @@ "@dialogUnsavedChanges": { "description": "Dialog message - unsaved changes" }, - "dialogDownloadFailed": "Ошибка скачивания", - "@dialogDownloadFailed": { - "description": "Dialog title - download error" - }, - "dialogTrackLabel": "Трек:", - "@dialogTrackLabel": { - "description": "Label for track name in error dialog" - }, - "dialogArtistLabel": "Исполнитель:", - "@dialogArtistLabel": { - "description": "Label for artist name in error dialog" - }, - "dialogErrorLabel": "Ошибка:", - "@dialogErrorLabel": { - "description": "Label for error message" - }, "dialogClearAll": "Очистить всё", "@dialogClearAll": { "description": "Dialog title - clear all items" }, - "dialogClearAllDownloads": "Вы уверены, что хотите очистить все загрузки?", - "@dialogClearAllDownloads": { - "description": "Dialog message - clear downloads confirmation" - }, - "dialogRemoveFromDevice": "Удалить с устройства?", - "@dialogRemoveFromDevice": { - "description": "Dialog title - delete file confirmation" - }, "dialogRemoveExtension": "Удалить расширение", "@dialogRemoveExtension": { "description": "Dialog title - uninstall extension" @@ -1257,15 +833,6 @@ "@snackbarViewQueue": { "description": "Snackbar action - view download queue" }, - "snackbarFailedToLoad": "Ошибка загрузки: {error}", - "@snackbarFailedToLoad": { - "description": "Snackbar - loading error", - "placeholders": { - "error": { - "type": "String" - } - } - }, "snackbarUrlCopied": "{platform} ссылка скопирована в буфер обмена", "@snackbarUrlCopied": { "description": "Snackbar - URL copied", @@ -1326,16 +893,6 @@ "@errorRateLimitedMessage": { "description": "Error message - rate limit explanation" }, - "errorFailedToLoad": "Ошибка загрузки {item}", - "@errorFailedToLoad": { - "description": "Error message - loading failed", - "placeholders": { - "item": { - "type": "String", - "description": "Item that failed to load (album/playlist/etc)" - } - } - }, "errorNoTracksFound": "Треки не найдены", "@errorNoTracksFound": { "description": "Error - search returned no results" @@ -1349,34 +906,6 @@ } } }, - "statusQueued": "В очереди", - "@statusQueued": { - "description": "Download status - waiting in queue" - }, - "statusDownloading": "Скачивание", - "@statusDownloading": { - "description": "Download status - in progress" - }, - "statusFinalizing": "Завершение", - "@statusFinalizing": { - "description": "Download status - writing metadata" - }, - "statusCompleted": "Завершено", - "@statusCompleted": { - "description": "Download status - finished" - }, - "statusFailed": "Неудачно", - "@statusFailed": { - "description": "Download status - error occurred" - }, - "statusSkipped": "Пропущено", - "@statusSkipped": { - "description": "Download status - already exists" - }, - "statusPaused": "Приостановлено", - "@statusPaused": { - "description": "Download status - paused" - }, "actionPause": "Пауза", "@actionPause": { "description": "Action button - pause download" @@ -1389,14 +918,6 @@ "@actionCancel": { "description": "Action button - cancel operation" }, - "actionStop": "Стоп", - "@actionStop": { - "description": "Action button - stop operation" - }, - "actionSelect": "Выбрать", - "@actionSelect": { - "description": "Action button - enter selection mode" - }, "actionSelectAll": "Выбрать все", "@actionSelectAll": { "description": "Action button - select all items" @@ -1405,14 +926,6 @@ "@actionDeselect": { "description": "Action button - deselect all" }, - "actionPaste": "Вставить", - "@actionPaste": { - "description": "Action button - paste from clipboard" - }, - "actionImportCsv": "Импорт CSV", - "@actionImportCsv": { - "description": "Action button - import CSV file" - }, "actionRemoveCredentials": "Удалить учётные данные", "@actionRemoveCredentials": { "description": "Action button - delete Spotify credentials" @@ -1434,19 +947,6 @@ "@selectionAllSelected": { "description": "Status - all items selected" }, - "selectionTapToSelect": "Нажмите на треки для выбора", - "@selectionTapToSelect": { - "description": "Hint - how to select items" - }, - "selectionDeleteTracks": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other {треков}}", - "@selectionDeleteTracks": { - "description": "Delete button with count", - "placeholders": { - "count": { - "type": "int" - } - } - }, "selectionSelectToDelete": "Выберите треки для удаления", "@selectionSelectToDelete": { "description": "Placeholder when nothing selected" @@ -1487,55 +987,10 @@ "@tooltipPlay": { "description": "Tooltip - play button" }, - "tooltipCancel": "Отмена", - "@tooltipCancel": { - "description": "Tooltip - cancel button" - }, - "tooltipStop": "Стоп", - "@tooltipStop": { - "description": "Tooltip - stop button" - }, - "tooltipRetry": "Повторить", - "@tooltipRetry": { - "description": "Tooltip - retry button" - }, - "tooltipRemove": "Убрать", - "@tooltipRemove": { - "description": "Tooltip - remove button" - }, - "tooltipClear": "Очистить", - "@tooltipClear": { - "description": "Tooltip - clear button" - }, - "tooltipPaste": "Вставить", - "@tooltipPaste": { - "description": "Tooltip - paste button" - }, "filenameFormat": "Формат имени файла", "@filenameFormat": { "description": "Setting title - filename pattern" }, - "filenameFormatPreview": "Предпросмотр: {preview}", - "@filenameFormatPreview": { - "description": "Preview of filename pattern", - "placeholders": { - "preview": { - "type": "String" - } - } - }, - "filenameAvailablePlaceholders": "Доступные заполнители:", - "@filenameAvailablePlaceholders": { - "description": "Label for placeholder list" - }, - "filenameHint": "{artist} - {title}", - "@filenameHint": { - "description": "Default filename format hint" - }, - "folderOrganization": "Организация папок", - "@folderOrganization": { - "description": "Setting title - folder structure" - }, "folderOrganizationNone": "Без организации", "@folderOrganizationNone": { "description": "Folder option - flat structure" @@ -1576,27 +1031,10 @@ "@updateAvailable": { "description": "Update dialog title" }, - "updateNewVersion": "Версия {version} доступна", - "@updateNewVersion": { - "description": "Update available message", - "placeholders": { - "version": { - "type": "String" - } - } - }, - "updateDownload": "Скачать", - "@updateDownload": { - "description": "Update button - download update" - }, "updateLater": "Позже", "@updateLater": { "description": "Update button - dismiss" }, - "updateChangelog": "Список изменений", - "@updateChangelog": { - "description": "Link to changelog" - }, "updateStartingDownload": "Загрузка началась...", "@updateStartingDownload": { "description": "Update status - initializing" @@ -1637,14 +1075,6 @@ "@updateDontRemind": { "description": "Update button - skip this version" }, - "providerPriority": "Приоритет провайдера", - "@providerPriority": { - "description": "Setting title - download provider order" - }, - "providerPrioritySubtitle": "Перетащите для изменения порядка", - "@providerPrioritySubtitle": { - "description": "Subtitle for provider priority" - }, "providerPriorityTitle": "Приоритет провайдера", "@providerPriorityTitle": { "description": "Provider priority page title" @@ -1665,14 +1095,6 @@ "@providerExtension": { "description": "Label for extension-provided providers" }, - "metadataProviderPriority": "Приоритет провайдера метаданных", - "@metadataProviderPriority": { - "description": "Setting title - metadata provider order" - }, - "metadataProviderPrioritySubtitle": "Порядок, используемый при получении метаданных", - "@metadataProviderPrioritySubtitle": { - "description": "Subtitle for metadata priority" - }, "metadataProviderPriorityTitle": "Приоритет метаданных", "@metadataProviderPriorityTitle": { "description": "Metadata priority page title" @@ -1697,22 +1119,6 @@ "@logTitle": { "description": "Logs screen title" }, - "logCopy": "Скопировать логи", - "@logCopy": { - "description": "Action - copy logs to clipboard" - }, - "logClear": "Очистить логи", - "@logClear": { - "description": "Action - delete all logs" - }, - "logShare": "Поделиться логами", - "@logShare": { - "description": "Action - share logs file" - }, - "logEmpty": "Логов нет", - "@logEmpty": { - "description": "Empty state title" - }, "logCopied": "Логи скопированы в буфер обмена", "@logCopied": { "description": "Snackbar - logs copied" @@ -1745,22 +1151,6 @@ "@logClearLogsMessage": { "description": "Clear logs confirmation message" }, - "logIspBlocking": "ОБНАРУЖЕНА БЛОКИРОВКА ИНТЕРНЕТ ПРОВАЙДЕРОМ", - "@logIspBlocking": { - "description": "Error category - ISP blocking" - }, - "logRateLimited": "ОГРАНИЧЕННАЯ СКОРОСТЬ", - "@logRateLimited": { - "description": "Error category - rate limiting" - }, - "logNetworkError": "ОШИБКА СЕТИ", - "@logNetworkError": { - "description": "Error category - network issues" - }, - "logTrackNotFound": "ТРЕК НЕ НАЙДЕН", - "@logTrackNotFound": { - "description": "Error category - missing tracks" - }, "logFilterBySeverity": "Фильтровать логи по серьезности", "@logFilterBySeverity": { "description": "Filter dialog title" @@ -1773,60 +1163,6 @@ "@logNoLogsYetSubtitle": { "description": "Empty state subtitle" }, - "logIssueSummary": "Краткое описание проблемы", - "@logIssueSummary": { - "description": "Section header for error summary" - }, - "logIspBlockingDescription": "Ваш провайдер может блокировать доступ к сервисам скачивания", - "@logIspBlockingDescription": { - "description": "ISP blocking explanation" - }, - "logIspBlockingSuggestion": "Попробуйте использовать VPN или измените DNS на 1.1.1.1 или 8.8.8.8", - "@logIspBlockingSuggestion": { - "description": "ISP blocking fix suggestion" - }, - "logRateLimitedDescription": "Слишком много запросов к сервису", - "@logRateLimitedDescription": { - "description": "Rate limit explanation" - }, - "logRateLimitedSuggestion": "Подождите несколько минут, прежде чем повторить попытку", - "@logRateLimitedSuggestion": { - "description": "Rate limit fix suggestion" - }, - "logNetworkErrorDescription": "Обнаружены проблемы с подключением", - "@logNetworkErrorDescription": { - "description": "Network error explanation" - }, - "logNetworkErrorSuggestion": "Проверьте подключение к Интернету", - "@logNetworkErrorSuggestion": { - "description": "Network error fix suggestion" - }, - "logTrackNotFoundDescription": "Некоторые треки не найдены в сервисах загрузки", - "@logTrackNotFoundDescription": { - "description": "Track not found explanation" - }, - "logTrackNotFoundSuggestion": "Трек может быть недоступен в lossless формате", - "@logTrackNotFoundSuggestion": { - "description": "Track not found explanation" - }, - "logTotalErrors": "Всего ошибок: {count}", - "@logTotalErrors": { - "description": "Error count display", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "logAffected": "Затронуто: {domains}", - "@logAffected": { - "description": "Affected domains display", - "placeholders": { - "domains": { - "type": "String" - } - } - }, "logEntriesFiltered": "Записи ({count} фильтровано)", "@logEntriesFiltered": { "description": "Log count with filter active", @@ -1969,10 +1305,6 @@ "@appearanceLanguage": { "description": "Language setting title" }, - "appearanceLanguageSubtitle": "Выберите предпочитаемый язык", - "@appearanceLanguageSubtitle": { - "description": "Language setting subtitle" - }, "settingsAppearanceSubtitle": "Тема, цвета, дисплей", "@settingsAppearanceSubtitle": { "description": "Appearance settings description" @@ -2001,10 +1333,6 @@ "@pressBackAgainToExit": { "description": "Exit confirmation message" }, - "tracksHeader": "Треки", - "@tracksHeader": { - "description": "Section header for track list" - }, "downloadAllCount": "Скачать все ({count})", "@downloadAllCount": { "description": "Download all button with count", @@ -2151,15 +1479,6 @@ "@trackDeleteConfirmMessage": { "description": "Delete confirmation message" }, - "trackCannotOpen": "Невозможно открыть: {message}", - "@trackCannotOpen": { - "description": "Error opening file", - "placeholders": { - "message": { - "type": "String" - } - } - }, "dateToday": "Сегодня", "@dateToday": { "description": "Relative date - today" @@ -2195,22 +1514,6 @@ } } }, - "concurrentSequential": "Последовательно", - "@concurrentSequential": { - "description": "Download mode - one at a time" - }, - "concurrentParallel2": "2 параллельно", - "@concurrentParallel2": { - "description": "Download mode - 2 simultaneous" - }, - "concurrentParallel3": "3 параллельно", - "@concurrentParallel3": { - "description": "Download mode - 3 simultaneous" - }, - "tapToSeeError": "Нажмите, чтобы увидеть подробности ошибки", - "@tapToSeeError": { - "description": "Tooltip for failed download" - }, "storeFilterAll": "Все", "@storeFilterAll": { "description": "Store filter - all extensions" @@ -2239,18 +1542,6 @@ "@storeClearFilters": { "description": "Button to clear all filters" }, - "storeNoResults": "Расширения не найдены", - "@storeNoResults": { - "description": "Empty state when no extensions match filters" - }, - "extensionProviderPriority": "Приоритет провайдера", - "@extensionProviderPriority": { - "description": "Extension capability - provider priority" - }, - "extensionInstallButton": "Установить расширение", - "@extensionInstallButton": { - "description": "Button to install extension" - }, "extensionDefaultProvider": "По умолчанию (Deezer/Spotify)", "@extensionDefaultProvider": { "description": "Default search provider option" @@ -2450,46 +1741,6 @@ "@qualityHiResFlacMaxSubtitle": { "description": "Technical spec for hi-res max" }, - "qualityLossy": "Lossy", - "@qualityLossy": { - "description": "Quality option - lossy format (MP3/Opus)" - }, - "qualityLossyMp3Subtitle": "Opus 320 кбит/с (конвертировать из FLAC)", - "@qualityLossyMp3Subtitle": { - "description": "Technical spec for lossy MP3" - }, - "qualityLossyOpusSubtitle": "Opus 128 кбит/с (конвертировать из FLAC)", - "@qualityLossyOpusSubtitle": { - "description": "Technical spec for lossy Opus" - }, - "enableLossyOption": "Включить опцию Lossy", - "@enableLossyOption": { - "description": "Setting - enable lossy quality option" - }, - "enableLossyOptionSubtitleOn": "Доступно качество с потерями", - "@enableLossyOptionSubtitleOn": { - "description": "Subtitle when lossy is enabled" - }, - "enableLossyOptionSubtitleOff": "Скачивать FLAC и конвертировать в MP3 320 кбит/с", - "@enableLossyOptionSubtitleOff": { - "description": "Subtitle when lossy is disabled" - }, - "lossyFormat": "Формат с потерями", - "@lossyFormat": { - "description": "Setting - choose lossy format" - }, - "lossyFormatDescription": "Выберите Lossy формат для конвертации", - "@lossyFormatDescription": { - "description": "Description for lossy format picker" - }, - "lossyFormatMp3Subtitle": "320Кбит/с, лучшая совместимость", - "@lossyFormatMp3Subtitle": { - "description": "MP3 format description" - }, - "lossyFormatOpusSubtitle": "128кбит/с, лучшее качество при меньших размерах", - "@lossyFormatOpusSubtitle": { - "description": "Opus format description" - }, "qualityNote": "Фактическое качество зависит от доступности треков в сервисе", "@qualityNote": { "description": "Note about quality availability" @@ -2518,14 +1769,6 @@ "@downloadUseAlbumArtistForFolders": { "description": "Setting - choose whether artist folders use Album Artist or Track Artist" }, - "downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available", - "@downloadUseAlbumArtistForFoldersAlbumSubtitle": { - "description": "Subtitle when Album Artist is used for folder naming" - }, - "downloadUseAlbumArtistForFoldersTrackSubtitle": "Папки исполнителя используют только трек исполнителя", - "@downloadUseAlbumArtistForFoldersTrackSubtitle": { - "description": "Subtitle when Track Artist is used for folder naming" - }, "downloadUsePrimaryArtistOnly": "Primary artist only for folders", "@downloadUsePrimaryArtistOnly": { "description": "Setting - strip featured artists from folder name" @@ -2538,14 +1781,6 @@ "@downloadUsePrimaryArtistOnlyDisabled": { "description": "Subtitle when primary artist only is disabled" }, - "downloadSaveFormat": "Формат сохранения", - "@downloadSaveFormat": { - "description": "Setting - output file format" - }, - "downloadSelectService": "Выбор сервиса", - "@downloadSelectService": { - "description": "Dialog title - choose download service" - }, "downloadSelectQuality": "Выбор качества", "@downloadSelectQuality": { "description": "Dialog title - choose audio quality" @@ -2554,66 +1789,6 @@ "@downloadFrom": { "description": "Label - download source" }, - "downloadDefaultQualityLabel": "Качество по умолчанию", - "@downloadDefaultQualityLabel": { - "description": "Label - default quality setting" - }, - "downloadBestAvailable": "Лучшее из доступных", - "@downloadBestAvailable": { - "description": "Quality option - highest available" - }, - "folderNone": "Отсутствует", - "@folderNone": { - "description": "Folder option - no organization" - }, - "folderNoneSubtitle": "Сохранить все файлы непосредственно в папку загрузки", - "@folderNoneSubtitle": { - "description": "Subtitle for no folder organization" - }, - "folderArtist": "Исполнитель", - "@folderArtist": { - "description": "Folder option - by artist" - }, - "folderArtistSubtitle": "Исполнитель/имя файла", - "@folderArtistSubtitle": { - "description": "Folder structure example" - }, - "folderAlbum": "Альбом", - "@folderAlbum": { - "description": "Folder option - by album" - }, - "folderAlbumSubtitle": "Альбом/имя файла", - "@folderAlbumSubtitle": { - "description": "Folder structure example" - }, - "folderArtistAlbum": "Исполнитель/Альбом", - "@folderArtistAlbum": { - "description": "Folder option - nested" - }, - "folderArtistAlbumSubtitle": "Исполнитель/ Альбом/имя файла", - "@folderArtistAlbumSubtitle": { - "description": "Folder structure example" - }, - "serviceTidal": "Tidal", - "@serviceTidal": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceQobuz": "Qobuz", - "@serviceQobuz": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceAmazon": "Amazon", - "@serviceAmazon": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceDeezer": "Deezer", - "@serviceDeezer": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceSpotify": "Spotify", - "@serviceSpotify": { - "description": "Service name - DO NOT TRANSLATE" - }, "appearanceAmoledDark": "AMOLED", "@appearanceAmoledDark": { "description": "Theme option - pure black" @@ -2622,18 +1797,6 @@ "@appearanceAmoledDarkSubtitle": { "description": "Subtitle for AMOLED dark" }, - "appearanceChooseAccentColor": "Выберите акцентный цвет", - "@appearanceChooseAccentColor": { - "description": "Color picker dialog title" - }, - "appearanceChooseTheme": "Режим темы", - "@appearanceChooseTheme": { - "description": "Theme picker dialog title" - }, - "queueTitle": "Очередь скачиваний", - "@queueTitle": { - "description": "Queue screen title" - }, "queueClearAll": "Очистить всё", "@queueClearAll": { "description": "Button - clear all queue items" @@ -2642,22 +1805,6 @@ "@queueClearAllMessage": { "description": "Clear queue confirmation" }, - "queueExportFailed": "Экспорт", - "@queueExportFailed": { - "description": "Button - export failed downloads to TXT" - }, - "queueExportFailedSuccess": "Сбой при экспорте загрузок в файл TXT", - "@queueExportFailedSuccess": { - "description": "Success message after exporting failed downloads" - }, - "queueExportFailedClear": "Не удалось очистить", - "@queueExportFailedClear": { - "description": "Action to clear failed downloads after export" - }, - "queueExportFailedError": "Не удалось экспортировать загрузки", - "@queueExportFailedError": { - "description": "Error message when export fails" - }, "settingsAutoExportFailed": "Автоэкспорт неудачных загрузок", "@settingsAutoExportFailed": { "description": "Setting toggle for auto-export" @@ -2682,38 +1829,6 @@ "@settingsDownloadNetworkSubtitle": { "description": "Subtitle explaining network preference" }, - "queueEmpty": "Нет загрузок в очереди", - "@queueEmpty": { - "description": "Empty queue state title" - }, - "queueEmptySubtitle": "Добавить треки с главного экрана", - "@queueEmptySubtitle": { - "description": "Empty queue state subtitle" - }, - "queueClearCompleted": "Очистка завершена", - "@queueClearCompleted": { - "description": "Button - clear finished downloads" - }, - "queueDownloadFailed": "Ошибка скачивания", - "@queueDownloadFailed": { - "description": "Error dialog title" - }, - "queueTrackLabel": "Трек:", - "@queueTrackLabel": { - "description": "Label in error dialog" - }, - "queueArtistLabel": "Исполнитель:", - "@queueArtistLabel": { - "description": "Label in error dialog" - }, - "queueErrorLabel": "Ошибка:", - "@queueErrorLabel": { - "description": "Label in error dialog" - }, - "queueUnknownError": "Неизвестная ошибка", - "@queueUnknownError": { - "description": "Fallback error message" - }, "albumFolderArtistAlbum": "Исполнитель / Альбом", "@albumFolderArtistAlbum": { "description": "Album folder option" @@ -2767,19 +1882,6 @@ } } }, - "downloadedAlbumTracksHeader": "Треки", - "@downloadedAlbumTracksHeader": { - "description": "Section header for tracks" - }, - "downloadedAlbumDownloadedCount": "{count} скачано", - "@downloadedAlbumDownloadedCount": { - "description": "Downloaded tracks count badge", - "placeholders": { - "count": { - "type": "int" - } - } - }, "downloadedAlbumSelectedCount": "{count} выбрано", "@downloadedAlbumSelectedCount": { "description": "Selection count indicator", @@ -2820,10 +1922,6 @@ } } }, - "utilityFunctions": "Функции утилиты", - "@utilityFunctions": { - "description": "Extension capability - utility functions" - }, "recentTypeArtist": "Исполнитель", "@recentTypeArtist": { "description": "Recent access item type - artist" @@ -2858,16 +1956,6 @@ } } }, - "errorGeneric": "Ошибка: {message}", - "@errorGeneric": { - "description": "Generic error message format", - "placeholders": { - "message": { - "type": "String", - "description": "Error message" - } - } - }, "discographyDownload": "Скачать дискографию", "@discographyDownload": { "description": "Button - download artist discography" @@ -3034,10 +2122,6 @@ "@libraryTitle": { "description": "Library settings page title" }, - "libraryStatus": "Статус Библиотеки", - "@libraryStatus": { - "description": "Section header for library status" - }, "libraryScanSettings": "Настройки сканирования", "@libraryScanSettings": { "description": "Section header for scan settings" @@ -3114,15 +2198,6 @@ "@libraryAboutDescription": { "description": "Description of local library feature" }, - "libraryTracksCount": "{count} {count, plural, one {трек} few {трека} many {треков} other{треков}}", - "@libraryTracksCount": { - "description": "Track count in library", - "placeholders": { - "count": { - "type": "int" - } - } - }, "libraryLastScanned": "Последнее сканирование: {time}", "@libraryLastScanned": { "description": "Last scan time display", @@ -3237,26 +2312,6 @@ "@libraryFilterFormat": { "description": "Filter section - file format" }, - "libraryFilterDate": "Дата добавления", - "@libraryFilterDate": { - "description": "Filter section - date range" - }, - "libraryFilterDateToday": "Сегодня", - "@libraryFilterDateToday": { - "description": "Filter option - today only" - }, - "libraryFilterDateWeek": "На этой неделе", - "@libraryFilterDateWeek": { - "description": "Filter option - this week" - }, - "libraryFilterDateMonth": "В этом месяце", - "@libraryFilterDateMonth": { - "description": "Filter option - this month" - }, - "libraryFilterDateYear": "В этом году", - "@libraryFilterDateYear": { - "description": "Filter option - this year" - }, "libraryFilterSort": "Сортировка", "@libraryFilterSort": { "description": "Filter section - sort order" @@ -3269,15 +2324,6 @@ "@libraryFilterSortOldest": { "description": "Sort option - oldest first" }, - "libraryFilterActive": "{count} фильтр(-ов) активно", - "@libraryFilterActive": { - "description": "Badge showing number of active filters", - "placeholders": { - "count": { - "type": "int" - } - } - }, "timeJustNow": "Только что", "@timeJustNow": { "description": "Relative time - less than a minute ago" @@ -3300,106 +2346,6 @@ } } }, - "storageSwitchTitle": "Сменить режим хранения", - "@storageSwitchTitle": { - "description": "Dialog title when switching storage mode" - }, - "storageSwitchToSafTitle": "Переключиться на SAF хранилище?", - "@storageSwitchToSafTitle": { - "description": "Dialog title when switching to SAF" - }, - "storageSwitchToAppTitle": "Переключиться хранилище приложения?", - "@storageSwitchToAppTitle": { - "description": "Dialog title when switching to app storage" - }, - "storageSwitchToSafMessage": "Ваши скачанные файлы останутся в текущем расположении и будут доступны.\n\nНовые файлы будут сохранены в выбранной вами папке SAF.", - "@storageSwitchToSafMessage": { - "description": "Explanation when switching to SAF" - }, - "storageSwitchToAppMessage": "Ваши скачанные файлы останутся в текущем выбранной вами папке SAF.\n\nНовые файлы будут сохранены в папке Music/SpotiFLAC.", - "@storageSwitchToAppMessage": { - "description": "Explanation when switching to app storage" - }, - "storageSwitchExistingDownloads": "Существующие загрузки", - "@storageSwitchExistingDownloads": { - "description": "Section header for existing downloads info" - }, - "storageSwitchExistingDownloadsInfo": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} other {{count} треков}} в {mode} хранилище", - "@storageSwitchExistingDownloadsInfo": { - "description": "Info about existing downloads count", - "placeholders": { - "count": { - "type": "int" - }, - "mode": { - "type": "String" - } - } - }, - "storageSwitchNewDownloads": "Новые загрузки", - "@storageSwitchNewDownloads": { - "description": "Section header for new downloads info" - }, - "storageSwitchNewDownloadsLocation": "Будет сохранено в: {location}", - "@storageSwitchNewDownloadsLocation": { - "description": "Shows where new downloads will go", - "placeholders": { - "location": { - "type": "String" - } - } - }, - "storageSwitchContinue": "Продолжить", - "@storageSwitchContinue": { - "description": "Button to proceed with storage switch" - }, - "storageSwitchSelectFolder": "Выберите папку SAF", - "@storageSwitchSelectFolder": { - "description": "Button to select SAF folder" - }, - "storageAppStorage": "Хранилище приложения", - "@storageAppStorage": { - "description": "Label for app storage mode" - }, - "storageSafStorage": "Хранилище SAF", - "@storageSafStorage": { - "description": "Label for SAF storage mode" - }, - "storageModeBadge": "Хранилище: {mode}", - "@storageModeBadge": { - "description": "Badge showing storage mode for a track", - "placeholders": { - "mode": { - "type": "String" - } - } - }, - "storageStatsTitle": "Статистика хранилища", - "@storageStatsTitle": { - "description": "Section title for storage stats" - }, - "storageStatsAppCount": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} other {{count} треков}} в хранилище приложения", - "@storageStatsAppCount": { - "description": "Count of tracks in app storage", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "storageStatsSafCount": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} other {{count} треков}} в вашей папке в SAF", - "@storageStatsSafCount": { - "description": "Count of tracks in SAF storage", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "storageModeInfo": "Ваши файлы хранятся в нескольких местах", - "@storageModeInfo": { - "description": "Info when user has files in both storage modes" - }, "tutorialWelcomeTitle": "Добро пожаловать в SpotiFLAC!", "@tutorialWelcomeTitle": { "description": "Tutorial welcome page title" @@ -3428,18 +2374,6 @@ "@tutorialSearchDesc": { "description": "Tutorial search page description" }, - "tutorialSearchTip1": "Вставьте ссылку Spotify или Deezer прямо в поле поиска", - "@tutorialSearchTip1": { - "description": "Tutorial search tip 1" - }, - "tutorialSearchTip2": "Или введите название песни, исполнителя или альбом для поиска", - "@tutorialSearchTip2": { - "description": "Tutorial search tip 2" - }, - "tutorialSearchTip3": "Поддержка треков, альбомов, плейлистов и страниц исполнителей", - "@tutorialSearchTip3": { - "description": "Tutorial search tip 3" - }, "tutorialDownloadTitle": "Скачивание музыки", "@tutorialDownloadTitle": { "description": "Tutorial download page title" @@ -3448,18 +2382,6 @@ "@tutorialDownloadDesc": { "description": "Tutorial download page description" }, - "tutorialDownloadTip1": "Нажмите кнопку скачать рядом с любым треком, чтобы начать скачивание", - "@tutorialDownloadTip1": { - "description": "Tutorial download tip 1" - }, - "tutorialDownloadTip2": "Выберите предпочитаемое качество (FLAC, Hi-Res или MP3)", - "@tutorialDownloadTip2": { - "description": "Tutorial download tip 2" - }, - "tutorialDownloadTip3": "Скачать все альбомы или плейлисты одним нажатием", - "@tutorialDownloadTip3": { - "description": "Tutorial download tip 3" - }, "tutorialLibraryTitle": "Ваша библиотека", "@tutorialLibraryTitle": { "description": "Tutorial library page title" @@ -3524,10 +2446,6 @@ "@tutorialReadyMessage": { "description": "Tutorial completion message" }, - "tutorialExample": "EXAMPLE", - "@tutorialExample": { - "description": "Example label in tutorial" - }, "libraryForceFullScan": "Полное сканирование", "@libraryForceFullScan": { "description": "Button to force a complete rescan of library" @@ -3750,14 +2668,10 @@ "@trackSaveLyricsProgress": { "description": "Snackbar while saving lyrics to file" }, - "trackReEnrich": "Re-enrich Metadata", + "trackReEnrich": "Re-enrich", "@trackReEnrich": { "description": "Menu action - re-embed metadata into audio file" }, - "trackReEnrichSubtitle": "Re-embed metadata without re-downloading", - "@trackReEnrichSubtitle": { - "description": "Subtitle for re-enrich metadata action" - }, "trackReEnrichOnlineSubtitle": "Search metadata online and embed into file", "@trackReEnrichOnlineSubtitle": { "description": "Subtitle for re-enrich metadata action for local items" @@ -3868,5 +2782,22 @@ "trackConvertFailed": "Conversion failed", "@trackConvertFailed": { "description": "Snackbar when conversion fails" + }, + "downloadedAlbumDownloadedCount": "{count} скачано", + "@downloadedAlbumDownloadedCount": { + "description": "Downloaded tracks count badge", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available", + "@downloadUseAlbumArtistForFoldersAlbumSubtitle": { + "description": "Subtitle when Album Artist is used for folder naming" + }, + "downloadUseAlbumArtistForFoldersTrackSubtitle": "Папки исполнителя используют только трек исполнителя", + "@downloadUseAlbumArtistForFoldersTrackSubtitle": { + "description": "Subtitle when Track Artist is used for folder naming" } -} \ No newline at end of file +} diff --git a/lib/l10n/arb/app_tr.arb b/lib/l10n/arb/app_tr.arb index 877cefc2..b7b16c7f 100644 --- a/lib/l10n/arb/app_tr.arb +++ b/lib/l10n/arb/app_tr.arb @@ -5,10 +5,6 @@ "@appName": { "description": "App name - DO NOT TRANSLATE" }, - "appDescription": "Spotify şarkılarını Tidal, Qobuz ve Amazon Music'den yüksek kalitede indir.", - "@appDescription": { - "description": "App description shown in about page" - }, "navHome": "Ara", "@navHome": { "description": "Bottom navigation - Home tab" @@ -17,10 +13,6 @@ "@navLibrary": { "description": "Bottom navigation - Library tab" }, - "navHistory": "Geçmiş", - "@navHistory": { - "description": "Bottom navigation - History tab (legacy)" - }, "navSettings": "Ayarlar", "@navSettings": { "description": "Bottom navigation - Settings tab" @@ -33,20 +25,6 @@ "@homeTitle": { "description": "Home screen title" }, - "homeSearchHint": "Spotify URL'i yapıştır veya ara...", - "@homeSearchHint": { - "description": "Placeholder text in search box" - }, - "homeSearchHintExtension": "{extensionName} ile arat...", - "@homeSearchHintExtension": { - "description": "Placeholder when extension search is active", - "placeholders": { - "extensionName": { - "type": "String", - "description": "Name of the active extension" - } - } - }, "homeSubtitle": "Spotify linki yapıştır veya isimle arat", "@homeSubtitle": { "description": "Subtitle shown below search box" @@ -59,24 +37,6 @@ "@homeRecent": { "description": "Section header for recent searches" }, - "historyTitle": "Geçmiş", - "@historyTitle": { - "description": "History screen title" - }, - "historyDownloading": "({count}) tane indiriliyor", - "@historyDownloading": { - "description": "Tab showing active downloads count", - "placeholders": { - "count": { - "type": "int", - "description": "Number of active downloads" - } - } - }, - "historyDownloaded": "İndirildi", - "@historyDownloaded": { - "description": "Tab showing completed downloads" - }, "historyFilterAll": "Tümü", "@historyFilterAll": { "description": "Filter chip - show all items" @@ -89,48 +49,6 @@ "@historyFilterSingles": { "description": "Filter chip - show singles only" }, - "historyTracksCount": "{count, plural, one {1 şarkı} other {{count} şarkı}}", - "@historyTracksCount": { - "description": "Track count with plural form", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "historyAlbumsCount": "{count, plural, one {1 albüm} other {{count} albüm}}", - "@historyAlbumsCount": { - "description": "Album count with plural form", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "historyNoDownloads": "İndirme geçmişi yok", - "@historyNoDownloads": { - "description": "Empty state title" - }, - "historyNoDownloadsSubtitle": "İndirilen şarkılar burada gözükecek", - "@historyNoDownloadsSubtitle": { - "description": "Empty state subtitle" - }, - "historyNoAlbums": "İndirilen albüm yok", - "@historyNoAlbums": { - "description": "Empty state when filtering albums" - }, - "historyNoAlbumsSubtitle": "Albümleri burada görmek için bir albümden birden fazla şarkı indir", - "@historyNoAlbumsSubtitle": { - "description": "Empty state subtitle for albums filter" - }, - "historyNoSingles": "Single indirilmemiş", - "@historyNoSingles": { - "description": "Empty state when filtering singles" - }, - "historyNoSinglesSubtitle": "Single şarkılar burada gözükecek", - "@historyNoSinglesSubtitle": { - "description": "Empty state subtitle for singles filter" - }, "historySearchHint": "Arama geçmişi...", "@historySearchHint": { "description": "Search bar placeholder in history" @@ -163,34 +81,6 @@ "@downloadTitle": { "description": "Download settings page title" }, - "downloadLocation": "İndirme Konumu", - "@downloadLocation": { - "description": "Setting for download folder" - }, - "downloadLocationSubtitle": "Dosyaları nereye kaydedeceğinizi seçin", - "@downloadLocationSubtitle": { - "description": "Subtitle for download location" - }, - "downloadLocationDefault": "Varsayılan dizin", - "@downloadLocationDefault": { - "description": "Shown when using default folder" - }, - "downloadDefaultService": "Varsayılan Hizmet", - "@downloadDefaultService": { - "description": "Setting for preferred download service (Tidal/Qobuz/Amazon)" - }, - "downloadDefaultServiceSubtitle": "İndirmeler için kullanılan hizmet", - "@downloadDefaultServiceSubtitle": { - "description": "Subtitle for default service" - }, - "downloadDefaultQuality": "Varsayılan Kalite", - "@downloadDefaultQuality": { - "description": "Setting for audio quality" - }, - "downloadAskQuality": "İndirmeden Önce Kaliteyi Sor", - "@downloadAskQuality": { - "description": "Toggle to show quality picker" - }, "downloadAskQualitySubtitle": "Her indirmeden önce kalite seçim ekranını göster", "@downloadAskQualitySubtitle": { "description": "Subtitle for ask quality toggle" @@ -203,38 +93,10 @@ "@downloadFolderOrganization": { "description": "Setting for folder structure" }, - "downloadSeparateSingles": "Single'ları Ayır", - "@downloadSeparateSingles": { - "description": "Toggle to separate single tracks" - }, - "downloadSeparateSinglesSubtitle": "Single şarkıları ayrı dosyaya koy", - "@downloadSeparateSinglesSubtitle": { - "description": "Subtitle for separate singles toggle" - }, - "qualityBest": "Mevcut en iyi", - "@qualityBest": { - "description": "Audio quality option - highest available" - }, - "qualityFlac": "FLAC", - "@qualityFlac": { - "description": "Audio quality option - FLAC lossless" - }, - "quality320": "320 kbps", - "@quality320": { - "description": "Audio quality option - 320kbps MP3" - }, - "quality128": "128 kbps", - "@quality128": { - "description": "Audio quality option - 128kbps MP3" - }, "appearanceTitle": "Görünüm", "@appearanceTitle": { "description": "Appearance settings page title" }, - "appearanceTheme": "Tema", - "@appearanceTheme": { - "description": "Theme mode setting" - }, "appearanceThemeSystem": "Sistem", "@appearanceThemeSystem": { "description": "Follow system theme" @@ -255,10 +117,6 @@ "@appearanceDynamicColorSubtitle": { "description": "Subtitle for dynamic color" }, - "appearanceAccentColor": "Vurgu Rengi", - "@appearanceAccentColor": { - "description": "Custom accent color picker" - }, "appearanceHistoryView": "Geçmiş Düzeni", "@appearanceHistoryView": { "description": "Layout style for history" @@ -275,10 +133,6 @@ "@optionsTitle": { "description": "Options settings page title" }, - "optionsSearchSource": "Arama Kaynağı", - "@optionsSearchSource": { - "description": "Section for search provider settings" - }, "optionsPrimaryProvider": "Ana Kaynek", "@optionsPrimaryProvider": { "description": "Main search provider setting" @@ -438,22 +292,6 @@ "@extensionsTitle": { "description": "Extensions page title" }, - "extensionsInstalled": "Kurulu Eklentiler", - "@extensionsInstalled": { - "description": "Section header for installed extensions" - }, - "extensionsNone": "Hiçbir eklenti kurulmamış", - "@extensionsNone": { - "description": "Empty state title" - }, - "extensionsNoneSubtitle": "Dükkan sekmesinden eklenti indir", - "@extensionsNoneSubtitle": { - "description": "Empty state subtitle" - }, - "extensionsEnabled": "Etkin", - "@extensionsEnabled": { - "description": "Extension status - active" - }, "extensionsDisabled": "Devre Dışı", "@extensionsDisabled": { "description": "Extension status - inactive" @@ -480,10 +318,6 @@ "@extensionsUninstall": { "description": "Uninstall extension button" }, - "extensionsSetAsSearch": "Arama Sağlayıcı olarak Ayarla", - "@extensionsSetAsSearch": { - "description": "Use extension for search" - }, "storeTitle": "Eklenti Dükkanı", "@storeTitle": { "description": "Store screen title" @@ -580,10 +414,6 @@ "@aboutSocial": { "description": "Section for social links" }, - "aboutSupport": "Destek", - "@aboutSupport": { - "description": "Section for support/donation links" - }, "aboutApp": "Uygulama", "@aboutApp": { "description": "Section for app info" @@ -604,14 +434,6 @@ "@aboutSjdonadoDesc": { "description": "Credit description for sjdonado" }, - "aboutDoubleDouble": "DoubleDouble", - "@aboutDoubleDouble": { - "description": "Name of Amazon API service - DO NOT TRANSLATE" - }, - "aboutDoubleDoubleDesc": "Amazom Music indirmeleri için harika bir API. Ücretsiz yaptığın için teşekkürler!", - "@aboutDoubleDoubleDesc": { - "description": "Credit for DoubleDouble API" - }, "aboutDabMusic": "DAB Music", "@aboutDabMusic": { "description": "Name of Qobuz API service - DO NOT TRANSLATE" @@ -632,35 +454,6 @@ "@aboutAppDescription": { "description": "App description in header card" }, - "albumTitle": "Albüm", - "@albumTitle": { - "description": "Album screen title" - }, - "albumTracks": "{count, plural, one {1 şarkı} other {{count} şarkı}}", - "@albumTracks": { - "description": "Album track count", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "albumDownloadAll": "Tümünü İndir", - "@albumDownloadAll": { - "description": "Button to download all tracks" - }, - "albumDownloadRemaining": "Kalanını İndir", - "@albumDownloadRemaining": { - "description": "Button to download remaining tracks" - }, - "playlistTitle": "Çalma Listesi", - "@playlistTitle": { - "description": "Playlist screen title" - }, - "artistTitle": "Sanatçı", - "@artistTitle": { - "description": "Artist screen title" - }, "artistAlbums": "Albümler", "@artistAlbums": { "description": "Section header for artist albums" @@ -673,15 +466,6 @@ "@artistCompilations": { "description": "Section header for compilations" }, - "artistReleases": "{count, plural, =1{1 yayın} other{{count} yayın}}", - "@artistReleases": { - "description": "Artist release count", - "placeholders": { - "count": { - "type": "int" - } - } - }, "artistPopular": "Popüler", "@artistPopular": { "description": "Section header for popular/top tracks" @@ -696,34 +480,6 @@ } } }, - "trackMetadataTitle": "Şarkı Bilgisi", - "@trackMetadataTitle": { - "description": "Track metadata screen title" - }, - "trackMetadataArtist": "Sanatçı", - "@trackMetadataArtist": { - "description": "Metadata field - artist name" - }, - "trackMetadataAlbum": "Albüm", - "@trackMetadataAlbum": { - "description": "Metadata field - album name" - }, - "trackMetadataDuration": "Süre", - "@trackMetadataDuration": { - "description": "Metadata field - track length" - }, - "trackMetadataQuality": "Kalite", - "@trackMetadataQuality": { - "description": "Metadata field - audio quality" - }, - "trackMetadataPath": "Dosya Yolu", - "@trackMetadataPath": { - "description": "Metadata field - file location" - }, - "trackMetadataDownloadedAt": "İndirme tarihi", - "@trackMetadataDownloadedAt": { - "description": "Metadata field - download date" - }, "trackMetadataService": "Hizmet", "@trackMetadataService": { "description": "Metadata field - download service used" @@ -740,54 +496,10 @@ "@trackMetadataDelete": { "description": "Action button - delete track" }, - "trackMetadataRedownload": "Yeniden İndir", - "@trackMetadataRedownload": { - "description": "Action button - download again" - }, - "trackMetadataOpenFolder": "Klasörü Aç", - "@trackMetadataOpenFolder": { - "description": "Action button - open containing folder" - }, - "setupTitle": "SpotiFLAC'e Hoşgeldiniz", - "@setupTitle": { - "description": "Setup wizard title" - }, - "setupSubtitle": "Hadi başlayalım", - "@setupSubtitle": { - "description": "Setup wizard subtitle" - }, - "setupStoragePermission": "Depolama İzni", - "@setupStoragePermission": { - "description": "Storage permission step title" - }, - "setupStoragePermissionSubtitle": "İndirilen dosyaları kaydetmek için gerekli", - "@setupStoragePermissionSubtitle": { - "description": "Explanation for storage permission" - }, - "setupStoragePermissionGranted": "İzin verildi", - "@setupStoragePermissionGranted": { - "description": "Status when permission granted" - }, - "setupStoragePermissionDenied": "İzin reddedildi", - "@setupStoragePermissionDenied": { - "description": "Status when permission denied" - }, "setupGrantPermission": "İzin Ver", "@setupGrantPermission": { "description": "Button to request permission" }, - "setupDownloadLocation": "İndirme Konumu", - "@setupDownloadLocation": { - "description": "Download folder step title" - }, - "setupChooseFolder": "Klasör Seç", - "@setupChooseFolder": { - "description": "Button to pick folder" - }, - "setupContinue": "Devam", - "@setupContinue": { - "description": "Continue to next step button" - }, "setupSkip": "Şimdilik atla", "@setupSkip": { "description": "Skip current step button" @@ -796,10 +508,6 @@ "@setupStorageAccessRequired": { "description": "Title when storage access needed" }, - "setupStorageAccessMessage": "SpotiFLAC'ın şarkıları seçili klasörünüze kaydetmek için \"Bütün dosyalara eriş\" iznine ihtiyacı var.", - "@setupStorageAccessMessage": { - "description": "Explanation for storage access" - }, "setupStorageAccessMessageAndroid11": "Android 11 ve sonrasında şarkıların seçili klasörünüze kaydedilebilmesi için \"Bütün dosyalara eriş\" iznine ihtiyaç var.", "@setupStorageAccessMessageAndroid11": { "description": "Android 11+ specific explanation" @@ -831,10 +539,6 @@ } } }, - "setupSelectDownloadFolder": "İndirilecek Klasörü Seç", - "@setupSelectDownloadFolder": { - "description": "Folder selection step title" - }, "setupUseDefaultFolder": "Varsayılan Klasörü Kullan?", "@setupUseDefaultFolder": { "description": "Dialog title for default folder" @@ -883,26 +587,6 @@ "@setupDownloadInFlac": { "description": "App tagline in setup" }, - "setupStepStorage": "Depolama", - "@setupStepStorage": { - "description": "Setup step indicator - storage" - }, - "setupStepNotification": "Bildirim", - "@setupStepNotification": { - "description": "Setup step indicator - notification" - }, - "setupStepFolder": "Klasör", - "@setupStepFolder": { - "description": "Setup step indicator - folder" - }, - "setupStepSpotify": "Spotify", - "@setupStepSpotify": { - "description": "Setup step indicator - Spotify API" - }, - "setupStepPermission": "İzin", - "@setupStepPermission": { - "description": "Setup step indicator - permission" - }, "setupStorageGranted": "Depolama İzni Verildi!", "@setupStorageGranted": { "description": "Success message for storage permission" @@ -923,14 +607,6 @@ "@setupNotificationEnable": { "description": "Button to enable notifications" }, - "setupNotificationDescription": "İndirmeler bittiğinde veya bakılması gereken bir şey olduğunda haberdar olun.", - "@setupNotificationDescription": { - "description": "Explanation for notifications" - }, - "setupFolderSelected": "İndirilecek Klasör Seçildi!", - "@setupFolderSelected": { - "description": "Success message for folder selection" - }, "setupFolderChoose": "İndirilecek Klasörü Seç", "@setupFolderChoose": { "description": "Button to choose folder" @@ -939,58 +615,14 @@ "@setupFolderDescription": { "description": "Explanation for folder selection" }, - "setupChangeFolder": "Klasörü Değiştir", - "@setupChangeFolder": { - "description": "Button to change selected folder" - }, "setupSelectFolder": "Klasör Seç", "@setupSelectFolder": { "description": "Button to select folder" }, - "setupSpotifyApiOptional": "Spotify API (İsteğe Bağlı)", - "@setupSpotifyApiOptional": { - "description": "Spotify API step title" - }, - "setupSpotifyApiDescription": "Daha iyi arama sonuçları ve Spotify'a özel içeriklere erişmek için Spotify API kimlik bilgilerini gir.", - "@setupSpotifyApiDescription": { - "description": "Explanation for Spotify API" - }, - "setupUseSpotifyApi": "Spotify API'ı kullan", - "@setupUseSpotifyApi": { - "description": "Toggle to enable Spotify API" - }, - "setupEnterCredentialsBelow": "Kimlik bilgilerini aşağıya gir", - "@setupEnterCredentialsBelow": { - "description": "Prompt to enter credentials" - }, - "setupUsingDeezer": "Deezer kullanılıyor (hesap gerekli değil)", - "@setupUsingDeezer": { - "description": "Status when using Deezer" - }, - "setupEnterClientId": "Spotify Client ID gir", - "@setupEnterClientId": { - "description": "Placeholder for client ID field" - }, - "setupEnterClientSecret": "Spotify Client Secret gir", - "@setupEnterClientSecret": { - "description": "Placeholder for client secret field" - }, - "setupGetFreeCredentials": "Spotify Developer Dashboard'tan API kimlik bilgilerini ücretsiz alın.", - "@setupGetFreeCredentials": { - "description": "Info about getting Spotify credentials" - }, "setupEnableNotifications": "Bildirimleri Etkinleştir", "@setupEnableNotifications": { "description": "Button to enable notifications" }, - "setupProceedToNextStep": "Bir sonraki adıma geçebilirsin.", - "@setupProceedToNextStep": { - "description": "Message after completing a step" - }, - "setupNotificationProgressDescription": "İndirme ilerlemelerinin bildirimlerini alacaksın.", - "@setupNotificationProgressDescription": { - "description": "Info about notification usage" - }, "setupNotificationBackgroundDescription": "İndirmelerin durumu hakkında bildirim al. Bunu açmak uygulama arka plandayken indirmelerinizi takip etmenizi sağlar.", "@setupNotificationBackgroundDescription": { "description": "Detailed notification explanation" @@ -999,10 +631,6 @@ "@setupSkipForNow": { "description": "Skip button text" }, - "setupBack": "Geri", - "@setupBack": { - "description": "Back button text" - }, "setupNext": "Sıradaki", "@setupNext": { "description": "Next button text" @@ -1011,26 +639,14 @@ "@setupGetStarted": { "description": "Final setup button" }, - "setupSkipAndStart": "Kurulumu atla", - "@setupSkipAndStart": { - "description": "Skip setup and start app" - }, "setupAllowAccessToManageFiles": "Lütfen bir sonraki ekranda \"Bütün dosyalara eriş\" iznini sağlayın.", "@setupAllowAccessToManageFiles": { "description": "Instruction for file access permission" }, - "setupGetCredentialsFromSpotify": "Kimlik bilgilerini developer.spotify.com'dan alın", - "@setupGetCredentialsFromSpotify": { - "description": "Link text for Spotify developer portal" - }, "dialogCancel": "İptal", "@dialogCancel": { "description": "Dialog button - cancel action" }, - "dialogOk": "Tamam", - "@dialogOk": { - "description": "Dialog button - confirm/acknowledge" - }, "dialogSave": "Kaydet", "@dialogSave": { "description": "Dialog button - save changes" @@ -1043,26 +659,10 @@ "@dialogRetry": { "description": "Dialog button - retry action" }, - "dialogClose": "Kapat", - "@dialogClose": { - "description": "Dialog button - close dialog" - }, - "dialogYes": "Evet", - "@dialogYes": { - "description": "Dialog button - confirm yes" - }, - "dialogNo": "Hayır", - "@dialogNo": { - "description": "Dialog button - confirm no" - }, "dialogClear": "Temizle", "@dialogClear": { "description": "Dialog button - clear items" }, - "dialogConfirm": "Onayla", - "@dialogConfirm": { - "description": "Dialog button - confirm action" - }, "dialogDone": "Tamamlandı", "@dialogDone": { "description": "Dialog button - action completed" @@ -1091,34 +691,10 @@ "@dialogUnsavedChanges": { "description": "Dialog message - unsaved changes" }, - "dialogDownloadFailed": "İndirme Başarısız", - "@dialogDownloadFailed": { - "description": "Dialog title - download error" - }, - "dialogTrackLabel": "Şarkı:", - "@dialogTrackLabel": { - "description": "Label for track name in error dialog" - }, - "dialogArtistLabel": "Sanatçı:", - "@dialogArtistLabel": { - "description": "Label for artist name in error dialog" - }, - "dialogErrorLabel": "Hata:", - "@dialogErrorLabel": { - "description": "Label for error message" - }, "dialogClearAll": "Tümünü Temizle", "@dialogClearAll": { "description": "Dialog title - clear all items" }, - "dialogClearAllDownloads": "Bütün indirmeleri temizlemek istediğinize emin misiniz?", - "@dialogClearAllDownloads": { - "description": "Dialog message - clear downloads confirmation" - }, - "dialogRemoveFromDevice": "Cihazdan kaldır?", - "@dialogRemoveFromDevice": { - "description": "Dialog title - delete file confirmation" - }, "dialogRemoveExtension": "Eklentiyi Kaldır", "@dialogRemoveExtension": { "description": "Dialog title - uninstall extension" @@ -1257,15 +833,6 @@ "@snackbarViewQueue": { "description": "Snackbar action - view download queue" }, - "snackbarFailedToLoad": "Yüklenemedi: {error}", - "@snackbarFailedToLoad": { - "description": "Snackbar - loading error", - "placeholders": { - "error": { - "type": "String" - } - } - }, "snackbarUrlCopied": "{platform} Bağlantı panoya kopyalandı", "@snackbarUrlCopied": { "description": "Snackbar - URL copied", @@ -1326,16 +893,6 @@ "@errorRateLimitedMessage": { "description": "Error message - rate limit explanation" }, - "errorFailedToLoad": "{item} yüklenirken hata oluştu", - "@errorFailedToLoad": { - "description": "Error message - loading failed", - "placeholders": { - "item": { - "type": "String", - "description": "Item that failed to load (album/playlist/etc)" - } - } - }, "errorNoTracksFound": "Parça bulunamadı", "@errorNoTracksFound": { "description": "Error - search returned no results" @@ -1349,34 +906,6 @@ } } }, - "statusQueued": "Sıraya alındı", - "@statusQueued": { - "description": "Download status - waiting in queue" - }, - "statusDownloading": "İndiriliyor", - "@statusDownloading": { - "description": "Download status - in progress" - }, - "statusFinalizing": "Tamamlanıyor", - "@statusFinalizing": { - "description": "Download status - writing metadata" - }, - "statusCompleted": "Tamamlandı", - "@statusCompleted": { - "description": "Download status - finished" - }, - "statusFailed": "Başarısız", - "@statusFailed": { - "description": "Download status - error occurred" - }, - "statusSkipped": "Atlandı", - "@statusSkipped": { - "description": "Download status - already exists" - }, - "statusPaused": "Durduruldu", - "@statusPaused": { - "description": "Download status - paused" - }, "actionPause": "Duraklat", "@actionPause": { "description": "Action button - pause download" @@ -1389,14 +918,6 @@ "@actionCancel": { "description": "Action button - cancel operation" }, - "actionStop": "Durdur", - "@actionStop": { - "description": "Action button - stop operation" - }, - "actionSelect": "Seç", - "@actionSelect": { - "description": "Action button - enter selection mode" - }, "actionSelectAll": "Tümünü Seç", "@actionSelectAll": { "description": "Action button - select all items" @@ -1405,14 +926,6 @@ "@actionDeselect": { "description": "Action button - deselect all" }, - "actionPaste": "Yapıştır", - "@actionPaste": { - "description": "Action button - paste from clipboard" - }, - "actionImportCsv": "CSV İçe Aktar", - "@actionImportCsv": { - "description": "Action button - import CSV file" - }, "actionRemoveCredentials": "Özellikleri kaldır", "@actionRemoveCredentials": { "description": "Action button - delete Spotify credentials" @@ -1434,19 +947,6 @@ "@selectionAllSelected": { "description": "Status - all items selected" }, - "selectionTapToSelect": "Seçmek için parçalara dokunun", - "@selectionTapToSelect": { - "description": "Hint - how to select items" - }, - "selectionDeleteTracks": "{count} {count, plural, =1{şarkıyı} other{şarkıyı}} sil", - "@selectionDeleteTracks": { - "description": "Delete button with count", - "placeholders": { - "count": { - "type": "int" - } - } - }, "selectionSelectToDelete": "Silinecek parçaları seçin", "@selectionSelectToDelete": { "description": "Placeholder when nothing selected" @@ -1487,55 +987,10 @@ "@tooltipPlay": { "description": "Tooltip - play button" }, - "tooltipCancel": "Vazgeç", - "@tooltipCancel": { - "description": "Tooltip - cancel button" - }, - "tooltipStop": "Durdur", - "@tooltipStop": { - "description": "Tooltip - stop button" - }, - "tooltipRetry": "Yeniden dene", - "@tooltipRetry": { - "description": "Tooltip - retry button" - }, - "tooltipRemove": "Kaldır", - "@tooltipRemove": { - "description": "Tooltip - remove button" - }, - "tooltipClear": "Temizle", - "@tooltipClear": { - "description": "Tooltip - clear button" - }, - "tooltipPaste": "Yapıştır", - "@tooltipPaste": { - "description": "Tooltip - paste button" - }, "filenameFormat": "Dosya adı formatı", "@filenameFormat": { "description": "Setting title - filename pattern" }, - "filenameFormatPreview": "Önizleme: {preview}", - "@filenameFormatPreview": { - "description": "Preview of filename pattern", - "placeholders": { - "preview": { - "type": "String" - } - } - }, - "filenameAvailablePlaceholders": "Kullanılabilir yer tutucular:", - "@filenameAvailablePlaceholders": { - "description": "Label for placeholder list" - }, - "filenameHint": "{artist} - {title}", - "@filenameHint": { - "description": "Default filename format hint" - }, - "folderOrganization": "Klasör Organizasyonu", - "@folderOrganization": { - "description": "Setting title - folder structure" - }, "folderOrganizationNone": "Organizasyon yok", "@folderOrganizationNone": { "description": "Folder option - flat structure" @@ -1576,27 +1031,10 @@ "@updateAvailable": { "description": "Update dialog title" }, - "updateNewVersion": "{version} sürümü mevcut", - "@updateNewVersion": { - "description": "Update available message", - "placeholders": { - "version": { - "type": "String" - } - } - }, - "updateDownload": "İndir", - "@updateDownload": { - "description": "Update button - download update" - }, "updateLater": "Daha Sonra", "@updateLater": { "description": "Update button - dismiss" }, - "updateChangelog": "Değişiklikler", - "@updateChangelog": { - "description": "Link to changelog" - }, "updateStartingDownload": "İndirme başlıyor...", "@updateStartingDownload": { "description": "Update status - initializing" @@ -1637,14 +1075,6 @@ "@updateDontRemind": { "description": "Update button - skip this version" }, - "providerPriority": "İndirme hizmetleri öncelik sırası", - "@providerPriority": { - "description": "Setting title - download provider order" - }, - "providerPrioritySubtitle": "İndirme hizmetlerini sıralamak için kaydır", - "@providerPrioritySubtitle": { - "description": "Subtitle for provider priority" - }, "providerPriorityTitle": "İndirme hizmetleri öncelik sırası", "@providerPriorityTitle": { "description": "Provider priority page title" @@ -1665,14 +1095,6 @@ "@providerExtension": { "description": "Label for extension-provided providers" }, - "metadataProviderPriority": "Metadata Sağlayıcı Önceliği", - "@metadataProviderPriority": { - "description": "Setting title - metadata provider order" - }, - "metadataProviderPrioritySubtitle": "Şarkı metadata'sı alınırken kullanılan sıra", - "@metadataProviderPrioritySubtitle": { - "description": "Subtitle for metadata priority" - }, "metadataProviderPriorityTitle": "Metadata Önceliği", "@metadataProviderPriorityTitle": { "description": "Metadata priority page title" @@ -1697,22 +1119,6 @@ "@logTitle": { "description": "Logs screen title" }, - "logCopy": "Kayıtları Kopyala", - "@logCopy": { - "description": "Action - copy logs to clipboard" - }, - "logClear": "Kayıtları temizle", - "@logClear": { - "description": "Action - delete all logs" - }, - "logShare": "Kayıtları Paylaş", - "@logShare": { - "description": "Action - share logs file" - }, - "logEmpty": "Henüz kayıt yok", - "@logEmpty": { - "description": "Empty state title" - }, "logCopied": "Kayıtlar panoya kopyalandı", "@logCopied": { "description": "Snackbar - logs copied" @@ -1745,22 +1151,6 @@ "@logClearLogsMessage": { "description": "Clear logs confirmation message" }, - "logIspBlocking": "ISP BLOCKING DETECTED", - "@logIspBlocking": { - "description": "Error category - ISP blocking" - }, - "logRateLimited": "RATE LIMITED", - "@logRateLimited": { - "description": "Error category - rate limiting" - }, - "logNetworkError": "NETWORK ERROR", - "@logNetworkError": { - "description": "Error category - network issues" - }, - "logTrackNotFound": "TRACK NOT FOUND", - "@logTrackNotFound": { - "description": "Error category - missing tracks" - }, "logFilterBySeverity": "Kayıtları önem derecesine göre filtrele", "@logFilterBySeverity": { "description": "Filter dialog title" @@ -1773,60 +1163,6 @@ "@logNoLogsYetSubtitle": { "description": "Empty state subtitle" }, - "logIssueSummary": "Sorun Özeti", - "@logIssueSummary": { - "description": "Section header for error summary" - }, - "logIspBlockingDescription": "İnternet sağlayıcınız indirme hizmetlerine erişimi engelliyor olabilir", - "@logIspBlockingDescription": { - "description": "ISP blocking explanation" - }, - "logIspBlockingSuggestion": "VPN kullanmayı veya DNS'i 1.1.1.1 ya da 8.8.8.8 olarak değiştirmeyi deneyin", - "@logIspBlockingSuggestion": { - "description": "ISP blocking fix suggestion" - }, - "logRateLimitedDescription": "Hizmete çok fazla istek gönderildi", - "@logRateLimitedDescription": { - "description": "Rate limit explanation" - }, - "logRateLimitedSuggestion": "Tekrar denemeden önce birkaç dakika bekleyin", - "@logRateLimitedSuggestion": { - "description": "Rate limit fix suggestion" - }, - "logNetworkErrorDescription": "Bağlantı sorunları tespit edildi", - "@logNetworkErrorDescription": { - "description": "Network error explanation" - }, - "logNetworkErrorSuggestion": "İnternet bağlantınızı kontrol edin", - "@logNetworkErrorSuggestion": { - "description": "Network error fix suggestion" - }, - "logTrackNotFoundDescription": "Bazı şarkılar indirme hizmetlerinde bulunamadı", - "@logTrackNotFoundDescription": { - "description": "Track not found explanation" - }, - "logTrackNotFoundSuggestion": "Şarkı kayıpsız kalitede mevcut olmayabilir", - "@logTrackNotFoundSuggestion": { - "description": "Track not found explanation" - }, - "logTotalErrors": "Total errors: {count}", - "@logTotalErrors": { - "description": "Error count display", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "logAffected": "Affected: {domains}", - "@logAffected": { - "description": "Affected domains display", - "placeholders": { - "domains": { - "type": "String" - } - } - }, "logEntriesFiltered": "Entries ({count} filtered)", "@logEntriesFiltered": { "description": "Log count with filter active", @@ -1969,10 +1305,6 @@ "@appearanceLanguage": { "description": "Language setting title" }, - "appearanceLanguageSubtitle": "Tercih ettiğiniz dili seçin", - "@appearanceLanguageSubtitle": { - "description": "Language setting subtitle" - }, "settingsAppearanceSubtitle": "Tema, renkler, görünüm", "@settingsAppearanceSubtitle": { "description": "Appearance settings description" @@ -2001,10 +1333,6 @@ "@pressBackAgainToExit": { "description": "Exit confirmation message" }, - "tracksHeader": "Şarkılar", - "@tracksHeader": { - "description": "Section header for track list" - }, "downloadAllCount": "Tümünü İndir ({count})", "@downloadAllCount": { "description": "Download all button with count", @@ -2151,15 +1479,6 @@ "@trackDeleteConfirmMessage": { "description": "Delete confirmation message" }, - "trackCannotOpen": "Cannot open: {message}", - "@trackCannotOpen": { - "description": "Error opening file", - "placeholders": { - "message": { - "type": "String" - } - } - }, "dateToday": "Bugün", "@dateToday": { "description": "Relative date - today" @@ -2195,22 +1514,6 @@ } } }, - "concurrentSequential": "Sıralı", - "@concurrentSequential": { - "description": "Download mode - one at a time" - }, - "concurrentParallel2": "2 Paralel", - "@concurrentParallel2": { - "description": "Download mode - 2 simultaneous" - }, - "concurrentParallel3": "3 Paralel", - "@concurrentParallel3": { - "description": "Download mode - 3 simultaneous" - }, - "tapToSeeError": "Hata detaylarını görmek için dokun", - "@tapToSeeError": { - "description": "Tooltip for failed download" - }, "storeFilterAll": "Tümü", "@storeFilterAll": { "description": "Store filter - all extensions" @@ -2239,18 +1542,6 @@ "@storeClearFilters": { "description": "Button to clear all filters" }, - "storeNoResults": "Eklenti bulunamadı", - "@storeNoResults": { - "description": "Empty state when no extensions match filters" - }, - "extensionProviderPriority": "Sağlayıcı Önceliği", - "@extensionProviderPriority": { - "description": "Extension capability - provider priority" - }, - "extensionInstallButton": "Eklenti Yükle", - "@extensionInstallButton": { - "description": "Button to install extension" - }, "extensionDefaultProvider": "Varsayılan (Deezer/Spotify)", "@extensionDefaultProvider": { "description": "Default search provider option" @@ -2450,46 +1741,6 @@ "@qualityHiResFlacMaxSubtitle": { "description": "Technical spec for hi-res max" }, - "qualityLossy": "Lossy", - "@qualityLossy": { - "description": "Quality option - lossy format (MP3/Opus)" - }, - "qualityLossyMp3Subtitle": "MP3 320kbps (converted from FLAC)", - "@qualityLossyMp3Subtitle": { - "description": "Technical spec for lossy MP3" - }, - "qualityLossyOpusSubtitle": "Opus 128kbps (converted from FLAC)", - "@qualityLossyOpusSubtitle": { - "description": "Technical spec for lossy Opus" - }, - "enableLossyOption": "Enable Lossy Option", - "@enableLossyOption": { - "description": "Setting - enable lossy quality option" - }, - "enableLossyOptionSubtitleOn": "Lossy quality option is available", - "@enableLossyOptionSubtitleOn": { - "description": "Subtitle when lossy is enabled" - }, - "enableLossyOptionSubtitleOff": "Downloads FLAC then converts to lossy format", - "@enableLossyOptionSubtitleOff": { - "description": "Subtitle when lossy is disabled" - }, - "lossyFormat": "Lossy Format", - "@lossyFormat": { - "description": "Setting - choose lossy format" - }, - "lossyFormatDescription": "Choose the lossy format for conversion", - "@lossyFormatDescription": { - "description": "Description for lossy format picker" - }, - "lossyFormatMp3Subtitle": "320kbps, best compatibility", - "@lossyFormatMp3Subtitle": { - "description": "MP3 format description" - }, - "lossyFormatOpusSubtitle": "128kbps, better quality at smaller size", - "@lossyFormatOpusSubtitle": { - "description": "Opus format description" - }, "qualityNote": "Actual quality depends on track availability from the service", "@qualityNote": { "description": "Note about quality availability" @@ -2518,14 +1769,6 @@ "@downloadUseAlbumArtistForFolders": { "description": "Setting - choose whether artist folders use Album Artist or Track Artist" }, - "downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available", - "@downloadUseAlbumArtistForFoldersAlbumSubtitle": { - "description": "Subtitle when Album Artist is used for folder naming" - }, - "downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only", - "@downloadUseAlbumArtistForFoldersTrackSubtitle": { - "description": "Subtitle when Track Artist is used for folder naming" - }, "downloadUsePrimaryArtistOnly": "Primary artist only for folders", "@downloadUsePrimaryArtistOnly": { "description": "Setting - strip featured artists from folder name" @@ -2538,14 +1781,6 @@ "@downloadUsePrimaryArtistOnlyDisabled": { "description": "Subtitle when primary artist only is disabled" }, - "downloadSaveFormat": "Save Format", - "@downloadSaveFormat": { - "description": "Setting - output file format" - }, - "downloadSelectService": "Select Service", - "@downloadSelectService": { - "description": "Dialog title - choose download service" - }, "downloadSelectQuality": "Select Quality", "@downloadSelectQuality": { "description": "Dialog title - choose audio quality" @@ -2554,66 +1789,6 @@ "@downloadFrom": { "description": "Label - download source" }, - "downloadDefaultQualityLabel": "Default Quality", - "@downloadDefaultQualityLabel": { - "description": "Label - default quality setting" - }, - "downloadBestAvailable": "Best available", - "@downloadBestAvailable": { - "description": "Quality option - highest available" - }, - "folderNone": "None", - "@folderNone": { - "description": "Folder option - no organization" - }, - "folderNoneSubtitle": "Save all files directly to download folder", - "@folderNoneSubtitle": { - "description": "Subtitle for no folder organization" - }, - "folderArtist": "Artist", - "@folderArtist": { - "description": "Folder option - by artist" - }, - "folderArtistSubtitle": "Artist Name/filename", - "@folderArtistSubtitle": { - "description": "Folder structure example" - }, - "folderAlbum": "Album", - "@folderAlbum": { - "description": "Folder option - by album" - }, - "folderAlbumSubtitle": "Album Name/filename", - "@folderAlbumSubtitle": { - "description": "Folder structure example" - }, - "folderArtistAlbum": "Artist/Album", - "@folderArtistAlbum": { - "description": "Folder option - nested" - }, - "folderArtistAlbumSubtitle": "Artist Name/Album Name/filename", - "@folderArtistAlbumSubtitle": { - "description": "Folder structure example" - }, - "serviceTidal": "Tidal", - "@serviceTidal": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceQobuz": "Qobuz", - "@serviceQobuz": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceAmazon": "Amazon", - "@serviceAmazon": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceDeezer": "Deezer", - "@serviceDeezer": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceSpotify": "Spotify", - "@serviceSpotify": { - "description": "Service name - DO NOT TRANSLATE" - }, "appearanceAmoledDark": "AMOLED Dark", "@appearanceAmoledDark": { "description": "Theme option - pure black" @@ -2622,18 +1797,6 @@ "@appearanceAmoledDarkSubtitle": { "description": "Subtitle for AMOLED dark" }, - "appearanceChooseAccentColor": "Choose Accent Color", - "@appearanceChooseAccentColor": { - "description": "Color picker dialog title" - }, - "appearanceChooseTheme": "Theme Mode", - "@appearanceChooseTheme": { - "description": "Theme picker dialog title" - }, - "queueTitle": "Download Queue", - "@queueTitle": { - "description": "Queue screen title" - }, "queueClearAll": "Clear All", "@queueClearAll": { "description": "Button - clear all queue items" @@ -2642,22 +1805,6 @@ "@queueClearAllMessage": { "description": "Clear queue confirmation" }, - "queueExportFailed": "Export", - "@queueExportFailed": { - "description": "Button - export failed downloads to TXT" - }, - "queueExportFailedSuccess": "Failed downloads exported to TXT file", - "@queueExportFailedSuccess": { - "description": "Success message after exporting failed downloads" - }, - "queueExportFailedClear": "Clear Failed", - "@queueExportFailedClear": { - "description": "Action to clear failed downloads after export" - }, - "queueExportFailedError": "Failed to export downloads", - "@queueExportFailedError": { - "description": "Error message when export fails" - }, "settingsAutoExportFailed": "Auto-export failed downloads", "@settingsAutoExportFailed": { "description": "Setting toggle for auto-export" @@ -2682,38 +1829,6 @@ "@settingsDownloadNetworkSubtitle": { "description": "Subtitle explaining network preference" }, - "queueEmpty": "No downloads in queue", - "@queueEmpty": { - "description": "Empty queue state title" - }, - "queueEmptySubtitle": "Add tracks from the home screen", - "@queueEmptySubtitle": { - "description": "Empty queue state subtitle" - }, - "queueClearCompleted": "Clear completed", - "@queueClearCompleted": { - "description": "Button - clear finished downloads" - }, - "queueDownloadFailed": "Download Failed", - "@queueDownloadFailed": { - "description": "Error dialog title" - }, - "queueTrackLabel": "Track:", - "@queueTrackLabel": { - "description": "Label in error dialog" - }, - "queueArtistLabel": "Artist:", - "@queueArtistLabel": { - "description": "Label in error dialog" - }, - "queueErrorLabel": "Error:", - "@queueErrorLabel": { - "description": "Label in error dialog" - }, - "queueUnknownError": "Unknown error", - "@queueUnknownError": { - "description": "Fallback error message" - }, "albumFolderArtistAlbum": "Artist / Album", "@albumFolderArtistAlbum": { "description": "Album folder option" @@ -2767,19 +1882,6 @@ } } }, - "downloadedAlbumTracksHeader": "Tracks", - "@downloadedAlbumTracksHeader": { - "description": "Section header for tracks" - }, - "downloadedAlbumDownloadedCount": "{count} downloaded", - "@downloadedAlbumDownloadedCount": { - "description": "Downloaded tracks count badge", - "placeholders": { - "count": { - "type": "int" - } - } - }, "downloadedAlbumSelectedCount": "{count} selected", "@downloadedAlbumSelectedCount": { "description": "Selection count indicator", @@ -2820,10 +1922,6 @@ } } }, - "utilityFunctions": "Utility Functions", - "@utilityFunctions": { - "description": "Extension capability - utility functions" - }, "recentTypeArtist": "Artist", "@recentTypeArtist": { "description": "Recent access item type - artist" @@ -2858,16 +1956,6 @@ } } }, - "errorGeneric": "Error: {message}", - "@errorGeneric": { - "description": "Generic error message format", - "placeholders": { - "message": { - "type": "String", - "description": "Error message" - } - } - }, "discographyDownload": "Download Discography", "@discographyDownload": { "description": "Button - download artist discography" @@ -3034,10 +2122,6 @@ "@libraryTitle": { "description": "Library settings page title" }, - "libraryStatus": "Library Status", - "@libraryStatus": { - "description": "Section header for library status" - }, "libraryScanSettings": "Scan Settings", "@libraryScanSettings": { "description": "Section header for scan settings" @@ -3114,15 +2198,6 @@ "@libraryAboutDescription": { "description": "Description of local library feature" }, - "libraryTracksCount": "{count} tracks", - "@libraryTracksCount": { - "description": "Track count in library", - "placeholders": { - "count": { - "type": "int" - } - } - }, "libraryLastScanned": "Last scanned: {time}", "@libraryLastScanned": { "description": "Last scan time display", @@ -3237,26 +2312,6 @@ "@libraryFilterFormat": { "description": "Filter section - file format" }, - "libraryFilterDate": "Date Added", - "@libraryFilterDate": { - "description": "Filter section - date range" - }, - "libraryFilterDateToday": "Today", - "@libraryFilterDateToday": { - "description": "Filter option - today only" - }, - "libraryFilterDateWeek": "This Week", - "@libraryFilterDateWeek": { - "description": "Filter option - this week" - }, - "libraryFilterDateMonth": "This Month", - "@libraryFilterDateMonth": { - "description": "Filter option - this month" - }, - "libraryFilterDateYear": "This Year", - "@libraryFilterDateYear": { - "description": "Filter option - this year" - }, "libraryFilterSort": "Sort", "@libraryFilterSort": { "description": "Filter section - sort order" @@ -3269,15 +2324,6 @@ "@libraryFilterSortOldest": { "description": "Sort option - oldest first" }, - "libraryFilterActive": "{count} filter(s) active", - "@libraryFilterActive": { - "description": "Badge showing number of active filters", - "placeholders": { - "count": { - "type": "int" - } - } - }, "timeJustNow": "Just now", "@timeJustNow": { "description": "Relative time - less than a minute ago" @@ -3300,106 +2346,6 @@ } } }, - "storageSwitchTitle": "Switch Storage Mode", - "@storageSwitchTitle": { - "description": "Dialog title when switching storage mode" - }, - "storageSwitchToSafTitle": "Switch to SAF Storage?", - "@storageSwitchToSafTitle": { - "description": "Dialog title when switching to SAF" - }, - "storageSwitchToAppTitle": "Switch to App Storage?", - "@storageSwitchToAppTitle": { - "description": "Dialog title when switching to app storage" - }, - "storageSwitchToSafMessage": "Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.", - "@storageSwitchToSafMessage": { - "description": "Explanation when switching to SAF" - }, - "storageSwitchToAppMessage": "Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.", - "@storageSwitchToAppMessage": { - "description": "Explanation when switching to app storage" - }, - "storageSwitchExistingDownloads": "Existing Downloads", - "@storageSwitchExistingDownloads": { - "description": "Section header for existing downloads info" - }, - "storageSwitchExistingDownloadsInfo": "{count} tracks in {mode} storage", - "@storageSwitchExistingDownloadsInfo": { - "description": "Info about existing downloads count", - "placeholders": { - "count": { - "type": "int" - }, - "mode": { - "type": "String" - } - } - }, - "storageSwitchNewDownloads": "New Downloads", - "@storageSwitchNewDownloads": { - "description": "Section header for new downloads info" - }, - "storageSwitchNewDownloadsLocation": "Will be saved to: {location}", - "@storageSwitchNewDownloadsLocation": { - "description": "Shows where new downloads will go", - "placeholders": { - "location": { - "type": "String" - } - } - }, - "storageSwitchContinue": "Continue", - "@storageSwitchContinue": { - "description": "Button to proceed with storage switch" - }, - "storageSwitchSelectFolder": "Select SAF Folder", - "@storageSwitchSelectFolder": { - "description": "Button to select SAF folder" - }, - "storageAppStorage": "App Storage", - "@storageAppStorage": { - "description": "Label for app storage mode" - }, - "storageSafStorage": "SAF Storage", - "@storageSafStorage": { - "description": "Label for SAF storage mode" - }, - "storageModeBadge": "Storage: {mode}", - "@storageModeBadge": { - "description": "Badge showing storage mode for a track", - "placeholders": { - "mode": { - "type": "String" - } - } - }, - "storageStatsTitle": "Storage Statistics", - "@storageStatsTitle": { - "description": "Section title for storage stats" - }, - "storageStatsAppCount": "{count} tracks in App Storage", - "@storageStatsAppCount": { - "description": "Count of tracks in app storage", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "storageStatsSafCount": "{count} tracks in SAF Storage", - "@storageStatsSafCount": { - "description": "Count of tracks in SAF storage", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "storageModeInfo": "Your files are stored in multiple locations", - "@storageModeInfo": { - "description": "Info when user has files in both storage modes" - }, "tutorialWelcomeTitle": "Welcome to SpotiFLAC!", "@tutorialWelcomeTitle": { "description": "Tutorial welcome page title" @@ -3428,18 +2374,6 @@ "@tutorialSearchDesc": { "description": "Tutorial search page description" }, - "tutorialSearchTip1": "Paste a Spotify or Deezer URL directly in the search box", - "@tutorialSearchTip1": { - "description": "Tutorial search tip 1" - }, - "tutorialSearchTip2": "Or type the song name, artist, or album to search", - "@tutorialSearchTip2": { - "description": "Tutorial search tip 2" - }, - "tutorialSearchTip3": "Supports tracks, albums, playlists, and artist pages", - "@tutorialSearchTip3": { - "description": "Tutorial search tip 3" - }, "tutorialDownloadTitle": "Downloading Music", "@tutorialDownloadTitle": { "description": "Tutorial download page title" @@ -3448,18 +2382,6 @@ "@tutorialDownloadDesc": { "description": "Tutorial download page description" }, - "tutorialDownloadTip1": "Tap the download button next to any track to start downloading", - "@tutorialDownloadTip1": { - "description": "Tutorial download tip 1" - }, - "tutorialDownloadTip2": "Choose your preferred quality (FLAC, Hi-Res, or MP3)", - "@tutorialDownloadTip2": { - "description": "Tutorial download tip 2" - }, - "tutorialDownloadTip3": "Download entire albums or playlists with one tap", - "@tutorialDownloadTip3": { - "description": "Tutorial download tip 3" - }, "tutorialLibraryTitle": "Your Library", "@tutorialLibraryTitle": { "description": "Tutorial library page title" @@ -3524,10 +2446,6 @@ "@tutorialReadyMessage": { "description": "Tutorial completion message" }, - "tutorialExample": "EXAMPLE", - "@tutorialExample": { - "description": "Example label in tutorial" - }, "libraryForceFullScan": "Force Full Scan", "@libraryForceFullScan": { "description": "Button to force a complete rescan of library" @@ -3750,14 +2668,10 @@ "@trackSaveLyricsProgress": { "description": "Snackbar while saving lyrics to file" }, - "trackReEnrich": "Re-enrich Metadata", + "trackReEnrich": "Re-enrich", "@trackReEnrich": { "description": "Menu action - re-embed metadata into audio file" }, - "trackReEnrichSubtitle": "Re-embed metadata without re-downloading", - "@trackReEnrichSubtitle": { - "description": "Subtitle for re-enrich metadata action" - }, "trackReEnrichOnlineSubtitle": "Search metadata online and embed into file", "@trackReEnrichOnlineSubtitle": { "description": "Subtitle for re-enrich metadata action for local items" @@ -3868,5 +2782,22 @@ "trackConvertFailed": "Conversion failed", "@trackConvertFailed": { "description": "Snackbar when conversion fails" + }, + "downloadedAlbumDownloadedCount": "{count} downloaded", + "@downloadedAlbumDownloadedCount": { + "description": "Downloaded tracks count badge", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available", + "@downloadUseAlbumArtistForFoldersAlbumSubtitle": { + "description": "Subtitle when Album Artist is used for folder naming" + }, + "downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only", + "@downloadUseAlbumArtistForFoldersTrackSubtitle": { + "description": "Subtitle when Track Artist is used for folder naming" } -} \ No newline at end of file +} diff --git a/lib/l10n/arb/app_zh.arb b/lib/l10n/arb/app_zh.arb index aad9e509..f6f4895f 100644 --- a/lib/l10n/arb/app_zh.arb +++ b/lib/l10n/arb/app_zh.arb @@ -5,18 +5,10 @@ "@appName": { "description": "App name - DO NOT TRANSLATE" }, - "appDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.", - "@appDescription": { - "description": "App description shown in about page" - }, "navHome": "Home", "@navHome": { "description": "Bottom navigation - Home tab" }, - "navHistory": "History", - "@navHistory": { - "description": "Bottom navigation - History tab" - }, "navSettings": "Settings", "@navSettings": { "description": "Bottom navigation - Settings tab" @@ -29,20 +21,6 @@ "@homeTitle": { "description": "Home screen title" }, - "homeSearchHint": "Paste Spotify URL or search...", - "@homeSearchHint": { - "description": "Placeholder text in search box" - }, - "homeSearchHintExtension": "Search with {extensionName}...", - "@homeSearchHintExtension": { - "description": "Placeholder when extension search is active", - "placeholders": { - "extensionName": { - "type": "String", - "description": "Name of the active extension" - } - } - }, "homeSubtitle": "Paste a Spotify link or search by name", "@homeSubtitle": { "description": "Subtitle shown below search box" @@ -55,24 +33,6 @@ "@homeRecent": { "description": "Section header for recent searches" }, - "historyTitle": "History", - "@historyTitle": { - "description": "History screen title" - }, - "historyDownloading": "Downloading ({count})", - "@historyDownloading": { - "description": "Tab showing active downloads count", - "placeholders": { - "count": { - "type": "int", - "description": "Number of active downloads" - } - } - }, - "historyDownloaded": "Downloaded", - "@historyDownloaded": { - "description": "Tab showing completed downloads" - }, "historyFilterAll": "All", "@historyFilterAll": { "description": "Filter chip - show all items" @@ -85,48 +45,6 @@ "@historyFilterSingles": { "description": "Filter chip - show singles only" }, - "historyTracksCount": "{count, plural, =1{1 track} other{{count} tracks}}", - "@historyTracksCount": { - "description": "Track count with plural form", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "historyAlbumsCount": "{count, plural, =1{1 album} other{{count} albums}}", - "@historyAlbumsCount": { - "description": "Album count with plural form", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "historyNoDownloads": "No download history", - "@historyNoDownloads": { - "description": "Empty state title" - }, - "historyNoDownloadsSubtitle": "Downloaded tracks will appear here", - "@historyNoDownloadsSubtitle": { - "description": "Empty state subtitle" - }, - "historyNoAlbums": "No album downloads", - "@historyNoAlbums": { - "description": "Empty state when filtering albums" - }, - "historyNoAlbumsSubtitle": "Download multiple tracks from an album to see them here", - "@historyNoAlbumsSubtitle": { - "description": "Empty state subtitle for albums filter" - }, - "historyNoSingles": "No single downloads", - "@historyNoSingles": { - "description": "Empty state when filtering singles" - }, - "historyNoSinglesSubtitle": "Single track downloads will appear here", - "@historyNoSinglesSubtitle": { - "description": "Empty state subtitle for singles filter" - }, "settingsTitle": "Settings", "@settingsTitle": { "description": "Settings screen title" @@ -155,34 +73,6 @@ "@downloadTitle": { "description": "Download settings page title" }, - "downloadLocation": "Download Location", - "@downloadLocation": { - "description": "Setting for download folder" - }, - "downloadLocationSubtitle": "Choose where to save files", - "@downloadLocationSubtitle": { - "description": "Subtitle for download location" - }, - "downloadLocationDefault": "Default location", - "@downloadLocationDefault": { - "description": "Shown when using default folder" - }, - "downloadDefaultService": "Default Service", - "@downloadDefaultService": { - "description": "Setting for preferred download service (Tidal/Qobuz/Amazon)" - }, - "downloadDefaultServiceSubtitle": "Service used for downloads", - "@downloadDefaultServiceSubtitle": { - "description": "Subtitle for default service" - }, - "downloadDefaultQuality": "Default Quality", - "@downloadDefaultQuality": { - "description": "Setting for audio quality" - }, - "downloadAskQuality": "Ask Quality Before Download", - "@downloadAskQuality": { - "description": "Toggle to show quality picker" - }, "downloadAskQualitySubtitle": "Show quality picker for each download", "@downloadAskQualitySubtitle": { "description": "Subtitle for ask quality toggle" @@ -195,38 +85,10 @@ "@downloadFolderOrganization": { "description": "Setting for folder structure" }, - "downloadSeparateSingles": "Separate Singles", - "@downloadSeparateSingles": { - "description": "Toggle to separate single tracks" - }, - "downloadSeparateSinglesSubtitle": "Put single tracks in a separate folder", - "@downloadSeparateSinglesSubtitle": { - "description": "Subtitle for separate singles toggle" - }, - "qualityBest": "Best Available", - "@qualityBest": { - "description": "Audio quality option - highest available" - }, - "qualityFlac": "FLAC", - "@qualityFlac": { - "description": "Audio quality option - FLAC lossless" - }, - "quality320": "320 kbps", - "@quality320": { - "description": "Audio quality option - 320kbps MP3" - }, - "quality128": "128 kbps", - "@quality128": { - "description": "Audio quality option - 128kbps MP3" - }, "appearanceTitle": "Appearance", "@appearanceTitle": { "description": "Appearance settings page title" }, - "appearanceTheme": "Theme", - "@appearanceTheme": { - "description": "Theme mode setting" - }, "appearanceThemeSystem": "System", "@appearanceThemeSystem": { "description": "Follow system theme" @@ -247,10 +109,6 @@ "@appearanceDynamicColorSubtitle": { "description": "Subtitle for dynamic color" }, - "appearanceAccentColor": "Accent Color", - "@appearanceAccentColor": { - "description": "Custom accent color picker" - }, "appearanceHistoryView": "History View", "@appearanceHistoryView": { "description": "Layout style for history" @@ -267,10 +125,6 @@ "@optionsTitle": { "description": "Options settings page title" }, - "optionsSearchSource": "Search Source", - "@optionsSearchSource": { - "description": "Section for search provider settings" - }, "optionsPrimaryProvider": "Primary Provider", "@optionsPrimaryProvider": { "description": "Main search provider setting" @@ -426,22 +280,6 @@ "@extensionsTitle": { "description": "Extensions page title" }, - "extensionsInstalled": "Installed Extensions", - "@extensionsInstalled": { - "description": "Section header for installed extensions" - }, - "extensionsNone": "No extensions installed", - "@extensionsNone": { - "description": "Empty state title" - }, - "extensionsNoneSubtitle": "Install extensions from the Store tab", - "@extensionsNoneSubtitle": { - "description": "Empty state subtitle" - }, - "extensionsEnabled": "Enabled", - "@extensionsEnabled": { - "description": "Extension status - active" - }, "extensionsDisabled": "Disabled", "@extensionsDisabled": { "description": "Extension status - inactive" @@ -468,10 +306,6 @@ "@extensionsUninstall": { "description": "Uninstall extension button" }, - "extensionsSetAsSearch": "Set as Search Provider", - "@extensionsSetAsSearch": { - "description": "Use extension for search" - }, "storeTitle": "Extension Store", "@storeTitle": { "description": "Store screen title" @@ -544,10 +378,6 @@ "@aboutFeatureRequestSubtitle": { "description": "Subtitle for feature request" }, - "aboutSupport": "Support", - "@aboutSupport": { - "description": "Section for support/donation links" - }, "aboutApp": "App", "@aboutApp": { "description": "Section for app info" @@ -564,14 +394,6 @@ "@aboutSachinsenalDesc": { "description": "Credit description for sachinsenal0x64" }, - "aboutDoubleDouble": "DoubleDouble", - "@aboutDoubleDouble": { - "description": "Name of Amazon API service - DO NOT TRANSLATE" - }, - "aboutDoubleDoubleDesc": "Amazing API for Amazon Music downloads. Thank you for making it free!", - "@aboutDoubleDoubleDesc": { - "description": "Credit for DoubleDouble API" - }, "aboutDabMusic": "DAB Music", "@aboutDabMusic": { "description": "Name of Qobuz API service - DO NOT TRANSLATE" @@ -584,35 +406,6 @@ "@aboutAppDescription": { "description": "App description in header card" }, - "albumTitle": "Album", - "@albumTitle": { - "description": "Album screen title" - }, - "albumTracks": "{count, plural, =1{1 track} other{{count} tracks}}", - "@albumTracks": { - "description": "Album track count", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "albumDownloadAll": "Download All", - "@albumDownloadAll": { - "description": "Button to download all tracks" - }, - "albumDownloadRemaining": "Download Remaining", - "@albumDownloadRemaining": { - "description": "Button to download remaining tracks" - }, - "playlistTitle": "Playlist", - "@playlistTitle": { - "description": "Playlist screen title" - }, - "artistTitle": "Artist", - "@artistTitle": { - "description": "Artist screen title" - }, "artistAlbums": "Albums", "@artistAlbums": { "description": "Section header for artist albums" @@ -625,43 +418,6 @@ "@artistCompilations": { "description": "Section header for compilations" }, - "artistReleases": "{count, plural, =1{1 release} other{{count} releases}}", - "@artistReleases": { - "description": "Artist release count", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "trackMetadataTitle": "Track Info", - "@trackMetadataTitle": { - "description": "Track metadata screen title" - }, - "trackMetadataArtist": "Artist", - "@trackMetadataArtist": { - "description": "Metadata field - artist name" - }, - "trackMetadataAlbum": "Album", - "@trackMetadataAlbum": { - "description": "Metadata field - album name" - }, - "trackMetadataDuration": "Duration", - "@trackMetadataDuration": { - "description": "Metadata field - track length" - }, - "trackMetadataQuality": "Quality", - "@trackMetadataQuality": { - "description": "Metadata field - audio quality" - }, - "trackMetadataPath": "File Path", - "@trackMetadataPath": { - "description": "Metadata field - file location" - }, - "trackMetadataDownloadedAt": "Downloaded", - "@trackMetadataDownloadedAt": { - "description": "Metadata field - download date" - }, "trackMetadataService": "Service", "@trackMetadataService": { "description": "Metadata field - download service used" @@ -678,54 +434,10 @@ "@trackMetadataDelete": { "description": "Action button - delete track" }, - "trackMetadataRedownload": "Re-download", - "@trackMetadataRedownload": { - "description": "Action button - download again" - }, - "trackMetadataOpenFolder": "Open Folder", - "@trackMetadataOpenFolder": { - "description": "Action button - open containing folder" - }, - "setupTitle": "Welcome to SpotiFLAC", - "@setupTitle": { - "description": "Setup wizard title" - }, - "setupSubtitle": "Let's get you started", - "@setupSubtitle": { - "description": "Setup wizard subtitle" - }, - "setupStoragePermission": "Storage Permission", - "@setupStoragePermission": { - "description": "Storage permission step title" - }, - "setupStoragePermissionSubtitle": "Required to save downloaded files", - "@setupStoragePermissionSubtitle": { - "description": "Explanation for storage permission" - }, - "setupStoragePermissionGranted": "Permission granted", - "@setupStoragePermissionGranted": { - "description": "Status when permission granted" - }, - "setupStoragePermissionDenied": "Permission denied", - "@setupStoragePermissionDenied": { - "description": "Status when permission denied" - }, "setupGrantPermission": "Grant Permission", "@setupGrantPermission": { "description": "Button to request permission" }, - "setupDownloadLocation": "Download Location", - "@setupDownloadLocation": { - "description": "Download folder step title" - }, - "setupChooseFolder": "Choose Folder", - "@setupChooseFolder": { - "description": "Button to pick folder" - }, - "setupContinue": "Continue", - "@setupContinue": { - "description": "Continue to next step button" - }, "setupSkip": "Skip for now", "@setupSkip": { "description": "Skip current step button" @@ -734,10 +446,6 @@ "@setupStorageAccessRequired": { "description": "Title when storage access needed" }, - "setupStorageAccessMessage": "SpotiFLAC needs \"All files access\" permission to save music files to your chosen folder.", - "@setupStorageAccessMessage": { - "description": "Explanation for storage access" - }, "setupStorageAccessMessageAndroid11": "Android 11+ requires \"All files access\" permission to save files to your chosen download folder.", "@setupStorageAccessMessageAndroid11": { "description": "Android 11+ specific explanation" @@ -769,10 +477,6 @@ } } }, - "setupSelectDownloadFolder": "Select Download Folder", - "@setupSelectDownloadFolder": { - "description": "Folder selection step title" - }, "setupUseDefaultFolder": "Use Default Folder?", "@setupUseDefaultFolder": { "description": "Dialog title for default folder" @@ -817,26 +521,6 @@ "@setupDownloadInFlac": { "description": "App tagline in setup" }, - "setupStepStorage": "Storage", - "@setupStepStorage": { - "description": "Setup step indicator - storage" - }, - "setupStepNotification": "Notification", - "@setupStepNotification": { - "description": "Setup step indicator - notification" - }, - "setupStepFolder": "Folder", - "@setupStepFolder": { - "description": "Setup step indicator - folder" - }, - "setupStepSpotify": "Spotify", - "@setupStepSpotify": { - "description": "Setup step indicator - Spotify API" - }, - "setupStepPermission": "Permission", - "@setupStepPermission": { - "description": "Setup step indicator - permission" - }, "setupStorageGranted": "Storage Permission Granted!", "@setupStorageGranted": { "description": "Success message for storage permission" @@ -857,14 +541,6 @@ "@setupNotificationEnable": { "description": "Button to enable notifications" }, - "setupNotificationDescription": "Get notified when downloads complete or require attention.", - "@setupNotificationDescription": { - "description": "Explanation for notifications" - }, - "setupFolderSelected": "Download Folder Selected!", - "@setupFolderSelected": { - "description": "Success message for folder selection" - }, "setupFolderChoose": "Choose Download Folder", "@setupFolderChoose": { "description": "Button to choose folder" @@ -873,58 +549,14 @@ "@setupFolderDescription": { "description": "Explanation for folder selection" }, - "setupChangeFolder": "Change Folder", - "@setupChangeFolder": { - "description": "Button to change selected folder" - }, "setupSelectFolder": "Select Folder", "@setupSelectFolder": { "description": "Button to select folder" }, - "setupSpotifyApiOptional": "Spotify API (Optional)", - "@setupSpotifyApiOptional": { - "description": "Spotify API step title" - }, - "setupSpotifyApiDescription": "Add your Spotify API credentials for better search results and access to Spotify-exclusive content.", - "@setupSpotifyApiDescription": { - "description": "Explanation for Spotify API" - }, - "setupUseSpotifyApi": "Use Spotify API", - "@setupUseSpotifyApi": { - "description": "Toggle to enable Spotify API" - }, - "setupEnterCredentialsBelow": "Enter your credentials below", - "@setupEnterCredentialsBelow": { - "description": "Prompt to enter credentials" - }, - "setupUsingDeezer": "Using Deezer (no account needed)", - "@setupUsingDeezer": { - "description": "Status when using Deezer" - }, - "setupEnterClientId": "Enter Spotify Client ID", - "@setupEnterClientId": { - "description": "Placeholder for client ID field" - }, - "setupEnterClientSecret": "Enter Spotify Client Secret", - "@setupEnterClientSecret": { - "description": "Placeholder for client secret field" - }, - "setupGetFreeCredentials": "Get your free API credentials from the Spotify Developer Dashboard.", - "@setupGetFreeCredentials": { - "description": "Info about getting Spotify credentials" - }, "setupEnableNotifications": "Enable Notifications", "@setupEnableNotifications": { "description": "Button to enable notifications" }, - "setupProceedToNextStep": "You can now proceed to the next step.", - "@setupProceedToNextStep": { - "description": "Message after completing a step" - }, - "setupNotificationProgressDescription": "You will receive download progress notifications.", - "@setupNotificationProgressDescription": { - "description": "Info about notification usage" - }, "setupNotificationBackgroundDescription": "Get notified about download progress and completion. This helps you track downloads when the app is in background.", "@setupNotificationBackgroundDescription": { "description": "Detailed notification explanation" @@ -933,10 +565,6 @@ "@setupSkipForNow": { "description": "Skip button text" }, - "setupBack": "Back", - "@setupBack": { - "description": "Back button text" - }, "setupNext": "Next", "@setupNext": { "description": "Next button text" @@ -945,26 +573,14 @@ "@setupGetStarted": { "description": "Final setup button" }, - "setupSkipAndStart": "Skip & Start", - "@setupSkipAndStart": { - "description": "Skip setup and start app" - }, "setupAllowAccessToManageFiles": "Please enable \"Allow access to manage all files\" in the next screen.", "@setupAllowAccessToManageFiles": { "description": "Instruction for file access permission" }, - "setupGetCredentialsFromSpotify": "Get credentials from developer.spotify.com", - "@setupGetCredentialsFromSpotify": { - "description": "Link text for Spotify developer portal" - }, "dialogCancel": "Cancel", "@dialogCancel": { "description": "Dialog button - cancel action" }, - "dialogOk": "OK", - "@dialogOk": { - "description": "Dialog button - confirm/acknowledge" - }, "dialogSave": "Save", "@dialogSave": { "description": "Dialog button - save changes" @@ -977,26 +593,10 @@ "@dialogRetry": { "description": "Dialog button - retry action" }, - "dialogClose": "Close", - "@dialogClose": { - "description": "Dialog button - close dialog" - }, - "dialogYes": "Yes", - "@dialogYes": { - "description": "Dialog button - confirm yes" - }, - "dialogNo": "No", - "@dialogNo": { - "description": "Dialog button - confirm no" - }, "dialogClear": "Clear", "@dialogClear": { "description": "Dialog button - clear items" }, - "dialogConfirm": "Confirm", - "@dialogConfirm": { - "description": "Dialog button - confirm action" - }, "dialogDone": "Done", "@dialogDone": { "description": "Dialog button - action completed" @@ -1025,34 +625,10 @@ "@dialogUnsavedChanges": { "description": "Dialog message - unsaved changes" }, - "dialogDownloadFailed": "Download Failed", - "@dialogDownloadFailed": { - "description": "Dialog title - download error" - }, - "dialogTrackLabel": "Track:", - "@dialogTrackLabel": { - "description": "Label for track name in error dialog" - }, - "dialogArtistLabel": "Artist:", - "@dialogArtistLabel": { - "description": "Label for artist name in error dialog" - }, - "dialogErrorLabel": "Error:", - "@dialogErrorLabel": { - "description": "Label for error message" - }, "dialogClearAll": "Clear All", "@dialogClearAll": { "description": "Dialog title - clear all items" }, - "dialogClearAllDownloads": "Are you sure you want to clear all downloads?", - "@dialogClearAllDownloads": { - "description": "Dialog message - clear downloads confirmation" - }, - "dialogRemoveFromDevice": "Remove from device?", - "@dialogRemoveFromDevice": { - "description": "Dialog title - delete file confirmation" - }, "dialogRemoveExtension": "Remove Extension", "@dialogRemoveExtension": { "description": "Dialog title - uninstall extension" @@ -1173,15 +749,6 @@ "@snackbarViewQueue": { "description": "Snackbar action - view download queue" }, - "snackbarFailedToLoad": "Failed to load: {error}", - "@snackbarFailedToLoad": { - "description": "Snackbar - loading error", - "placeholders": { - "error": { - "type": "String" - } - } - }, "snackbarUrlCopied": "{platform} URL copied to clipboard", "@snackbarUrlCopied": { "description": "Snackbar - URL copied", @@ -1242,16 +809,6 @@ "@errorRateLimitedMessage": { "description": "Error message - rate limit explanation" }, - "errorFailedToLoad": "Failed to load {item}", - "@errorFailedToLoad": { - "description": "Error message - loading failed", - "placeholders": { - "item": { - "type": "String", - "description": "Item that failed to load (album/playlist/etc)" - } - } - }, "errorNoTracksFound": "No tracks found", "@errorNoTracksFound": { "description": "Error - search returned no results" @@ -1265,34 +822,6 @@ } } }, - "statusQueued": "Queued", - "@statusQueued": { - "description": "Download status - waiting in queue" - }, - "statusDownloading": "Downloading", - "@statusDownloading": { - "description": "Download status - in progress" - }, - "statusFinalizing": "Finalizing", - "@statusFinalizing": { - "description": "Download status - writing metadata" - }, - "statusCompleted": "Completed", - "@statusCompleted": { - "description": "Download status - finished" - }, - "statusFailed": "Failed", - "@statusFailed": { - "description": "Download status - error occurred" - }, - "statusSkipped": "Skipped", - "@statusSkipped": { - "description": "Download status - already exists" - }, - "statusPaused": "Paused", - "@statusPaused": { - "description": "Download status - paused" - }, "actionPause": "Pause", "@actionPause": { "description": "Action button - pause download" @@ -1305,14 +834,6 @@ "@actionCancel": { "description": "Action button - cancel operation" }, - "actionStop": "Stop", - "@actionStop": { - "description": "Action button - stop operation" - }, - "actionSelect": "Select", - "@actionSelect": { - "description": "Action button - enter selection mode" - }, "actionSelectAll": "Select All", "@actionSelectAll": { "description": "Action button - select all items" @@ -1321,14 +842,6 @@ "@actionDeselect": { "description": "Action button - deselect all" }, - "actionPaste": "Paste", - "@actionPaste": { - "description": "Action button - paste from clipboard" - }, - "actionImportCsv": "Import CSV", - "@actionImportCsv": { - "description": "Action button - import CSV file" - }, "actionRemoveCredentials": "Remove Credentials", "@actionRemoveCredentials": { "description": "Action button - delete Spotify credentials" @@ -1350,19 +863,6 @@ "@selectionAllSelected": { "description": "Status - all items selected" }, - "selectionTapToSelect": "Tap tracks to select", - "@selectionTapToSelect": { - "description": "Hint - how to select items" - }, - "selectionDeleteTracks": "Delete {count} {count, plural, =1{track} other{tracks}}", - "@selectionDeleteTracks": { - "description": "Delete button with count", - "placeholders": { - "count": { - "type": "int" - } - } - }, "selectionSelectToDelete": "Select tracks to delete", "@selectionSelectToDelete": { "description": "Placeholder when nothing selected" @@ -1403,55 +903,10 @@ "@tooltipPlay": { "description": "Tooltip - play button" }, - "tooltipCancel": "Cancel", - "@tooltipCancel": { - "description": "Tooltip - cancel button" - }, - "tooltipStop": "Stop", - "@tooltipStop": { - "description": "Tooltip - stop button" - }, - "tooltipRetry": "Retry", - "@tooltipRetry": { - "description": "Tooltip - retry button" - }, - "tooltipRemove": "Remove", - "@tooltipRemove": { - "description": "Tooltip - remove button" - }, - "tooltipClear": "Clear", - "@tooltipClear": { - "description": "Tooltip - clear button" - }, - "tooltipPaste": "Paste", - "@tooltipPaste": { - "description": "Tooltip - paste button" - }, "filenameFormat": "Filename Format", "@filenameFormat": { "description": "Setting title - filename pattern" }, - "filenameFormatPreview": "Preview: {preview}", - "@filenameFormatPreview": { - "description": "Preview of filename pattern", - "placeholders": { - "preview": { - "type": "String" - } - } - }, - "filenameAvailablePlaceholders": "Available placeholders:", - "@filenameAvailablePlaceholders": { - "description": "Label for placeholder list" - }, - "filenameHint": "{artist} - {title}", - "@filenameHint": { - "description": "Default filename format hint" - }, - "folderOrganization": "Folder Organization", - "@folderOrganization": { - "description": "Setting title - folder structure" - }, "folderOrganizationNone": "No organization", "@folderOrganizationNone": { "description": "Folder option - flat structure" @@ -1492,27 +947,10 @@ "@updateAvailable": { "description": "Update dialog title" }, - "updateNewVersion": "Version {version} is available", - "@updateNewVersion": { - "description": "Update available message", - "placeholders": { - "version": { - "type": "String" - } - } - }, - "updateDownload": "Download", - "@updateDownload": { - "description": "Update button - download update" - }, "updateLater": "Later", "@updateLater": { "description": "Update button - dismiss" }, - "updateChangelog": "Changelog", - "@updateChangelog": { - "description": "Link to changelog" - }, "updateStartingDownload": "Starting download...", "@updateStartingDownload": { "description": "Update status - initializing" @@ -1553,14 +991,6 @@ "@updateDontRemind": { "description": "Update button - skip this version" }, - "providerPriority": "Provider Priority", - "@providerPriority": { - "description": "Setting title - download provider order" - }, - "providerPrioritySubtitle": "Drag to reorder download providers", - "@providerPrioritySubtitle": { - "description": "Subtitle for provider priority" - }, "providerPriorityTitle": "Provider Priority", "@providerPriorityTitle": { "description": "Provider priority page title" @@ -1581,14 +1011,6 @@ "@providerExtension": { "description": "Label for extension-provided providers" }, - "metadataProviderPriority": "Metadata Provider Priority", - "@metadataProviderPriority": { - "description": "Setting title - metadata provider order" - }, - "metadataProviderPrioritySubtitle": "Order used when fetching track metadata", - "@metadataProviderPrioritySubtitle": { - "description": "Subtitle for metadata priority" - }, "metadataProviderPriorityTitle": "Metadata Priority", "@metadataProviderPriorityTitle": { "description": "Metadata priority page title" @@ -1613,22 +1035,6 @@ "@logTitle": { "description": "Logs screen title" }, - "logCopy": "Copy Logs", - "@logCopy": { - "description": "Action - copy logs to clipboard" - }, - "logClear": "Clear Logs", - "@logClear": { - "description": "Action - delete all logs" - }, - "logShare": "Share Logs", - "@logShare": { - "description": "Action - share logs file" - }, - "logEmpty": "No logs yet", - "@logEmpty": { - "description": "Empty state title" - }, "logCopied": "Logs copied to clipboard", "@logCopied": { "description": "Snackbar - logs copied" @@ -1661,22 +1067,6 @@ "@logClearLogsMessage": { "description": "Clear logs confirmation message" }, - "logIspBlocking": "ISP BLOCKING DETECTED", - "@logIspBlocking": { - "description": "Error category - ISP blocking" - }, - "logRateLimited": "RATE LIMITED", - "@logRateLimited": { - "description": "Error category - rate limiting" - }, - "logNetworkError": "NETWORK ERROR", - "@logNetworkError": { - "description": "Error category - network issues" - }, - "logTrackNotFound": "TRACK NOT FOUND", - "@logTrackNotFound": { - "description": "Error category - missing tracks" - }, "logFilterBySeverity": "Filter logs by severity", "@logFilterBySeverity": { "description": "Filter dialog title" @@ -1689,60 +1079,6 @@ "@logNoLogsYetSubtitle": { "description": "Empty state subtitle" }, - "logIssueSummary": "Issue Summary", - "@logIssueSummary": { - "description": "Section header for error summary" - }, - "logIspBlockingDescription": "Your ISP may be blocking access to download services", - "@logIspBlockingDescription": { - "description": "ISP blocking explanation" - }, - "logIspBlockingSuggestion": "Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8", - "@logIspBlockingSuggestion": { - "description": "ISP blocking fix suggestion" - }, - "logRateLimitedDescription": "Too many requests to the service", - "@logRateLimitedDescription": { - "description": "Rate limit explanation" - }, - "logRateLimitedSuggestion": "Wait a few minutes before trying again", - "@logRateLimitedSuggestion": { - "description": "Rate limit fix suggestion" - }, - "logNetworkErrorDescription": "Connection issues detected", - "@logNetworkErrorDescription": { - "description": "Network error explanation" - }, - "logNetworkErrorSuggestion": "Check your internet connection", - "@logNetworkErrorSuggestion": { - "description": "Network error fix suggestion" - }, - "logTrackNotFoundDescription": "Some tracks could not be found on download services", - "@logTrackNotFoundDescription": { - "description": "Track not found explanation" - }, - "logTrackNotFoundSuggestion": "The track may not be available in lossless quality", - "@logTrackNotFoundSuggestion": { - "description": "Track not found explanation" - }, - "logTotalErrors": "Total errors: {count}", - "@logTotalErrors": { - "description": "Error count display", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "logAffected": "Affected: {domains}", - "@logAffected": { - "description": "Affected domains display", - "placeholders": { - "domains": { - "type": "String" - } - } - }, "logEntriesFiltered": "Entries ({count} filtered)", "@logEntriesFiltered": { "description": "Log count with filter active", @@ -1849,22 +1185,6 @@ "@appearanceLanguage": { "description": "Setting title for language selection" }, - "appearanceLanguageSubtitle": "Choose your preferred language", - "@appearanceLanguageSubtitle": { - "description": "Subtitle for language setting" - }, - "languageSystem": "System Default", - "@languageSystem": { - "description": "Use device system language" - }, - "languageEnglish": "English", - "@languageEnglish": { - "description": "English language option" - }, - "languageIndonesian": "Bahasa Indonesia", - "@languageIndonesian": { - "description": "Indonesian language option" - }, "settingsAppearanceSubtitle": "Theme, colors, display", "@settingsAppearanceSubtitle": { "description": "Appearance settings description" @@ -1893,10 +1213,6 @@ "@pressBackAgainToExit": { "description": "Exit confirmation message" }, - "tracksHeader": "Tracks", - "@tracksHeader": { - "description": "Section header for track list" - }, "downloadAllCount": "Download All ({count})", "@downloadAllCount": { "description": "Download all button with count", @@ -2019,15 +1335,6 @@ "@trackDeleteConfirmMessage": { "description": "Delete confirmation message" }, - "trackCannotOpen": "Cannot open: {message}", - "@trackCannotOpen": { - "description": "Error opening file", - "placeholders": { - "message": { - "type": "String" - } - } - }, "dateToday": "Today", "@dateToday": { "description": "Relative date - today" @@ -2063,22 +1370,6 @@ } } }, - "concurrentSequential": "Sequential", - "@concurrentSequential": { - "description": "Download mode - one at a time" - }, - "concurrentParallel2": "2 Parallel", - "@concurrentParallel2": { - "description": "Download mode - 2 simultaneous" - }, - "concurrentParallel3": "3 Parallel", - "@concurrentParallel3": { - "description": "Download mode - 3 simultaneous" - }, - "tapToSeeError": "Tap to see error details", - "@tapToSeeError": { - "description": "Tooltip for failed download" - }, "storeFilterAll": "All", "@storeFilterAll": { "description": "Store filter - all extensions" @@ -2107,18 +1398,6 @@ "@storeClearFilters": { "description": "Button to clear all filters" }, - "storeNoResults": "No extensions found", - "@storeNoResults": { - "description": "Empty state when no extensions match filters" - }, - "extensionProviderPriority": "Provider Priority", - "@extensionProviderPriority": { - "description": "Extension capability - provider priority" - }, - "extensionInstallButton": "Install Extension", - "@extensionInstallButton": { - "description": "Button to install extension" - }, "extensionDefaultProvider": "Default (Deezer/Spotify)", "@extensionDefaultProvider": { "description": "Default search provider option" @@ -2338,14 +1617,6 @@ "@downloadAlbumFolderStructure": { "description": "Setting - album folder organization" }, - "downloadSaveFormat": "Save Format", - "@downloadSaveFormat": { - "description": "Setting - output file format" - }, - "downloadSelectService": "Select Service", - "@downloadSelectService": { - "description": "Dialog title - choose download service" - }, "downloadSelectQuality": "Select Quality", "@downloadSelectQuality": { "description": "Dialog title - choose audio quality" @@ -2354,66 +1625,6 @@ "@downloadFrom": { "description": "Label - download source" }, - "downloadDefaultQualityLabel": "Default Quality", - "@downloadDefaultQualityLabel": { - "description": "Label - default quality setting" - }, - "downloadBestAvailable": "Best available", - "@downloadBestAvailable": { - "description": "Quality option - highest available" - }, - "folderNone": "None", - "@folderNone": { - "description": "Folder option - no organization" - }, - "folderNoneSubtitle": "Save all files directly to download folder", - "@folderNoneSubtitle": { - "description": "Subtitle for no folder organization" - }, - "folderArtist": "Artist", - "@folderArtist": { - "description": "Folder option - by artist" - }, - "folderArtistSubtitle": "Artist Name/filename", - "@folderArtistSubtitle": { - "description": "Folder structure example" - }, - "folderAlbum": "Album", - "@folderAlbum": { - "description": "Folder option - by album" - }, - "folderAlbumSubtitle": "Album Name/filename", - "@folderAlbumSubtitle": { - "description": "Folder structure example" - }, - "folderArtistAlbum": "Artist/Album", - "@folderArtistAlbum": { - "description": "Folder option - nested" - }, - "folderArtistAlbumSubtitle": "Artist Name/Album Name/filename", - "@folderArtistAlbumSubtitle": { - "description": "Folder structure example" - }, - "serviceTidal": "Tidal", - "@serviceTidal": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceQobuz": "Qobuz", - "@serviceQobuz": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceAmazon": "Amazon", - "@serviceAmazon": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceDeezer": "Deezer", - "@serviceDeezer": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceSpotify": "Spotify", - "@serviceSpotify": { - "description": "Service name - DO NOT TRANSLATE" - }, "appearanceAmoledDark": "AMOLED Dark", "@appearanceAmoledDark": { "description": "Theme option - pure black" @@ -2422,18 +1633,6 @@ "@appearanceAmoledDarkSubtitle": { "description": "Subtitle for AMOLED dark" }, - "appearanceChooseAccentColor": "Choose Accent Color", - "@appearanceChooseAccentColor": { - "description": "Color picker dialog title" - }, - "appearanceChooseTheme": "Theme Mode", - "@appearanceChooseTheme": { - "description": "Theme picker dialog title" - }, - "queueTitle": "Download Queue", - "@queueTitle": { - "description": "Queue screen title" - }, "queueClearAll": "Clear All", "@queueClearAll": { "description": "Button - clear all queue items" @@ -2442,38 +1641,6 @@ "@queueClearAllMessage": { "description": "Clear queue confirmation" }, - "queueEmpty": "No downloads in queue", - "@queueEmpty": { - "description": "Empty queue state title" - }, - "queueEmptySubtitle": "Add tracks from the home screen", - "@queueEmptySubtitle": { - "description": "Empty queue state subtitle" - }, - "queueClearCompleted": "Clear completed", - "@queueClearCompleted": { - "description": "Button - clear finished downloads" - }, - "queueDownloadFailed": "Download Failed", - "@queueDownloadFailed": { - "description": "Error dialog title" - }, - "queueTrackLabel": "Track:", - "@queueTrackLabel": { - "description": "Label in error dialog" - }, - "queueArtistLabel": "Artist:", - "@queueArtistLabel": { - "description": "Label in error dialog" - }, - "queueErrorLabel": "Error:", - "@queueErrorLabel": { - "description": "Label in error dialog" - }, - "queueUnknownError": "Unknown error", - "@queueUnknownError": { - "description": "Fallback error message" - }, "albumFolderArtistAlbum": "Artist / Album", "@albumFolderArtistAlbum": { "description": "Album folder option" @@ -2519,19 +1686,6 @@ } } }, - "downloadedAlbumTracksHeader": "Tracks", - "@downloadedAlbumTracksHeader": { - "description": "Section header for tracks" - }, - "downloadedAlbumDownloadedCount": "{count} downloaded", - "@downloadedAlbumDownloadedCount": { - "description": "Downloaded tracks count badge", - "placeholders": { - "count": { - "type": "int" - } - } - }, "downloadedAlbumSelectedCount": "{count} selected", "@downloadedAlbumSelectedCount": { "description": "Selection count indicator", @@ -2562,8 +1716,13 @@ "@downloadedAlbumSelectToDelete": { "description": "Placeholder when nothing selected" }, - "utilityFunctions": "Utility Functions", - "@utilityFunctions": { - "description": "Extension capability - utility functions" + "downloadedAlbumDownloadedCount": "{count} downloaded", + "@downloadedAlbumDownloadedCount": { + "description": "Downloaded tracks count badge", + "placeholders": { + "count": { + "type": "int" + } + } } -} \ 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 f200ce55..67d8b58c 100644 --- a/lib/l10n/arb/app_zh_CN.arb +++ b/lib/l10n/arb/app_zh_CN.arb @@ -5,10 +5,6 @@ "@appName": { "description": "App name - DO NOT TRANSLATE" }, - "appDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.", - "@appDescription": { - "description": "App description shown in about page" - }, "navHome": "Home", "@navHome": { "description": "Bottom navigation - Home tab" @@ -17,10 +13,6 @@ "@navLibrary": { "description": "Bottom navigation - Library tab" }, - "navHistory": "History", - "@navHistory": { - "description": "Bottom navigation - History tab (legacy)" - }, "navSettings": "Settings", "@navSettings": { "description": "Bottom navigation - Settings tab" @@ -33,20 +25,6 @@ "@homeTitle": { "description": "Home screen title" }, - "homeSearchHint": "Paste Spotify URL or search...", - "@homeSearchHint": { - "description": "Placeholder text in search box" - }, - "homeSearchHintExtension": "Search with {extensionName}...", - "@homeSearchHintExtension": { - "description": "Placeholder when extension search is active", - "placeholders": { - "extensionName": { - "type": "String", - "description": "Name of the active extension" - } - } - }, "homeSubtitle": "Paste a Spotify link or search by name", "@homeSubtitle": { "description": "Subtitle shown below search box" @@ -59,24 +37,6 @@ "@homeRecent": { "description": "Section header for recent searches" }, - "historyTitle": "History", - "@historyTitle": { - "description": "History screen title" - }, - "historyDownloading": "Downloading ({count})", - "@historyDownloading": { - "description": "Tab showing active downloads count", - "placeholders": { - "count": { - "type": "int", - "description": "Number of active downloads" - } - } - }, - "historyDownloaded": "Downloaded", - "@historyDownloaded": { - "description": "Tab showing completed downloads" - }, "historyFilterAll": "All", "@historyFilterAll": { "description": "Filter chip - show all items" @@ -89,48 +49,6 @@ "@historyFilterSingles": { "description": "Filter chip - show singles only" }, - "historyTracksCount": "{count, plural, =1{1 track} other{{count} tracks}}", - "@historyTracksCount": { - "description": "Track count with plural form", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "historyAlbumsCount": "{count, plural, =1{1 album} other{{count} albums}}", - "@historyAlbumsCount": { - "description": "Album count with plural form", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "historyNoDownloads": "No download history", - "@historyNoDownloads": { - "description": "Empty state title" - }, - "historyNoDownloadsSubtitle": "Downloaded tracks will appear here", - "@historyNoDownloadsSubtitle": { - "description": "Empty state subtitle" - }, - "historyNoAlbums": "No album downloads", - "@historyNoAlbums": { - "description": "Empty state when filtering albums" - }, - "historyNoAlbumsSubtitle": "Download multiple tracks from an album to see them here", - "@historyNoAlbumsSubtitle": { - "description": "Empty state subtitle for albums filter" - }, - "historyNoSingles": "No single downloads", - "@historyNoSingles": { - "description": "Empty state when filtering singles" - }, - "historyNoSinglesSubtitle": "Single track downloads will appear here", - "@historyNoSinglesSubtitle": { - "description": "Empty state subtitle for singles filter" - }, "historySearchHint": "Search history...", "@historySearchHint": { "description": "Search bar placeholder in history" @@ -163,34 +81,6 @@ "@downloadTitle": { "description": "Download settings page title" }, - "downloadLocation": "Download Location", - "@downloadLocation": { - "description": "Setting for download folder" - }, - "downloadLocationSubtitle": "Choose where to save files", - "@downloadLocationSubtitle": { - "description": "Subtitle for download location" - }, - "downloadLocationDefault": "Default location", - "@downloadLocationDefault": { - "description": "Shown when using default folder" - }, - "downloadDefaultService": "Default Service", - "@downloadDefaultService": { - "description": "Setting for preferred download service (Tidal/Qobuz/Amazon)" - }, - "downloadDefaultServiceSubtitle": "Service used for downloads", - "@downloadDefaultServiceSubtitle": { - "description": "Subtitle for default service" - }, - "downloadDefaultQuality": "Default Quality", - "@downloadDefaultQuality": { - "description": "Setting for audio quality" - }, - "downloadAskQuality": "Ask Quality Before Download", - "@downloadAskQuality": { - "description": "Toggle to show quality picker" - }, "downloadAskQualitySubtitle": "Show quality picker for each download", "@downloadAskQualitySubtitle": { "description": "Subtitle for ask quality toggle" @@ -203,38 +93,10 @@ "@downloadFolderOrganization": { "description": "Setting for folder structure" }, - "downloadSeparateSingles": "Separate Singles", - "@downloadSeparateSingles": { - "description": "Toggle to separate single tracks" - }, - "downloadSeparateSinglesSubtitle": "Put single tracks in a separate folder", - "@downloadSeparateSinglesSubtitle": { - "description": "Subtitle for separate singles toggle" - }, - "qualityBest": "Best Available", - "@qualityBest": { - "description": "Audio quality option - highest available" - }, - "qualityFlac": "FLAC", - "@qualityFlac": { - "description": "Audio quality option - FLAC lossless" - }, - "quality320": "320 kbps", - "@quality320": { - "description": "Audio quality option - 320kbps MP3" - }, - "quality128": "128 kbps", - "@quality128": { - "description": "Audio quality option - 128kbps MP3" - }, "appearanceTitle": "Appearance", "@appearanceTitle": { "description": "Appearance settings page title" }, - "appearanceTheme": "Theme", - "@appearanceTheme": { - "description": "Theme mode setting" - }, "appearanceThemeSystem": "System", "@appearanceThemeSystem": { "description": "Follow system theme" @@ -255,10 +117,6 @@ "@appearanceDynamicColorSubtitle": { "description": "Subtitle for dynamic color" }, - "appearanceAccentColor": "Accent Color", - "@appearanceAccentColor": { - "description": "Custom accent color picker" - }, "appearanceHistoryView": "History View", "@appearanceHistoryView": { "description": "Layout style for history" @@ -275,10 +133,6 @@ "@optionsTitle": { "description": "Options settings page title" }, - "optionsSearchSource": "Search Source", - "@optionsSearchSource": { - "description": "Section for search provider settings" - }, "optionsPrimaryProvider": "Primary Provider", "@optionsPrimaryProvider": { "description": "Main search provider setting" @@ -438,22 +292,6 @@ "@extensionsTitle": { "description": "Extensions page title" }, - "extensionsInstalled": "Installed Extensions", - "@extensionsInstalled": { - "description": "Section header for installed extensions" - }, - "extensionsNone": "No extensions installed", - "@extensionsNone": { - "description": "Empty state title" - }, - "extensionsNoneSubtitle": "Install extensions from the Store tab", - "@extensionsNoneSubtitle": { - "description": "Empty state subtitle" - }, - "extensionsEnabled": "Enabled", - "@extensionsEnabled": { - "description": "Extension status - active" - }, "extensionsDisabled": "Disabled", "@extensionsDisabled": { "description": "Extension status - inactive" @@ -480,10 +318,6 @@ "@extensionsUninstall": { "description": "Uninstall extension button" }, - "extensionsSetAsSearch": "Set as Search Provider", - "@extensionsSetAsSearch": { - "description": "Use extension for search" - }, "storeTitle": "Extension Store", "@storeTitle": { "description": "Store screen title" @@ -580,10 +414,6 @@ "@aboutSocial": { "description": "Section for social links" }, - "aboutSupport": "Support", - "@aboutSupport": { - "description": "Section for support/donation links" - }, "aboutApp": "App", "@aboutApp": { "description": "Section for app info" @@ -604,14 +434,6 @@ "@aboutSjdonadoDesc": { "description": "Credit description for sjdonado" }, - "aboutDoubleDouble": "DoubleDouble", - "@aboutDoubleDouble": { - "description": "Name of Amazon API service - DO NOT TRANSLATE" - }, - "aboutDoubleDoubleDesc": "Amazing API for Amazon Music downloads. Thank you for making it free!", - "@aboutDoubleDoubleDesc": { - "description": "Credit for DoubleDouble API" - }, "aboutDabMusic": "DAB Music", "@aboutDabMusic": { "description": "Name of Qobuz API service - DO NOT TRANSLATE" @@ -632,35 +454,6 @@ "@aboutAppDescription": { "description": "App description in header card" }, - "albumTitle": "Album", - "@albumTitle": { - "description": "Album screen title" - }, - "albumTracks": "{count, plural, =1{1 track} other{{count} tracks}}", - "@albumTracks": { - "description": "Album track count", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "albumDownloadAll": "Download All", - "@albumDownloadAll": { - "description": "Button to download all tracks" - }, - "albumDownloadRemaining": "Download Remaining", - "@albumDownloadRemaining": { - "description": "Button to download remaining tracks" - }, - "playlistTitle": "Playlist", - "@playlistTitle": { - "description": "Playlist screen title" - }, - "artistTitle": "Artist", - "@artistTitle": { - "description": "Artist screen title" - }, "artistAlbums": "Albums", "@artistAlbums": { "description": "Section header for artist albums" @@ -673,15 +466,6 @@ "@artistCompilations": { "description": "Section header for compilations" }, - "artistReleases": "{count, plural, =1{1 release} other{{count} releases}}", - "@artistReleases": { - "description": "Artist release count", - "placeholders": { - "count": { - "type": "int" - } - } - }, "artistPopular": "Popular", "@artistPopular": { "description": "Section header for popular/top tracks" @@ -696,34 +480,6 @@ } } }, - "trackMetadataTitle": "Track Info", - "@trackMetadataTitle": { - "description": "Track metadata screen title" - }, - "trackMetadataArtist": "Artist", - "@trackMetadataArtist": { - "description": "Metadata field - artist name" - }, - "trackMetadataAlbum": "Album", - "@trackMetadataAlbum": { - "description": "Metadata field - album name" - }, - "trackMetadataDuration": "Duration", - "@trackMetadataDuration": { - "description": "Metadata field - track length" - }, - "trackMetadataQuality": "Quality", - "@trackMetadataQuality": { - "description": "Metadata field - audio quality" - }, - "trackMetadataPath": "File Path", - "@trackMetadataPath": { - "description": "Metadata field - file location" - }, - "trackMetadataDownloadedAt": "Downloaded", - "@trackMetadataDownloadedAt": { - "description": "Metadata field - download date" - }, "trackMetadataService": "Service", "@trackMetadataService": { "description": "Metadata field - download service used" @@ -740,54 +496,10 @@ "@trackMetadataDelete": { "description": "Action button - delete track" }, - "trackMetadataRedownload": "Re-download", - "@trackMetadataRedownload": { - "description": "Action button - download again" - }, - "trackMetadataOpenFolder": "Open Folder", - "@trackMetadataOpenFolder": { - "description": "Action button - open containing folder" - }, - "setupTitle": "Welcome to SpotiFLAC", - "@setupTitle": { - "description": "Setup wizard title" - }, - "setupSubtitle": "Let's get you started", - "@setupSubtitle": { - "description": "Setup wizard subtitle" - }, - "setupStoragePermission": "Storage Permission", - "@setupStoragePermission": { - "description": "Storage permission step title" - }, - "setupStoragePermissionSubtitle": "Required to save downloaded files", - "@setupStoragePermissionSubtitle": { - "description": "Explanation for storage permission" - }, - "setupStoragePermissionGranted": "Permission granted", - "@setupStoragePermissionGranted": { - "description": "Status when permission granted" - }, - "setupStoragePermissionDenied": "Permission denied", - "@setupStoragePermissionDenied": { - "description": "Status when permission denied" - }, "setupGrantPermission": "Grant Permission", "@setupGrantPermission": { "description": "Button to request permission" }, - "setupDownloadLocation": "Download Location", - "@setupDownloadLocation": { - "description": "Download folder step title" - }, - "setupChooseFolder": "Choose Folder", - "@setupChooseFolder": { - "description": "Button to pick folder" - }, - "setupContinue": "Continue", - "@setupContinue": { - "description": "Continue to next step button" - }, "setupSkip": "Skip for now", "@setupSkip": { "description": "Skip current step button" @@ -796,10 +508,6 @@ "@setupStorageAccessRequired": { "description": "Title when storage access needed" }, - "setupStorageAccessMessage": "SpotiFLAC needs \"All files access\" permission to save music files to your chosen folder.", - "@setupStorageAccessMessage": { - "description": "Explanation for storage access" - }, "setupStorageAccessMessageAndroid11": "Android 11+ requires \"All files access\" permission to save files to your chosen download folder.", "@setupStorageAccessMessageAndroid11": { "description": "Android 11+ specific explanation" @@ -831,10 +539,6 @@ } } }, - "setupSelectDownloadFolder": "Select Download Folder", - "@setupSelectDownloadFolder": { - "description": "Folder selection step title" - }, "setupUseDefaultFolder": "Use Default Folder?", "@setupUseDefaultFolder": { "description": "Dialog title for default folder" @@ -883,26 +587,6 @@ "@setupDownloadInFlac": { "description": "App tagline in setup" }, - "setupStepStorage": "Storage", - "@setupStepStorage": { - "description": "Setup step indicator - storage" - }, - "setupStepNotification": "Notification", - "@setupStepNotification": { - "description": "Setup step indicator - notification" - }, - "setupStepFolder": "Folder", - "@setupStepFolder": { - "description": "Setup step indicator - folder" - }, - "setupStepSpotify": "Spotify", - "@setupStepSpotify": { - "description": "Setup step indicator - Spotify API" - }, - "setupStepPermission": "Permission", - "@setupStepPermission": { - "description": "Setup step indicator - permission" - }, "setupStorageGranted": "Storage Permission Granted!", "@setupStorageGranted": { "description": "Success message for storage permission" @@ -923,14 +607,6 @@ "@setupNotificationEnable": { "description": "Button to enable notifications" }, - "setupNotificationDescription": "Get notified when downloads complete or require attention.", - "@setupNotificationDescription": { - "description": "Explanation for notifications" - }, - "setupFolderSelected": "Download Folder Selected!", - "@setupFolderSelected": { - "description": "Success message for folder selection" - }, "setupFolderChoose": "Choose Download Folder", "@setupFolderChoose": { "description": "Button to choose folder" @@ -939,58 +615,14 @@ "@setupFolderDescription": { "description": "Explanation for folder selection" }, - "setupChangeFolder": "Change Folder", - "@setupChangeFolder": { - "description": "Button to change selected folder" - }, "setupSelectFolder": "Select Folder", "@setupSelectFolder": { "description": "Button to select folder" }, - "setupSpotifyApiOptional": "Spotify API (Optional)", - "@setupSpotifyApiOptional": { - "description": "Spotify API step title" - }, - "setupSpotifyApiDescription": "Add your Spotify API credentials for better search results and access to Spotify-exclusive content.", - "@setupSpotifyApiDescription": { - "description": "Explanation for Spotify API" - }, - "setupUseSpotifyApi": "Use Spotify API", - "@setupUseSpotifyApi": { - "description": "Toggle to enable Spotify API" - }, - "setupEnterCredentialsBelow": "Enter your credentials below", - "@setupEnterCredentialsBelow": { - "description": "Prompt to enter credentials" - }, - "setupUsingDeezer": "Using Deezer (no account needed)", - "@setupUsingDeezer": { - "description": "Status when using Deezer" - }, - "setupEnterClientId": "Enter Spotify Client ID", - "@setupEnterClientId": { - "description": "Placeholder for client ID field" - }, - "setupEnterClientSecret": "Enter Spotify Client Secret", - "@setupEnterClientSecret": { - "description": "Placeholder for client secret field" - }, - "setupGetFreeCredentials": "Get your free API credentials from the Spotify Developer Dashboard.", - "@setupGetFreeCredentials": { - "description": "Info about getting Spotify credentials" - }, "setupEnableNotifications": "Enable Notifications", "@setupEnableNotifications": { "description": "Button to enable notifications" }, - "setupProceedToNextStep": "You can now proceed to the next step.", - "@setupProceedToNextStep": { - "description": "Message after completing a step" - }, - "setupNotificationProgressDescription": "You will receive download progress notifications.", - "@setupNotificationProgressDescription": { - "description": "Info about notification usage" - }, "setupNotificationBackgroundDescription": "Get notified about download progress and completion. This helps you track downloads when the app is in background.", "@setupNotificationBackgroundDescription": { "description": "Detailed notification explanation" @@ -999,10 +631,6 @@ "@setupSkipForNow": { "description": "Skip button text" }, - "setupBack": "Back", - "@setupBack": { - "description": "Back button text" - }, "setupNext": "Next", "@setupNext": { "description": "Next button text" @@ -1011,26 +639,14 @@ "@setupGetStarted": { "description": "Final setup button" }, - "setupSkipAndStart": "Skip & Start", - "@setupSkipAndStart": { - "description": "Skip setup and start app" - }, "setupAllowAccessToManageFiles": "Please enable \"Allow access to manage all files\" in the next screen.", "@setupAllowAccessToManageFiles": { "description": "Instruction for file access permission" }, - "setupGetCredentialsFromSpotify": "Get credentials from developer.spotify.com", - "@setupGetCredentialsFromSpotify": { - "description": "Link text for Spotify developer portal" - }, "dialogCancel": "Cancel", "@dialogCancel": { "description": "Dialog button - cancel action" }, - "dialogOk": "OK", - "@dialogOk": { - "description": "Dialog button - confirm/acknowledge" - }, "dialogSave": "Save", "@dialogSave": { "description": "Dialog button - save changes" @@ -1043,26 +659,10 @@ "@dialogRetry": { "description": "Dialog button - retry action" }, - "dialogClose": "Close", - "@dialogClose": { - "description": "Dialog button - close dialog" - }, - "dialogYes": "Yes", - "@dialogYes": { - "description": "Dialog button - confirm yes" - }, - "dialogNo": "No", - "@dialogNo": { - "description": "Dialog button - confirm no" - }, "dialogClear": "Clear", "@dialogClear": { "description": "Dialog button - clear items" }, - "dialogConfirm": "Confirm", - "@dialogConfirm": { - "description": "Dialog button - confirm action" - }, "dialogDone": "Done", "@dialogDone": { "description": "Dialog button - action completed" @@ -1091,34 +691,10 @@ "@dialogUnsavedChanges": { "description": "Dialog message - unsaved changes" }, - "dialogDownloadFailed": "Download Failed", - "@dialogDownloadFailed": { - "description": "Dialog title - download error" - }, - "dialogTrackLabel": "Track:", - "@dialogTrackLabel": { - "description": "Label for track name in error dialog" - }, - "dialogArtistLabel": "Artist:", - "@dialogArtistLabel": { - "description": "Label for artist name in error dialog" - }, - "dialogErrorLabel": "Error:", - "@dialogErrorLabel": { - "description": "Label for error message" - }, "dialogClearAll": "Clear All", "@dialogClearAll": { "description": "Dialog title - clear all items" }, - "dialogClearAllDownloads": "Are you sure you want to clear all downloads?", - "@dialogClearAllDownloads": { - "description": "Dialog message - clear downloads confirmation" - }, - "dialogRemoveFromDevice": "Remove from device?", - "@dialogRemoveFromDevice": { - "description": "Dialog title - delete file confirmation" - }, "dialogRemoveExtension": "Remove Extension", "@dialogRemoveExtension": { "description": "Dialog title - uninstall extension" @@ -1257,15 +833,6 @@ "@snackbarViewQueue": { "description": "Snackbar action - view download queue" }, - "snackbarFailedToLoad": "Failed to load: {error}", - "@snackbarFailedToLoad": { - "description": "Snackbar - loading error", - "placeholders": { - "error": { - "type": "String" - } - } - }, "snackbarUrlCopied": "{platform} URL copied to clipboard", "@snackbarUrlCopied": { "description": "Snackbar - URL copied", @@ -1326,16 +893,6 @@ "@errorRateLimitedMessage": { "description": "Error message - rate limit explanation" }, - "errorFailedToLoad": "Failed to load {item}", - "@errorFailedToLoad": { - "description": "Error message - loading failed", - "placeholders": { - "item": { - "type": "String", - "description": "Item that failed to load (album/playlist/etc)" - } - } - }, "errorNoTracksFound": "No tracks found", "@errorNoTracksFound": { "description": "Error - search returned no results" @@ -1349,34 +906,6 @@ } } }, - "statusQueued": "Queued", - "@statusQueued": { - "description": "Download status - waiting in queue" - }, - "statusDownloading": "Downloading", - "@statusDownloading": { - "description": "Download status - in progress" - }, - "statusFinalizing": "Finalizing", - "@statusFinalizing": { - "description": "Download status - writing metadata" - }, - "statusCompleted": "Completed", - "@statusCompleted": { - "description": "Download status - finished" - }, - "statusFailed": "Failed", - "@statusFailed": { - "description": "Download status - error occurred" - }, - "statusSkipped": "Skipped", - "@statusSkipped": { - "description": "Download status - already exists" - }, - "statusPaused": "Paused", - "@statusPaused": { - "description": "Download status - paused" - }, "actionPause": "Pause", "@actionPause": { "description": "Action button - pause download" @@ -1389,14 +918,6 @@ "@actionCancel": { "description": "Action button - cancel operation" }, - "actionStop": "Stop", - "@actionStop": { - "description": "Action button - stop operation" - }, - "actionSelect": "Select", - "@actionSelect": { - "description": "Action button - enter selection mode" - }, "actionSelectAll": "Select All", "@actionSelectAll": { "description": "Action button - select all items" @@ -1405,14 +926,6 @@ "@actionDeselect": { "description": "Action button - deselect all" }, - "actionPaste": "Paste", - "@actionPaste": { - "description": "Action button - paste from clipboard" - }, - "actionImportCsv": "Import CSV", - "@actionImportCsv": { - "description": "Action button - import CSV file" - }, "actionRemoveCredentials": "Remove Credentials", "@actionRemoveCredentials": { "description": "Action button - delete Spotify credentials" @@ -1434,19 +947,6 @@ "@selectionAllSelected": { "description": "Status - all items selected" }, - "selectionTapToSelect": "Tap tracks to select", - "@selectionTapToSelect": { - "description": "Hint - how to select items" - }, - "selectionDeleteTracks": "Delete {count} {count, plural, =1{track} other{tracks}}", - "@selectionDeleteTracks": { - "description": "Delete button with count", - "placeholders": { - "count": { - "type": "int" - } - } - }, "selectionSelectToDelete": "Select tracks to delete", "@selectionSelectToDelete": { "description": "Placeholder when nothing selected" @@ -1487,55 +987,10 @@ "@tooltipPlay": { "description": "Tooltip - play button" }, - "tooltipCancel": "Cancel", - "@tooltipCancel": { - "description": "Tooltip - cancel button" - }, - "tooltipStop": "Stop", - "@tooltipStop": { - "description": "Tooltip - stop button" - }, - "tooltipRetry": "Retry", - "@tooltipRetry": { - "description": "Tooltip - retry button" - }, - "tooltipRemove": "Remove", - "@tooltipRemove": { - "description": "Tooltip - remove button" - }, - "tooltipClear": "Clear", - "@tooltipClear": { - "description": "Tooltip - clear button" - }, - "tooltipPaste": "Paste", - "@tooltipPaste": { - "description": "Tooltip - paste button" - }, "filenameFormat": "Filename Format", "@filenameFormat": { "description": "Setting title - filename pattern" }, - "filenameFormatPreview": "Preview: {preview}", - "@filenameFormatPreview": { - "description": "Preview of filename pattern", - "placeholders": { - "preview": { - "type": "String" - } - } - }, - "filenameAvailablePlaceholders": "Available placeholders:", - "@filenameAvailablePlaceholders": { - "description": "Label for placeholder list" - }, - "filenameHint": "{artist} - {title}", - "@filenameHint": { - "description": "Default filename format hint" - }, - "folderOrganization": "Folder Organization", - "@folderOrganization": { - "description": "Setting title - folder structure" - }, "folderOrganizationNone": "No organization", "@folderOrganizationNone": { "description": "Folder option - flat structure" @@ -1576,27 +1031,10 @@ "@updateAvailable": { "description": "Update dialog title" }, - "updateNewVersion": "Version {version} is available", - "@updateNewVersion": { - "description": "Update available message", - "placeholders": { - "version": { - "type": "String" - } - } - }, - "updateDownload": "Download", - "@updateDownload": { - "description": "Update button - download update" - }, "updateLater": "Later", "@updateLater": { "description": "Update button - dismiss" }, - "updateChangelog": "Changelog", - "@updateChangelog": { - "description": "Link to changelog" - }, "updateStartingDownload": "Starting download...", "@updateStartingDownload": { "description": "Update status - initializing" @@ -1637,14 +1075,6 @@ "@updateDontRemind": { "description": "Update button - skip this version" }, - "providerPriority": "Provider Priority", - "@providerPriority": { - "description": "Setting title - download provider order" - }, - "providerPrioritySubtitle": "Drag to reorder download providers", - "@providerPrioritySubtitle": { - "description": "Subtitle for provider priority" - }, "providerPriorityTitle": "Provider Priority", "@providerPriorityTitle": { "description": "Provider priority page title" @@ -1665,14 +1095,6 @@ "@providerExtension": { "description": "Label for extension-provided providers" }, - "metadataProviderPriority": "Metadata Provider Priority", - "@metadataProviderPriority": { - "description": "Setting title - metadata provider order" - }, - "metadataProviderPrioritySubtitle": "Order used when fetching track metadata", - "@metadataProviderPrioritySubtitle": { - "description": "Subtitle for metadata priority" - }, "metadataProviderPriorityTitle": "Metadata Priority", "@metadataProviderPriorityTitle": { "description": "Metadata priority page title" @@ -1697,22 +1119,6 @@ "@logTitle": { "description": "Logs screen title" }, - "logCopy": "Copy Logs", - "@logCopy": { - "description": "Action - copy logs to clipboard" - }, - "logClear": "Clear Logs", - "@logClear": { - "description": "Action - delete all logs" - }, - "logShare": "Share Logs", - "@logShare": { - "description": "Action - share logs file" - }, - "logEmpty": "No logs yet", - "@logEmpty": { - "description": "Empty state title" - }, "logCopied": "Logs copied to clipboard", "@logCopied": { "description": "Snackbar - logs copied" @@ -1745,22 +1151,6 @@ "@logClearLogsMessage": { "description": "Clear logs confirmation message" }, - "logIspBlocking": "ISP BLOCKING DETECTED", - "@logIspBlocking": { - "description": "Error category - ISP blocking" - }, - "logRateLimited": "RATE LIMITED", - "@logRateLimited": { - "description": "Error category - rate limiting" - }, - "logNetworkError": "NETWORK ERROR", - "@logNetworkError": { - "description": "Error category - network issues" - }, - "logTrackNotFound": "TRACK NOT FOUND", - "@logTrackNotFound": { - "description": "Error category - missing tracks" - }, "logFilterBySeverity": "Filter logs by severity", "@logFilterBySeverity": { "description": "Filter dialog title" @@ -1773,60 +1163,6 @@ "@logNoLogsYetSubtitle": { "description": "Empty state subtitle" }, - "logIssueSummary": "Issue Summary", - "@logIssueSummary": { - "description": "Section header for error summary" - }, - "logIspBlockingDescription": "Your ISP may be blocking access to download services", - "@logIspBlockingDescription": { - "description": "ISP blocking explanation" - }, - "logIspBlockingSuggestion": "Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8", - "@logIspBlockingSuggestion": { - "description": "ISP blocking fix suggestion" - }, - "logRateLimitedDescription": "Too many requests to the service", - "@logRateLimitedDescription": { - "description": "Rate limit explanation" - }, - "logRateLimitedSuggestion": "Wait a few minutes before trying again", - "@logRateLimitedSuggestion": { - "description": "Rate limit fix suggestion" - }, - "logNetworkErrorDescription": "Connection issues detected", - "@logNetworkErrorDescription": { - "description": "Network error explanation" - }, - "logNetworkErrorSuggestion": "Check your internet connection", - "@logNetworkErrorSuggestion": { - "description": "Network error fix suggestion" - }, - "logTrackNotFoundDescription": "Some tracks could not be found on download services", - "@logTrackNotFoundDescription": { - "description": "Track not found explanation" - }, - "logTrackNotFoundSuggestion": "The track may not be available in lossless quality", - "@logTrackNotFoundSuggestion": { - "description": "Track not found explanation" - }, - "logTotalErrors": "Total errors: {count}", - "@logTotalErrors": { - "description": "Error count display", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "logAffected": "Affected: {domains}", - "@logAffected": { - "description": "Affected domains display", - "placeholders": { - "domains": { - "type": "String" - } - } - }, "logEntriesFiltered": "Entries ({count} filtered)", "@logEntriesFiltered": { "description": "Log count with filter active", @@ -1969,10 +1305,6 @@ "@appearanceLanguage": { "description": "Language setting title" }, - "appearanceLanguageSubtitle": "Choose your preferred language", - "@appearanceLanguageSubtitle": { - "description": "Language setting subtitle" - }, "settingsAppearanceSubtitle": "Theme, colors, display", "@settingsAppearanceSubtitle": { "description": "Appearance settings description" @@ -2001,10 +1333,6 @@ "@pressBackAgainToExit": { "description": "Exit confirmation message" }, - "tracksHeader": "Tracks", - "@tracksHeader": { - "description": "Section header for track list" - }, "downloadAllCount": "Download All ({count})", "@downloadAllCount": { "description": "Download all button with count", @@ -2151,15 +1479,6 @@ "@trackDeleteConfirmMessage": { "description": "Delete confirmation message" }, - "trackCannotOpen": "Cannot open: {message}", - "@trackCannotOpen": { - "description": "Error opening file", - "placeholders": { - "message": { - "type": "String" - } - } - }, "dateToday": "Today", "@dateToday": { "description": "Relative date - today" @@ -2195,22 +1514,6 @@ } } }, - "concurrentSequential": "Sequential", - "@concurrentSequential": { - "description": "Download mode - one at a time" - }, - "concurrentParallel2": "2 Parallel", - "@concurrentParallel2": { - "description": "Download mode - 2 simultaneous" - }, - "concurrentParallel3": "3 Parallel", - "@concurrentParallel3": { - "description": "Download mode - 3 simultaneous" - }, - "tapToSeeError": "Tap to see error details", - "@tapToSeeError": { - "description": "Tooltip for failed download" - }, "storeFilterAll": "All", "@storeFilterAll": { "description": "Store filter - all extensions" @@ -2239,18 +1542,6 @@ "@storeClearFilters": { "description": "Button to clear all filters" }, - "storeNoResults": "No extensions found", - "@storeNoResults": { - "description": "Empty state when no extensions match filters" - }, - "extensionProviderPriority": "Provider Priority", - "@extensionProviderPriority": { - "description": "Extension capability - provider priority" - }, - "extensionInstallButton": "Install Extension", - "@extensionInstallButton": { - "description": "Button to install extension" - }, "extensionDefaultProvider": "Default (Deezer/Spotify)", "@extensionDefaultProvider": { "description": "Default search provider option" @@ -2450,46 +1741,6 @@ "@qualityHiResFlacMaxSubtitle": { "description": "Technical spec for hi-res max" }, - "qualityLossy": "Lossy", - "@qualityLossy": { - "description": "Quality option - lossy format (MP3/Opus)" - }, - "qualityLossyMp3Subtitle": "MP3 320kbps (converted from FLAC)", - "@qualityLossyMp3Subtitle": { - "description": "Technical spec for lossy MP3" - }, - "qualityLossyOpusSubtitle": "Opus 128kbps (converted from FLAC)", - "@qualityLossyOpusSubtitle": { - "description": "Technical spec for lossy Opus" - }, - "enableLossyOption": "Enable Lossy Option", - "@enableLossyOption": { - "description": "Setting - enable lossy quality option" - }, - "enableLossyOptionSubtitleOn": "Lossy quality option is available", - "@enableLossyOptionSubtitleOn": { - "description": "Subtitle when lossy is enabled" - }, - "enableLossyOptionSubtitleOff": "Downloads FLAC then converts to lossy format", - "@enableLossyOptionSubtitleOff": { - "description": "Subtitle when lossy is disabled" - }, - "lossyFormat": "Lossy Format", - "@lossyFormat": { - "description": "Setting - choose lossy format" - }, - "lossyFormatDescription": "Choose the lossy format for conversion", - "@lossyFormatDescription": { - "description": "Description for lossy format picker" - }, - "lossyFormatMp3Subtitle": "320kbps, best compatibility", - "@lossyFormatMp3Subtitle": { - "description": "MP3 format description" - }, - "lossyFormatOpusSubtitle": "128kbps, better quality at smaller size", - "@lossyFormatOpusSubtitle": { - "description": "Opus format description" - }, "qualityNote": "Actual quality depends on track availability from the service", "@qualityNote": { "description": "Note about quality availability" @@ -2518,14 +1769,6 @@ "@downloadUseAlbumArtistForFolders": { "description": "Setting - choose whether artist folders use Album Artist or Track Artist" }, - "downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available", - "@downloadUseAlbumArtistForFoldersAlbumSubtitle": { - "description": "Subtitle when Album Artist is used for folder naming" - }, - "downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only", - "@downloadUseAlbumArtistForFoldersTrackSubtitle": { - "description": "Subtitle when Track Artist is used for folder naming" - }, "downloadUsePrimaryArtistOnly": "Primary artist only for folders", "@downloadUsePrimaryArtistOnly": { "description": "Setting - strip featured artists from folder name" @@ -2538,14 +1781,6 @@ "@downloadUsePrimaryArtistOnlyDisabled": { "description": "Subtitle when primary artist only is disabled" }, - "downloadSaveFormat": "Save Format", - "@downloadSaveFormat": { - "description": "Setting - output file format" - }, - "downloadSelectService": "Select Service", - "@downloadSelectService": { - "description": "Dialog title - choose download service" - }, "downloadSelectQuality": "Select Quality", "@downloadSelectQuality": { "description": "Dialog title - choose audio quality" @@ -2554,66 +1789,6 @@ "@downloadFrom": { "description": "Label - download source" }, - "downloadDefaultQualityLabel": "Default Quality", - "@downloadDefaultQualityLabel": { - "description": "Label - default quality setting" - }, - "downloadBestAvailable": "Best available", - "@downloadBestAvailable": { - "description": "Quality option - highest available" - }, - "folderNone": "None", - "@folderNone": { - "description": "Folder option - no organization" - }, - "folderNoneSubtitle": "Save all files directly to download folder", - "@folderNoneSubtitle": { - "description": "Subtitle for no folder organization" - }, - "folderArtist": "Artist", - "@folderArtist": { - "description": "Folder option - by artist" - }, - "folderArtistSubtitle": "Artist Name/filename", - "@folderArtistSubtitle": { - "description": "Folder structure example" - }, - "folderAlbum": "Album", - "@folderAlbum": { - "description": "Folder option - by album" - }, - "folderAlbumSubtitle": "Album Name/filename", - "@folderAlbumSubtitle": { - "description": "Folder structure example" - }, - "folderArtistAlbum": "Artist/Album", - "@folderArtistAlbum": { - "description": "Folder option - nested" - }, - "folderArtistAlbumSubtitle": "Artist Name/Album Name/filename", - "@folderArtistAlbumSubtitle": { - "description": "Folder structure example" - }, - "serviceTidal": "Tidal", - "@serviceTidal": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceQobuz": "Qobuz", - "@serviceQobuz": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceAmazon": "Amazon", - "@serviceAmazon": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceDeezer": "Deezer", - "@serviceDeezer": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceSpotify": "Spotify", - "@serviceSpotify": { - "description": "Service name - DO NOT TRANSLATE" - }, "appearanceAmoledDark": "AMOLED Dark", "@appearanceAmoledDark": { "description": "Theme option - pure black" @@ -2622,18 +1797,6 @@ "@appearanceAmoledDarkSubtitle": { "description": "Subtitle for AMOLED dark" }, - "appearanceChooseAccentColor": "Choose Accent Color", - "@appearanceChooseAccentColor": { - "description": "Color picker dialog title" - }, - "appearanceChooseTheme": "Theme Mode", - "@appearanceChooseTheme": { - "description": "Theme picker dialog title" - }, - "queueTitle": "Download Queue", - "@queueTitle": { - "description": "Queue screen title" - }, "queueClearAll": "Clear All", "@queueClearAll": { "description": "Button - clear all queue items" @@ -2642,22 +1805,6 @@ "@queueClearAllMessage": { "description": "Clear queue confirmation" }, - "queueExportFailed": "Export", - "@queueExportFailed": { - "description": "Button - export failed downloads to TXT" - }, - "queueExportFailedSuccess": "Failed downloads exported to TXT file", - "@queueExportFailedSuccess": { - "description": "Success message after exporting failed downloads" - }, - "queueExportFailedClear": "Clear Failed", - "@queueExportFailedClear": { - "description": "Action to clear failed downloads after export" - }, - "queueExportFailedError": "Failed to export downloads", - "@queueExportFailedError": { - "description": "Error message when export fails" - }, "settingsAutoExportFailed": "Auto-export failed downloads", "@settingsAutoExportFailed": { "description": "Setting toggle for auto-export" @@ -2682,38 +1829,6 @@ "@settingsDownloadNetworkSubtitle": { "description": "Subtitle explaining network preference" }, - "queueEmpty": "No downloads in queue", - "@queueEmpty": { - "description": "Empty queue state title" - }, - "queueEmptySubtitle": "Add tracks from the home screen", - "@queueEmptySubtitle": { - "description": "Empty queue state subtitle" - }, - "queueClearCompleted": "Clear completed", - "@queueClearCompleted": { - "description": "Button - clear finished downloads" - }, - "queueDownloadFailed": "Download Failed", - "@queueDownloadFailed": { - "description": "Error dialog title" - }, - "queueTrackLabel": "Track:", - "@queueTrackLabel": { - "description": "Label in error dialog" - }, - "queueArtistLabel": "Artist:", - "@queueArtistLabel": { - "description": "Label in error dialog" - }, - "queueErrorLabel": "Error:", - "@queueErrorLabel": { - "description": "Label in error dialog" - }, - "queueUnknownError": "Unknown error", - "@queueUnknownError": { - "description": "Fallback error message" - }, "albumFolderArtistAlbum": "Artist / Album", "@albumFolderArtistAlbum": { "description": "Album folder option" @@ -2767,19 +1882,6 @@ } } }, - "downloadedAlbumTracksHeader": "Tracks", - "@downloadedAlbumTracksHeader": { - "description": "Section header for tracks" - }, - "downloadedAlbumDownloadedCount": "{count} downloaded", - "@downloadedAlbumDownloadedCount": { - "description": "Downloaded tracks count badge", - "placeholders": { - "count": { - "type": "int" - } - } - }, "downloadedAlbumSelectedCount": "{count} selected", "@downloadedAlbumSelectedCount": { "description": "Selection count indicator", @@ -2820,10 +1922,6 @@ } } }, - "utilityFunctions": "Utility Functions", - "@utilityFunctions": { - "description": "Extension capability - utility functions" - }, "recentTypeArtist": "Artist", "@recentTypeArtist": { "description": "Recent access item type - artist" @@ -2858,16 +1956,6 @@ } } }, - "errorGeneric": "Error: {message}", - "@errorGeneric": { - "description": "Generic error message format", - "placeholders": { - "message": { - "type": "String", - "description": "Error message" - } - } - }, "discographyDownload": "Download Discography", "@discographyDownload": { "description": "Button - download artist discography" @@ -3034,10 +2122,6 @@ "@libraryTitle": { "description": "Library settings page title" }, - "libraryStatus": "Library Status", - "@libraryStatus": { - "description": "Section header for library status" - }, "libraryScanSettings": "Scan Settings", "@libraryScanSettings": { "description": "Section header for scan settings" @@ -3114,15 +2198,6 @@ "@libraryAboutDescription": { "description": "Description of local library feature" }, - "libraryTracksCount": "{count} tracks", - "@libraryTracksCount": { - "description": "Track count in library", - "placeholders": { - "count": { - "type": "int" - } - } - }, "libraryLastScanned": "Last scanned: {time}", "@libraryLastScanned": { "description": "Last scan time display", @@ -3237,26 +2312,6 @@ "@libraryFilterFormat": { "description": "Filter section - file format" }, - "libraryFilterDate": "Date Added", - "@libraryFilterDate": { - "description": "Filter section - date range" - }, - "libraryFilterDateToday": "Today", - "@libraryFilterDateToday": { - "description": "Filter option - today only" - }, - "libraryFilterDateWeek": "This Week", - "@libraryFilterDateWeek": { - "description": "Filter option - this week" - }, - "libraryFilterDateMonth": "This Month", - "@libraryFilterDateMonth": { - "description": "Filter option - this month" - }, - "libraryFilterDateYear": "This Year", - "@libraryFilterDateYear": { - "description": "Filter option - this year" - }, "libraryFilterSort": "Sort", "@libraryFilterSort": { "description": "Filter section - sort order" @@ -3269,15 +2324,6 @@ "@libraryFilterSortOldest": { "description": "Sort option - oldest first" }, - "libraryFilterActive": "{count} filter(s) active", - "@libraryFilterActive": { - "description": "Badge showing number of active filters", - "placeholders": { - "count": { - "type": "int" - } - } - }, "timeJustNow": "Just now", "@timeJustNow": { "description": "Relative time - less than a minute ago" @@ -3300,106 +2346,6 @@ } } }, - "storageSwitchTitle": "Switch Storage Mode", - "@storageSwitchTitle": { - "description": "Dialog title when switching storage mode" - }, - "storageSwitchToSafTitle": "Switch to SAF Storage?", - "@storageSwitchToSafTitle": { - "description": "Dialog title when switching to SAF" - }, - "storageSwitchToAppTitle": "Switch to App Storage?", - "@storageSwitchToAppTitle": { - "description": "Dialog title when switching to app storage" - }, - "storageSwitchToSafMessage": "Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.", - "@storageSwitchToSafMessage": { - "description": "Explanation when switching to SAF" - }, - "storageSwitchToAppMessage": "Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.", - "@storageSwitchToAppMessage": { - "description": "Explanation when switching to app storage" - }, - "storageSwitchExistingDownloads": "Existing Downloads", - "@storageSwitchExistingDownloads": { - "description": "Section header for existing downloads info" - }, - "storageSwitchExistingDownloadsInfo": "{count} tracks in {mode} storage", - "@storageSwitchExistingDownloadsInfo": { - "description": "Info about existing downloads count", - "placeholders": { - "count": { - "type": "int" - }, - "mode": { - "type": "String" - } - } - }, - "storageSwitchNewDownloads": "New Downloads", - "@storageSwitchNewDownloads": { - "description": "Section header for new downloads info" - }, - "storageSwitchNewDownloadsLocation": "Will be saved to: {location}", - "@storageSwitchNewDownloadsLocation": { - "description": "Shows where new downloads will go", - "placeholders": { - "location": { - "type": "String" - } - } - }, - "storageSwitchContinue": "Continue", - "@storageSwitchContinue": { - "description": "Button to proceed with storage switch" - }, - "storageSwitchSelectFolder": "Select SAF Folder", - "@storageSwitchSelectFolder": { - "description": "Button to select SAF folder" - }, - "storageAppStorage": "App Storage", - "@storageAppStorage": { - "description": "Label for app storage mode" - }, - "storageSafStorage": "SAF Storage", - "@storageSafStorage": { - "description": "Label for SAF storage mode" - }, - "storageModeBadge": "Storage: {mode}", - "@storageModeBadge": { - "description": "Badge showing storage mode for a track", - "placeholders": { - "mode": { - "type": "String" - } - } - }, - "storageStatsTitle": "Storage Statistics", - "@storageStatsTitle": { - "description": "Section title for storage stats" - }, - "storageStatsAppCount": "{count} tracks in App Storage", - "@storageStatsAppCount": { - "description": "Count of tracks in app storage", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "storageStatsSafCount": "{count} tracks in SAF Storage", - "@storageStatsSafCount": { - "description": "Count of tracks in SAF storage", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "storageModeInfo": "Your files are stored in multiple locations", - "@storageModeInfo": { - "description": "Info when user has files in both storage modes" - }, "tutorialWelcomeTitle": "Welcome to SpotiFLAC!", "@tutorialWelcomeTitle": { "description": "Tutorial welcome page title" @@ -3428,18 +2374,6 @@ "@tutorialSearchDesc": { "description": "Tutorial search page description" }, - "tutorialSearchTip1": "Paste a Spotify or Deezer URL directly in the search box", - "@tutorialSearchTip1": { - "description": "Tutorial search tip 1" - }, - "tutorialSearchTip2": "Or type the song name, artist, or album to search", - "@tutorialSearchTip2": { - "description": "Tutorial search tip 2" - }, - "tutorialSearchTip3": "Supports tracks, albums, playlists, and artist pages", - "@tutorialSearchTip3": { - "description": "Tutorial search tip 3" - }, "tutorialDownloadTitle": "Downloading Music", "@tutorialDownloadTitle": { "description": "Tutorial download page title" @@ -3448,18 +2382,6 @@ "@tutorialDownloadDesc": { "description": "Tutorial download page description" }, - "tutorialDownloadTip1": "Tap the download button next to any track to start downloading", - "@tutorialDownloadTip1": { - "description": "Tutorial download tip 1" - }, - "tutorialDownloadTip2": "Choose your preferred quality (FLAC, Hi-Res, or MP3)", - "@tutorialDownloadTip2": { - "description": "Tutorial download tip 2" - }, - "tutorialDownloadTip3": "Download entire albums or playlists with one tap", - "@tutorialDownloadTip3": { - "description": "Tutorial download tip 3" - }, "tutorialLibraryTitle": "Your Library", "@tutorialLibraryTitle": { "description": "Tutorial library page title" @@ -3524,10 +2446,6 @@ "@tutorialReadyMessage": { "description": "Tutorial completion message" }, - "tutorialExample": "EXAMPLE", - "@tutorialExample": { - "description": "Example label in tutorial" - }, "libraryForceFullScan": "Force Full Scan", "@libraryForceFullScan": { "description": "Button to force a complete rescan of library" @@ -3750,14 +2668,10 @@ "@trackSaveLyricsProgress": { "description": "Snackbar while saving lyrics to file" }, - "trackReEnrich": "Re-enrich Metadata", + "trackReEnrich": "Re-enrich", "@trackReEnrich": { "description": "Menu action - re-embed metadata into audio file" }, - "trackReEnrichSubtitle": "Re-embed metadata without re-downloading", - "@trackReEnrichSubtitle": { - "description": "Subtitle for re-enrich metadata action" - }, "trackReEnrichOnlineSubtitle": "Search metadata online and embed into file", "@trackReEnrichOnlineSubtitle": { "description": "Subtitle for re-enrich metadata action for local items" @@ -3868,5 +2782,22 @@ "trackConvertFailed": "Conversion failed", "@trackConvertFailed": { "description": "Snackbar when conversion fails" + }, + "downloadedAlbumDownloadedCount": "{count} downloaded", + "@downloadedAlbumDownloadedCount": { + "description": "Downloaded tracks count badge", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available", + "@downloadUseAlbumArtistForFoldersAlbumSubtitle": { + "description": "Subtitle when Album Artist is used for folder naming" + }, + "downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only", + "@downloadUseAlbumArtistForFoldersTrackSubtitle": { + "description": "Subtitle when Track Artist is used for folder naming" } -} \ 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 168506b3..d230aa50 100644 --- a/lib/l10n/arb/app_zh_TW.arb +++ b/lib/l10n/arb/app_zh_TW.arb @@ -5,10 +5,6 @@ "@appName": { "description": "App name - DO NOT TRANSLATE" }, - "appDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.", - "@appDescription": { - "description": "App description shown in about page" - }, "navHome": "Home", "@navHome": { "description": "Bottom navigation - Home tab" @@ -17,10 +13,6 @@ "@navLibrary": { "description": "Bottom navigation - Library tab" }, - "navHistory": "History", - "@navHistory": { - "description": "Bottom navigation - History tab (legacy)" - }, "navSettings": "Settings", "@navSettings": { "description": "Bottom navigation - Settings tab" @@ -33,20 +25,6 @@ "@homeTitle": { "description": "Home screen title" }, - "homeSearchHint": "Paste Spotify URL or search...", - "@homeSearchHint": { - "description": "Placeholder text in search box" - }, - "homeSearchHintExtension": "Search with {extensionName}...", - "@homeSearchHintExtension": { - "description": "Placeholder when extension search is active", - "placeholders": { - "extensionName": { - "type": "String", - "description": "Name of the active extension" - } - } - }, "homeSubtitle": "Paste a Spotify link or search by name", "@homeSubtitle": { "description": "Subtitle shown below search box" @@ -59,24 +37,6 @@ "@homeRecent": { "description": "Section header for recent searches" }, - "historyTitle": "History", - "@historyTitle": { - "description": "History screen title" - }, - "historyDownloading": "Downloading ({count})", - "@historyDownloading": { - "description": "Tab showing active downloads count", - "placeholders": { - "count": { - "type": "int", - "description": "Number of active downloads" - } - } - }, - "historyDownloaded": "Downloaded", - "@historyDownloaded": { - "description": "Tab showing completed downloads" - }, "historyFilterAll": "All", "@historyFilterAll": { "description": "Filter chip - show all items" @@ -89,48 +49,6 @@ "@historyFilterSingles": { "description": "Filter chip - show singles only" }, - "historyTracksCount": "{count, plural, =1{1 track} other{{count} tracks}}", - "@historyTracksCount": { - "description": "Track count with plural form", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "historyAlbumsCount": "{count, plural, =1{1 album} other{{count} albums}}", - "@historyAlbumsCount": { - "description": "Album count with plural form", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "historyNoDownloads": "No download history", - "@historyNoDownloads": { - "description": "Empty state title" - }, - "historyNoDownloadsSubtitle": "Downloaded tracks will appear here", - "@historyNoDownloadsSubtitle": { - "description": "Empty state subtitle" - }, - "historyNoAlbums": "No album downloads", - "@historyNoAlbums": { - "description": "Empty state when filtering albums" - }, - "historyNoAlbumsSubtitle": "Download multiple tracks from an album to see them here", - "@historyNoAlbumsSubtitle": { - "description": "Empty state subtitle for albums filter" - }, - "historyNoSingles": "No single downloads", - "@historyNoSingles": { - "description": "Empty state when filtering singles" - }, - "historyNoSinglesSubtitle": "Single track downloads will appear here", - "@historyNoSinglesSubtitle": { - "description": "Empty state subtitle for singles filter" - }, "historySearchHint": "Search history...", "@historySearchHint": { "description": "Search bar placeholder in history" @@ -163,34 +81,6 @@ "@downloadTitle": { "description": "Download settings page title" }, - "downloadLocation": "Download Location", - "@downloadLocation": { - "description": "Setting for download folder" - }, - "downloadLocationSubtitle": "Choose where to save files", - "@downloadLocationSubtitle": { - "description": "Subtitle for download location" - }, - "downloadLocationDefault": "Default location", - "@downloadLocationDefault": { - "description": "Shown when using default folder" - }, - "downloadDefaultService": "Default Service", - "@downloadDefaultService": { - "description": "Setting for preferred download service (Tidal/Qobuz/Amazon)" - }, - "downloadDefaultServiceSubtitle": "Service used for downloads", - "@downloadDefaultServiceSubtitle": { - "description": "Subtitle for default service" - }, - "downloadDefaultQuality": "Default Quality", - "@downloadDefaultQuality": { - "description": "Setting for audio quality" - }, - "downloadAskQuality": "Ask Quality Before Download", - "@downloadAskQuality": { - "description": "Toggle to show quality picker" - }, "downloadAskQualitySubtitle": "Show quality picker for each download", "@downloadAskQualitySubtitle": { "description": "Subtitle for ask quality toggle" @@ -203,38 +93,10 @@ "@downloadFolderOrganization": { "description": "Setting for folder structure" }, - "downloadSeparateSingles": "Separate Singles", - "@downloadSeparateSingles": { - "description": "Toggle to separate single tracks" - }, - "downloadSeparateSinglesSubtitle": "Put single tracks in a separate folder", - "@downloadSeparateSinglesSubtitle": { - "description": "Subtitle for separate singles toggle" - }, - "qualityBest": "Best Available", - "@qualityBest": { - "description": "Audio quality option - highest available" - }, - "qualityFlac": "FLAC", - "@qualityFlac": { - "description": "Audio quality option - FLAC lossless" - }, - "quality320": "320 kbps", - "@quality320": { - "description": "Audio quality option - 320kbps MP3" - }, - "quality128": "128 kbps", - "@quality128": { - "description": "Audio quality option - 128kbps MP3" - }, "appearanceTitle": "Appearance", "@appearanceTitle": { "description": "Appearance settings page title" }, - "appearanceTheme": "Theme", - "@appearanceTheme": { - "description": "Theme mode setting" - }, "appearanceThemeSystem": "System", "@appearanceThemeSystem": { "description": "Follow system theme" @@ -255,10 +117,6 @@ "@appearanceDynamicColorSubtitle": { "description": "Subtitle for dynamic color" }, - "appearanceAccentColor": "Accent Color", - "@appearanceAccentColor": { - "description": "Custom accent color picker" - }, "appearanceHistoryView": "History View", "@appearanceHistoryView": { "description": "Layout style for history" @@ -275,10 +133,6 @@ "@optionsTitle": { "description": "Options settings page title" }, - "optionsSearchSource": "Search Source", - "@optionsSearchSource": { - "description": "Section for search provider settings" - }, "optionsPrimaryProvider": "Primary Provider", "@optionsPrimaryProvider": { "description": "Main search provider setting" @@ -438,22 +292,6 @@ "@extensionsTitle": { "description": "Extensions page title" }, - "extensionsInstalled": "Installed Extensions", - "@extensionsInstalled": { - "description": "Section header for installed extensions" - }, - "extensionsNone": "No extensions installed", - "@extensionsNone": { - "description": "Empty state title" - }, - "extensionsNoneSubtitle": "Install extensions from the Store tab", - "@extensionsNoneSubtitle": { - "description": "Empty state subtitle" - }, - "extensionsEnabled": "Enabled", - "@extensionsEnabled": { - "description": "Extension status - active" - }, "extensionsDisabled": "Disabled", "@extensionsDisabled": { "description": "Extension status - inactive" @@ -480,10 +318,6 @@ "@extensionsUninstall": { "description": "Uninstall extension button" }, - "extensionsSetAsSearch": "Set as Search Provider", - "@extensionsSetAsSearch": { - "description": "Use extension for search" - }, "storeTitle": "Extension Store", "@storeTitle": { "description": "Store screen title" @@ -580,10 +414,6 @@ "@aboutSocial": { "description": "Section for social links" }, - "aboutSupport": "Support", - "@aboutSupport": { - "description": "Section for support/donation links" - }, "aboutApp": "App", "@aboutApp": { "description": "Section for app info" @@ -604,14 +434,6 @@ "@aboutSjdonadoDesc": { "description": "Credit description for sjdonado" }, - "aboutDoubleDouble": "DoubleDouble", - "@aboutDoubleDouble": { - "description": "Name of Amazon API service - DO NOT TRANSLATE" - }, - "aboutDoubleDoubleDesc": "Amazing API for Amazon Music downloads. Thank you for making it free!", - "@aboutDoubleDoubleDesc": { - "description": "Credit for DoubleDouble API" - }, "aboutDabMusic": "DAB Music", "@aboutDabMusic": { "description": "Name of Qobuz API service - DO NOT TRANSLATE" @@ -632,35 +454,6 @@ "@aboutAppDescription": { "description": "App description in header card" }, - "albumTitle": "Album", - "@albumTitle": { - "description": "Album screen title" - }, - "albumTracks": "{count, plural, =1{1 track} other{{count} tracks}}", - "@albumTracks": { - "description": "Album track count", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "albumDownloadAll": "Download All", - "@albumDownloadAll": { - "description": "Button to download all tracks" - }, - "albumDownloadRemaining": "Download Remaining", - "@albumDownloadRemaining": { - "description": "Button to download remaining tracks" - }, - "playlistTitle": "Playlist", - "@playlistTitle": { - "description": "Playlist screen title" - }, - "artistTitle": "Artist", - "@artistTitle": { - "description": "Artist screen title" - }, "artistAlbums": "Albums", "@artistAlbums": { "description": "Section header for artist albums" @@ -673,15 +466,6 @@ "@artistCompilations": { "description": "Section header for compilations" }, - "artistReleases": "{count, plural, =1{1 release} other{{count} releases}}", - "@artistReleases": { - "description": "Artist release count", - "placeholders": { - "count": { - "type": "int" - } - } - }, "artistPopular": "Popular", "@artistPopular": { "description": "Section header for popular/top tracks" @@ -696,34 +480,6 @@ } } }, - "trackMetadataTitle": "Track Info", - "@trackMetadataTitle": { - "description": "Track metadata screen title" - }, - "trackMetadataArtist": "Artist", - "@trackMetadataArtist": { - "description": "Metadata field - artist name" - }, - "trackMetadataAlbum": "Album", - "@trackMetadataAlbum": { - "description": "Metadata field - album name" - }, - "trackMetadataDuration": "Duration", - "@trackMetadataDuration": { - "description": "Metadata field - track length" - }, - "trackMetadataQuality": "Quality", - "@trackMetadataQuality": { - "description": "Metadata field - audio quality" - }, - "trackMetadataPath": "File Path", - "@trackMetadataPath": { - "description": "Metadata field - file location" - }, - "trackMetadataDownloadedAt": "Downloaded", - "@trackMetadataDownloadedAt": { - "description": "Metadata field - download date" - }, "trackMetadataService": "Service", "@trackMetadataService": { "description": "Metadata field - download service used" @@ -740,54 +496,10 @@ "@trackMetadataDelete": { "description": "Action button - delete track" }, - "trackMetadataRedownload": "Re-download", - "@trackMetadataRedownload": { - "description": "Action button - download again" - }, - "trackMetadataOpenFolder": "Open Folder", - "@trackMetadataOpenFolder": { - "description": "Action button - open containing folder" - }, - "setupTitle": "Welcome to SpotiFLAC", - "@setupTitle": { - "description": "Setup wizard title" - }, - "setupSubtitle": "Let's get you started", - "@setupSubtitle": { - "description": "Setup wizard subtitle" - }, - "setupStoragePermission": "Storage Permission", - "@setupStoragePermission": { - "description": "Storage permission step title" - }, - "setupStoragePermissionSubtitle": "Required to save downloaded files", - "@setupStoragePermissionSubtitle": { - "description": "Explanation for storage permission" - }, - "setupStoragePermissionGranted": "Permission granted", - "@setupStoragePermissionGranted": { - "description": "Status when permission granted" - }, - "setupStoragePermissionDenied": "Permission denied", - "@setupStoragePermissionDenied": { - "description": "Status when permission denied" - }, "setupGrantPermission": "Grant Permission", "@setupGrantPermission": { "description": "Button to request permission" }, - "setupDownloadLocation": "Download Location", - "@setupDownloadLocation": { - "description": "Download folder step title" - }, - "setupChooseFolder": "Choose Folder", - "@setupChooseFolder": { - "description": "Button to pick folder" - }, - "setupContinue": "Continue", - "@setupContinue": { - "description": "Continue to next step button" - }, "setupSkip": "Skip for now", "@setupSkip": { "description": "Skip current step button" @@ -796,10 +508,6 @@ "@setupStorageAccessRequired": { "description": "Title when storage access needed" }, - "setupStorageAccessMessage": "SpotiFLAC needs \"All files access\" permission to save music files to your chosen folder.", - "@setupStorageAccessMessage": { - "description": "Explanation for storage access" - }, "setupStorageAccessMessageAndroid11": "Android 11+ requires \"All files access\" permission to save files to your chosen download folder.", "@setupStorageAccessMessageAndroid11": { "description": "Android 11+ specific explanation" @@ -831,10 +539,6 @@ } } }, - "setupSelectDownloadFolder": "Select Download Folder", - "@setupSelectDownloadFolder": { - "description": "Folder selection step title" - }, "setupUseDefaultFolder": "Use Default Folder?", "@setupUseDefaultFolder": { "description": "Dialog title for default folder" @@ -883,26 +587,6 @@ "@setupDownloadInFlac": { "description": "App tagline in setup" }, - "setupStepStorage": "Storage", - "@setupStepStorage": { - "description": "Setup step indicator - storage" - }, - "setupStepNotification": "Notification", - "@setupStepNotification": { - "description": "Setup step indicator - notification" - }, - "setupStepFolder": "Folder", - "@setupStepFolder": { - "description": "Setup step indicator - folder" - }, - "setupStepSpotify": "Spotify", - "@setupStepSpotify": { - "description": "Setup step indicator - Spotify API" - }, - "setupStepPermission": "Permission", - "@setupStepPermission": { - "description": "Setup step indicator - permission" - }, "setupStorageGranted": "Storage Permission Granted!", "@setupStorageGranted": { "description": "Success message for storage permission" @@ -923,14 +607,6 @@ "@setupNotificationEnable": { "description": "Button to enable notifications" }, - "setupNotificationDescription": "Get notified when downloads complete or require attention.", - "@setupNotificationDescription": { - "description": "Explanation for notifications" - }, - "setupFolderSelected": "Download Folder Selected!", - "@setupFolderSelected": { - "description": "Success message for folder selection" - }, "setupFolderChoose": "Choose Download Folder", "@setupFolderChoose": { "description": "Button to choose folder" @@ -939,58 +615,14 @@ "@setupFolderDescription": { "description": "Explanation for folder selection" }, - "setupChangeFolder": "Change Folder", - "@setupChangeFolder": { - "description": "Button to change selected folder" - }, "setupSelectFolder": "Select Folder", "@setupSelectFolder": { "description": "Button to select folder" }, - "setupSpotifyApiOptional": "Spotify API (Optional)", - "@setupSpotifyApiOptional": { - "description": "Spotify API step title" - }, - "setupSpotifyApiDescription": "Add your Spotify API credentials for better search results and access to Spotify-exclusive content.", - "@setupSpotifyApiDescription": { - "description": "Explanation for Spotify API" - }, - "setupUseSpotifyApi": "Use Spotify API", - "@setupUseSpotifyApi": { - "description": "Toggle to enable Spotify API" - }, - "setupEnterCredentialsBelow": "Enter your credentials below", - "@setupEnterCredentialsBelow": { - "description": "Prompt to enter credentials" - }, - "setupUsingDeezer": "Using Deezer (no account needed)", - "@setupUsingDeezer": { - "description": "Status when using Deezer" - }, - "setupEnterClientId": "Enter Spotify Client ID", - "@setupEnterClientId": { - "description": "Placeholder for client ID field" - }, - "setupEnterClientSecret": "Enter Spotify Client Secret", - "@setupEnterClientSecret": { - "description": "Placeholder for client secret field" - }, - "setupGetFreeCredentials": "Get your free API credentials from the Spotify Developer Dashboard.", - "@setupGetFreeCredentials": { - "description": "Info about getting Spotify credentials" - }, "setupEnableNotifications": "Enable Notifications", "@setupEnableNotifications": { "description": "Button to enable notifications" }, - "setupProceedToNextStep": "You can now proceed to the next step.", - "@setupProceedToNextStep": { - "description": "Message after completing a step" - }, - "setupNotificationProgressDescription": "You will receive download progress notifications.", - "@setupNotificationProgressDescription": { - "description": "Info about notification usage" - }, "setupNotificationBackgroundDescription": "Get notified about download progress and completion. This helps you track downloads when the app is in background.", "@setupNotificationBackgroundDescription": { "description": "Detailed notification explanation" @@ -999,10 +631,6 @@ "@setupSkipForNow": { "description": "Skip button text" }, - "setupBack": "Back", - "@setupBack": { - "description": "Back button text" - }, "setupNext": "Next", "@setupNext": { "description": "Next button text" @@ -1011,26 +639,14 @@ "@setupGetStarted": { "description": "Final setup button" }, - "setupSkipAndStart": "Skip & Start", - "@setupSkipAndStart": { - "description": "Skip setup and start app" - }, "setupAllowAccessToManageFiles": "Please enable \"Allow access to manage all files\" in the next screen.", "@setupAllowAccessToManageFiles": { "description": "Instruction for file access permission" }, - "setupGetCredentialsFromSpotify": "Get credentials from developer.spotify.com", - "@setupGetCredentialsFromSpotify": { - "description": "Link text for Spotify developer portal" - }, "dialogCancel": "Cancel", "@dialogCancel": { "description": "Dialog button - cancel action" }, - "dialogOk": "OK", - "@dialogOk": { - "description": "Dialog button - confirm/acknowledge" - }, "dialogSave": "Save", "@dialogSave": { "description": "Dialog button - save changes" @@ -1043,26 +659,10 @@ "@dialogRetry": { "description": "Dialog button - retry action" }, - "dialogClose": "Close", - "@dialogClose": { - "description": "Dialog button - close dialog" - }, - "dialogYes": "Yes", - "@dialogYes": { - "description": "Dialog button - confirm yes" - }, - "dialogNo": "No", - "@dialogNo": { - "description": "Dialog button - confirm no" - }, "dialogClear": "Clear", "@dialogClear": { "description": "Dialog button - clear items" }, - "dialogConfirm": "Confirm", - "@dialogConfirm": { - "description": "Dialog button - confirm action" - }, "dialogDone": "Done", "@dialogDone": { "description": "Dialog button - action completed" @@ -1091,34 +691,10 @@ "@dialogUnsavedChanges": { "description": "Dialog message - unsaved changes" }, - "dialogDownloadFailed": "Download Failed", - "@dialogDownloadFailed": { - "description": "Dialog title - download error" - }, - "dialogTrackLabel": "Track:", - "@dialogTrackLabel": { - "description": "Label for track name in error dialog" - }, - "dialogArtistLabel": "Artist:", - "@dialogArtistLabel": { - "description": "Label for artist name in error dialog" - }, - "dialogErrorLabel": "Error:", - "@dialogErrorLabel": { - "description": "Label for error message" - }, "dialogClearAll": "Clear All", "@dialogClearAll": { "description": "Dialog title - clear all items" }, - "dialogClearAllDownloads": "Are you sure you want to clear all downloads?", - "@dialogClearAllDownloads": { - "description": "Dialog message - clear downloads confirmation" - }, - "dialogRemoveFromDevice": "Remove from device?", - "@dialogRemoveFromDevice": { - "description": "Dialog title - delete file confirmation" - }, "dialogRemoveExtension": "Remove Extension", "@dialogRemoveExtension": { "description": "Dialog title - uninstall extension" @@ -1257,15 +833,6 @@ "@snackbarViewQueue": { "description": "Snackbar action - view download queue" }, - "snackbarFailedToLoad": "Failed to load: {error}", - "@snackbarFailedToLoad": { - "description": "Snackbar - loading error", - "placeholders": { - "error": { - "type": "String" - } - } - }, "snackbarUrlCopied": "{platform} URL copied to clipboard", "@snackbarUrlCopied": { "description": "Snackbar - URL copied", @@ -1326,16 +893,6 @@ "@errorRateLimitedMessage": { "description": "Error message - rate limit explanation" }, - "errorFailedToLoad": "Failed to load {item}", - "@errorFailedToLoad": { - "description": "Error message - loading failed", - "placeholders": { - "item": { - "type": "String", - "description": "Item that failed to load (album/playlist/etc)" - } - } - }, "errorNoTracksFound": "No tracks found", "@errorNoTracksFound": { "description": "Error - search returned no results" @@ -1349,34 +906,6 @@ } } }, - "statusQueued": "Queued", - "@statusQueued": { - "description": "Download status - waiting in queue" - }, - "statusDownloading": "Downloading", - "@statusDownloading": { - "description": "Download status - in progress" - }, - "statusFinalizing": "Finalizing", - "@statusFinalizing": { - "description": "Download status - writing metadata" - }, - "statusCompleted": "Completed", - "@statusCompleted": { - "description": "Download status - finished" - }, - "statusFailed": "Failed", - "@statusFailed": { - "description": "Download status - error occurred" - }, - "statusSkipped": "Skipped", - "@statusSkipped": { - "description": "Download status - already exists" - }, - "statusPaused": "Paused", - "@statusPaused": { - "description": "Download status - paused" - }, "actionPause": "Pause", "@actionPause": { "description": "Action button - pause download" @@ -1389,14 +918,6 @@ "@actionCancel": { "description": "Action button - cancel operation" }, - "actionStop": "Stop", - "@actionStop": { - "description": "Action button - stop operation" - }, - "actionSelect": "Select", - "@actionSelect": { - "description": "Action button - enter selection mode" - }, "actionSelectAll": "Select All", "@actionSelectAll": { "description": "Action button - select all items" @@ -1405,14 +926,6 @@ "@actionDeselect": { "description": "Action button - deselect all" }, - "actionPaste": "Paste", - "@actionPaste": { - "description": "Action button - paste from clipboard" - }, - "actionImportCsv": "Import CSV", - "@actionImportCsv": { - "description": "Action button - import CSV file" - }, "actionRemoveCredentials": "Remove Credentials", "@actionRemoveCredentials": { "description": "Action button - delete Spotify credentials" @@ -1434,19 +947,6 @@ "@selectionAllSelected": { "description": "Status - all items selected" }, - "selectionTapToSelect": "Tap tracks to select", - "@selectionTapToSelect": { - "description": "Hint - how to select items" - }, - "selectionDeleteTracks": "Delete {count} {count, plural, =1{track} other{tracks}}", - "@selectionDeleteTracks": { - "description": "Delete button with count", - "placeholders": { - "count": { - "type": "int" - } - } - }, "selectionSelectToDelete": "Select tracks to delete", "@selectionSelectToDelete": { "description": "Placeholder when nothing selected" @@ -1487,55 +987,10 @@ "@tooltipPlay": { "description": "Tooltip - play button" }, - "tooltipCancel": "Cancel", - "@tooltipCancel": { - "description": "Tooltip - cancel button" - }, - "tooltipStop": "Stop", - "@tooltipStop": { - "description": "Tooltip - stop button" - }, - "tooltipRetry": "Retry", - "@tooltipRetry": { - "description": "Tooltip - retry button" - }, - "tooltipRemove": "Remove", - "@tooltipRemove": { - "description": "Tooltip - remove button" - }, - "tooltipClear": "Clear", - "@tooltipClear": { - "description": "Tooltip - clear button" - }, - "tooltipPaste": "Paste", - "@tooltipPaste": { - "description": "Tooltip - paste button" - }, "filenameFormat": "Filename Format", "@filenameFormat": { "description": "Setting title - filename pattern" }, - "filenameFormatPreview": "Preview: {preview}", - "@filenameFormatPreview": { - "description": "Preview of filename pattern", - "placeholders": { - "preview": { - "type": "String" - } - } - }, - "filenameAvailablePlaceholders": "Available placeholders:", - "@filenameAvailablePlaceholders": { - "description": "Label for placeholder list" - }, - "filenameHint": "{artist} - {title}", - "@filenameHint": { - "description": "Default filename format hint" - }, - "folderOrganization": "Folder Organization", - "@folderOrganization": { - "description": "Setting title - folder structure" - }, "folderOrganizationNone": "No organization", "@folderOrganizationNone": { "description": "Folder option - flat structure" @@ -1576,27 +1031,10 @@ "@updateAvailable": { "description": "Update dialog title" }, - "updateNewVersion": "Version {version} is available", - "@updateNewVersion": { - "description": "Update available message", - "placeholders": { - "version": { - "type": "String" - } - } - }, - "updateDownload": "Download", - "@updateDownload": { - "description": "Update button - download update" - }, "updateLater": "Later", "@updateLater": { "description": "Update button - dismiss" }, - "updateChangelog": "Changelog", - "@updateChangelog": { - "description": "Link to changelog" - }, "updateStartingDownload": "Starting download...", "@updateStartingDownload": { "description": "Update status - initializing" @@ -1637,14 +1075,6 @@ "@updateDontRemind": { "description": "Update button - skip this version" }, - "providerPriority": "Provider Priority", - "@providerPriority": { - "description": "Setting title - download provider order" - }, - "providerPrioritySubtitle": "Drag to reorder download providers", - "@providerPrioritySubtitle": { - "description": "Subtitle for provider priority" - }, "providerPriorityTitle": "Provider Priority", "@providerPriorityTitle": { "description": "Provider priority page title" @@ -1665,14 +1095,6 @@ "@providerExtension": { "description": "Label for extension-provided providers" }, - "metadataProviderPriority": "Metadata Provider Priority", - "@metadataProviderPriority": { - "description": "Setting title - metadata provider order" - }, - "metadataProviderPrioritySubtitle": "Order used when fetching track metadata", - "@metadataProviderPrioritySubtitle": { - "description": "Subtitle for metadata priority" - }, "metadataProviderPriorityTitle": "Metadata Priority", "@metadataProviderPriorityTitle": { "description": "Metadata priority page title" @@ -1697,22 +1119,6 @@ "@logTitle": { "description": "Logs screen title" }, - "logCopy": "Copy Logs", - "@logCopy": { - "description": "Action - copy logs to clipboard" - }, - "logClear": "Clear Logs", - "@logClear": { - "description": "Action - delete all logs" - }, - "logShare": "Share Logs", - "@logShare": { - "description": "Action - share logs file" - }, - "logEmpty": "No logs yet", - "@logEmpty": { - "description": "Empty state title" - }, "logCopied": "Logs copied to clipboard", "@logCopied": { "description": "Snackbar - logs copied" @@ -1745,22 +1151,6 @@ "@logClearLogsMessage": { "description": "Clear logs confirmation message" }, - "logIspBlocking": "ISP BLOCKING DETECTED", - "@logIspBlocking": { - "description": "Error category - ISP blocking" - }, - "logRateLimited": "RATE LIMITED", - "@logRateLimited": { - "description": "Error category - rate limiting" - }, - "logNetworkError": "NETWORK ERROR", - "@logNetworkError": { - "description": "Error category - network issues" - }, - "logTrackNotFound": "TRACK NOT FOUND", - "@logTrackNotFound": { - "description": "Error category - missing tracks" - }, "logFilterBySeverity": "Filter logs by severity", "@logFilterBySeverity": { "description": "Filter dialog title" @@ -1773,60 +1163,6 @@ "@logNoLogsYetSubtitle": { "description": "Empty state subtitle" }, - "logIssueSummary": "Issue Summary", - "@logIssueSummary": { - "description": "Section header for error summary" - }, - "logIspBlockingDescription": "Your ISP may be blocking access to download services", - "@logIspBlockingDescription": { - "description": "ISP blocking explanation" - }, - "logIspBlockingSuggestion": "Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8", - "@logIspBlockingSuggestion": { - "description": "ISP blocking fix suggestion" - }, - "logRateLimitedDescription": "Too many requests to the service", - "@logRateLimitedDescription": { - "description": "Rate limit explanation" - }, - "logRateLimitedSuggestion": "Wait a few minutes before trying again", - "@logRateLimitedSuggestion": { - "description": "Rate limit fix suggestion" - }, - "logNetworkErrorDescription": "Connection issues detected", - "@logNetworkErrorDescription": { - "description": "Network error explanation" - }, - "logNetworkErrorSuggestion": "Check your internet connection", - "@logNetworkErrorSuggestion": { - "description": "Network error fix suggestion" - }, - "logTrackNotFoundDescription": "Some tracks could not be found on download services", - "@logTrackNotFoundDescription": { - "description": "Track not found explanation" - }, - "logTrackNotFoundSuggestion": "The track may not be available in lossless quality", - "@logTrackNotFoundSuggestion": { - "description": "Track not found explanation" - }, - "logTotalErrors": "Total errors: {count}", - "@logTotalErrors": { - "description": "Error count display", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "logAffected": "Affected: {domains}", - "@logAffected": { - "description": "Affected domains display", - "placeholders": { - "domains": { - "type": "String" - } - } - }, "logEntriesFiltered": "Entries ({count} filtered)", "@logEntriesFiltered": { "description": "Log count with filter active", @@ -1969,10 +1305,6 @@ "@appearanceLanguage": { "description": "Language setting title" }, - "appearanceLanguageSubtitle": "Choose your preferred language", - "@appearanceLanguageSubtitle": { - "description": "Language setting subtitle" - }, "settingsAppearanceSubtitle": "Theme, colors, display", "@settingsAppearanceSubtitle": { "description": "Appearance settings description" @@ -2001,10 +1333,6 @@ "@pressBackAgainToExit": { "description": "Exit confirmation message" }, - "tracksHeader": "Tracks", - "@tracksHeader": { - "description": "Section header for track list" - }, "downloadAllCount": "Download All ({count})", "@downloadAllCount": { "description": "Download all button with count", @@ -2151,15 +1479,6 @@ "@trackDeleteConfirmMessage": { "description": "Delete confirmation message" }, - "trackCannotOpen": "Cannot open: {message}", - "@trackCannotOpen": { - "description": "Error opening file", - "placeholders": { - "message": { - "type": "String" - } - } - }, "dateToday": "Today", "@dateToday": { "description": "Relative date - today" @@ -2195,22 +1514,6 @@ } } }, - "concurrentSequential": "Sequential", - "@concurrentSequential": { - "description": "Download mode - one at a time" - }, - "concurrentParallel2": "2 Parallel", - "@concurrentParallel2": { - "description": "Download mode - 2 simultaneous" - }, - "concurrentParallel3": "3 Parallel", - "@concurrentParallel3": { - "description": "Download mode - 3 simultaneous" - }, - "tapToSeeError": "Tap to see error details", - "@tapToSeeError": { - "description": "Tooltip for failed download" - }, "storeFilterAll": "All", "@storeFilterAll": { "description": "Store filter - all extensions" @@ -2239,18 +1542,6 @@ "@storeClearFilters": { "description": "Button to clear all filters" }, - "storeNoResults": "No extensions found", - "@storeNoResults": { - "description": "Empty state when no extensions match filters" - }, - "extensionProviderPriority": "Provider Priority", - "@extensionProviderPriority": { - "description": "Extension capability - provider priority" - }, - "extensionInstallButton": "Install Extension", - "@extensionInstallButton": { - "description": "Button to install extension" - }, "extensionDefaultProvider": "Default (Deezer/Spotify)", "@extensionDefaultProvider": { "description": "Default search provider option" @@ -2450,46 +1741,6 @@ "@qualityHiResFlacMaxSubtitle": { "description": "Technical spec for hi-res max" }, - "qualityLossy": "Lossy", - "@qualityLossy": { - "description": "Quality option - lossy format (MP3/Opus)" - }, - "qualityLossyMp3Subtitle": "MP3 320kbps (converted from FLAC)", - "@qualityLossyMp3Subtitle": { - "description": "Technical spec for lossy MP3" - }, - "qualityLossyOpusSubtitle": "Opus 128kbps (converted from FLAC)", - "@qualityLossyOpusSubtitle": { - "description": "Technical spec for lossy Opus" - }, - "enableLossyOption": "Enable Lossy Option", - "@enableLossyOption": { - "description": "Setting - enable lossy quality option" - }, - "enableLossyOptionSubtitleOn": "Lossy quality option is available", - "@enableLossyOptionSubtitleOn": { - "description": "Subtitle when lossy is enabled" - }, - "enableLossyOptionSubtitleOff": "Downloads FLAC then converts to lossy format", - "@enableLossyOptionSubtitleOff": { - "description": "Subtitle when lossy is disabled" - }, - "lossyFormat": "Lossy Format", - "@lossyFormat": { - "description": "Setting - choose lossy format" - }, - "lossyFormatDescription": "Choose the lossy format for conversion", - "@lossyFormatDescription": { - "description": "Description for lossy format picker" - }, - "lossyFormatMp3Subtitle": "320kbps, best compatibility", - "@lossyFormatMp3Subtitle": { - "description": "MP3 format description" - }, - "lossyFormatOpusSubtitle": "128kbps, better quality at smaller size", - "@lossyFormatOpusSubtitle": { - "description": "Opus format description" - }, "qualityNote": "Actual quality depends on track availability from the service", "@qualityNote": { "description": "Note about quality availability" @@ -2518,14 +1769,6 @@ "@downloadUseAlbumArtistForFolders": { "description": "Setting - choose whether artist folders use Album Artist or Track Artist" }, - "downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available", - "@downloadUseAlbumArtistForFoldersAlbumSubtitle": { - "description": "Subtitle when Album Artist is used for folder naming" - }, - "downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only", - "@downloadUseAlbumArtistForFoldersTrackSubtitle": { - "description": "Subtitle when Track Artist is used for folder naming" - }, "downloadUsePrimaryArtistOnly": "Primary artist only for folders", "@downloadUsePrimaryArtistOnly": { "description": "Setting - strip featured artists from folder name" @@ -2538,14 +1781,6 @@ "@downloadUsePrimaryArtistOnlyDisabled": { "description": "Subtitle when primary artist only is disabled" }, - "downloadSaveFormat": "Save Format", - "@downloadSaveFormat": { - "description": "Setting - output file format" - }, - "downloadSelectService": "Select Service", - "@downloadSelectService": { - "description": "Dialog title - choose download service" - }, "downloadSelectQuality": "Select Quality", "@downloadSelectQuality": { "description": "Dialog title - choose audio quality" @@ -2554,66 +1789,6 @@ "@downloadFrom": { "description": "Label - download source" }, - "downloadDefaultQualityLabel": "Default Quality", - "@downloadDefaultQualityLabel": { - "description": "Label - default quality setting" - }, - "downloadBestAvailable": "Best available", - "@downloadBestAvailable": { - "description": "Quality option - highest available" - }, - "folderNone": "None", - "@folderNone": { - "description": "Folder option - no organization" - }, - "folderNoneSubtitle": "Save all files directly to download folder", - "@folderNoneSubtitle": { - "description": "Subtitle for no folder organization" - }, - "folderArtist": "Artist", - "@folderArtist": { - "description": "Folder option - by artist" - }, - "folderArtistSubtitle": "Artist Name/filename", - "@folderArtistSubtitle": { - "description": "Folder structure example" - }, - "folderAlbum": "Album", - "@folderAlbum": { - "description": "Folder option - by album" - }, - "folderAlbumSubtitle": "Album Name/filename", - "@folderAlbumSubtitle": { - "description": "Folder structure example" - }, - "folderArtistAlbum": "Artist/Album", - "@folderArtistAlbum": { - "description": "Folder option - nested" - }, - "folderArtistAlbumSubtitle": "Artist Name/Album Name/filename", - "@folderArtistAlbumSubtitle": { - "description": "Folder structure example" - }, - "serviceTidal": "Tidal", - "@serviceTidal": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceQobuz": "Qobuz", - "@serviceQobuz": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceAmazon": "Amazon", - "@serviceAmazon": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceDeezer": "Deezer", - "@serviceDeezer": { - "description": "Service name - DO NOT TRANSLATE" - }, - "serviceSpotify": "Spotify", - "@serviceSpotify": { - "description": "Service name - DO NOT TRANSLATE" - }, "appearanceAmoledDark": "AMOLED Dark", "@appearanceAmoledDark": { "description": "Theme option - pure black" @@ -2622,18 +1797,6 @@ "@appearanceAmoledDarkSubtitle": { "description": "Subtitle for AMOLED dark" }, - "appearanceChooseAccentColor": "Choose Accent Color", - "@appearanceChooseAccentColor": { - "description": "Color picker dialog title" - }, - "appearanceChooseTheme": "Theme Mode", - "@appearanceChooseTheme": { - "description": "Theme picker dialog title" - }, - "queueTitle": "Download Queue", - "@queueTitle": { - "description": "Queue screen title" - }, "queueClearAll": "Clear All", "@queueClearAll": { "description": "Button - clear all queue items" @@ -2642,22 +1805,6 @@ "@queueClearAllMessage": { "description": "Clear queue confirmation" }, - "queueExportFailed": "Export", - "@queueExportFailed": { - "description": "Button - export failed downloads to TXT" - }, - "queueExportFailedSuccess": "Failed downloads exported to TXT file", - "@queueExportFailedSuccess": { - "description": "Success message after exporting failed downloads" - }, - "queueExportFailedClear": "Clear Failed", - "@queueExportFailedClear": { - "description": "Action to clear failed downloads after export" - }, - "queueExportFailedError": "Failed to export downloads", - "@queueExportFailedError": { - "description": "Error message when export fails" - }, "settingsAutoExportFailed": "Auto-export failed downloads", "@settingsAutoExportFailed": { "description": "Setting toggle for auto-export" @@ -2682,38 +1829,6 @@ "@settingsDownloadNetworkSubtitle": { "description": "Subtitle explaining network preference" }, - "queueEmpty": "No downloads in queue", - "@queueEmpty": { - "description": "Empty queue state title" - }, - "queueEmptySubtitle": "Add tracks from the home screen", - "@queueEmptySubtitle": { - "description": "Empty queue state subtitle" - }, - "queueClearCompleted": "Clear completed", - "@queueClearCompleted": { - "description": "Button - clear finished downloads" - }, - "queueDownloadFailed": "Download Failed", - "@queueDownloadFailed": { - "description": "Error dialog title" - }, - "queueTrackLabel": "Track:", - "@queueTrackLabel": { - "description": "Label in error dialog" - }, - "queueArtistLabel": "Artist:", - "@queueArtistLabel": { - "description": "Label in error dialog" - }, - "queueErrorLabel": "Error:", - "@queueErrorLabel": { - "description": "Label in error dialog" - }, - "queueUnknownError": "Unknown error", - "@queueUnknownError": { - "description": "Fallback error message" - }, "albumFolderArtistAlbum": "Artist / Album", "@albumFolderArtistAlbum": { "description": "Album folder option" @@ -2767,19 +1882,6 @@ } } }, - "downloadedAlbumTracksHeader": "Tracks", - "@downloadedAlbumTracksHeader": { - "description": "Section header for tracks" - }, - "downloadedAlbumDownloadedCount": "{count} downloaded", - "@downloadedAlbumDownloadedCount": { - "description": "Downloaded tracks count badge", - "placeholders": { - "count": { - "type": "int" - } - } - }, "downloadedAlbumSelectedCount": "{count} selected", "@downloadedAlbumSelectedCount": { "description": "Selection count indicator", @@ -2820,10 +1922,6 @@ } } }, - "utilityFunctions": "Utility Functions", - "@utilityFunctions": { - "description": "Extension capability - utility functions" - }, "recentTypeArtist": "Artist", "@recentTypeArtist": { "description": "Recent access item type - artist" @@ -2858,16 +1956,6 @@ } } }, - "errorGeneric": "Error: {message}", - "@errorGeneric": { - "description": "Generic error message format", - "placeholders": { - "message": { - "type": "String", - "description": "Error message" - } - } - }, "discographyDownload": "Download Discography", "@discographyDownload": { "description": "Button - download artist discography" @@ -3034,10 +2122,6 @@ "@libraryTitle": { "description": "Library settings page title" }, - "libraryStatus": "Library Status", - "@libraryStatus": { - "description": "Section header for library status" - }, "libraryScanSettings": "Scan Settings", "@libraryScanSettings": { "description": "Section header for scan settings" @@ -3114,15 +2198,6 @@ "@libraryAboutDescription": { "description": "Description of local library feature" }, - "libraryTracksCount": "{count} tracks", - "@libraryTracksCount": { - "description": "Track count in library", - "placeholders": { - "count": { - "type": "int" - } - } - }, "libraryLastScanned": "Last scanned: {time}", "@libraryLastScanned": { "description": "Last scan time display", @@ -3237,26 +2312,6 @@ "@libraryFilterFormat": { "description": "Filter section - file format" }, - "libraryFilterDate": "Date Added", - "@libraryFilterDate": { - "description": "Filter section - date range" - }, - "libraryFilterDateToday": "Today", - "@libraryFilterDateToday": { - "description": "Filter option - today only" - }, - "libraryFilterDateWeek": "This Week", - "@libraryFilterDateWeek": { - "description": "Filter option - this week" - }, - "libraryFilterDateMonth": "This Month", - "@libraryFilterDateMonth": { - "description": "Filter option - this month" - }, - "libraryFilterDateYear": "This Year", - "@libraryFilterDateYear": { - "description": "Filter option - this year" - }, "libraryFilterSort": "Sort", "@libraryFilterSort": { "description": "Filter section - sort order" @@ -3269,15 +2324,6 @@ "@libraryFilterSortOldest": { "description": "Sort option - oldest first" }, - "libraryFilterActive": "{count} filter(s) active", - "@libraryFilterActive": { - "description": "Badge showing number of active filters", - "placeholders": { - "count": { - "type": "int" - } - } - }, "timeJustNow": "Just now", "@timeJustNow": { "description": "Relative time - less than a minute ago" @@ -3300,106 +2346,6 @@ } } }, - "storageSwitchTitle": "Switch Storage Mode", - "@storageSwitchTitle": { - "description": "Dialog title when switching storage mode" - }, - "storageSwitchToSafTitle": "Switch to SAF Storage?", - "@storageSwitchToSafTitle": { - "description": "Dialog title when switching to SAF" - }, - "storageSwitchToAppTitle": "Switch to App Storage?", - "@storageSwitchToAppTitle": { - "description": "Dialog title when switching to app storage" - }, - "storageSwitchToSafMessage": "Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.", - "@storageSwitchToSafMessage": { - "description": "Explanation when switching to SAF" - }, - "storageSwitchToAppMessage": "Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.", - "@storageSwitchToAppMessage": { - "description": "Explanation when switching to app storage" - }, - "storageSwitchExistingDownloads": "Existing Downloads", - "@storageSwitchExistingDownloads": { - "description": "Section header for existing downloads info" - }, - "storageSwitchExistingDownloadsInfo": "{count} tracks in {mode} storage", - "@storageSwitchExistingDownloadsInfo": { - "description": "Info about existing downloads count", - "placeholders": { - "count": { - "type": "int" - }, - "mode": { - "type": "String" - } - } - }, - "storageSwitchNewDownloads": "New Downloads", - "@storageSwitchNewDownloads": { - "description": "Section header for new downloads info" - }, - "storageSwitchNewDownloadsLocation": "Will be saved to: {location}", - "@storageSwitchNewDownloadsLocation": { - "description": "Shows where new downloads will go", - "placeholders": { - "location": { - "type": "String" - } - } - }, - "storageSwitchContinue": "Continue", - "@storageSwitchContinue": { - "description": "Button to proceed with storage switch" - }, - "storageSwitchSelectFolder": "Select SAF Folder", - "@storageSwitchSelectFolder": { - "description": "Button to select SAF folder" - }, - "storageAppStorage": "App Storage", - "@storageAppStorage": { - "description": "Label for app storage mode" - }, - "storageSafStorage": "SAF Storage", - "@storageSafStorage": { - "description": "Label for SAF storage mode" - }, - "storageModeBadge": "Storage: {mode}", - "@storageModeBadge": { - "description": "Badge showing storage mode for a track", - "placeholders": { - "mode": { - "type": "String" - } - } - }, - "storageStatsTitle": "Storage Statistics", - "@storageStatsTitle": { - "description": "Section title for storage stats" - }, - "storageStatsAppCount": "{count} tracks in App Storage", - "@storageStatsAppCount": { - "description": "Count of tracks in app storage", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "storageStatsSafCount": "{count} tracks in SAF Storage", - "@storageStatsSafCount": { - "description": "Count of tracks in SAF storage", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "storageModeInfo": "Your files are stored in multiple locations", - "@storageModeInfo": { - "description": "Info when user has files in both storage modes" - }, "tutorialWelcomeTitle": "Welcome to SpotiFLAC!", "@tutorialWelcomeTitle": { "description": "Tutorial welcome page title" @@ -3428,18 +2374,6 @@ "@tutorialSearchDesc": { "description": "Tutorial search page description" }, - "tutorialSearchTip1": "Paste a Spotify or Deezer URL directly in the search box", - "@tutorialSearchTip1": { - "description": "Tutorial search tip 1" - }, - "tutorialSearchTip2": "Or type the song name, artist, or album to search", - "@tutorialSearchTip2": { - "description": "Tutorial search tip 2" - }, - "tutorialSearchTip3": "Supports tracks, albums, playlists, and artist pages", - "@tutorialSearchTip3": { - "description": "Tutorial search tip 3" - }, "tutorialDownloadTitle": "Downloading Music", "@tutorialDownloadTitle": { "description": "Tutorial download page title" @@ -3448,18 +2382,6 @@ "@tutorialDownloadDesc": { "description": "Tutorial download page description" }, - "tutorialDownloadTip1": "Tap the download button next to any track to start downloading", - "@tutorialDownloadTip1": { - "description": "Tutorial download tip 1" - }, - "tutorialDownloadTip2": "Choose your preferred quality (FLAC, Hi-Res, or MP3)", - "@tutorialDownloadTip2": { - "description": "Tutorial download tip 2" - }, - "tutorialDownloadTip3": "Download entire albums or playlists with one tap", - "@tutorialDownloadTip3": { - "description": "Tutorial download tip 3" - }, "tutorialLibraryTitle": "Your Library", "@tutorialLibraryTitle": { "description": "Tutorial library page title" @@ -3524,10 +2446,6 @@ "@tutorialReadyMessage": { "description": "Tutorial completion message" }, - "tutorialExample": "EXAMPLE", - "@tutorialExample": { - "description": "Example label in tutorial" - }, "libraryForceFullScan": "Force Full Scan", "@libraryForceFullScan": { "description": "Button to force a complete rescan of library" @@ -3750,14 +2668,10 @@ "@trackSaveLyricsProgress": { "description": "Snackbar while saving lyrics to file" }, - "trackReEnrich": "Re-enrich Metadata", + "trackReEnrich": "Re-enrich", "@trackReEnrich": { "description": "Menu action - re-embed metadata into audio file" }, - "trackReEnrichSubtitle": "Re-embed metadata without re-downloading", - "@trackReEnrichSubtitle": { - "description": "Subtitle for re-enrich metadata action" - }, "trackReEnrichOnlineSubtitle": "Search metadata online and embed into file", "@trackReEnrichOnlineSubtitle": { "description": "Subtitle for re-enrich metadata action for local items" @@ -3868,5 +2782,22 @@ "trackConvertFailed": "Conversion failed", "@trackConvertFailed": { "description": "Snackbar when conversion fails" + }, + "downloadedAlbumDownloadedCount": "{count} downloaded", + "@downloadedAlbumDownloadedCount": { + "description": "Downloaded tracks count badge", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available", + "@downloadUseAlbumArtistForFoldersAlbumSubtitle": { + "description": "Subtitle when Album Artist is used for folder naming" + }, + "downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only", + "@downloadUseAlbumArtistForFoldersTrackSubtitle": { + "description": "Subtitle when Track Artist is used for folder naming" } -} \ No newline at end of file +} diff --git a/lib/main.dart b/lib/main.dart index 1fc79299..6963a9ee 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -6,6 +6,8 @@ import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/app.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; +import 'package:spotiflac_android/providers/library_collections_provider.dart'; +import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/services/notification_service.dart'; import 'package:spotiflac_android/services/share_intent_service.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; @@ -93,6 +95,8 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> { _initializeAppServices(); _initializeExtensions(); ref.read(downloadHistoryProvider); + ref.read(localLibraryProvider); + ref.read(libraryCollectionsProvider); } Future _initializeAppServices() async { diff --git a/lib/models/settings.dart b/lib/models/settings.dart index ec889913..545e7440 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -11,6 +11,7 @@ class AppSettings { final String storageMode; // 'app' or 'saf' final String downloadTreeUri; // SAF persistable tree URI final bool autoFallback; + final bool embedMetadata; // Master switch for metadata/cover/lyrics embedding final bool embedLyrics; final bool maxQualityCover; final bool isFirstLaunch; @@ -39,12 +40,20 @@ class AppSettings { final String lyricsMode; final String tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320', 'opus_256', or 'opus_128' + final int + youtubeOpusBitrate; // YouTube Opus bitrate (supported: 128/256 kbps) + final int + youtubeMp3Bitrate; // YouTube MP3 bitrate (supported: 128/256/320 kbps) final bool useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE final bool autoExportFailedDownloads; // Auto export failed downloads to TXT file final String downloadNetworkMode; // 'any' = WiFi + Mobile, 'wifi_only' = WiFi only + final bool + networkCompatibilityMode; // Try HTTP + allow invalid TLS cert for API requests + final String + songLinkRegion; // SongLink userCountry region code used for platform lookup // Local Library Settings final bool localLibraryEnabled; // Enable local library scanning @@ -68,6 +77,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', @@ -76,6 +89,7 @@ class AppSettings { this.storageMode = 'app', this.downloadTreeUri = '', this.autoFallback = true, + this.embedMetadata = true, this.embedLyrics = true, this.maxQualityCover = true, this.isFirstLaunch = true, @@ -103,9 +117,13 @@ class AppSettings { this.locale = 'system', this.lyricsMode = 'embed', this.tidalHighFormat = 'mp3_320', + this.youtubeOpusBitrate = 256, + this.youtubeMp3Bitrate = 320, this.useAllFilesAccess = false, this.autoExportFailedDownloads = false, this.downloadNetworkMode = 'any', + this.networkCompatibilityMode = false, + this.songLinkRegion = 'US', // Local Library defaults this.localLibraryEnabled = false, this.localLibraryPath = '', @@ -113,11 +131,20 @@ class AppSettings { // Tutorial default this.hasCompletedTutorial = false, // Lyrics providers default order - this.lyricsProviders = const ['lrclib', 'musixmatch', 'netease', 'apple_music', 'qqmusic'], + this.lyricsProviders = const [ + 'lrclib', + 'spotify_api', + 'musixmatch', + 'netease', + 'apple_music', + 'qqmusic', + ], this.lyricsIncludeTranslationNetease = false, this.lyricsIncludeRomanizationNetease = false, - this.lyricsMultiPersonWordByWord = true, + this.lyricsMultiPersonWordByWord = false, this.musixmatchLanguage = '', + // Version upgrade tracking + this.lastSeenVersion = '', }); AppSettings copyWith({ @@ -127,7 +154,8 @@ class AppSettings { String? downloadDirectory, String? storageMode, String? downloadTreeUri, - bool? autoFallback, + bool? autoFallback, + bool? embedMetadata, bool? embedLyrics, bool? maxQualityCover, bool? isFirstLaunch, @@ -156,9 +184,13 @@ class AppSettings { String? locale, String? lyricsMode, String? tidalHighFormat, + int? youtubeOpusBitrate, + int? youtubeMp3Bitrate, bool? useAllFilesAccess, bool? autoExportFailedDownloads, String? downloadNetworkMode, + bool? networkCompatibilityMode, + String? songLinkRegion, // Local Library bool? localLibraryEnabled, String? localLibraryPath, @@ -171,6 +203,8 @@ class AppSettings { bool? lyricsIncludeRomanizationNetease, bool? lyricsMultiPersonWordByWord, String? musixmatchLanguage, + // Version upgrade tracking + String? lastSeenVersion, }) { return AppSettings( defaultService: defaultService ?? this.defaultService, @@ -180,6 +214,7 @@ class AppSettings { storageMode: storageMode ?? this.storageMode, downloadTreeUri: downloadTreeUri ?? this.downloadTreeUri, autoFallback: autoFallback ?? this.autoFallback, + embedMetadata: embedMetadata ?? this.embedMetadata, embedLyrics: embedLyrics ?? this.embedLyrics, maxQualityCover: maxQualityCover ?? this.maxQualityCover, isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch, @@ -215,10 +250,15 @@ class AppSettings { locale: locale ?? this.locale, lyricsMode: lyricsMode ?? this.lyricsMode, tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat, + youtubeOpusBitrate: youtubeOpusBitrate ?? this.youtubeOpusBitrate, + youtubeMp3Bitrate: youtubeMp3Bitrate ?? this.youtubeMp3Bitrate, useAllFilesAccess: useAllFilesAccess ?? this.useAllFilesAccess, autoExportFailedDownloads: autoExportFailedDownloads ?? this.autoExportFailedDownloads, downloadNetworkMode: downloadNetworkMode ?? this.downloadNetworkMode, + networkCompatibilityMode: + networkCompatibilityMode ?? this.networkCompatibilityMode, + songLinkRegion: songLinkRegion ?? this.songLinkRegion, // Local Library localLibraryEnabled: localLibraryEnabled ?? this.localLibraryEnabled, localLibraryPath: localLibraryPath ?? this.localLibraryPath, @@ -229,12 +269,16 @@ class AppSettings { // Lyrics providers lyricsProviders: lyricsProviders ?? this.lyricsProviders, lyricsIncludeTranslationNetease: - lyricsIncludeTranslationNetease ?? this.lyricsIncludeTranslationNetease, + lyricsIncludeTranslationNetease ?? + this.lyricsIncludeTranslationNetease, lyricsIncludeRomanizationNetease: - lyricsIncludeRomanizationNetease ?? this.lyricsIncludeRomanizationNetease, + lyricsIncludeRomanizationNetease ?? + this.lyricsIncludeRomanizationNetease, 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 2002ffd1..933178e3 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -14,6 +14,7 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( storageMode: json['storageMode'] as String? ?? 'app', downloadTreeUri: json['downloadTreeUri'] as String? ?? '', autoFallback: json['autoFallback'] 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, @@ -44,10 +45,14 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( locale: json['locale'] as String? ?? 'system', lyricsMode: json['lyricsMode'] as String? ?? 'embed', tidalHighFormat: json['tidalHighFormat'] as String? ?? 'mp3_320', + youtubeOpusBitrate: (json['youtubeOpusBitrate'] as num?)?.toInt() ?? 256, + youtubeMp3Bitrate: (json['youtubeMp3Bitrate'] as num?)?.toInt() ?? 320, useAllFilesAccess: json['useAllFilesAccess'] as bool? ?? false, autoExportFailedDownloads: json['autoExportFailedDownloads'] as bool? ?? false, downloadNetworkMode: json['downloadNetworkMode'] as String? ?? 'any', + networkCompatibilityMode: json['networkCompatibilityMode'] as bool? ?? false, + songLinkRegion: json['songLinkRegion'] as String? ?? 'US', localLibraryEnabled: json['localLibraryEnabled'] as bool? ?? false, localLibraryPath: json['localLibraryPath'] as String? ?? '', localLibraryShowDuplicates: @@ -57,14 +62,22 @@ 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: json['lyricsIncludeRomanizationNetease'] as bool? ?? false, lyricsMultiPersonWordByWord: - json['lyricsMultiPersonWordByWord'] as bool? ?? true, + json['lyricsMultiPersonWordByWord'] as bool? ?? false, musixmatchLanguage: json['musixmatchLanguage'] as String? ?? '', + lastSeenVersion: json['lastSeenVersion'] as String? ?? '', ); Map _$AppSettingsToJson( @@ -77,6 +90,7 @@ Map _$AppSettingsToJson( 'storageMode': instance.storageMode, 'downloadTreeUri': instance.downloadTreeUri, 'autoFallback': instance.autoFallback, + 'embedMetadata': instance.embedMetadata, 'embedLyrics': instance.embedLyrics, 'maxQualityCover': instance.maxQualityCover, 'isFirstLaunch': instance.isFirstLaunch, @@ -105,9 +119,13 @@ Map _$AppSettingsToJson( 'locale': instance.locale, 'lyricsMode': instance.lyricsMode, 'tidalHighFormat': instance.tidalHighFormat, + 'youtubeOpusBitrate': instance.youtubeOpusBitrate, + 'youtubeMp3Bitrate': instance.youtubeMp3Bitrate, 'useAllFilesAccess': instance.useAllFilesAccess, 'autoExportFailedDownloads': instance.autoExportFailedDownloads, 'downloadNetworkMode': instance.downloadNetworkMode, + 'networkCompatibilityMode': instance.networkCompatibilityMode, + 'songLinkRegion': instance.songLinkRegion, 'localLibraryEnabled': instance.localLibraryEnabled, 'localLibraryPath': instance.localLibraryPath, 'localLibraryShowDuplicates': instance.localLibraryShowDuplicates, @@ -117,4 +135,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 35871a09..4f2059db 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -4,13 +4,13 @@ import 'dart:convert'; import 'dart:io'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path_provider/path_provider.dart'; -import 'package:shared_preferences/shared_preferences.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:spotiflac_android/models/download_item.dart'; import 'package:spotiflac_android/models/settings.dart'; import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; +import 'package:spotiflac_android/services/app_state_database.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/download_request_payload.dart'; import 'package:spotiflac_android/services/ffmpeg_service.dart'; @@ -18,21 +18,16 @@ import 'package:spotiflac_android/services/notification_service.dart'; import 'package:spotiflac_android/services/history_database.dart'; import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/utils/file_access.dart'; +import 'package:spotiflac_android/utils/string_utils.dart'; final _log = AppLogger('DownloadQueue'); final _historyLog = AppLogger('DownloadHistory'); -String? _normalizeOptionalString(String? value) { - if (value == null) return null; - final trimmed = value.trim(); - if (trimmed.isEmpty) return null; - if (trimmed.toLowerCase() == 'null') return null; - return trimmed; -} - final _invalidFolderChars = RegExp(r'[<>:"/\\|?*]'); final _trailingDotsRegex = RegExp(r'\.+$'); final _yearRegex = RegExp(r'^(\d{4})'); +const _defaultOutputFolderName = 'SpotiFLAC'; +const _defaultAndroidMusicSubpath = 'Music/$_defaultOutputFolderName'; class DownloadHistoryItem { final String id; @@ -126,7 +121,7 @@ class DownloadHistoryItem { trackName: json['trackName'] as String, artistName: json['artistName'] as String, albumName: json['albumName'] as String, - albumArtist: _normalizeOptionalString(json['albumArtist'] as String?), + albumArtist: normalizeOptionalString(json['albumArtist'] as String?), coverUrl: json['coverUrl'] as String?, filePath: json['filePath'] as String, storageMode: json['storageMode'] as String?, @@ -232,6 +227,22 @@ class DownloadHistoryState { DownloadHistoryItem? getByIsrc(String isrc) => _byIsrc[isrc]; + DownloadHistoryItem? findByTrackAndArtist( + String trackName, + String artistName, + ) { + final normalizedTrack = trackName.trim().toLowerCase(); + final normalizedArtist = artistName.trim().toLowerCase(); + if (normalizedTrack.isEmpty) return null; + for (final item in items) { + if (item.trackName.trim().toLowerCase() == normalizedTrack && + item.artistName.trim().toLowerCase() == normalizedArtist) { + return item; + } + } + return null; + } + DownloadHistoryState copyWith({List? items}) { return DownloadHistoryState(items: items ?? this.items); } @@ -435,14 +446,14 @@ class DownloadHistoryNotifier extends Notifier { ? item : item.copyWith( genre: - _normalizeOptionalString(item.genre) ?? - _normalizeOptionalString(existing.genre), + normalizeOptionalString(item.genre) ?? + normalizeOptionalString(existing.genre), label: - _normalizeOptionalString(item.label) ?? - _normalizeOptionalString(existing.label), + normalizeOptionalString(item.label) ?? + normalizeOptionalString(existing.label), copyright: - _normalizeOptionalString(item.copyright) ?? - _normalizeOptionalString(existing.copyright), + normalizeOptionalString(item.copyright) ?? + normalizeOptionalString(existing.copyright), ); if (existing != null) { @@ -614,6 +625,7 @@ final downloadHistoryProvider = ); class DownloadQueueState { + static const Object _noChange = Object(); final List items; final DownloadItem? currentDownload; final bool isProcessing; @@ -638,7 +650,7 @@ class DownloadQueueState { DownloadQueueState copyWith({ List? items, - DownloadItem? currentDownload, + Object? currentDownload = _noChange, bool? isProcessing, bool? isPaused, String? outputDir, @@ -649,7 +661,9 @@ class DownloadQueueState { }) { return DownloadQueueState( items: items ?? this.items, - currentDownload: currentDownload ?? this.currentDownload, + currentDownload: identical(currentDownload, _noChange) + ? this.currentDownload + : currentDownload as DownloadItem?, isProcessing: isProcessing ?? this.isProcessing, isPaused: isPaused ?? this.isPaused, outputDir: outputDir ?? this.outputDir, @@ -691,21 +705,29 @@ class _ProgressUpdate { class DownloadQueueNotifier extends Notifier { Timer? _progressTimer; + Timer? _progressStreamBootstrapTimer; + Timer? _queuePersistDebounce; + StreamSubscription>? _progressStreamSub; int _downloadCount = 0; static const _cleanupInterval = 50; - static const _queueStorageKey = 'download_queue'; static const _progressPollingInterval = Duration(milliseconds: 800); + static const _idleProgressPollEveryTicks = 3; static const _queueSchedulingInterval = Duration(milliseconds: 250); + static const _queuePersistDebounceDuration = Duration(milliseconds: 350); static const _bytesUiStep = 104857; // ~0.1 MiB, matches one-decimal MB UI. final NotificationService _notificationService = NotificationService(); - final Future _prefs = SharedPreferences.getInstance(); + final AppStateDatabase _appStateDb = AppStateDatabase.instance; int _totalQueuedAtStart = 0; int _completedInSession = 0; int _failedInSession = 0; + int _queueItemSequence = 0; bool _isLoaded = false; final Set _ensuredDirs = {}; int _progressPollingErrorCount = 0; bool _isProgressPollingInFlight = false; + int _idleProgressPollTick = 0; + bool _hasReceivedProgressStreamEvent = false; + bool _usingProgressStream = false; String? _lastServiceTrackName; String? _lastServiceArtistName; int _lastServicePercent = -1; @@ -717,6 +739,7 @@ class DownloadQueueNotifier extends Notifier { String? _lastNotifArtistName; int _lastNotifPercent = -1; int _lastNotifQueueCount = -1; + final Set _locallyCancelledItemIds = {}; double _normalizeProgressForUi(double value) { final clamped = value.clamp(0.0, 1.0).toDouble(); @@ -776,7 +799,18 @@ class DownloadQueueNotifier extends Notifier { ref.onDispose(() { _progressTimer?.cancel(); + _progressStreamBootstrapTimer?.cancel(); + _progressStreamSub?.cancel(); _progressTimer = null; + _progressStreamBootstrapTimer = null; + _progressStreamSub = null; + if (_queuePersistDebounce?.isActive == true) { + _queuePersistDebounce?.cancel(); + unawaited(_flushQueueToStorage()); + } else { + _queuePersistDebounce?.cancel(); + } + _queuePersistDebounce = null; }); Future.microtask(() async { @@ -792,46 +826,59 @@ class DownloadQueueNotifier extends Notifier { _isLoaded = true; try { - final prefs = await _prefs; - final jsonStr = prefs.getString(_queueStorageKey); - if (jsonStr != null && jsonStr.isNotEmpty) { - final List jsonList = jsonDecode(jsonStr); - final items = jsonList - .map((e) => DownloadItem.fromJson(e as Map)) - .toList(); - - final restoredItems = items.map((item) { - if (item.status == DownloadStatus.downloading) { - return item.copyWith(status: DownloadStatus.queued, progress: 0); - } - return item; - }).toList(); - - final pendingItems = restoredItems - .where((item) => item.status == DownloadStatus.queued) - .toList(); - - if (pendingItems.isNotEmpty) { - state = state.copyWith(items: pendingItems); - _log.i('Restored ${pendingItems.length} pending items from storage'); - - Future.microtask(() => _processQueue()); - } else { - _log.d('No pending items to restore'); - await prefs.remove(_queueStorageKey); - } - } else { + await _appStateDb.migrateQueueFromSharedPreferences(); + final rows = await _appStateDb.getPendingDownloadQueueRows(); + if (rows.isEmpty) { _log.d('No queue found in storage'); + return; } + + final pendingItems = []; + for (final row in rows) { + final itemJson = row['item_json'] as String?; + if (itemJson == null || itemJson.isEmpty) continue; + + try { + final decoded = jsonDecode(itemJson); + if (decoded is! Map) continue; + var item = DownloadItem.fromJson(Map.from(decoded)); + if (item.status == DownloadStatus.downloading) { + item = item.copyWith(status: DownloadStatus.queued, progress: 0); + } + if (item.status == DownloadStatus.queued) { + pendingItems.add(item); + } + } catch (_) { + continue; + } + } + + if (pendingItems.isEmpty) { + _log.d('No pending items to restore'); + await _appStateDb.replacePendingDownloadQueueRows(const []); + return; + } + + final normalizedPendingItems = _normalizeRestoredQueueIds(pendingItems); + state = state.copyWith(items: normalizedPendingItems); + _log.i( + 'Restored ${normalizedPendingItems.length} pending items from storage', + ); + Future.microtask(() => _processQueue()); } catch (e) { _log.e('Failed to load queue from storage: $e'); } } - Future _saveQueueToStorage() async { - try { - final prefs = await _prefs; + void _saveQueueToStorage() { + _queuePersistDebounce?.cancel(); + _queuePersistDebounce = Timer(_queuePersistDebounceDuration, () { + _flushQueueToStorage(); + }); + } + Future _flushQueueToStorage() async { + try { final pendingItems = state.items .where( (item) => @@ -841,11 +888,22 @@ class DownloadQueueNotifier extends Notifier { .toList(); if (pendingItems.isEmpty) { - await prefs.remove(_queueStorageKey); + await _appStateDb.replacePendingDownloadQueueRows(const []); _log.d('Cleared queue storage (no pending items)'); } else { - final jsonList = pendingItems.map((e) => e.toJson()).toList(); - await prefs.setString(_queueStorageKey, jsonEncode(jsonList)); + final nowIso = DateTime.now().toIso8601String(); + final rows = pendingItems + .map( + (item) => { + 'id': item.id, + 'item_json': jsonEncode(item.toJson()), + 'status': item.status.name, + 'created_at': item.createdAt.toIso8601String(), + 'updated_at': nowIso, + }, + ) + .toList(growable: false); + await _appStateDb.replacePendingDownloadQueueRows(rows); _log.d('Saved ${pendingItems.length} pending items to storage'); } } catch (e) { @@ -854,213 +912,105 @@ class DownloadQueueNotifier extends Notifier { } void _startMultiProgressPolling() { + _progressTimer?.cancel(); + _progressStreamBootstrapTimer?.cancel(); + _progressStreamBootstrapTimer = null; + _progressStreamSub?.cancel(); + _progressStreamSub = null; + _hasReceivedProgressStreamEvent = false; + _usingProgressStream = false; + _idleProgressPollTick = 0; + + if (Platform.isAndroid || Platform.isIOS) { + _attachDownloadProgressStream(); + return; + } + + _startMultiProgressPollingTimer(); + } + + void _attachDownloadProgressStream() { + _progressStreamSub = PlatformBridge.downloadProgressStream().listen( + (allProgress) { + _hasReceivedProgressStreamEvent = true; + _usingProgressStream = true; + _progressStreamBootstrapTimer?.cancel(); + _progressStreamBootstrapTimer = null; + if (_isProgressPollingInFlight) return; + _isProgressPollingInFlight = true; + try { + _processAllDownloadProgress(allProgress); + _progressPollingErrorCount = 0; + } catch (e) { + _progressPollingErrorCount++; + if (_progressPollingErrorCount <= 3) { + _log.w('Progress stream processing failed: $e'); + } + } finally { + _isProgressPollingInFlight = false; + } + }, + onError: (Object error, StackTrace stackTrace) { + if (_usingProgressStream) { + _log.w( + 'Download progress stream failed, fallback to polling: $error', + ); + } + _progressStreamSub?.cancel(); + _progressStreamSub = null; + _usingProgressStream = false; + _progressStreamBootstrapTimer?.cancel(); + _progressStreamBootstrapTimer = null; + _startMultiProgressPollingTimer(); + }, + cancelOnError: false, + ); + + _progressStreamBootstrapTimer = Timer(const Duration(seconds: 3), () { + if (_hasReceivedProgressStreamEvent) { + return; + } + _log.w('Download progress stream timeout, fallback to polling'); + _progressStreamSub?.cancel(); + _progressStreamSub = null; + _usingProgressStream = false; + _startMultiProgressPollingTimer(); + }); + } + + void _startMultiProgressPollingTimer() { _progressTimer?.cancel(); _progressTimer = Timer.periodic(_progressPollingInterval, (timer) async { if (_isProgressPollingInFlight) return; _isProgressPollingInFlight = true; try { - final allProgress = await PlatformBridge.getAllDownloadProgress(); - final items = allProgress['items'] as Map? ?? {}; final currentItems = state.items; - final itemsById = {}; - final itemIndexById = {}; - int queuedCount = 0; - int downloadingCount = 0; - DownloadItem? firstDownloading; - for (int i = 0; i < currentItems.length; i++) { - final item = currentItems[i]; - itemsById[item.id] = item; - itemIndexById[item.id] = i; - if (item.status == DownloadStatus.downloading) { - downloadingCount++; - firstDownloading ??= item; - } - if (item.status == DownloadStatus.queued || - item.status == DownloadStatus.downloading) { - queuedCount++; - } - } - final progressUpdates = {}; + final hasQueuedItems = currentItems.any( + (item) => item.status == DownloadStatus.queued, + ); + final hasActiveItems = currentItems.any( + (item) => + item.status == DownloadStatus.downloading || + item.status == DownloadStatus.finalizing, + ); - bool hasFinalizingItem = false; - String? finalizingTrackName; - String? finalizingArtistName; - - for (final entry in items.entries) { - final itemId = entry.key; - final localItem = itemsById[itemId]; - if (localItem == null) { - continue; - } - if (localItem.status == DownloadStatus.skipped) { - PlatformBridge.clearItemProgress(itemId).catchError((_) {}); - continue; - } - if (localItem.status == DownloadStatus.completed || - localItem.status == DownloadStatus.failed) { - continue; - } - final itemProgress = entry.value as Map; - final bytesReceived = itemProgress['bytes_received'] as int? ?? 0; - final bytesTotal = itemProgress['bytes_total'] as int? ?? 0; - final speedMBps = - (itemProgress['speed_mbps'] as num?)?.toDouble() ?? 0.0; - final isDownloading = - itemProgress['is_downloading'] as bool? ?? false; - final status = itemProgress['status'] as String? ?? 'downloading'; - - if (status == 'finalizing' && bytesTotal > 0) { - progressUpdates[itemId] = const _ProgressUpdate( - status: DownloadStatus.finalizing, - progress: 1.0, - ); - hasFinalizingItem = true; - finalizingTrackName = localItem.track.name; - finalizingArtistName = localItem.track.artistName; - continue; + if (!hasActiveItems) { + if (state.isPaused || !hasQueuedItems) { + _idleProgressPollTick = 0; + return; } - final progressFromBackend = - (itemProgress['progress'] as num?)?.toDouble() ?? 0.0; - - if (isDownloading) { - double percentage = 0.0; - if (bytesTotal > 0) { - percentage = bytesReceived / bytesTotal; - } else { - percentage = progressFromBackend; - } - final normalizedProgress = _normalizeProgressForUi(percentage); - final normalizedSpeed = _normalizeSpeedForUi(speedMBps); - final normalizedBytes = _normalizeBytesForUi(bytesReceived); - - progressUpdates[itemId] = _ProgressUpdate( - status: DownloadStatus.downloading, - progress: normalizedProgress, - speedMBps: normalizedSpeed, - bytesReceived: normalizedBytes, - ); - - if (LogBuffer.loggingEnabled) { - final mbReceived = bytesReceived / (1024 * 1024); - final mbTotal = bytesTotal / (1024 * 1024); - if (bytesTotal > 0) { - _log.d( - 'Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (${mbReceived.toStringAsFixed(2)}/${mbTotal.toStringAsFixed(2)} MB) @ ${speedMBps.toStringAsFixed(2)} MB/s', - ); - } else { - _log.d( - 'Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (DASH segments/unknown size) @ ${speedMBps.toStringAsFixed(2)} MB/s', - ); - } - } + _idleProgressPollTick = + (_idleProgressPollTick + 1) % _idleProgressPollEveryTicks; + if (_idleProgressPollTick != 0) { + return; } + } else { + _idleProgressPollTick = 0; } - if (progressUpdates.isNotEmpty) { - var updatedItems = currentItems; - bool changed = false; - - for (final entry in progressUpdates.entries) { - final index = itemIndexById[entry.key]; - if (index == null) continue; - final current = updatedItems[index]; - if (current.status == DownloadStatus.skipped || - current.status == DownloadStatus.completed || - current.status == DownloadStatus.failed) { - continue; - } - final update = entry.value; - final next = current.copyWith( - status: update.status, - progress: update.progress, - speedMBps: update.speedMBps ?? current.speedMBps, - bytesReceived: update.bytesReceived ?? current.bytesReceived, - ); - if (current.status != next.status || - current.progress != next.progress || - current.speedMBps != next.speedMBps || - current.bytesReceived != next.bytesReceived) { - if (!changed) { - updatedItems = List.from(updatedItems); - changed = true; - } - updatedItems[index] = next; - } - } - - if (changed) { - state = state.copyWith(items: updatedItems); - } - } - - if (hasFinalizingItem && finalizingTrackName != null) { - final safeArtistName = finalizingArtistName ?? ''; - if (finalizingTrackName != _lastFinalizingTrackName || - safeArtistName != _lastFinalizingArtistName) { - _notificationService.showDownloadFinalizing( - trackName: finalizingTrackName, - artistName: safeArtistName, - ); - _lastFinalizingTrackName = finalizingTrackName; - _lastFinalizingArtistName = safeArtistName; - } - return; - } - _lastFinalizingTrackName = null; - _lastFinalizingArtistName = null; - - if (items.isNotEmpty) { - final firstEntry = items.entries.first; - final firstProgress = firstEntry.value as Map; - final bytesReceived = firstProgress['bytes_received'] as int? ?? 0; - final bytesTotal = firstProgress['bytes_total'] as int? ?? 0; - - if (downloadingCount > 0 && firstDownloading != null) { - final trackName = downloadingCount == 1 - ? firstDownloading.track.name - : '$downloadingCount downloads'; - final artistName = downloadingCount == 1 - ? firstDownloading.track.artistName - : 'Downloading...'; - - int notifProgress = bytesReceived; - int notifTotal = bytesTotal; - - if (bytesTotal <= 0) { - final progressPercent = - (firstProgress['progress'] as num?)?.toDouble() ?? 0.0; - notifProgress = (progressPercent * 100).toInt(); - notifTotal = 100; - } - - final safeNotifTotal = notifTotal > 0 ? notifTotal : 1; - if (_shouldUpdateProgressNotification( - trackName: trackName, - artistName: artistName, - progress: notifProgress, - total: safeNotifTotal, - queueCount: queuedCount, - )) { - _notificationService.showDownloadProgress( - trackName: trackName, - artistName: artistName, - progress: notifProgress, - total: safeNotifTotal, - ); - } - - if (Platform.isAndroid) { - _maybeUpdateAndroidDownloadService( - trackName: firstDownloading.track.name, - artistName: firstDownloading.track.artistName, - progress: notifProgress, - total: safeNotifTotal, - queueCount: queuedCount, - ); - } - } - } + final allProgress = await PlatformBridge.getAllDownloadProgress(); + _processAllDownloadProgress(allProgress); _progressPollingErrorCount = 0; } catch (e) { _progressPollingErrorCount++; @@ -1073,6 +1023,221 @@ class DownloadQueueNotifier extends Notifier { }); } + void _processAllDownloadProgress(Map allProgress) { + final rawItems = allProgress['items']; + final items = rawItems is Map + ? rawItems.map((key, value) => MapEntry(key.toString(), value)) + : const {}; + final currentItems = state.items; + final itemsById = {}; + final itemIndexById = {}; + int queuedCount = 0; + int downloadingCount = 0; + DownloadItem? firstDownloading; + for (int i = 0; i < currentItems.length; i++) { + final item = currentItems[i]; + itemsById[item.id] = item; + itemIndexById[item.id] = i; + if (item.status == DownloadStatus.downloading) { + downloadingCount++; + firstDownloading ??= item; + } + if (item.status == DownloadStatus.queued || + item.status == DownloadStatus.downloading) { + queuedCount++; + } + } + final progressUpdates = {}; + + bool hasFinalizingItem = false; + String? finalizingTrackName; + String? finalizingArtistName; + + for (final entry in items.entries) { + final itemId = entry.key; + final localItem = itemsById[itemId]; + if (localItem == null) { + continue; + } + if (localItem.status == DownloadStatus.skipped) { + PlatformBridge.clearItemProgress(itemId).catchError((_) {}); + continue; + } + if (localItem.status == DownloadStatus.completed || + localItem.status == DownloadStatus.failed) { + continue; + } + final rawItemProgress = entry.value; + if (rawItemProgress is! Map) { + continue; + } + final itemProgress = Map.from(rawItemProgress); + final bytesReceived = + (itemProgress['bytes_received'] as num?)?.toInt() ?? 0; + final bytesTotal = (itemProgress['bytes_total'] as num?)?.toInt() ?? 0; + final speedMBps = (itemProgress['speed_mbps'] as num?)?.toDouble() ?? 0.0; + final isDownloading = itemProgress['is_downloading'] as bool? ?? false; + final status = itemProgress['status'] as String? ?? 'downloading'; + + if (status == 'finalizing' && bytesTotal > 0) { + progressUpdates[itemId] = const _ProgressUpdate( + status: DownloadStatus.finalizing, + progress: 1.0, + ); + hasFinalizingItem = true; + finalizingTrackName = localItem.track.name; + finalizingArtistName = localItem.track.artistName; + continue; + } + + final progressFromBackend = + (itemProgress['progress'] as num?)?.toDouble() ?? 0.0; + + if (isDownloading) { + double percentage = 0.0; + if (bytesTotal > 0) { + percentage = bytesReceived / bytesTotal; + } else { + percentage = progressFromBackend; + } + final normalizedProgress = _normalizeProgressForUi(percentage); + final normalizedSpeed = _normalizeSpeedForUi(speedMBps); + final normalizedBytes = _normalizeBytesForUi(bytesReceived); + + progressUpdates[itemId] = _ProgressUpdate( + status: DownloadStatus.downloading, + progress: normalizedProgress, + speedMBps: normalizedSpeed, + bytesReceived: normalizedBytes, + ); + + if (LogBuffer.loggingEnabled) { + final mbReceived = bytesReceived / (1024 * 1024); + final mbTotal = bytesTotal / (1024 * 1024); + if (bytesTotal > 0) { + _log.d( + 'Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (${mbReceived.toStringAsFixed(2)}/${mbTotal.toStringAsFixed(2)} MB) @ ${speedMBps.toStringAsFixed(2)} MB/s', + ); + } else { + _log.d( + 'Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (DASH segments/unknown size) @ ${speedMBps.toStringAsFixed(2)} MB/s', + ); + } + } + } + } + + if (progressUpdates.isNotEmpty) { + var updatedItems = currentItems; + bool changed = false; + + for (final entry in progressUpdates.entries) { + final index = itemIndexById[entry.key]; + if (index == null) continue; + final current = updatedItems[index]; + if (current.status == DownloadStatus.skipped || + current.status == DownloadStatus.completed || + current.status == DownloadStatus.failed) { + continue; + } + final update = entry.value; + final next = current.copyWith( + status: update.status, + progress: update.progress, + speedMBps: update.speedMBps ?? current.speedMBps, + bytesReceived: update.bytesReceived ?? current.bytesReceived, + ); + if (current.status != next.status || + current.progress != next.progress || + current.speedMBps != next.speedMBps || + current.bytesReceived != next.bytesReceived) { + if (!changed) { + updatedItems = List.from(updatedItems); + changed = true; + } + updatedItems[index] = next; + } + } + + if (changed) { + state = state.copyWith(items: updatedItems); + } + } + + if (hasFinalizingItem && finalizingTrackName != null) { + final safeArtistName = finalizingArtistName ?? ''; + if (finalizingTrackName != _lastFinalizingTrackName || + safeArtistName != _lastFinalizingArtistName) { + _notificationService.showDownloadFinalizing( + trackName: finalizingTrackName, + artistName: safeArtistName, + ); + _lastFinalizingTrackName = finalizingTrackName; + _lastFinalizingArtistName = safeArtistName; + } + return; + } + _lastFinalizingTrackName = null; + _lastFinalizingArtistName = null; + + if (items.isNotEmpty) { + final firstEntry = items.entries.first; + final rawFirstProgress = firstEntry.value; + if (rawFirstProgress is! Map) { + return; + } + final firstProgress = Map.from(rawFirstProgress); + final bytesReceived = + (firstProgress['bytes_received'] as num?)?.toInt() ?? 0; + final bytesTotal = (firstProgress['bytes_total'] as num?)?.toInt() ?? 0; + + if (downloadingCount > 0 && firstDownloading != null) { + final trackName = downloadingCount == 1 + ? firstDownloading.track.name + : '$downloadingCount downloads'; + final artistName = downloadingCount == 1 + ? firstDownloading.track.artistName + : 'Downloading...'; + + int notifProgress = bytesReceived; + int notifTotal = bytesTotal; + + if (bytesTotal <= 0) { + final progressPercent = + (firstProgress['progress'] as num?)?.toDouble() ?? 0.0; + notifProgress = (progressPercent * 100).toInt(); + notifTotal = 100; + } + + final safeNotifTotal = notifTotal > 0 ? notifTotal : 1; + if (_shouldUpdateProgressNotification( + trackName: trackName, + artistName: artistName, + progress: notifProgress, + total: safeNotifTotal, + queueCount: queuedCount, + )) { + _notificationService.showDownloadProgress( + trackName: trackName, + artistName: artistName, + progress: notifProgress, + total: safeNotifTotal, + ); + } + + if (Platform.isAndroid) { + _maybeUpdateAndroidDownloadService( + trackName: firstDownloading.track.name, + artistName: firstDownloading.track.artistName, + progress: notifProgress, + total: safeNotifTotal, + queueCount: queuedCount, + ); + } + } + } + } + void _maybeUpdateAndroidDownloadService({ required String trackName, required String artistName, @@ -1116,9 +1281,16 @@ class DownloadQueueNotifier extends Notifier { void _stopProgressPolling() { _progressTimer?.cancel(); + _progressStreamBootstrapTimer?.cancel(); + _progressStreamSub?.cancel(); _progressTimer = null; + _progressStreamBootstrapTimer = null; + _progressStreamSub = null; _progressPollingErrorCount = 0; _isProgressPollingInFlight = false; + _idleProgressPollTick = 0; + _hasReceivedProgressStreamEvent = false; + _usingProgressStream = false; _lastServiceTrackName = null; _lastServiceArtistName = null; _lastServicePercent = -1; @@ -1132,41 +1304,50 @@ class DownloadQueueNotifier extends Notifier { _lastNotifQueueCount = -1; } + Directory _defaultDocumentsOutputDir(String documentsPath) { + return Directory('$documentsPath/$_defaultOutputFolderName'); + } + + Directory _defaultAndroidMusicOutputDir(String storageRootPath) { + return Directory('$storageRootPath/$_defaultAndroidMusicSubpath'); + } + + Future _ensureDefaultDocumentsOutputDir() async { + final dir = await getApplicationDocumentsDirectory(); + final musicDir = _defaultDocumentsOutputDir(dir.path); + if (!await musicDir.exists()) { + await musicDir.create(recursive: true); + } + return musicDir; + } + + Future _ensureDefaultAndroidMusicOutputDir() async { + final dir = await getExternalStorageDirectory(); + if (dir == null) return null; + + final musicDir = _defaultAndroidMusicOutputDir( + dir.parent.parent.parent.parent.path, + ); + if (!await musicDir.exists()) { + await musicDir.create(recursive: true); + } + return musicDir; + } + Future _initOutputDir() async { if (state.outputDir.isEmpty) { try { if (Platform.isIOS) { - final dir = await getApplicationDocumentsDirectory(); - final musicDir = Directory('${dir.path}/SpotiFLAC'); - if (!await musicDir.exists()) { - await musicDir.create(recursive: true); - } + final musicDir = await _ensureDefaultDocumentsOutputDir(); state = state.copyWith(outputDir: musicDir.path); } else { - final dir = await getExternalStorageDirectory(); - if (dir != null) { - final musicDir = Directory( - '${dir.parent.parent.parent.parent.path}/Music/SpotiFLAC', - ); - if (!await musicDir.exists()) { - await musicDir.create(recursive: true); - } - state = state.copyWith(outputDir: musicDir.path); - } else { - final docDir = await getApplicationDocumentsDirectory(); - final musicDir = Directory('${docDir.path}/SpotiFLAC'); - if (!await musicDir.exists()) { - await musicDir.create(recursive: true); - } - state = state.copyWith(outputDir: musicDir.path); - } + final musicDir = + await _ensureDefaultAndroidMusicOutputDir() ?? + await _ensureDefaultDocumentsOutputDir(); + state = state.copyWith(outputDir: musicDir.path); } } catch (e) { - final dir = await getApplicationDocumentsDirectory(); - final musicDir = Directory('${dir.path}/SpotiFLAC'); - if (!await musicDir.exists()) { - await musicDir.create(recursive: true); - } + final musicDir = await _ensureDefaultDocumentsOutputDir(); state = state.copyWith(outputDir: musicDir.path); } } @@ -1200,7 +1381,7 @@ class DownloadQueueNotifier extends Notifier { bool filterContributingArtistsInAlbumArtist = false, }) async { String baseDir = state.outputDir; - final normalizedAlbumArtist = _normalizeOptionalString(track.albumArtist); + final normalizedAlbumArtist = normalizeOptionalString(track.albumArtist); var folderArtist = useAlbumArtistForFolders ? normalizedAlbumArtist ?? track.artistName : track.artistName; @@ -1318,7 +1499,7 @@ class DownloadQueueNotifier extends Notifier { String _resolveAlbumArtistForMetadata(Track track, AppSettings settings) { var albumArtist = - _normalizeOptionalString(track.albumArtist) ?? track.artistName; + normalizeOptionalString(track.albumArtist) ?? track.artistName; if (settings.filterContributingArtistsInAlbumArtist) { albumArtist = _extractPrimaryArtist(albumArtist); } @@ -1351,7 +1532,7 @@ class DownloadQueueNotifier extends Notifier { bool usePrimaryArtistOnly = false, bool filterContributingArtistsInAlbumArtist = false, }) async { - final normalizedAlbumArtist = _normalizeOptionalString(track.albumArtist); + final normalizedAlbumArtist = normalizeOptionalString(track.albumArtist); var folderArtist = useAlbumArtistForFolders ? normalizedAlbumArtist ?? track.artistName : track.artistName; @@ -1471,6 +1652,53 @@ class DownloadQueueNotifier extends Notifier { return _isrcRegex.hasMatch(value.toUpperCase()); } + String _newQueueItemId(Track track, {Set? takenIds}) { + final trimmedIsrc = track.isrc?.trim(); + final trimmedTrackId = track.id.trim(); + final base = (trimmedIsrc != null && trimmedIsrc.isNotEmpty) + ? trimmedIsrc + : (trimmedTrackId.isNotEmpty ? trimmedTrackId : 'track'); + + while (true) { + _queueItemSequence++; + final candidate = + '$base-${DateTime.now().microsecondsSinceEpoch}-$_queueItemSequence'; + if (takenIds == null || !takenIds.contains(candidate)) { + return candidate; + } + } + } + + List _normalizeRestoredQueueIds(List items) { + if (items.isEmpty) return items; + + final seen = {}; + var regeneratedCount = 0; + final normalized = []; + + for (final item in items) { + final trimmedId = item.id.trim(); + final shouldRegenerate = trimmedId.isEmpty || seen.contains(trimmedId); + if (shouldRegenerate) { + final newId = _newQueueItemId(item.track, takenIds: seen); + seen.add(newId); + normalized.add(item.copyWith(id: newId)); + regeneratedCount++; + } else { + seen.add(trimmedId); + normalized.add(item); + } + } + + if (regeneratedCount > 0) { + _log.w( + 'Regenerated $regeneratedCount duplicate/empty queue item IDs during restore', + ); + } + + return normalized; + } + void updateSettings(AppSettings settings) { final concurrentDownloads = settings.concurrentDownloads.clamp(1, 5); state = state.copyWith( @@ -1488,8 +1716,8 @@ class DownloadQueueNotifier extends Notifier { final settings = ref.read(settingsProvider); updateSettings(settings); - final id = - '${track.isrc ?? track.id}-${DateTime.now().millisecondsSinceEpoch}'; + final takenIds = state.items.map((item) => item.id).toSet(); + final id = _newQueueItemId(track, takenIds: takenIds); final item = DownloadItem( id: id, track: track, @@ -1516,9 +1744,10 @@ class DownloadQueueNotifier extends Notifier { final settings = ref.read(settingsProvider); updateSettings(settings); + final takenIds = state.items.map((item) => item.id).toSet(); final newItems = tracks.map((track) { - final id = - '${track.isrc ?? track.id}-${DateTime.now().millisecondsSinceEpoch}'; + final id = _newQueueItemId(track, takenIds: takenIds); + takenIds.add(id); return DownloadItem( id: id, track: track, @@ -1597,12 +1826,30 @@ class DownloadQueueNotifier extends Notifier { ); } - void cancelItem(String id) { - updateItemStatus(id, DownloadStatus.skipped); + DownloadItem? _findItemById(String id) { + for (final item in state.items) { + if (item.id == id) return item; + } + return null; + } + + bool _isLocallyCancelled(String id, {DownloadItem? item}) { + if (_locallyCancelledItemIds.contains(id)) return true; + final resolved = item ?? _findItemById(id); + return resolved?.status == DownloadStatus.skipped; + } + + void _requestNativeCancel(String id) { PlatformBridge.cancelDownload(id).catchError((_) {}); PlatformBridge.clearItemProgress(id).catchError((_) {}); } + void cancelItem(String id) { + _locallyCancelledItemIds.add(id); + updateItemStatus(id, DownloadStatus.skipped); + _requestNativeCancel(id); + } + void clearCompleted() { final items = state.items .where( @@ -1618,8 +1865,30 @@ class DownloadQueueNotifier extends Notifier { } void clearAll() { - state = state.copyWith(items: [], isPaused: false); + final wasProcessing = state.isProcessing; + final activeIds = state.items + .where( + (item) => + item.status == DownloadStatus.queued || + item.status == DownloadStatus.downloading || + item.status == DownloadStatus.finalizing, + ) + .map((item) => item.id) + .toList(growable: false); + + if (activeIds.isNotEmpty) { + _locallyCancelledItemIds.addAll(activeIds); + for (final id in activeIds) { + _requestNativeCancel(id); + } + } + + state = state.copyWith(items: [], isPaused: false, currentDownload: null); + _notificationService.cancelDownloadNotification(); _saveQueueToStorage(); + if (!wasProcessing) { + _locallyCancelledItemIds.clear(); + } } void pauseQueue() { @@ -1662,6 +1931,7 @@ class DownloadQueueNotifier extends Notifier { } _log.i('Retrying item: ${item.track.name} (id: $id)'); + _locallyCancelledItemIds.remove(id); final items = state.items.map((i) { if (i.id == id) { @@ -1685,6 +1955,7 @@ class DownloadQueueNotifier extends Notifier { } void removeItem(String id) { + _locallyCancelledItemIds.remove(id); final items = state.items.where((item) => item.id != id).toList(); state = state.copyWith(items: items); _saveQueueToStorage(); @@ -1857,10 +2128,10 @@ class DownloadQueueNotifier extends Notifier { ) { final backendTrackNum = _parsePositiveInt(backendResult['track_number']); final backendDiscNum = _parsePositiveInt(backendResult['disc_number']); - final backendYear = _normalizeOptionalString( + final backendYear = normalizeOptionalString( backendResult['release_date'] as String?, ); - final backendAlbum = _normalizeOptionalString( + final backendAlbum = normalizeOptionalString( backendResult['album'] as String?, ); @@ -1877,6 +2148,8 @@ class DownloadQueueNotifier extends Notifier { artistName: baseTrack.artistName, albumName: backendAlbum ?? baseTrack.albumName, albumArtist: resolvedAlbumArtist, + artistId: baseTrack.artistId, + albumId: baseTrack.albumId, coverUrl: baseTrack.coverUrl, duration: baseTrack.duration, isrc: baseTrack.isrc, @@ -1896,8 +2169,13 @@ class DownloadQueueNotifier extends Notifier { String? genre, String? label, String? copyright, + bool writeExternalLrc = true, }) async { final settings = ref.read(settingsProvider); + if (!settings.embedMetadata) { + _log.d('Metadata embedding disabled, skipping FLAC metadata/cover embed'); + return; + } String? coverPath; var coverUrl = track.coverUrl; @@ -1977,26 +2255,68 @@ class DownloadQueueNotifier extends Notifier { _log.d('Metadata map content: $metadata'); - try { - final durationMs = track.duration * 1000; + final lyricsMode = settings.lyricsMode; + final shouldEmbedLyrics = + settings.embedLyrics && + (lyricsMode == 'embed' || lyricsMode == 'both'); + final shouldSaveExternalLyrics = + settings.embedLyrics && + (lyricsMode == 'external' || lyricsMode == 'both'); + final shouldFetchLyrics = shouldEmbedLyrics || shouldSaveExternalLyrics; + String? lrcContent; - final lrcContent = await PlatformBridge.getLyricsLRC( - track.id, - track.name, - track.artistName, - filePath: '', - durationMs: durationMs, - ); + if (shouldFetchLyrics) { + try { + final durationMs = track.duration * 1000; - if (lrcContent.isNotEmpty && lrcContent != '[instrumental:true]') { + final fetchedLrc = await PlatformBridge.getLyricsLRC( + track.id, + track.name, + track.artistName, + filePath: '', + durationMs: durationMs, + ); + + if (fetchedLrc.isNotEmpty && fetchedLrc != '[instrumental:true]') { + lrcContent = fetchedLrc; + _log.d('Lyrics fetched for FLAC (${fetchedLrc.length} chars)'); + } else if (fetchedLrc == '[instrumental:true]') { + _log.d('Track is instrumental, skipping lyrics handling'); + } else { + _log.d('No lyrics returned for FLAC download'); + } + } catch (e) { + _log.w('Failed to fetch lyrics for FLAC: $e'); + } + } + + if (shouldEmbedLyrics) { + if (lrcContent != null) { metadata['LYRICS'] = lrcContent; metadata['UNSYNCEDLYRICS'] = lrcContent; - _log.d('Lyrics fetched for embedding (${lrcContent.length} chars)'); - } else if (lrcContent == '[instrumental:true]') { - _log.d('Track is instrumental, skipping lyrics embedding'); + _log.d('Lyrics added to FLAC metadata'); + } else { + _log.d('No lyrics available for FLAC embedding'); + } + } else { + metadata['LYRICS'] = ''; + metadata['UNSYNCEDLYRICS'] = ''; + _log.d( + 'Lyrics embedding disabled by settings, skipping lyric embedding', + ); + } + + if (writeExternalLrc && shouldSaveExternalLyrics && lrcContent != null) { + try { + final replacedPath = flacPath.replaceAll(RegExp(r'\.[^.]+$'), '.lrc'); + final lrcPath = replacedPath == flacPath + ? '$flacPath.lrc' + : replacedPath; + await File(lrcPath).writeAsString(lrcContent); + _log.d('External LRC file saved: $lrcPath'); + } catch (e) { + _log.w('Failed to save external LRC file for FLAC: $e'); } - } catch (e) { - _log.w('Failed to fetch lyrics for embedding: $e'); } _log.d('Generating tags for FLAC: $metadata'); @@ -2038,6 +2358,10 @@ class DownloadQueueNotifier extends Notifier { String? copyright, }) async { final settings = ref.read(settingsProvider); + if (!settings.embedMetadata) { + _log.d('Metadata embedding disabled, skipping MP3 metadata/cover embed'); + return; + } String? coverPath; var coverUrl = track.coverUrl; @@ -2202,6 +2526,10 @@ class DownloadQueueNotifier extends Notifier { String? copyright, }) async { final settings = ref.read(settingsProvider); + if (!settings.embedMetadata) { + _log.d('Metadata embedding disabled, skipping Opus metadata/cover embed'); + return; + } String? coverPath; var coverUrl = track.coverUrl; @@ -2483,11 +2811,7 @@ class DownloadQueueNotifier extends Notifier { 'iOS: iCloud Drive path detected, falling back to app Documents folder', ); _log.w('Go backend cannot write to iCloud Drive due to iOS sandboxing'); - final dir = await getApplicationDocumentsDirectory(); - final musicDir = Directory('${dir.path}/SpotiFLAC'); - if (!await musicDir.exists()) { - await musicDir.create(recursive: true); - } + final musicDir = await _ensureDefaultDocumentsOutputDir(); state = state.copyWith(outputDir: musicDir.path); ref.read(settingsProvider.notifier).setDownloadDirectory(musicDir.path); } else if (!isValidIosWritablePath(state.outputDir)) { @@ -2505,11 +2829,7 @@ class DownloadQueueNotifier extends Notifier { if (!isSafMode && state.outputDir.isEmpty) { _log.d('Using fallback directory...'); - final dir = await getApplicationDocumentsDirectory(); - final musicDir = Directory('${dir.path}/SpotiFLAC'); - if (!await musicDir.exists()) { - await musicDir.create(recursive: true); - } + final musicDir = await _ensureDefaultDocumentsOutputDir(); state = state.copyWith(outputDir: musicDir.path); } @@ -2670,17 +2990,16 @@ class DownloadQueueNotifier extends Notifier { } _stopProgressPolling(); + final remainingIds = state.items.map((item) => item.id).toSet(); + _locallyCancelledItemIds.removeWhere((id) => !remainingIds.contains(id)); } Future _downloadSingleItem(DownloadItem item) async { _log.d('Processing: ${item.track.name} by ${item.track.artistName}'); _log.d('Cover URL: ${item.track.coverUrl}'); - final currentItem = state.items.firstWhere( - (i) => i.id == item.id, - orElse: () => item, - ); - if (currentItem.status == DownloadStatus.skipped) { + final currentItem = _findItemById(item.id) ?? item; + if (_isLocallyCancelled(item.id, item: currentItem)) { _log.i('Download was cancelled before start, skipping'); return; } @@ -2691,6 +3010,7 @@ class DownloadQueueNotifier extends Notifier { try { final settings = ref.read(settingsProvider); + final metadataEmbeddingEnabled = settings.embedMetadata; Track trackToDownload = item.track; final needsEnrichment = @@ -2733,6 +3053,11 @@ class DownloadQueueNotifier extends Notifier { (data['album_name'] as String?) ?? trackToDownload.albumName, albumArtist: data['album_artist'] as String?, + artistId: + (data['artist_id'] ?? data['artistId'])?.toString() ?? + trackToDownload.artistId, + albumId: + data['album_id']?.toString() ?? trackToDownload.albumId, coverUrl: data['images'] as String?, duration: ((data['duration_ms'] as int?) ?? @@ -2771,7 +3096,29 @@ class DownloadQueueNotifier extends Notifier { settings, ); - final quality = item.qualityOverride ?? state.audioQuality; + var quality = item.qualityOverride ?? state.audioQuality; + if (item.service.toLowerCase() == 'youtube') { + final normalized = quality.toLowerCase(); + final isYoutubeQuality = + normalized.startsWith('mp3_') || normalized.startsWith('opus_'); + if (!isYoutubeQuality) { + final mp3Bitrate = (() { + const supported = [128, 256, 320]; + var nearest = supported.first; + var nearestDistance = (settings.youtubeMp3Bitrate - nearest).abs(); + for (final option in supported.skip(1)) { + final distance = (settings.youtubeMp3Bitrate - option).abs(); + if (distance < nearestDistance || + (distance == nearestDistance && option > nearest)) { + nearest = option; + nearestDistance = distance; + } + } + return nearest; + })(); + quality = 'mp3_$mp3Bitrate'; + } + } final isSafMode = _isSafMode(settings); final relativeOutputDir = isSafMode ? await _buildRelativeOutputDir( @@ -2886,10 +3233,10 @@ class DownloadQueueNotifier extends Notifier { } // Enrich track metadata from Deezer response (release_date, isrc, etc.) - final deezerReleaseDate = _normalizeOptionalString( + final deezerReleaseDate = normalizeOptionalString( trackData['release_date'] as String?, ); - final deezerIsrc = _normalizeOptionalString( + final deezerIsrc = normalizeOptionalString( trackData['isrc'] as String?, ); final deezerTrackNum = trackData['track_number'] as int?; @@ -2917,6 +3264,8 @@ class DownloadQueueNotifier extends Notifier { artistName: trackToDownload.artistName, albumName: trackToDownload.albumName, albumArtist: trackToDownload.albumArtist, + artistId: trackToDownload.artistId, + albumId: trackToDownload.albumId, coverUrl: trackToDownload.coverUrl, duration: trackToDownload.duration, isrc: (deezerIsrc != null && _isValidISRC(deezerIsrc)) @@ -2990,8 +3339,7 @@ class DownloadQueueNotifier extends Notifier { final outputExt = useSaf ? safOutputExt : ''; final isYouTube = item.service == 'youtube'; final shouldUseExtensions = !isYouTube && useExtensions; - final shouldUseFallback = - !isYouTube && !shouldUseExtensions && state.autoFallback; + final shouldUseFallback = !isYouTube && state.autoFallback; if (isYouTube) { _log.d('Using YouTube/Cobalt provider for download'); @@ -3028,13 +3376,16 @@ class DownloadQueueNotifier extends Notifier { artistName: trackToDownload.artistName, albumName: trackToDownload.albumName, albumArtist: resolvedAlbumArtist, - coverUrl: trackToDownload.coverUrl ?? '', + coverUrl: metadataEmbeddingEnabled + ? (trackToDownload.coverUrl ?? '') + : '', outputDir: outputDir, filenameFormat: state.filenameFormat, quality: quality, - // Keep prior behavior: non-YouTube paths were implicitly true. - embedLyrics: isYouTube ? settings.embedLyrics : true, - embedMaxQualityCover: settings.maxQualityCover, + embedMetadata: metadataEmbeddingEnabled, + embedLyrics: metadataEmbeddingEnabled && settings.embedLyrics, + embedMaxQualityCover: + metadataEmbeddingEnabled && settings.maxQualityCover, trackNumber: normalizedTrackNumber, discNumber: normalizedDiscNumber, releaseDate: trackToDownload.releaseDate ?? '', @@ -3051,6 +3402,7 @@ class DownloadQueueNotifier extends Notifier { safRelativeDir: relativeDir, safFileName: fileName, safOutputExt: outputExt, + songLinkRegion: settings.songLinkRegion, ); return PlatformBridge.downloadByStrategy( @@ -3060,6 +3412,11 @@ class DownloadQueueNotifier extends Notifier { ); } + if (_isLocallyCancelled(item.id)) { + _log.i('Download was cancelled before native download start, skipping'); + return; + } + result = await runDownload( useSaf: effectiveSafMode, outputDir: effectiveOutputDir, @@ -3068,6 +3425,10 @@ class DownloadQueueNotifier extends Notifier { if (effectiveSafMode && result['success'] != true && _isSafWriteFailure(result)) { + if (_isLocallyCancelled(item.id)) { + _log.i('Download was cancelled before SAF fallback, skipping'); + return; + } _log.w('SAF write failed, retrying with app-private storage'); appOutputDir ??= await _buildOutputDir( trackToDownload, @@ -3093,11 +3454,11 @@ class DownloadQueueNotifier extends Notifier { _log.d('Result: $result'); - final currentItem = state.items.firstWhere( - (i) => i.id == item.id, - orElse: () => item, - ); - if (currentItem.status == DownloadStatus.skipped) { + final itemAfterResult = _findItemById(item.id); + final cancelledAfterResult = + itemAfterResult == null || + _isLocallyCancelled(item.id, item: itemAfterResult); + if (cancelledAfterResult) { _log.i('Download was cancelled, skipping result processing'); final filePath = result['file_path'] as String?; if (filePath != null && result['success'] == true) { @@ -3428,6 +3789,7 @@ class DownloadQueueNotifier extends Notifier { genre: backendGenre ?? genre, label: backendLabel ?? label, copyright: backendCopyright, + writeExternalLrc: false, ); final newFileName = '${safBaseName ?? 'track'}.flac'; @@ -3619,7 +3981,8 @@ class DownloadQueueNotifier extends Notifier { } } } - } else if (isContentUriPath && + } else if (metadataEmbeddingEnabled && + isContentUriPath && effectiveSafMode && isFlacFile && !wasExisting) { @@ -3651,6 +4014,7 @@ class DownloadQueueNotifier extends Notifier { genre: backendGenre ?? genre, label: backendLabel ?? label, copyright: backendCopyright, + writeExternalLrc: false, ); final newFileName = '${safBaseName ?? 'track'}.flac'; @@ -3680,7 +4044,8 @@ class DownloadQueueNotifier extends Notifier { } catch (_) {} } } - } else if (!isContentUriPath && + } else if (metadataEmbeddingEnabled && + !isContentUriPath && !effectiveSafMode && isFlacFile && !wasExisting && @@ -3719,7 +4084,10 @@ class DownloadQueueNotifier extends Notifier { } // YouTube downloads: embed metadata to raw Opus/MP3 files from Cobalt - if (!wasExisting && item.service == 'youtube' && filePath != null) { + if (metadataEmbeddingEnabled && + !wasExisting && + item.service == 'youtube' && + filePath != null) { final isOpusFile = filePath.endsWith('.opus'); final isMp3File = filePath.endsWith('.mp3'); @@ -3821,11 +4189,9 @@ class DownloadQueueNotifier extends Notifier { } } - final itemAfterDownload = state.items.firstWhere( - (i) => i.id == item.id, - orElse: () => item, - ); - if (itemAfterDownload.status == DownloadStatus.skipped) { + final itemAfterDownload = _findItemById(item.id); + if (itemAfterDownload == null || + _isLocallyCancelled(item.id, item: itemAfterDownload)) { _log.i('Download was cancelled during finalization, cleaning up'); if (filePath != null) { await deleteFile(filePath); @@ -3884,6 +4250,7 @@ class DownloadQueueNotifier extends Notifier { final lyricsMode = settings.lyricsMode; final shouldSaveExternalLrc = + metadataEmbeddingEnabled && settings.embedLyrics && (lyricsMode == 'external' || lyricsMode == 'both'); if (shouldSaveExternalLrc && @@ -3968,17 +4335,17 @@ class DownloadQueueNotifier extends Notifier { final backendLabel = result['label'] as String?; final backendCopyright = result['copyright'] as String?; final effectiveGenre = - _normalizeOptionalString(backendGenre) ?? - _normalizeOptionalString(genre) ?? - _normalizeOptionalString(existingInHistory?.genre); + normalizeOptionalString(backendGenre) ?? + normalizeOptionalString(genre) ?? + normalizeOptionalString(existingInHistory?.genre); final effectiveLabel = - _normalizeOptionalString(backendLabel) ?? - _normalizeOptionalString(label) ?? - _normalizeOptionalString(existingInHistory?.label); + normalizeOptionalString(backendLabel) ?? + normalizeOptionalString(label) ?? + normalizeOptionalString(existingInHistory?.label); final effectiveCopyright = - _normalizeOptionalString(backendCopyright) ?? - _normalizeOptionalString(copyright) ?? - _normalizeOptionalString(existingInHistory?.copyright); + normalizeOptionalString(backendCopyright) ?? + normalizeOptionalString(copyright) ?? + normalizeOptionalString(existingInHistory?.copyright); _log.d('Saving to history - coverUrl: ${trackToDownload.coverUrl}'); @@ -4046,11 +4413,9 @@ class DownloadQueueNotifier extends Notifier { removeItem(item.id); } } else { - final itemAfterFailure = state.items.firstWhere( - (i) => i.id == item.id, - orElse: () => item, - ); - if (itemAfterFailure.status == DownloadStatus.skipped) { + final itemAfterFailure = _findItemById(item.id); + if (itemAfterFailure == null || + _isLocallyCancelled(item.id, item: itemAfterFailure)) { _log.i('Download was cancelled, skipping error handling'); return; } @@ -4111,11 +4476,9 @@ class DownloadQueueNotifier extends Notifier { } } } catch (e, stackTrace) { - final itemAfterError = state.items.firstWhere( - (i) => i.id == item.id, - orElse: () => item, - ); - if (itemAfterError.status == DownloadStatus.skipped) { + final itemAfterError = _findItemById(item.id); + if (itemAfterError == null || + _isLocallyCancelled(item.id, item: itemAfterError)) { _log.i('Download was cancelled, skipping error handling'); return; } diff --git a/lib/providers/extension_provider.dart b/lib/providers/extension_provider.dart index 31722a7b..1b55f744 100644 --- a/lib/providers/extension_provider.dart +++ b/lib/providers/extension_provider.dart @@ -1,5 +1,9 @@ +import 'dart:async'; import 'dart:convert'; +import 'dart:io'; +import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/logger.dart'; @@ -9,6 +13,7 @@ final _log = AppLogger('ExtensionProvider'); const _metadataProviderPriorityKey = 'metadata_provider_priority'; const _providerPriorityKey = 'provider_priority'; +const _spotifyWebExtensionId = 'spotify-web'; class Extension { final String id; @@ -27,12 +32,14 @@ class Extension { final bool hasMetadataProvider; final bool hasDownloadProvider; final bool hasLyricsProvider; - final bool skipMetadataEnrichment; // If true, use metadata from extension instead of enriching + final bool + skipMetadataEnrichment; // If true, use metadata from extension instead of enriching final SearchBehavior? searchBehavior; final URLHandler? urlHandler; final TrackMatching? trackMatching; final PostProcessing? postProcessing; - final Map capabilities; // Extension capabilities (homeFeed, browseCategories, etc.) + final Map + capabilities; // Extension capabilities (homeFeed, browseCategories, etc.) const Extension({ required this.id, @@ -63,7 +70,8 @@ class Extension { return Extension( id: json['id'] as String? ?? '', name: json['name'] as String? ?? '', - displayName: json['display_name'] as String? ?? json['name'] as String? ?? '', + displayName: + json['display_name'] as String? ?? json['name'] as String? ?? '', version: json['version'] as String? ?? '0.0.0', author: json['author'] as String? ?? 'Unknown', description: json['description'] as String? ?? '', @@ -71,28 +79,40 @@ class Extension { status: json['status'] as String? ?? 'loaded', errorMessage: json['error_message'] as String?, iconPath: json['icon_path'] as String?, - permissions: (json['permissions'] as List?)?.cast() ?? [], - settings: (json['settings'] as List?) - ?.map((s) => ExtensionSetting.fromJson(s as Map)) - .toList() ?? [], - qualityOptions: (json['quality_options'] as List?) - ?.map((q) => QualityOption.fromJson(q as Map)) - .toList() ?? [], + permissions: + (json['permissions'] as List?)?.cast() ?? [], + settings: + (json['settings'] as List?) + ?.map((s) => ExtensionSetting.fromJson(s as Map)) + .toList() ?? + [], + qualityOptions: + (json['quality_options'] as List?) + ?.map((q) => QualityOption.fromJson(q as Map)) + .toList() ?? + [], hasMetadataProvider: json['has_metadata_provider'] as bool? ?? false, hasDownloadProvider: json['has_download_provider'] as bool? ?? false, hasLyricsProvider: json['has_lyrics_provider'] as bool? ?? false, - skipMetadataEnrichment: json['skip_metadata_enrichment'] as bool? ?? false, - searchBehavior: json['search_behavior'] != null - ? SearchBehavior.fromJson(json['search_behavior'] as Map) + skipMetadataEnrichment: + json['skip_metadata_enrichment'] as bool? ?? false, + searchBehavior: json['search_behavior'] != null + ? SearchBehavior.fromJson( + json['search_behavior'] as Map, + ) : null, urlHandler: json['url_handler'] != null ? URLHandler.fromJson(json['url_handler'] as Map) : null, trackMatching: json['track_matching'] != null - ? TrackMatching.fromJson(json['track_matching'] as Map) + ? TrackMatching.fromJson( + json['track_matching'] as Map, + ) : null, postProcessing: json['post_processing'] != null - ? PostProcessing.fromJson(json['post_processing'] as Map) + ? PostProcessing.fromJson( + json['post_processing'] as Map, + ) : null, capabilities: (json['capabilities'] as Map?) ?? const {}, ); @@ -139,7 +159,8 @@ class Extension { hasMetadataProvider: hasMetadataProvider ?? this.hasMetadataProvider, hasDownloadProvider: hasDownloadProvider ?? this.hasDownloadProvider, hasLyricsProvider: hasLyricsProvider ?? this.hasLyricsProvider, - skipMetadataEnrichment: skipMetadataEnrichment ?? this.skipMetadataEnrichment, + skipMetadataEnrichment: + skipMetadataEnrichment ?? this.skipMetadataEnrichment, searchBehavior: searchBehavior ?? this.searchBehavior, urlHandler: urlHandler ?? this.urlHandler, trackMatching: trackMatching ?? this.trackMatching, @@ -161,11 +182,7 @@ class SearchFilter { final String? label; final String? icon; - const SearchFilter({ - required this.id, - this.label, - this.icon, - }); + const SearchFilter({required this.id, this.label, this.icon}); factory SearchFilter.fromJson(Map json) { return SearchFilter( @@ -181,10 +198,12 @@ class SearchBehavior { final String? placeholder; final bool primary; final String? icon; - final String? thumbnailRatio; // "square" (1:1), "wide" (16:9), "portrait" (2:3) + final String? + thumbnailRatio; // "square" (1:1), "wide" (16:9), "portrait" (2:3) final int? thumbnailWidth; final int? thumbnailHeight; - final List filters; // Available search filters (e.g., track, album, artist, playlist) + final List + filters; // Available search filters (e.g., track, album, artist, playlist) const SearchBehavior({ required this.enabled, @@ -206,9 +225,11 @@ class SearchBehavior { thumbnailRatio: json['thumbnailRatio'] as String?, thumbnailWidth: json['thumbnailWidth'] as int?, thumbnailHeight: json['thumbnailHeight'] as int?, - filters: (json['filters'] as List?) - ?.map((f) => SearchFilter.fromJson(f as Map)) - .toList() ?? [], + filters: + (json['filters'] as List?) + ?.map((f) => SearchFilter.fromJson(f as Map)) + .toList() ?? + [], ); } @@ -216,7 +237,7 @@ class SearchBehavior { if (thumbnailWidth != null && thumbnailHeight != null) { return (thumbnailWidth!.toDouble(), thumbnailHeight!.toDouble()); } - + switch (thumbnailRatio) { case 'wide': // 16:9 - YouTube style return (defaultSize * 16 / 9, defaultSize); @@ -253,17 +274,18 @@ class PostProcessing { final bool enabled; final List hooks; - const PostProcessing({ - required this.enabled, - this.hooks = const [], - }); + const PostProcessing({required this.enabled, this.hooks = const []}); factory PostProcessing.fromJson(Map json) { return PostProcessing( enabled: json['enabled'] as bool? ?? false, - hooks: (json['hooks'] as List?) - ?.map((h) => PostProcessingHook.fromJson(h as Map)) - .toList() ?? [], + hooks: + (json['hooks'] as List?) + ?.map( + (h) => PostProcessingHook.fromJson(h as Map), + ) + .toList() ?? + [], ); } } @@ -273,10 +295,7 @@ class URLHandler { final bool enabled; final List patterns; - const URLHandler({ - required this.enabled, - this.patterns = const [], - }); + const URLHandler({required this.enabled, this.patterns = const []}); factory URLHandler.fromJson(Map json) { return URLHandler( @@ -319,7 +338,8 @@ class PostProcessingHook { name: json['name'] as String? ?? '', description: json['description'] as String?, defaultEnabled: json['defaultEnabled'] as bool? ?? false, - supportedFormats: (json['supportedFormats'] as List?)?.cast() ?? [], + supportedFormats: + (json['supportedFormats'] as List?)?.cast() ?? [], ); } } @@ -342,9 +362,14 @@ class QualityOption { id: json['id'] as String? ?? '', label: json['label'] as String? ?? '', description: json['description'] as String?, - settings: (json['settings'] as List?) - ?.map((s) => QualitySpecificSetting.fromJson(s as Map)) - .toList() ?? [], + settings: + (json['settings'] as List?) + ?.map( + (s) => + QualitySpecificSetting.fromJson(s as Map), + ) + .toList() ?? + [], ); } } @@ -447,7 +472,8 @@ class ExtensionState { return ExtensionState( extensions: extensions ?? this.extensions, providerPriority: providerPriority ?? this.providerPriority, - metadataProviderPriority: metadataProviderPriority ?? this.metadataProviderPriority, + metadataProviderPriority: + metadataProviderPriority ?? this.metadataProviderPriority, isLoading: isLoading ?? this.isLoading, error: error, isInitialized: isInitialized ?? this.isInitialized, @@ -455,18 +481,44 @@ class ExtensionState { } } - class ExtensionNotifier extends Notifier { + AppLifecycleListener? _appLifecycleListener; + bool _cleanupInFlight = false; + @override ExtensionState build() { + _appLifecycleListener ??= AppLifecycleListener( + onDetach: _scheduleLifecycleCleanup, + ); + ref.onDispose(() { + _appLifecycleListener?.dispose(); + _appLifecycleListener = null; + }); return const ExtensionState(); } + void _scheduleLifecycleCleanup() { + if (_cleanupInFlight) return; + _cleanupInFlight = true; + unawaited(_cleanupExtensions(reason: 'lifecycle detach')); + } + + Future _cleanupExtensions({required String reason}) async { + try { + await PlatformBridge.cleanupExtensions(); + _log.d('Extensions cleaned up ($reason)'); + } catch (e) { + _log.w('Extension cleanup failed ($reason): $e'); + } finally { + _cleanupInFlight = false; + } + } + Future initialize(String extensionsDir, String dataDir) async { if (state.isInitialized) return; - + state = state.copyWith(isLoading: true, error: null); - + try { await PlatformBridge.initExtensionSystem(extensionsDir, dataDir); await loadExtensions(extensionsDir); @@ -482,7 +534,7 @@ class ExtensionNotifier extends Notifier { Future loadExtensions(String dirPath) async { state = state.copyWith(isLoading: true, error: null); - + try { final result = await PlatformBridge.loadExtensionsFromDir(dirPath); _log.d('Load extensions result: $result'); @@ -500,10 +552,12 @@ class ExtensionNotifier extends Notifier { final extensions = list.map((e) => Extension.fromJson(e)).toList(); state = state.copyWith(extensions: extensions); _log.d('Loaded ${extensions.length} extensions'); - + for (final ext in extensions) { if (ext.searchBehavior != null) { - _log.d('Extension ${ext.id}: thumbnailRatio=${ext.searchBehavior!.thumbnailRatio}'); + _log.d( + 'Extension ${ext.id}: thumbnailRatio=${ext.searchBehavior!.thumbnailRatio}', + ); } } } catch (e) { @@ -512,14 +566,13 @@ class ExtensionNotifier extends Notifier { } } - void clearError() { state = state.copyWith(error: null); } Future installExtension(String filePath) async { state = state.copyWith(isLoading: true, error: null); - + try { final result = await PlatformBridge.loadExtensionFromPath(filePath); _log.i('Installed extension: ${result['name']}'); @@ -544,10 +597,12 @@ class ExtensionNotifier extends Notifier { Future upgradeExtension(String filePath) async { state = state.copyWith(isLoading: true, error: null); - + try { final result = await PlatformBridge.upgradeExtension(filePath); - _log.i('Upgraded extension: ${result['display_name']} to v${result['version']}'); + _log.i( + 'Upgraded extension: ${result['display_name']} to v${result['version']}', + ); await refreshExtensions(); state = state.copyWith(isLoading: false); return true; @@ -560,7 +615,7 @@ class ExtensionNotifier extends Notifier { Future removeExtension(String extensionId) async { state = state.copyWith(isLoading: true, error: null); - + try { await PlatformBridge.removeExtension(extensionId); _log.i('Removed extension: $extensionId'); @@ -574,35 +629,40 @@ class ExtensionNotifier extends Notifier { } } - Future setExtensionEnabled(String extensionId, bool enabled) async { try { await PlatformBridge.setExtensionEnabled(extensionId, enabled); _log.d('Set extension $extensionId enabled: $enabled'); - - final ext = state.extensions.where((e) => e.id == extensionId).firstOrNull; - + + final ext = state.extensions + .where((e) => e.id == extensionId) + .firstOrNull; + final extensions = state.extensions.map((e) { if (e.id == extensionId) { return e.copyWith(enabled: enabled); } return e; }).toList(); - + state = state.copyWith(extensions: extensions); - + if (!enabled && ext != null) { final settings = ref.read(settingsProvider); - + if (settings.searchProvider == extensionId) { ref.read(settingsProvider.notifier).setSearchProvider(null); ref.read(settingsProvider.notifier).setMetadataSource('deezer'); - _log.d('Cleared search provider and reset to Deezer because extension $extensionId was disabled'); + _log.d( + 'Cleared search provider and reset to Deezer because extension $extensionId was disabled', + ); } - + if (ext.hasDownloadProvider && settings.defaultService == extensionId) { ref.read(settingsProvider.notifier).setDefaultService('tidal'); - _log.d('Reset default service to Tidal because extension $extensionId was disabled'); + _log.d( + 'Reset default service to Tidal because extension $extensionId was disabled', + ); } } } catch (e) { @@ -611,6 +671,68 @@ class ExtensionNotifier extends Notifier { } } + Future ensureSpotifyWebExtensionReady({ + bool setAsSearchProvider = true, + }) async { + try { + await refreshExtensions(); + + var ext = state.extensions + .where((e) => e.id == _spotifyWebExtensionId) + .firstOrNull; + + if (ext == null) { + final cacheDir = await getTemporaryDirectory(); + await PlatformBridge.initExtensionStore(cacheDir.path); + + final tempRoot = await getTemporaryDirectory(); + final installDir = await Directory( + '${tempRoot.path}/spotiflac_bootstrap_spotify_web', + ).create(recursive: true); + + final downloadPath = await PlatformBridge.downloadStoreExtension( + _spotifyWebExtensionId, + installDir.path, + ); + + final installed = await installExtension(downloadPath); + if (!installed) { + _log.w('Failed to install spotify-web extension from store'); + return false; + } + + await refreshExtensions(); + ext = state.extensions + .where((e) => e.id == _spotifyWebExtensionId) + .firstOrNull; + } + + if (ext == null) { + _log.w('spotify-web extension is still not available after install'); + return false; + } + + if (!ext.enabled) { + await setExtensionEnabled(_spotifyWebExtensionId, true); + } + + if (setAsSearchProvider) { + final settings = ref.read(settingsProvider); + if (settings.searchProvider != _spotifyWebExtensionId) { + ref + .read(settingsProvider.notifier) + .setSearchProvider(_spotifyWebExtensionId); + } + } + + _log.i('spotify-web extension is ready'); + return true; + } catch (e) { + _log.w('Failed to ensure spotify-web extension is ready: $e'); + return false; + } + } + Future> getExtensionSettings(String extensionId) async { try { return await PlatformBridge.getExtensionSettings(extensionId); @@ -620,7 +742,10 @@ class ExtensionNotifier extends Notifier { } } - Future setExtensionSettings(String extensionId, Map settings) async { + Future setExtensionSettings( + String extensionId, + Map settings, + ) async { try { await PlatformBridge.setExtensionSettings(extensionId, settings); _log.d('Updated settings for extension: $extensionId'); @@ -635,49 +760,72 @@ class ExtensionNotifier extends Notifier { // Load from SharedPreferences first (persisted) final prefs = await SharedPreferences.getInstance(); final savedJson = prefs.getString(_providerPriorityKey); - + List priority; if (savedJson != null) { final saved = jsonDecode(savedJson) as List; priority = saved.map((e) => e as String).toList(); + priority = _sanitizeDownloadProviderPriority(priority); _log.d('Loaded provider priority from prefs: $priority'); + await prefs.setString(_providerPriorityKey, jsonEncode(priority)); // Sync to Go backend await PlatformBridge.setProviderPriority(priority); } else { // Fallback to Go backend default priority = await PlatformBridge.getProviderPriority(); + priority = _sanitizeDownloadProviderPriority(priority); + await PlatformBridge.setProviderPriority(priority); _log.d('Using default provider priority: $priority'); } - + state = state.copyWith(providerPriority: priority); } catch (e) { _log.e('Failed to load provider priority: $e'); } } - Future setProviderPriority(List priority) async { try { + final sanitized = _sanitizeDownloadProviderPriority(priority); // Save to SharedPreferences for persistence final prefs = await SharedPreferences.getInstance(); - await prefs.setString(_providerPriorityKey, jsonEncode(priority)); - + await prefs.setString(_providerPriorityKey, jsonEncode(sanitized)); + // Sync to Go backend - await PlatformBridge.setProviderPriority(priority); - state = state.copyWith(providerPriority: priority); - _log.d('Saved provider priority: $priority'); + await PlatformBridge.setProviderPriority(sanitized); + state = state.copyWith(providerPriority: sanitized); + _log.d('Saved provider priority: $sanitized'); } catch (e) { _log.e('Failed to set provider priority: $e'); state = state.copyWith(error: e.toString()); } } + List _sanitizeDownloadProviderPriority(List input) { + final allowed = getAllDownloadProviders().toSet(); + final result = []; + + for (final provider in input) { + if (allowed.contains(provider) && !result.contains(provider)) { + result.add(provider); + } + } + + for (final provider in const ['tidal', 'qobuz', 'amazon', 'deezer']) { + if (!result.contains(provider)) { + result.add(provider); + } + } + + return result; + } + Future loadMetadataProviderPriority() async { try { // Load from SharedPreferences first (persisted) final prefs = await SharedPreferences.getInstance(); final savedJson = prefs.getString(_metadataProviderPriorityKey); - + List priority; if (savedJson != null) { final saved = jsonDecode(savedJson) as List; @@ -690,7 +838,7 @@ class ExtensionNotifier extends Notifier { priority = await PlatformBridge.getMetadataProviderPriority(); _log.d('Using default metadata provider priority: $priority'); } - + state = state.copyWith(metadataProviderPriority: priority); } catch (e) { _log.e('Failed to load metadata provider priority: $e'); @@ -702,7 +850,7 @@ class ExtensionNotifier extends Notifier { // Save to SharedPreferences for persistence final prefs = await SharedPreferences.getInstance(); await prefs.setString(_metadataProviderPriorityKey, jsonEncode(priority)); - + // Sync to Go backend await PlatformBridge.setMetadataProviderPriority(priority); state = state.copyWith(metadataProviderPriority: priority); @@ -714,12 +862,9 @@ class ExtensionNotifier extends Notifier { } Future cleanup() async { - try { - await PlatformBridge.cleanupExtensions(); - _log.d('Extensions cleaned up'); - } catch (e) { - _log.e('Failed to cleanup extensions: $e'); - } + if (_cleanupInFlight) return; + _cleanupInFlight = true; + await _cleanupExtensions(reason: 'manual'); } Extension? getExtension(String extensionId) { @@ -735,7 +880,7 @@ class ExtensionNotifier extends Notifier { } List getAllDownloadProviders() { - final providers = ['tidal', 'qobuz', 'amazon']; + final providers = ['tidal', 'qobuz', 'amazon', 'deezer']; for (final ext in state.extensions) { if (ext.enabled && ext.hasDownloadProvider) { providers.add(ext.id); @@ -755,7 +900,9 @@ class ExtensionNotifier extends Notifier { } List get searchProviders { - return state.extensions.where((ext) => ext.enabled && ext.hasCustomSearch).toList(); + return state.extensions + .where((ext) => ext.enabled && ext.hasCustomSearch) + .toList(); } } diff --git a/lib/providers/library_collections_provider.dart b/lib/providers/library_collections_provider.dart new file mode 100644 index 00000000..a7a3e373 --- /dev/null +++ b/lib/providers/library_collections_provider.dart @@ -0,0 +1,714 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/services/library_collections_database.dart'; + +String trackCollectionKey(Track track) { + final isrc = track.isrc?.trim(); + if (isrc != null && isrc.isNotEmpty) { + return 'isrc:${isrc.toUpperCase()}'; + } + final source = (track.source?.trim().isNotEmpty ?? false) + ? track.source!.trim() + : 'builtin'; + return '$source:${track.id}'; +} + +class CollectionTrackEntry { + final String key; + final Track track; + final DateTime addedAt; + + const CollectionTrackEntry({ + required this.key, + required this.track, + required this.addedAt, + }); + + Map toJson() => { + 'key': key, + 'track': track.toJson(), + 'addedAt': addedAt.toIso8601String(), + }; + + factory CollectionTrackEntry.fromJson(Map json) { + final addedAtRaw = json['addedAt'] as String?; + return CollectionTrackEntry( + key: json['key'] as String, + track: Track.fromJson(Map.from(json['track'] as Map)), + addedAt: DateTime.tryParse(addedAtRaw ?? '') ?? DateTime.now(), + ); + } +} + +class UserPlaylistCollection { + final String id; + final String name; + final String? coverImagePath; + final DateTime createdAt; + final DateTime updatedAt; + final List tracks; + final Set _trackKeys; + + UserPlaylistCollection({ + required this.id, + required this.name, + this.coverImagePath, + required this.createdAt, + required this.updatedAt, + required this.tracks, + Set? trackKeys, + }) : _trackKeys = trackKeys ?? tracks.map((entry) => entry.key).toSet(); + + UserPlaylistCollection copyWith({ + String? id, + String? name, + String? Function()? coverImagePath, + DateTime? createdAt, + DateTime? updatedAt, + List? tracks, + }) { + final nextTracks = tracks ?? this.tracks; + final keepTrackIndex = identical(nextTracks, this.tracks); + return UserPlaylistCollection( + id: id ?? this.id, + name: name ?? this.name, + coverImagePath: coverImagePath != null + ? coverImagePath() + : this.coverImagePath, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + tracks: nextTracks, + trackKeys: keepTrackIndex ? _trackKeys : null, + ); + } + + bool containsTrack(Track track) { + final key = trackCollectionKey(track); + return _trackKeys.contains(key); + } + + bool containsTrackKey(String trackKey) { + return _trackKeys.contains(trackKey); + } + + Map toJson() => { + 'id': id, + 'name': name, + if (coverImagePath != null) 'coverImagePath': coverImagePath, + 'createdAt': createdAt.toIso8601String(), + 'updatedAt': updatedAt.toIso8601String(), + 'tracks': tracks.map((e) => e.toJson()).toList(), + }; + + factory UserPlaylistCollection.fromJson(Map json) { + final createdAtRaw = json['createdAt'] as String?; + final updatedAtRaw = json['updatedAt'] as String?; + final createdAt = DateTime.tryParse(createdAtRaw ?? '') ?? DateTime.now(); + final updatedAt = DateTime.tryParse(updatedAtRaw ?? '') ?? createdAt; + final tracksRaw = (json['tracks'] as List?) ?? const []; + return UserPlaylistCollection( + id: json['id'] as String, + name: json['name'] as String? ?? '', + coverImagePath: json['coverImagePath'] as String?, + createdAt: createdAt, + updatedAt: updatedAt, + tracks: tracksRaw + .whereType() + .map( + (e) => CollectionTrackEntry.fromJson(Map.from(e)), + ) + .toList(growable: false), + ); + } +} + +class LibraryCollectionsState { + final List wishlist; + final List loved; + final List playlists; + final bool isLoaded; + final Set _wishlistKeys; + final Set _lovedKeys; + final Map _playlistsById; + final Set _allPlaylistTrackKeys; + + LibraryCollectionsState({ + this.wishlist = const [], + this.loved = const [], + this.playlists = const [], + this.isLoaded = false, + Set? wishlistKeys, + Set? lovedKeys, + Map? playlistsById, + Set? allPlaylistTrackKeys, + }) : _wishlistKeys = + wishlistKeys ?? wishlist.map((entry) => entry.key).toSet(), + _lovedKeys = lovedKeys ?? loved.map((entry) => entry.key).toSet(), + _playlistsById = + playlistsById ?? + Map.fromEntries( + playlists.map((playlist) => MapEntry(playlist.id, playlist)), + ), + _allPlaylistTrackKeys = + allPlaylistTrackKeys ?? _buildPlaylistTrackKeys(playlists); + + int get wishlistCount => wishlist.length; + int get lovedCount => loved.length; + int get playlistCount => playlists.length; + + bool isInWishlist(Track track) { + final key = trackCollectionKey(track); + return _wishlistKeys.contains(key); + } + + bool isLoved(Track track) { + final key = trackCollectionKey(track); + return _lovedKeys.contains(key); + } + + bool containsWishlistKey(String trackKey) { + return _wishlistKeys.contains(trackKey); + } + + bool containsLovedKey(String trackKey) { + return _lovedKeys.contains(trackKey); + } + + UserPlaylistCollection? playlistById(String playlistId) { + return _playlistsById[playlistId]; + } + + bool playlistContainsTrack(String playlistId, String trackKey) { + final playlist = _playlistsById[playlistId]; + if (playlist == null) return false; + return playlist.containsTrackKey(trackKey); + } + + bool isTrackInAnyPlaylist(String trackKey) { + return _allPlaylistTrackKeys.contains(trackKey); + } + + bool get hasPlaylistTracks => _allPlaylistTrackKeys.isNotEmpty; + + LibraryCollectionsState copyWith({ + List? wishlist, + List? loved, + List? playlists, + bool? isLoaded, + }) { + final nextWishlist = wishlist ?? this.wishlist; + final nextLoved = loved ?? this.loved; + final nextPlaylists = playlists ?? this.playlists; + final keepWishlistIndex = identical(nextWishlist, this.wishlist); + final keepLovedIndex = identical(nextLoved, this.loved); + final keepPlaylistIndex = identical(nextPlaylists, this.playlists); + + return LibraryCollectionsState( + wishlist: nextWishlist, + loved: nextLoved, + playlists: nextPlaylists, + isLoaded: isLoaded ?? this.isLoaded, + wishlistKeys: keepWishlistIndex ? _wishlistKeys : null, + lovedKeys: keepLovedIndex ? _lovedKeys : null, + playlistsById: keepPlaylistIndex ? _playlistsById : null, + allPlaylistTrackKeys: keepPlaylistIndex ? _allPlaylistTrackKeys : null, + ); + } + + Map toJson() => { + 'wishlist': wishlist.map((e) => e.toJson()).toList(), + 'loved': loved.map((e) => e.toJson()).toList(), + 'playlists': playlists.map((e) => e.toJson()).toList(), + }; + + factory LibraryCollectionsState.fromJson(Map json) { + final wishlistRaw = (json['wishlist'] as List?) ?? const []; + final lovedRaw = (json['loved'] as List?) ?? const []; + final playlistsRaw = (json['playlists'] as List?) ?? const []; + + return LibraryCollectionsState( + wishlist: wishlistRaw + .whereType() + .map( + (e) => CollectionTrackEntry.fromJson(Map.from(e)), + ) + .toList(growable: false), + loved: lovedRaw + .whereType() + .map( + (e) => CollectionTrackEntry.fromJson(Map.from(e)), + ) + .toList(growable: false), + playlists: playlistsRaw + .whereType() + .map( + (e) => + UserPlaylistCollection.fromJson(Map.from(e)), + ) + .toList(growable: false), + isLoaded: true, + ); + } +} + +Set _buildPlaylistTrackKeys(List playlists) { + final keys = {}; + for (final playlist in playlists) { + for (final entry in playlist.tracks) { + keys.add(entry.key); + } + } + return keys; +} + +class PlaylistAddBatchResult { + final int addedCount; + final int alreadyInPlaylistCount; + + const PlaylistAddBatchResult({ + required this.addedCount, + required this.alreadyInPlaylistCount, + }); +} + +class LibraryCollectionsNotifier extends Notifier { + final LibraryCollectionsDatabase _db = LibraryCollectionsDatabase.instance; + Future? _loadFuture; + + @override + LibraryCollectionsState build() { + _loadFuture = _load(); + return LibraryCollectionsState(); + } + + Future _load() async { + try { + await _db.migrateFromSharedPreferences(); + final snapshot = await _db.loadSnapshot(); + + final wishlist = []; + for (final row in snapshot.wishlistRows) { + final parsed = _parseTrackEntryRow(row); + if (parsed != null) { + wishlist.add(parsed); + } + } + + final loved = []; + for (final row in snapshot.lovedRows) { + final parsed = _parseTrackEntryRow(row); + if (parsed != null) { + loved.add(parsed); + } + } + + final tracksByPlaylist = >{}; + for (final row in snapshot.playlistTrackRows) { + final playlistId = row['playlist_id'] as String?; + if (playlistId == null || playlistId.isEmpty) continue; + final parsed = _parseTrackEntryRow(row); + if (parsed == null) continue; + tracksByPlaylist.putIfAbsent(playlistId, () => []).add(parsed); + } + + final playlists = []; + for (final row in snapshot.playlistRows) { + final id = row['id'] as String?; + if (id == null || id.isEmpty) continue; + + final createdAtRaw = row['created_at'] as String?; + final updatedAtRaw = row['updated_at'] as String?; + final createdAt = + DateTime.tryParse(createdAtRaw ?? '') ?? DateTime.now(); + final updatedAt = DateTime.tryParse(updatedAtRaw ?? '') ?? createdAt; + + playlists.add( + UserPlaylistCollection( + id: id, + name: row['name'] as String? ?? '', + coverImagePath: row['cover_image_path'] as String?, + createdAt: createdAt, + updatedAt: updatedAt, + tracks: tracksByPlaylist[id] ?? const [], + ), + ); + } + + state = LibraryCollectionsState( + wishlist: wishlist, + loved: loved, + playlists: playlists, + isLoaded: true, + ); + } catch (_) { + state = state.copyWith(isLoaded: true); + } + } + + Future _ensureLoaded() async { + if (state.isLoaded) return; + await (_loadFuture ?? _load()); + } + + CollectionTrackEntry? _parseTrackEntryRow(Map row) { + final key = row['track_key'] as String?; + final trackJson = row['track_json'] as String?; + if (key == null || key.isEmpty || trackJson == null || trackJson.isEmpty) { + return null; + } + + try { + final decoded = jsonDecode(trackJson); + if (decoded is! Map) return null; + final track = Track.fromJson(Map.from(decoded)); + final addedAtRaw = row['added_at'] as String?; + return CollectionTrackEntry( + key: key, + track: track, + addedAt: DateTime.tryParse(addedAtRaw ?? '') ?? DateTime.now(), + ); + } catch (_) { + return null; + } + } + + bool _replacePlaylistById( + String playlistId, + UserPlaylistCollection Function(UserPlaylistCollection playlist) update, + ) { + final playlist = state.playlistById(playlistId); + if (playlist == null) return false; + + final playlistIndex = state.playlists.indexWhere((p) => p.id == playlistId); + if (playlistIndex < 0) return false; + + final nextPlaylist = update(playlist); + if (identical(nextPlaylist, playlist)) return false; + + final updatedPlaylists = [...state.playlists]; + updatedPlaylists[playlistIndex] = nextPlaylist; + state = state.copyWith(playlists: updatedPlaylists); + return true; + } + + Future toggleWishlist(Track track) async { + await _ensureLoaded(); + final key = trackCollectionKey(track); + if (state.containsWishlistKey(key)) { + await _db.deleteWishlistEntry(key); + final updated = state.wishlist + .where((entry) => entry.key != key) + .toList(growable: false); + state = state.copyWith(wishlist: updated); + return false; + } + + final entry = CollectionTrackEntry( + key: key, + track: track, + addedAt: DateTime.now(), + ); + await _db.upsertWishlistEntry( + trackKey: key, + trackJson: jsonEncode(track.toJson()), + addedAt: entry.addedAt.toIso8601String(), + ); + final updated = [entry, ...state.wishlist]; + state = state.copyWith(wishlist: updated); + return true; + } + + Future toggleLoved(Track track) async { + await _ensureLoaded(); + final key = trackCollectionKey(track); + if (state.containsLovedKey(key)) { + await _db.deleteLovedEntry(key); + final updated = state.loved + .where((entry) => entry.key != key) + .toList(growable: false); + state = state.copyWith(loved: updated); + return false; + } + + final entry = CollectionTrackEntry( + key: key, + track: track, + addedAt: DateTime.now(), + ); + await _db.upsertLovedEntry( + trackKey: key, + trackJson: jsonEncode(track.toJson()), + addedAt: entry.addedAt.toIso8601String(), + ); + final updated = [entry, ...state.loved]; + state = state.copyWith(loved: updated); + return true; + } + + Future removeFromWishlist(String trackKey) async { + await _ensureLoaded(); + if (!state.containsWishlistKey(trackKey)) return; + + await _db.deleteWishlistEntry(trackKey); + final updated = state.wishlist + .where((entry) => entry.key != trackKey) + .toList(growable: false); + state = state.copyWith(wishlist: updated); + } + + Future removeFromLoved(String trackKey) async { + await _ensureLoaded(); + if (!state.containsLovedKey(trackKey)) return; + + await _db.deleteLovedEntry(trackKey); + final updated = state.loved + .where((entry) => entry.key != trackKey) + .toList(growable: false); + state = state.copyWith(loved: updated); + } + + Future createPlaylist(String name) async { + await _ensureLoaded(); + final now = DateTime.now(); + final id = 'pl_${now.microsecondsSinceEpoch}'; + final trimmedName = name.trim(); + + final playlist = UserPlaylistCollection( + id: id, + name: trimmedName, + createdAt: now, + updatedAt: now, + tracks: const [], + ); + + await _db.upsertPlaylist( + id: id, + name: trimmedName, + coverImagePath: null, + createdAt: now.toIso8601String(), + updatedAt: now.toIso8601String(), + ); + state = state.copyWith(playlists: [playlist, ...state.playlists]); + return id; + } + + Future renamePlaylist(String playlistId, String newName) async { + await _ensureLoaded(); + final trimmed = newName.trim(); + if (trimmed.isEmpty) return; + final playlist = state.playlistById(playlistId); + if (playlist == null || playlist.name == trimmed) return; + + final now = DateTime.now(); + await _db.renamePlaylist( + playlistId: playlistId, + name: trimmed, + updatedAt: now.toIso8601String(), + ); + _replacePlaylistById(playlistId, (playlist) { + return playlist.copyWith(name: trimmed, updatedAt: now); + }); + } + + Future deletePlaylist(String playlistId) async { + await _ensureLoaded(); + final playlistIndex = state.playlists.indexWhere((p) => p.id == playlistId); + if (playlistIndex < 0) return; + + await _db.deletePlaylist(playlistId); + final updatedPlaylists = [...state.playlists]..removeAt(playlistIndex); + state = state.copyWith(playlists: updatedPlaylists); + } + + Future addTrackToPlaylist(String playlistId, Track track) async { + await _ensureLoaded(); + final playlist = state.playlistById(playlistId); + if (playlist == null) return false; + + final key = trackCollectionKey(track); + if (playlist.containsTrackKey(key)) return false; + + final now = DateTime.now(); + final entry = CollectionTrackEntry(key: key, track: track, addedAt: now); + await _db.upsertPlaylistTrack( + playlistId: playlistId, + trackKey: key, + trackJson: jsonEncode(track.toJson()), + addedAt: entry.addedAt.toIso8601String(), + playlistUpdatedAt: now.toIso8601String(), + ); + final changed = _replacePlaylistById(playlistId, (playlist) { + if (playlist.containsTrackKey(key)) return playlist; + return playlist.copyWith( + tracks: [entry, ...playlist.tracks], + updatedAt: now, + ); + }); + if (!changed) return false; + return true; + } + + Future addTracksToPlaylist( + String playlistId, + Iterable tracks, + ) async { + await _ensureLoaded(); + final playlist = state.playlistById(playlistId); + if (playlist == null) { + return const PlaylistAddBatchResult( + addedCount: 0, + alreadyInPlaylistCount: 0, + ); + } + + final now = DateTime.now(); + final knownKeys = {...playlist._trackKeys}; + final entriesToAdd = []; + var alreadyInPlaylistCount = 0; + + for (final track in tracks) { + final key = trackCollectionKey(track); + if (!knownKeys.add(key)) { + alreadyInPlaylistCount++; + continue; + } + + entriesToAdd.add( + CollectionTrackEntry(key: key, track: track, addedAt: now), + ); + } + + if (entriesToAdd.isEmpty) { + return PlaylistAddBatchResult( + addedCount: 0, + alreadyInPlaylistCount: alreadyInPlaylistCount, + ); + } + + await _db.upsertPlaylistTracksBatch( + playlistId: playlistId, + playlistUpdatedAt: now.toIso8601String(), + tracks: entriesToAdd + .map( + (entry) => { + 'track_key': entry.key, + 'track_json': jsonEncode(entry.track.toJson()), + 'added_at': entry.addedAt.toIso8601String(), + }, + ) + .toList(growable: false), + ); + final changed = _replacePlaylistById(playlistId, (current) { + return current.copyWith( + tracks: [...entriesToAdd.reversed, ...current.tracks], + updatedAt: now, + ); + }); + if (!changed) { + return PlaylistAddBatchResult( + addedCount: 0, + alreadyInPlaylistCount: alreadyInPlaylistCount, + ); + } + return PlaylistAddBatchResult( + addedCount: entriesToAdd.length, + alreadyInPlaylistCount: alreadyInPlaylistCount, + ); + } + + Future removeTrackFromPlaylist( + String playlistId, + String trackKey, + ) async { + await _ensureLoaded(); + final playlist = state.playlistById(playlistId); + if (playlist == null || !playlist.containsTrackKey(trackKey)) return; + + final now = DateTime.now(); + await _db.deletePlaylistTrack( + playlistId: playlistId, + trackKey: trackKey, + playlistUpdatedAt: now.toIso8601String(), + ); + _replacePlaylistById(playlistId, (playlist) { + final nextTracks = playlist.tracks + .where((entry) => entry.key != trackKey) + .toList(growable: false); + if (nextTracks.length == playlist.tracks.length) return playlist; + return playlist.copyWith(tracks: nextTracks, updatedAt: now); + }); + } + + Future _playlistCoversDir() async { + final appDir = await getApplicationSupportDirectory(); + final dir = Directory(p.join(appDir.path, 'playlist_covers')); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + return dir; + } + + Future setPlaylistCover( + String playlistId, + String sourceFilePath, + ) async { + await _ensureLoaded(); + final playlist = state.playlistById(playlistId); + if (playlist == null) return; + + final coversDir = await _playlistCoversDir(); + final ext = p.extension(sourceFilePath).toLowerCase(); + final destPath = p.join(coversDir.path, '$playlistId$ext'); + if (playlist.coverImagePath == destPath) return; + + // Copy image to persistent location + await File(sourceFilePath).copy(destPath); + + final now = DateTime.now(); + await _db.updatePlaylistCover( + playlistId: playlistId, + coverImagePath: destPath, + updatedAt: now.toIso8601String(), + ); + _replacePlaylistById(playlistId, (playlist) { + if (playlist.coverImagePath == destPath) return playlist; + return playlist.copyWith(coverImagePath: () => destPath, updatedAt: now); + }); + } + + Future removePlaylistCover(String playlistId) async { + await _ensureLoaded(); + final playlist = state.playlistById(playlistId); + if (playlist == null || playlist.coverImagePath == null) return; + + // Delete the file if it exists + final path = playlist.coverImagePath; + if (path != null) { + final file = File(path); + if (await file.exists()) { + await file.delete(); + } + } + + final now = DateTime.now(); + await _db.updatePlaylistCover( + playlistId: playlistId, + coverImagePath: null, + updatedAt: now.toIso8601String(), + ); + _replacePlaylistById(playlistId, (playlist) { + if (playlist.coverImagePath == null) return playlist; + return playlist.copyWith(coverImagePath: () => null, updatedAt: now); + }); + } +} + +final libraryCollectionsProvider = + NotifierProvider( + LibraryCollectionsNotifier.new, + ); diff --git a/lib/providers/local_library_provider.dart b/lib/providers/local_library_provider.dart index 2f4ce8f6..afa25f66 100644 --- a/lib/providers/local_library_provider.dart +++ b/lib/providers/local_library_provider.dart @@ -121,15 +121,25 @@ class LocalLibraryNotifier extends Notifier { final NotificationService _notificationService = NotificationService(); static const _progressPollingInterval = Duration(milliseconds: 800); Timer? _progressTimer; + Timer? _progressStreamBootstrapTimer; + StreamSubscription>? _progressStreamSub; bool _isLoaded = false; bool _scanCancelRequested = false; int _progressPollingErrorCount = 0; bool _isProgressPollingInFlight = false; + bool _hasReceivedProgressStreamEvent = false; + bool _usingProgressStream = false; + static const _scanNotificationHeartbeat = Duration(seconds: 4); + int _lastScanNotificationPercent = -1; + int _lastScanNotificationTotalFiles = -1; + DateTime _lastScanNotificationAt = DateTime.fromMillisecondsSinceEpoch(0); @override LocalLibraryState build() { ref.onDispose(() { _progressTimer?.cancel(); + _progressStreamBootstrapTimer?.cancel(); + _progressStreamSub?.cancel(); }); Future.microtask(() async { @@ -188,7 +198,7 @@ class LocalLibraryNotifier extends Notifier { if (raw.isEmpty) return const {}; final cleaned = raw.startsWith('EXISTS:') ? raw.substring(7) : raw; - final keys = {cleaned}; + final keys = {}; void addNormalized(String value) { final trimmed = value.trim(); @@ -207,18 +217,42 @@ class LocalLibraryNotifier extends Notifier { keys.add(decoded.toLowerCase()); } catch (_) {} } + + Uri? parsed; + try { + parsed = Uri.parse(trimmed); + } catch (_) {} + + if (parsed != null && parsed.hasScheme) { + final noQueryOrFragment = parsed.replace(query: null, fragment: null); + keys.add(noQueryOrFragment.toString()); + keys.add(noQueryOrFragment.toString().toLowerCase()); + + if (parsed.scheme == 'file') { + try { + final fileOnly = parsed.toFilePath(); + if (fileOnly.isNotEmpty) { + keys.add(fileOnly); + keys.add(fileOnly.toLowerCase()); + if (fileOnly.contains('\\')) { + final slash = fileOnly.replaceAll('\\', '/'); + keys.add(slash); + keys.add(slash.toLowerCase()); + } + } + } catch (_) {} + } + } else if (trimmed.startsWith('/')) { + try { + final asFileUri = Uri.file(trimmed).toString(); + keys.add(asFileUri); + keys.add(asFileUri.toLowerCase()); + } catch (_) {} + } } addNormalized(cleaned); - if (cleaned.startsWith('content://')) { - try { - final uri = Uri.parse(cleaned); - addNormalized(uri.toString()); - addNormalized(uri.replace(query: null, fragment: null).toString()); - } catch (_) {} - } - return keys; } @@ -257,12 +291,19 @@ class LocalLibraryNotifier extends Notifier { scanErrorCount: 0, scanWasCancelled: false, ); - await _showScanProgressNotification( + _resetScanNotificationTracking(); + if (_shouldShowScanProgressNotification( progress: 0, - scannedFiles: 0, totalFiles: 0, - currentFile: null, - ); + isComplete: false, + )) { + await _showScanProgressNotification( + progress: 0, + scannedFiles: 0, + totalFiles: 0, + currentFile: null, + ); + } try { final appSupportDir = await getApplicationSupportDirectory(); @@ -328,7 +369,11 @@ class LocalLibraryNotifier extends Notifier { _log.i('Skipped $skippedDownloads files already in download history'); } - await _db.upsertBatch(items.map((e) => e.toJson()).toList()); + // Full scan should replace library index entirely. + await _db.clearAll(); + if (items.isNotEmpty) { + await _db.upsertBatch(items.map((e) => e.toJson()).toList()); + } final now = DateTime.now(); try { @@ -420,10 +465,24 @@ class LocalLibraryNotifier extends Notifier { final currentByPath = { for (final item in state.items) item.filePath: item, }; + final existingDownloadedPaths = []; + currentByPath.removeWhere((path, _) { + final shouldExclude = _isDownloadedPath(path, downloadedPathKeys); + if (shouldExclude) { + existingDownloadedPaths.add(path); + } + return shouldExclude; + }); + if (existingDownloadedPaths.isNotEmpty) { + final removed = await _db.deleteByPaths(existingDownloadedPaths); + _log.i( + 'Removed $removed downloaded tracks already present in local library index', + ); + } // Upsert new/modified items (excluding downloaded files) final updatedItems = []; - int skippedDownloads = 0; + int skippedDownloads = existingDownloadedPaths.length; if (scannedList.isNotEmpty) { for (final json in scannedList) { final map = json as Map; @@ -499,49 +558,75 @@ class LocalLibraryNotifier extends Notifier { } void _startProgressPolling() { + _progressTimer?.cancel(); + _progressStreamBootstrapTimer?.cancel(); + _progressStreamBootstrapTimer = null; + _progressStreamSub?.cancel(); + _progressStreamSub = null; + _hasReceivedProgressStreamEvent = false; + _usingProgressStream = false; + + if (Platform.isAndroid || Platform.isIOS) { + _progressStreamSub = PlatformBridge.libraryScanProgressStream().listen( + (progress) async { + _hasReceivedProgressStreamEvent = true; + _usingProgressStream = true; + _progressStreamBootstrapTimer?.cancel(); + _progressStreamBootstrapTimer = null; + if (_isProgressPollingInFlight) return; + _isProgressPollingInFlight = true; + try { + await _handleLibraryScanProgress(progress); + _progressPollingErrorCount = 0; + } catch (e) { + _progressPollingErrorCount++; + if (_progressPollingErrorCount <= 3) { + _log.w('Library scan progress stream processing failed: $e'); + } + } finally { + _isProgressPollingInFlight = false; + } + }, + onError: (Object error, StackTrace stackTrace) { + if (_usingProgressStream) { + _log.w( + 'Library scan progress stream failed, fallback to polling: $error', + ); + } + _progressStreamSub?.cancel(); + _progressStreamSub = null; + _usingProgressStream = false; + _progressStreamBootstrapTimer?.cancel(); + _progressStreamBootstrapTimer = null; + _startProgressPollingTimer(); + }, + cancelOnError: false, + ); + + _progressStreamBootstrapTimer = Timer(const Duration(seconds: 3), () { + if (_hasReceivedProgressStreamEvent) { + return; + } + _log.w('Library scan progress stream timeout, fallback to polling'); + _progressStreamSub?.cancel(); + _progressStreamSub = null; + _usingProgressStream = false; + _startProgressPollingTimer(); + }); + return; + } + + _startProgressPollingTimer(); + } + + void _startProgressPollingTimer() { _progressTimer?.cancel(); _progressTimer = Timer.periodic(_progressPollingInterval, (_) async { if (_isProgressPollingInFlight) return; _isProgressPollingInFlight = true; try { final progress = await PlatformBridge.getLibraryScanProgress(); - final nextProgress = - (progress['progress_pct'] as num?)?.toDouble() ?? 0; - final normalizedProgress = ((nextProgress * 10).round() / 10).clamp( - 0.0, - 100.0, - ); - final currentFile = progress['current_file'] as String?; - final totalFiles = progress['total_files'] as int? ?? 0; - final scannedFiles = progress['scanned_files'] as int? ?? 0; - final errorCount = progress['error_count'] as int? ?? 0; - - final shouldUpdateState = - state.scanProgress != normalizedProgress || - state.scanCurrentFile != currentFile || - state.scanTotalFiles != totalFiles || - state.scannedFiles != scannedFiles || - state.scanErrorCount != errorCount; - - if (shouldUpdateState) { - state = state.copyWith( - scanProgress: normalizedProgress, - scanCurrentFile: currentFile, - scanTotalFiles: totalFiles, - scannedFiles: scannedFiles, - scanErrorCount: errorCount, - ); - await _showScanProgressNotification( - progress: normalizedProgress, - scannedFiles: scannedFiles, - totalFiles: totalFiles, - currentFile: currentFile, - ); - } - - if (progress['is_complete'] == true) { - _stopProgressPolling(); - } + await _handleLibraryScanProgress(progress); _progressPollingErrorCount = 0; } catch (e) { _progressPollingErrorCount++; @@ -554,11 +639,93 @@ class LocalLibraryNotifier extends Notifier { }); } + Future _handleLibraryScanProgress(Map progress) async { + final nextProgress = (progress['progress_pct'] as num?)?.toDouble() ?? 0; + final normalizedProgress = ((nextProgress * 10).round() / 10).clamp( + 0.0, + 100.0, + ); + final currentFile = progress['current_file'] as String?; + final totalFiles = (progress['total_files'] as num?)?.toInt() ?? 0; + final scannedFiles = (progress['scanned_files'] as num?)?.toInt() ?? 0; + final errorCount = (progress['error_count'] as num?)?.toInt() ?? 0; + final isComplete = progress['is_complete'] == true; + + final shouldUpdateState = + state.scanProgress != normalizedProgress || + state.scanCurrentFile != currentFile || + state.scanTotalFiles != totalFiles || + state.scannedFiles != scannedFiles || + state.scanErrorCount != errorCount; + + if (shouldUpdateState) { + state = state.copyWith( + scanProgress: normalizedProgress, + scanCurrentFile: currentFile, + scanTotalFiles: totalFiles, + scannedFiles: scannedFiles, + scanErrorCount: errorCount, + ); + } + + if (_shouldShowScanProgressNotification( + progress: normalizedProgress, + totalFiles: totalFiles, + isComplete: isComplete, + )) { + await _showScanProgressNotification( + progress: normalizedProgress, + scannedFiles: scannedFiles, + totalFiles: totalFiles, + currentFile: currentFile, + ); + } + + if (isComplete) { + _stopProgressPolling(); + } + } + void _stopProgressPolling() { _progressTimer?.cancel(); + _progressStreamBootstrapTimer?.cancel(); + _progressStreamSub?.cancel(); _progressTimer = null; + _progressStreamBootstrapTimer = null; + _progressStreamSub = null; _progressPollingErrorCount = 0; _isProgressPollingInFlight = false; + _hasReceivedProgressStreamEvent = false; + _usingProgressStream = false; + _resetScanNotificationTracking(); + } + + void _resetScanNotificationTracking() { + _lastScanNotificationPercent = -1; + _lastScanNotificationTotalFiles = -1; + _lastScanNotificationAt = DateTime.fromMillisecondsSinceEpoch(0); + } + + bool _shouldShowScanProgressNotification({ + required double progress, + required int totalFiles, + required bool isComplete, + }) { + final now = DateTime.now(); + final percent = progress.round().clamp(0, 100); + final percentChanged = percent != _lastScanNotificationPercent; + final totalFilesChanged = totalFiles != _lastScanNotificationTotalFiles; + final heartbeatDue = + now.difference(_lastScanNotificationAt) >= _scanNotificationHeartbeat; + + if (!percentChanged && !totalFilesChanged && !isComplete && !heartbeatDue) { + return false; + } + + _lastScanNotificationPercent = percent; + _lastScanNotificationTotalFiles = totalFiles; + _lastScanNotificationAt = now; + return true; } Future cancelScan() async { diff --git a/lib/providers/playback_provider.dart b/lib/providers/playback_provider.dart new file mode 100644 index 00000000..c35f6a00 --- /dev/null +++ b/lib/providers/playback_provider.dart @@ -0,0 +1,167 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/providers/download_queue_provider.dart'; +import 'package:spotiflac_android/providers/local_library_provider.dart'; +import 'package:spotiflac_android/services/library_database.dart'; +import 'package:spotiflac_android/utils/file_access.dart'; +import 'package:spotiflac_android/utils/logger.dart'; + +final _log = AppLogger('PlaybackProvider'); + +class PlaybackState { + const PlaybackState(); +} + +class PlaybackController extends Notifier { + @override + PlaybackState build() => const PlaybackState(); + + Future playLocalPath({ + required String path, + required String title, + required String artist, + String album = '', + String coverUrl = '', + Track? track, + }) async { + _log.d('Opening external player for "$title" by $artist: $path'); + await openFile(path); + } + + Future playTrackList(List tracks, {int startIndex = 0}) async { + if (tracks.isEmpty) return; + + final orderedTracks = _orderedTracksFromStartIndex(tracks, startIndex); + for (final track in orderedTracks) { + final resolvedPath = await _resolveTrackPath(track); + if (resolvedPath == null) { + continue; + } + + _log.d( + 'Opening first available external track for list playback: ' + '"${track.name}" by ${track.artistName} -> $resolvedPath', + ); + await openFile(resolvedPath); + return; + } + + throw Exception( + 'No local audio file is available to open. Download the track first.', + ); + } + + List _orderedTracksFromStartIndex(List tracks, int startIndex) { + final safeStart = startIndex.clamp(0, tracks.length - 1); + if (safeStart == 0) { + return List.from(tracks, growable: false); + } + + return [ + ...tracks.sublist(safeStart), + ...tracks.sublist(0, safeStart), + ]; + } + + Future _resolveTrackPath(Track track) async { + final localState = ref.read(localLibraryProvider); + final historyState = ref.read(downloadHistoryProvider); + final historyNotifier = ref.read(downloadHistoryProvider.notifier); + + final localItem = _findLocalLibraryItemForTrack(track, localState); + if (localItem != null && await fileExists(localItem.filePath)) { + return localItem.filePath; + } + + final historyItem = _findDownloadHistoryItemForTrack(track, historyState); + if (historyItem != null) { + if (await fileExists(historyItem.filePath)) { + return historyItem.filePath; + } + historyNotifier.removeFromHistory(historyItem.id); + } + + return null; + } + + LocalLibraryItem? _findLocalLibraryItemForTrack( + Track track, + LocalLibraryState localState, + ) { + final isLocalSource = (track.source ?? '').toLowerCase() == 'local'; + if (isLocalSource) { + for (final item in localState.items) { + if (item.id == track.id) { + return item; + } + } + } + + final isrc = track.isrc?.trim(); + if (isrc != null && isrc.isNotEmpty) { + final byIsrc = localState.getByIsrc(isrc); + if (byIsrc != null) { + return byIsrc; + } + } + + return localState.findByTrackAndArtist(track.name, track.artistName); + } + + DownloadHistoryItem? _findDownloadHistoryItemForTrack( + Track track, + DownloadHistoryState historyState, + ) { + for (final candidateId in _spotifyIdLookupCandidates(track.id)) { + final bySpotifyId = historyState.getBySpotifyId(candidateId); + if (bySpotifyId != null) { + return bySpotifyId; + } + } + + final isrc = track.isrc?.trim(); + if (isrc != null && isrc.isNotEmpty) { + final byIsrc = historyState.getByIsrc(isrc); + if (byIsrc != null) { + return byIsrc; + } + } + + return historyState.findByTrackAndArtist(track.name, track.artistName); + } + + List _spotifyIdLookupCandidates(String rawId) { + final trimmed = rawId.trim(); + if (trimmed.isEmpty) { + return const []; + } + + final candidates = {trimmed}; + final lowered = trimmed.toLowerCase(); + if (lowered.startsWith('spotify:track:')) { + final compact = trimmed.split(':').last.trim(); + if (compact.isNotEmpty) { + candidates.add(compact); + } + } else if (!trimmed.contains(':')) { + candidates.add('spotify:track:$trimmed'); + } + + final uri = Uri.tryParse(trimmed); + final segments = uri?.pathSegments ?? const []; + final trackIndex = segments.indexOf('track'); + if (trackIndex >= 0 && trackIndex + 1 < segments.length) { + final pathId = segments[trackIndex + 1].trim(); + if (pathId.isNotEmpty) { + candidates.add(pathId); + candidates.add('spotify:track:$pathId'); + } + } + + return candidates.toList(growable: false); + } +} + +final playbackProvider = NotifierProvider( + PlaybackController.new, +); diff --git a/lib/providers/recent_access_provider.dart b/lib/providers/recent_access_provider.dart index e0475aef..8a2ba671 100644 --- a/lib/providers/recent_access_provider.dart +++ b/lib/providers/recent_access_provider.dart @@ -1,18 +1,12 @@ +import 'dart:async'; import 'dart:convert'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:shared_preferences/shared_preferences.dart'; +import 'package:spotiflac_android/services/app_state_database.dart'; -const _recentAccessKey = 'recent_access_history'; -const _hiddenDownloadsKey = 'hidden_downloads_in_recents'; const _maxRecentItems = 20; /// Types of items that can be accessed -enum RecentAccessType { - artist, - album, - track, - playlist, -} +enum RecentAccessType { artist, album, track, playlist } /// Represents a recently accessed item class RecentAccessItem { @@ -100,7 +94,7 @@ class RecentAccessState { /// Provider for managing recent access history class RecentAccessNotifier extends Notifier { - final Future _prefs = SharedPreferences.getInstance(); + final AppStateDatabase _appStateDb = AppStateDatabase.instance; @override RecentAccessState build() { @@ -109,40 +103,36 @@ class RecentAccessNotifier extends Notifier { } Future _loadHistory() async { - final prefs = await _prefs; - final json = prefs.getString(_recentAccessKey); - final hiddenJson = prefs.getStringList(_hiddenDownloadsKey); - - List items = []; - Set hiddenIds = {}; - - if (json != null) { - try { - final List decoded = jsonDecode(json); - items = decoded - .map((e) => RecentAccessItem.fromJson(e as Map)) - .toList(); - } catch (_) { - // Ignore JSON parse errors, use empty list + try { + await _appStateDb.migrateRecentAccessFromSharedPreferences(); + final rows = await _appStateDb.getRecentAccessRows( + limit: _maxRecentItems, + ); + final hiddenIds = await _appStateDb.getHiddenRecentDownloadIds(); + + final items = []; + for (final row in rows) { + final itemJson = row['item_json'] as String?; + if (itemJson == null || itemJson.isEmpty) continue; + try { + final decoded = jsonDecode(itemJson); + if (decoded is! Map) continue; + items.add( + RecentAccessItem.fromJson(Map.from(decoded)), + ); + } catch (_) { + continue; + } } - } - - if (hiddenJson != null) { - hiddenIds = hiddenJson.toSet(); - } - - state = state.copyWith(items: items, hiddenDownloadIds: hiddenIds, isLoaded: true); - } - Future _saveHistory() async { - final prefs = await _prefs; - final json = jsonEncode(state.items.map((e) => e.toJson()).toList()); - await prefs.setString(_recentAccessKey, json); - } - - Future _saveHiddenDownloads() async { - final prefs = await _prefs; - await prefs.setStringList(_hiddenDownloadsKey, state.hiddenDownloadIds.toList()); + state = state.copyWith( + items: items, + hiddenDownloadIds: hiddenIds, + isLoaded: true, + ); + } catch (_) { + state = state.copyWith(isLoaded: true); + } } /// Record an access to an artist @@ -152,14 +142,16 @@ class RecentAccessNotifier extends Notifier { String? imageUrl, String? providerId, }) { - _recordAccess(RecentAccessItem( - id: id, - name: name, - imageUrl: imageUrl, - type: RecentAccessType.artist, - accessedAt: DateTime.now(), - providerId: providerId, - )); + _recordAccess( + RecentAccessItem( + id: id, + name: name, + imageUrl: imageUrl, + type: RecentAccessType.artist, + accessedAt: DateTime.now(), + providerId: providerId, + ), + ); } /// Record an access to an album @@ -170,15 +162,17 @@ class RecentAccessNotifier extends Notifier { String? imageUrl, String? providerId, }) { - _recordAccess(RecentAccessItem( - id: id, - name: name, - subtitle: artistName, - imageUrl: imageUrl, - type: RecentAccessType.album, - accessedAt: DateTime.now(), - providerId: providerId, - )); + _recordAccess( + RecentAccessItem( + id: id, + name: name, + subtitle: artistName, + imageUrl: imageUrl, + type: RecentAccessType.album, + accessedAt: DateTime.now(), + providerId: providerId, + ), + ); } /// Record an access to a track @@ -189,15 +183,17 @@ class RecentAccessNotifier extends Notifier { String? imageUrl, String? providerId, }) { - _recordAccess(RecentAccessItem( - id: id, - name: name, - subtitle: artistName, - imageUrl: imageUrl, - type: RecentAccessType.track, - accessedAt: DateTime.now(), - providerId: providerId, - )); + _recordAccess( + RecentAccessItem( + id: id, + name: name, + subtitle: artistName, + imageUrl: imageUrl, + type: RecentAccessType.track, + accessedAt: DateTime.now(), + providerId: providerId, + ), + ); } /// Record an access to a playlist @@ -208,30 +204,42 @@ class RecentAccessNotifier extends Notifier { String? imageUrl, String? providerId, }) { - _recordAccess(RecentAccessItem( - id: id, - name: name, - subtitle: ownerName, - imageUrl: imageUrl, - type: RecentAccessType.playlist, - accessedAt: DateTime.now(), - providerId: providerId, - )); + _recordAccess( + RecentAccessItem( + id: id, + name: name, + subtitle: ownerName, + imageUrl: imageUrl, + type: RecentAccessType.playlist, + accessedAt: DateTime.now(), + providerId: providerId, + ), + ); } void _recordAccess(RecentAccessItem item) { final updatedItems = state.items .where((e) => e.uniqueKey != item.uniqueKey) .toList(); - + updatedItems.insert(0, item); - + + RecentAccessItem? removedTail; if (updatedItems.length > _maxRecentItems) { - updatedItems.removeRange(_maxRecentItems, updatedItems.length); + removedTail = updatedItems.removeLast(); } - + state = state.copyWith(items: updatedItems); - _saveHistory(); + unawaited( + _appStateDb.upsertRecentAccessRow( + uniqueKey: item.uniqueKey, + itemJson: jsonEncode(item.toJson()), + accessedAt: item.accessedAt.toIso8601String(), + ), + ); + if (removedTail != null) { + unawaited(_appStateDb.deleteRecentAccessRow(removedTail.uniqueKey)); + } } /// Remove a specific item from history @@ -240,14 +248,14 @@ class RecentAccessNotifier extends Notifier { .where((e) => e.uniqueKey != item.uniqueKey) .toList(); state = state.copyWith(items: updatedItems); - _saveHistory(); + unawaited(_appStateDb.deleteRecentAccessRow(item.uniqueKey)); } /// Hide a download item from recents (without deleting the actual download) void hideDownloadFromRecents(String downloadId) { final updatedHidden = {...state.hiddenDownloadIds, downloadId}; state = state.copyWith(hiddenDownloadIds: updatedHidden); - _saveHiddenDownloads(); + unawaited(_appStateDb.addHiddenRecentDownloadId(downloadId)); } /// Check if a download is hidden from recents @@ -258,16 +266,17 @@ class RecentAccessNotifier extends Notifier { /// Clear all history void clearHistory() { state = state.copyWith(items: []); - _saveHistory(); + unawaited(_appStateDb.clearRecentAccessRows()); } /// Clear hidden downloads (show all again) void clearHiddenDownloads() { state = state.copyWith(hiddenDownloadIds: {}); - _saveHiddenDownloads(); + unawaited(_appStateDb.clearHiddenRecentDownloadIds()); } } -final recentAccessProvider = NotifierProvider( - RecentAccessNotifier.new, -); +final recentAccessProvider = + NotifierProvider( + RecentAccessNotifier.new, + ); diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 9e469cd2..133bdeec 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -3,16 +3,21 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:spotiflac_android/models/settings.dart'; +import 'package:spotiflac_android/constants/app_info.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/logger.dart'; const _settingsKey = 'app_settings'; const _migrationVersionKey = 'settings_migration_version'; -const _currentMigrationVersion = 2; +const _currentMigrationVersion = 4; const _spotifyClientSecretKey = 'spotify_client_secret'; final _log = AppLogger('SettingsProvider'); class SettingsNotifier extends Notifier { + static const List _youtubeOpusSupportedBitrates = [128, 256]; + static const List _youtubeMp3SupportedBitrates = [128, 256, 320]; + static final RegExp _isoRegionPattern = RegExp(r'^[A-Z]{2}$'); + final Future _prefs = SharedPreferences.getInstance(); final FlutterSecureStorage _secureStorage = const FlutterSecureStorage(); bool _isSavingSettings = false; @@ -32,6 +37,8 @@ class SettingsNotifier extends Notifier { state = AppSettings.fromJson(jsonDecode(json)); await _runMigrations(prefs); + await _normalizeYouTubeBitratesIfNeeded(); + await _normalizeSongLinkRegionIfNeeded(); } await _loadSpotifyClientSecret(prefs); @@ -41,6 +48,7 @@ class SettingsNotifier extends Notifier { LogBuffer.loggingEnabled = state.enableLogging; _syncLyricsSettingsToBackend(); + _syncNetworkCompatibilitySettingsToBackend(); } void _syncLyricsSettingsToBackend() { @@ -58,6 +66,16 @@ class SettingsNotifier extends Notifier { }); } + void _syncNetworkCompatibilitySettingsToBackend() { + final compatibilityMode = state.networkCompatibilityMode; + PlatformBridge.setNetworkCompatibilityOptions( + allowHttp: compatibilityMode, + insecureTls: compatibilityMode, + ).catchError((e) { + _log.w('Failed to sync network compatibility options to backend: $e'); + }); + } + Future _runMigrations(SharedPreferences prefs) async { final lastMigration = prefs.getInt(_migrationVersionKey) ?? 0; @@ -76,6 +94,18 @@ class SettingsNotifier extends Notifier { if (!state.isFirstLaunch && !state.hasCompletedTutorial) { state = state.copyWith(hasCompletedTutorial: true); } + // Migration 4: include Spotify Lyrics API in provider order for existing users + if (!state.lyricsProviders.contains('spotify_api')) { + final updatedProviders = List.from(state.lyricsProviders); + final lrclibIndex = updatedProviders.indexOf('lrclib'); + if (lrclibIndex >= 0) { + updatedProviders.insert(lrclibIndex + 1, 'spotify_api'); + } else { + updatedProviders.add('spotify_api'); + } + state = state.copyWith(lyricsProviders: updatedProviders); + } + state = state.copyWith(lastSeenVersion: AppInfo.version); await prefs.setInt(_migrationVersionKey, _currentMigrationVersion); await _saveSettings(); } @@ -107,6 +137,62 @@ class SettingsNotifier extends Notifier { } } + int _nearestSupportedBitrate(int value, List supported) { + var nearest = supported.first; + var nearestDistance = (value - nearest).abs(); + + for (final option in supported.skip(1)) { + final distance = (value - option).abs(); + // On tie, prefer higher quality bitrate. + if (distance < nearestDistance || + (distance == nearestDistance && option > nearest)) { + nearest = option; + nearestDistance = distance; + } + } + + return nearest; + } + + int _normalizeYouTubeOpusBitrate(int bitrate) { + return _nearestSupportedBitrate(bitrate, _youtubeOpusSupportedBitrates); + } + + int _normalizeYouTubeMp3Bitrate(int bitrate) { + return _nearestSupportedBitrate(bitrate, _youtubeMp3SupportedBitrates); + } + + Future _normalizeYouTubeBitratesIfNeeded() async { + final normalizedOpus = _normalizeYouTubeOpusBitrate( + state.youtubeOpusBitrate, + ); + final normalizedMp3 = _normalizeYouTubeMp3Bitrate(state.youtubeMp3Bitrate); + + if (normalizedOpus == state.youtubeOpusBitrate && + normalizedMp3 == state.youtubeMp3Bitrate) { + return; + } + + state = state.copyWith( + youtubeOpusBitrate: normalizedOpus, + youtubeMp3Bitrate: normalizedMp3, + ); + await _saveSettings(); + } + + String _normalizeSongLinkRegion(String region) { + final normalized = region.trim().toUpperCase(); + if (_isoRegionPattern.hasMatch(normalized)) return normalized; + return 'US'; + } + + Future _normalizeSongLinkRegionIfNeeded() async { + final normalized = _normalizeSongLinkRegion(state.songLinkRegion); + if (normalized == state.songLinkRegion) return; + state = state.copyWith(songLinkRegion: normalized); + await _saveSettings(); + } + Future _loadSpotifyClientSecret(SharedPreferences prefs) async { final storedSecret = await _secureStorage.read( key: _spotifyClientSecretKey, @@ -198,6 +284,11 @@ class SettingsNotifier extends Notifier { _saveSettings(); } + void setEmbedMetadata(bool enabled) { + state = state.copyWith(embedMetadata: enabled); + _saveSettings(); + } + void setLyricsMode(String mode) { if (mode == 'embed' || mode == 'external' || mode == 'both') { state = state.copyWith(lyricsMode: mode); @@ -230,7 +321,9 @@ class SettingsNotifier extends Notifier { } void setMusixmatchLanguage(String languageCode) { - state = state.copyWith(musixmatchLanguage: languageCode.trim().toLowerCase()); + state = state.copyWith( + musixmatchLanguage: languageCode.trim().toLowerCase(), + ); _saveSettings(); _syncLyricsSettingsToBackend(); } @@ -390,6 +483,18 @@ class SettingsNotifier extends Notifier { _saveSettings(); } + void setYoutubeOpusBitrate(int bitrate) { + final normalized = _normalizeYouTubeOpusBitrate(bitrate); + state = state.copyWith(youtubeOpusBitrate: normalized); + _saveSettings(); + } + + void setYoutubeMp3Bitrate(int bitrate) { + final normalized = _normalizeYouTubeMp3Bitrate(bitrate); + state = state.copyWith(youtubeMp3Bitrate: normalized); + _saveSettings(); + } + void setUseAllFilesAccess(bool enabled) { state = state.copyWith(useAllFilesAccess: enabled); _saveSettings(); @@ -405,6 +510,18 @@ class SettingsNotifier extends Notifier { _saveSettings(); } + void setNetworkCompatibilityMode(bool enabled) { + state = state.copyWith(networkCompatibilityMode: enabled); + _saveSettings(); + _syncNetworkCompatibilitySettingsToBackend(); + } + + void setSongLinkRegion(String region) { + final normalized = _normalizeSongLinkRegion(region); + state = state.copyWith(songLinkRegion: normalized); + _saveSettings(); + } + void setLocalLibraryEnabled(bool enabled) { state = state.copyWith(localLibraryEnabled: enabled); _saveSettings(); diff --git a/lib/providers/track_provider.dart b/lib/providers/track_provider.dart index 3ecc340e..32022b81 100644 --- a/lib/providers/track_provider.dart +++ b/lib/providers/track_provider.dart @@ -551,6 +551,7 @@ class TrackNotifier extends Notifier { state = TrackState( isLoading: true, hasSearchText: state.hasSearchText, + isShowingRecentAccess: state.isShowingRecentAccess, selectedSearchFilter: currentFilter, ); @@ -713,6 +714,7 @@ class TrackNotifier extends Notifier { searchPlaylists: playlists, isLoading: false, hasSearchText: state.hasSearchText, + isShowingRecentAccess: state.isShowingRecentAccess, selectedSearchFilter: currentFilter, // Preserve filter in results ); } catch (e, stackTrace) { @@ -722,6 +724,7 @@ class TrackNotifier extends Notifier { isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText, + isShowingRecentAccess: state.isShowingRecentAccess, selectedSearchFilter: currentFilter, ); } @@ -737,6 +740,7 @@ class TrackNotifier extends Notifier { state = TrackState( isLoading: true, hasSearchText: state.hasSearchText, + isShowingRecentAccess: state.isShowingRecentAccess, selectedSearchFilter: state.selectedSearchFilter, // Preserve filter during loading ); @@ -776,6 +780,7 @@ class TrackNotifier extends Notifier { searchArtists: [], isLoading: false, hasSearchText: state.hasSearchText, + isShowingRecentAccess: state.isShowingRecentAccess, searchExtensionId: extensionId, // Store which extension was used selectedSearchFilter: state.selectedSearchFilter, // Preserve selected filter @@ -787,6 +792,7 @@ class TrackNotifier extends Notifier { isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText, + isShowingRecentAccess: state.isShowingRecentAccess, ); } } @@ -808,6 +814,8 @@ class TrackNotifier extends Notifier { artistName: track.artistName, albumName: track.albumName, albumArtist: track.albumArtist, + artistId: track.artistId, + albumId: track.albumId, coverUrl: track.coverUrl, isrc: track.isrc, duration: track.duration, @@ -876,19 +884,23 @@ class TrackNotifier extends Notifier { playlistName: playlistName, coverUrl: coverUrl, hasSearchText: state.hasSearchText, + isShowingRecentAccess: state.isShowingRecentAccess, ); } Track _parseTrack(Map data) { + final durationMs = _extractDurationMs(data); return Track( id: data['spotify_id'] as String? ?? '', name: data['name'] as String? ?? '', artistName: data['artists'] as String? ?? '', albumName: data['album_name'] as String? ?? '', albumArtist: data['album_artist'] as String?, + artistId: (data['artist_id'] ?? data['artistId'])?.toString(), + albumId: data['album_id']?.toString(), coverUrl: data['images'] as String?, isrc: data['isrc'] as String?, - duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(), + duration: (durationMs / 1000).round(), trackNumber: data['track_number'] as int?, discNumber: data['disc_number'] as int?, releaseDate: data['release_date'] as String?, @@ -896,13 +908,7 @@ class TrackNotifier extends Notifier { } Track _parseSearchTrack(Map data, {String? source}) { - int durationMs = 0; - final durationValue = data['duration_ms']; - if (durationValue is int) { - durationMs = durationValue; - } else if (durationValue is double) { - durationMs = durationValue.toInt(); - } + final durationMs = _extractDurationMs(data); final itemType = data['item_type']?.toString(); @@ -912,6 +918,8 @@ class TrackNotifier extends Notifier { artistName: (data['artists'] ?? data['artist'] ?? '').toString(), albumName: (data['album_name'] ?? data['album'] ?? '').toString(), albumArtist: data['album_artist']?.toString(), + artistId: (data['artist_id'] ?? data['artistId'])?.toString(), + albumId: data['album_id']?.toString(), coverUrl: (data['cover_url'] ?? data['images'])?.toString(), isrc: data['isrc']?.toString(), duration: (durationMs / 1000).round(), @@ -927,6 +935,32 @@ class TrackNotifier extends Notifier { ); } + int _extractDurationMs(Map data) { + final durationMsRaw = data['duration_ms']; + if (durationMsRaw is num && durationMsRaw > 0) { + return durationMsRaw.toInt(); + } + if (durationMsRaw is String) { + final parsed = num.tryParse(durationMsRaw.trim()); + if (parsed != null && parsed > 0) { + return parsed.toInt(); + } + } + + final durationSecRaw = data['duration']; + if (durationSecRaw is num && durationSecRaw > 0) { + return (durationSecRaw * 1000).toInt(); + } + if (durationSecRaw is String) { + final parsed = num.tryParse(durationSecRaw.trim()); + if (parsed != null && parsed > 0) { + return (parsed * 1000).toInt(); + } + } + + return 0; + } + ArtistAlbum _parseArtistAlbum(Map data) { return ArtistAlbum( id: data['id'] as String? ?? '', diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index 8224c2b5..a5411ae3 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -1,22 +1,21 @@ -import 'dart:ui'; - import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/models/track.dart'; -import 'package:spotiflac_android/models/download_item.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/recent_access_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; +import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/file_access.dart'; +import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; -import 'package:spotiflac_android/screens/artist_screen.dart'; -import 'package:spotiflac_android/screens/home_tab.dart' - show ExtensionArtistScreen; +import 'package:spotiflac_android/providers/library_collections_provider.dart'; +import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart'; +import 'package:spotiflac_android/utils/clickable_metadata.dart'; class _AlbumCache { static final Map _cache = {}; @@ -117,12 +116,39 @@ class _AlbumScreenState extends ConsumerState { } void _onScroll() { - final shouldShow = _scrollController.offset > 280; + final expandedHeight = _calculateExpandedHeight(context); + final shouldShow = + _scrollController.offset > (expandedHeight - kToolbarHeight - 20); if (shouldShow != _showTitleInAppBar) { setState(() => _showTitleInAppBar = shouldShow); } } + double _calculateExpandedHeight(BuildContext context) { + final mediaSize = MediaQuery.of(context).size; + return (mediaSize.height * 0.55).clamp(360.0, 520.0); + } + + /// Upgrade cover URL to a reasonable resolution for full-screen display. + /// Spotify CDN only has 300, 640, ~2000 — we stay at 640 (no intermediate). + /// Deezer CDN: upgrade to 1000x1000 (available: 56, 250, 500, 1000, 1400, 1800). + String? _highResCoverUrl(String? url) { + if (url == null) return null; + // Spotify CDN: upgrade 300 → 640 only (no intermediate between 640 and 2000) + if (url.contains('ab67616d00001e02')) { + return url.replaceAll('ab67616d00001e02', 'ab67616d0000b273'); + } + // Deezer CDN: upgrade to 1000x1000 + final deezerRegex = RegExp(r'/(\d+)x(\d+)-(\d+)-(\d+)-(\d+)-(\d+)\.jpg$'); + if (url.contains('cdn-images.dzcdn.net') && deezerRegex.hasMatch(url)) { + return url.replaceAllMapped( + deezerRegex, + (m) => '/1000x1000-${m[3]}-${m[4]}-${m[5]}-${m[6]}.jpg', + ); + } + return url; + } + String _formatReleaseDate(String date) { if (date.length >= 10) { final parts = date.substring(0, 10).split('-'); @@ -160,7 +186,8 @@ class _AlbumScreenState extends ConsumerState { .toList(); final albumInfo = metadata['album_info'] as Map?; - final artistId = albumInfo?['artist_id'] as String?; + final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId']) + ?.toString(); _AlbumCache.set(widget.albumId, tracks); @@ -188,6 +215,9 @@ class _AlbumScreenState extends ConsumerState { artistName: data['artists'] as String? ?? '', albumName: data['album_name'] as String? ?? '', albumArtist: data['album_artist'] as String?, + artistId: + (data['artist_id'] ?? data['artistId'])?.toString() ?? _artistId, + albumId: data['album_id']?.toString() ?? widget.albumId, coverUrl: data['images'] as String?, isrc: data['isrc'] as String?, duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(), @@ -201,12 +231,14 @@ class _AlbumScreenState extends ConsumerState { Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final tracks = _tracks ?? []; + final pageBackgroundColor = colorScheme.surface; return Scaffold( + backgroundColor: pageBackgroundColor, body: CustomScrollView( controller: _scrollController, slivers: [ - _buildAppBar(context, colorScheme), + _buildAppBar(context, colorScheme, pageBackgroundColor), _buildInfoCard(context, colorScheme), if (_isLoading) const SliverToBoxAdapter( @@ -223,7 +255,6 @@ class _AlbumScreenState extends ConsumerState { ), ), if (!_isLoading && _error == null && tracks.isNotEmpty) ...[ - _buildTrackListHeader(context, colorScheme), _buildTrackList(context, colorScheme, tracks), ], const SliverToBoxAdapter(child: SizedBox(height: 32)), @@ -232,21 +263,21 @@ class _AlbumScreenState extends ConsumerState { ); } - Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) { - final mediaSize = MediaQuery.of(context).size; - final screenWidth = mediaSize.width; - final shortestSide = mediaSize.shortestSide; - final coverSize = (screenWidth * 0.5).clamp(140.0, 220.0); - final expandedHeight = (shortestSide * 0.82).clamp(280.0, 340.0); - final bottomGradientHeight = (shortestSide * 0.2).clamp(56.0, 80.0); - final coverTopPadding = (shortestSide * 0.14).clamp(40.0, 60.0); - final fallbackIconSize = (coverSize * 0.32).clamp(44.0, 64.0); + Widget _buildAppBar( + BuildContext context, + ColorScheme colorScheme, + Color pageBackgroundColor, + ) { + final expandedHeight = _calculateExpandedHeight(context); + final tracks = _tracks ?? []; + final artistName = tracks.isNotEmpty ? tracks.first.artistName : null; + final releaseDate = tracks.isNotEmpty ? tracks.first.releaseDate : null; return SliverAppBar( expandedHeight: expandedHeight, pinned: true, stretch: true, - backgroundColor: colorScheme.surface, + backgroundColor: pageBackgroundColor, surfaceTintColor: Colors.transparent, title: AnimatedOpacity( duration: const Duration(milliseconds: 200), @@ -268,25 +299,18 @@ class _AlbumScreenState extends ConsumerState { (constraints.maxHeight - kToolbarHeight) / (expandedHeight - kToolbarHeight); final showContent = collapseRatio > 0.3; - final dpr = MediaQuery.devicePixelRatioOf( - context, - ).clamp(1.0, 3.0).toDouble(); - final backgroundMemCacheWidth = (constraints.maxWidth * dpr) - .round() - .clamp(720, 1440) - .toInt(); return FlexibleSpaceBar( - collapseMode: CollapseMode.none, + collapseMode: CollapseMode.pin, background: Stack( fit: StackFit.expand, children: [ - // Blurred cover background + // Full-screen cover background (no blur, full resolution) if (widget.coverUrl != null) CachedNetworkImage( - imageUrl: widget.coverUrl!, + imageUrl: + _highResCoverUrl(widget.coverUrl) ?? widget.coverUrl!, fit: BoxFit.cover, - memCacheWidth: backgroundMemCacheWidth, cacheManager: CoverCacheManager.instance, placeholder: (_, _) => Container(color: colorScheme.surface), @@ -294,80 +318,175 @@ class _AlbumScreenState extends ConsumerState { Container(color: colorScheme.surface), ) else - Container(color: colorScheme.surface), - ClipRect( - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30), - child: Container( - color: colorScheme.surface.withValues(alpha: 0.4), + Container( + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.album, + size: 80, + color: colorScheme.onSurfaceVariant, ), ), - ), + // Bottom gradient for readability Positioned( left: 0, right: 0, bottom: 0, - height: bottomGradientHeight, + height: expandedHeight * 0.65, child: Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ - colorScheme.surface.withValues(alpha: 0.0), - colorScheme.surface, + Colors.transparent, + Colors.black.withValues(alpha: 0.85), ], ), ), ), ), - AnimatedOpacity( - duration: const Duration(milliseconds: 150), - opacity: showContent ? 1.0 : 0.0, - child: Center( - child: Padding( - padding: EdgeInsets.only(top: coverTopPadding), - child: Container( - width: coverSize, - height: coverSize, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.4), - blurRadius: 30, - offset: const Offset(0, 15), - ), - ], + // Album info overlay at bottom + Positioned( + left: 20, + right: 20, + bottom: 40, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 150), + opacity: showContent ? 1.0 : 0.0, + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + widget.albumName, + style: const TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.bold, + height: 1.2, + ), + textAlign: TextAlign.center, + maxLines: 3, + overflow: TextOverflow.ellipsis, ), - child: ClipRRect( - borderRadius: BorderRadius.circular(20), - child: widget.coverUrl != null - ? CachedNetworkImage( - imageUrl: widget.coverUrl!, - fit: BoxFit.cover, - memCacheWidth: (coverSize * 2).toInt(), - cacheManager: CoverCacheManager.instance, - ) - : Container( - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.album, - size: fallbackIconSize, - color: colorScheme.onSurfaceVariant, + if (artistName != null && artistName.isNotEmpty) ...[ + const SizedBox(height: 6), + ClickableArtistName( + artistName: artistName, + artistId: _artistId, + coverUrl: widget.coverUrl, + extensionId: widget.extensionId, + style: TextStyle( + color: colorScheme.primary, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + if (tracks.isNotEmpty) ...[ + const SizedBox(height: 12), + Wrap( + alignment: WrapAlignment.center, + spacing: 8, + runSpacing: 8, + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.music_note, + size: 14, + color: Colors.white, + ), + const SizedBox(width: 4), + Text( + context.l10n.tracksCount(tracks.length), + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 12, + ), + ), + ], + ), + ), + if (releaseDate != null && releaseDate.isNotEmpty) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.calendar_today, + size: 14, + color: Colors.white, + ), + const SizedBox(width: 4), + Text( + _formatReleaseDate(releaseDate), + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 12, + ), + ), + ], ), ), - ), - ), + ], + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildLoveAllButton(), + const SizedBox(width: 12), + FilledButton.icon( + onPressed: () => _downloadAll(context), + icon: Icon(Icons.download, size: 18), + label: Text( + context.l10n.downloadAllCount(tracks.length), + ), + style: FilledButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.black87, + minimumSize: const Size(0, 48), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), + ), + ), + const SizedBox(width: 12), + _buildAddToPlaylistButton(context), + ], + ), + ], + ], ), ), ), ], ), - stretchModes: const [ - StretchMode.zoomBackground, - StretchMode.blurBackground, - ], + stretchModes: const [StretchMode.zoomBackground], ); }, ), @@ -375,10 +494,10 @@ class _AlbumScreenState extends ConsumerState { icon: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: colorScheme.surface.withValues(alpha: 0.8), + color: Colors.black.withValues(alpha: 0.4), shape: BoxShape.circle, ), - child: Icon(Icons.arrow_back, color: colorScheme.onSurface), + child: const Icon(Icons.arrow_back, color: Colors.white), ), onPressed: () => Navigator.pop(context), ), @@ -386,151 +505,8 @@ class _AlbumScreenState extends ConsumerState { } Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) { - final tracks = _tracks ?? []; - final artistName = tracks.isNotEmpty ? tracks.first.artistName : null; - final releaseDate = tracks.isNotEmpty ? tracks.first.releaseDate : null; - - return SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(16), - child: Card( - elevation: 0, - color: colorScheme.surfaceContainerLow, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widget.albumName, - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.bold, - color: colorScheme.onSurface, - ), - ), - if (artistName != null && artistName.isNotEmpty) ...[ - const SizedBox(height: 4), - GestureDetector( - onTap: () => _navigateToArtist(context, artistName), - child: Text( - artistName, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: colorScheme.primary, - ), - ), - ), - ], - const SizedBox(height: 12), - if (tracks.isNotEmpty) - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: colorScheme.secondaryContainer, - borderRadius: BorderRadius.circular(20), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.music_note, - size: 14, - color: colorScheme.onSecondaryContainer, - ), - const SizedBox(width: 4), - Text( - context.l10n.tracksCount(tracks.length), - style: TextStyle( - color: colorScheme.onSecondaryContainer, - fontWeight: FontWeight.w600, - fontSize: 12, - ), - ), - ], - ), - ), - if (releaseDate != null && releaseDate.isNotEmpty) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: colorScheme.tertiaryContainer, - borderRadius: BorderRadius.circular(20), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.calendar_today, - size: 14, - color: colorScheme.onTertiaryContainer, - ), - const SizedBox(width: 4), - Text( - _formatReleaseDate(releaseDate), - style: TextStyle( - color: colorScheme.onTertiaryContainer, - fontWeight: FontWeight.w600, - fontSize: 12, - ), - ), - ], - ), - ), - ], - ), - if (tracks.isNotEmpty) ...[ - const SizedBox(height: 16), - FilledButton.icon( - onPressed: () => _downloadAll(context), - icon: const Icon(Icons.download, size: 18), - label: Text(context.l10n.downloadAllCount(tracks.length)), - style: FilledButton.styleFrom( - minimumSize: const Size.fromHeight(48), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(24), - ), - ), - ), - ], - ], - ), - ), - ), - ), - ); - } - - Widget _buildTrackListHeader(BuildContext context, ColorScheme colorScheme) { - return SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(20, 8, 20, 8), - child: Row( - children: [ - Icon(Icons.queue_music, size: 20, color: colorScheme.primary), - const SizedBox(width: 8), - Text( - context.l10n.tracksHeader, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - color: colorScheme.onSurface, - ), - ), - ], - ), - ), - ); + // Info is now displayed in the full-screen cover overlay + return const SliverToBoxAdapter(child: SizedBox.shrink()); } Widget _buildTrackList( @@ -615,47 +591,94 @@ class _AlbumScreenState extends ConsumerState { } } - void _navigateToArtist(BuildContext context, String artistName) { - final artistId = - _artistId ?? - (widget.albumId.startsWith('deezer:') ? 'deezer:unknown' : 'unknown'); + Widget _buildLoveAllButton() { + final collectionsState = ref.watch(libraryCollectionsProvider); + final tracks = _tracks; + final allLoved = + tracks != null && + tracks.isNotEmpty && + tracks.every((t) => collectionsState.isLoved(t)); - if (artistId == 'unknown' || - artistId == 'deezer:unknown' || - artistId.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Artist information not available')), - ); - return; - } - - if (widget.extensionId != null) { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => ExtensionArtistScreen( - extensionId: widget.extensionId!, - artistId: artistId, - artistName: artistName, - coverUrl: widget.coverUrl, - ), - ), - ); - return; - } - - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => ArtistScreen( - artistId: artistId, - artistName: artistName, - coverUrl: widget.coverUrl, + return Container( + width: 48, + height: 48, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white.withValues(alpha: 0.15), + border: Border.all( + color: Colors.white.withValues(alpha: 0.3), + width: 1, ), ), + child: IconButton( + onPressed: tracks == null || tracks.isEmpty + ? null + : () => _loveAll(tracks), + icon: Icon( + allLoved ? Icons.favorite : Icons.favorite_border, + size: 22, + color: allLoved ? Colors.redAccent : Colors.white, + ), + tooltip: allLoved ? 'Remove from Loved' : 'Love All', + padding: EdgeInsets.zero, + ), ); } + Widget _buildAddToPlaylistButton(BuildContext context) { + return Container( + width: 48, + height: 48, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white.withValues(alpha: 0.15), + border: Border.all( + color: Colors.white.withValues(alpha: 0.3), + width: 1, + ), + ), + child: IconButton( + onPressed: _tracks == null || _tracks!.isEmpty + ? null + : () => showAddTracksToPlaylistSheet(context, ref, _tracks!), + icon: const Icon(Icons.add, size: 22, color: Colors.white), + tooltip: 'Add to Playlist', + padding: EdgeInsets.zero, + ), + ); + } + + Future _loveAll(List tracks) async { + final notifier = ref.read(libraryCollectionsProvider.notifier); + final state = ref.read(libraryCollectionsProvider); + final allLoved = tracks.every((t) => state.isLoved(t)); + + if (allLoved) { + for (final track in tracks) { + final key = trackCollectionKey(track); + await notifier.removeFromLoved(key); + } + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Removed ${tracks.length} tracks from Loved')), + ); + } + } else { + int addedCount = 0; + for (final track in tracks) { + if (!state.isLoved(track)) { + await notifier.toggleLoved(track); + addedCount++; + } + } + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Added $addedCount tracks to Loved')), + ); + } + } + } + Widget _buildErrorWidget(String error, ColorScheme colorScheme) { final isRateLimit = error.contains('429') || @@ -739,7 +762,12 @@ class _AlbumTrackItem extends ConsumerWidget { final isInHistory = ref.watch( downloadHistoryProvider.select((state) { - return state.isDownloaded(track.id); + if (state.isDownloaded(track.id)) return true; + final isrc = track.isrc?.trim(); + if (isrc != null && isrc.isNotEmpty && state.getByIsrc(isrc) != null) { + return true; + } + return state.findByTrackAndArtist(track.name, track.artistName) != null; }), ); @@ -761,13 +789,6 @@ class _AlbumTrackItem extends ConsumerWidget { : false; final isQueued = queueItem != null; - final isDownloading = queueItem?.status == DownloadStatus.downloading; - final isFinalizing = queueItem?.status == DownloadStatus.finalizing; - final isCompleted = queueItem?.status == DownloadStatus.completed; - final progress = queueItem?.progress ?? 0.0; - - final showAsDownloaded = - isCompleted || (!isQueued && isInHistory) || isInLocalLibrary; return Padding( padding: const EdgeInsets.symmetric(horizontal: 8), @@ -802,14 +823,16 @@ class _AlbumTrackItem extends ConsumerWidget { subtitle: Row( children: [ Flexible( - child: Text( - track.artistName, + child: ClickableArtistName( + artistName: track.artistName, + artistId: track.artistId, + coverUrl: track.coverUrl, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant), ), ), - if (isInLocalLibrary) ...[ + if (isInLocalLibrary || isInHistory) ...[ const SizedBox(width: 6), Container( padding: const EdgeInsets.symmetric( @@ -843,24 +866,12 @@ class _AlbumTrackItem extends ConsumerWidget { ], ], ), - trailing: _buildDownloadButton( + trailing: TrackCollectionQuickActions(track: track), + onTap: () => _handleTap(context, ref, isQueued: isQueued), + onLongPress: () => TrackCollectionQuickActions.showTrackOptionsSheet( context, ref, - colorScheme, - isQueued: isQueued, - isDownloading: isDownloading, - isFinalizing: isFinalizing, - showAsDownloaded: showAsDownloaded, - isInHistory: isInHistory, - isInLocalLibrary: isInLocalLibrary, - progress: progress, - ), - onTap: () => _handleTap( - context, - ref, - isQueued: isQueued, - isInHistory: isInHistory, - isInLocalLibrary: isInLocalLibrary, + track, ), ), ), @@ -871,160 +882,84 @@ class _AlbumTrackItem extends ConsumerWidget { BuildContext context, WidgetRef ref, { required bool isQueued, - required bool isInHistory, - required bool isInLocalLibrary, }) async { if (isQueued) return; - if (isInLocalLibrary) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.snackbarAlreadyInLibrary(track.name)), - ), - ); - } + final playedLocal = await _playLocalIfAvailable(context, ref); + if (playedLocal) { return; } - if (isInHistory) { - final historyItem = ref - .read(downloadHistoryProvider.notifier) - .getBySpotifyId(track.id); - if (historyItem != null) { - final exists = await fileExists(historyItem.filePath); - if (exists) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.snackbarAlreadyDownloaded(track.name), - ), - ), - ); - } - return; - } else { - ref - .read(downloadHistoryProvider.notifier) - .removeBySpotifyId(track.id); - } - } - } - onDownload(); } - Widget _buildDownloadButton( + Future _playLocalIfAvailable( BuildContext context, WidgetRef ref, - ColorScheme colorScheme, { - required bool isQueued, - required bool isDownloading, - required bool isFinalizing, - required bool showAsDownloaded, - required bool isInHistory, - required bool isInLocalLibrary, - required double progress, - }) { - const double size = 44.0; - const double iconSize = 20.0; + ) async { + final localState = ref.read(localLibraryProvider); + final historyState = ref.read(downloadHistoryProvider); + final historyNotifier = ref.read(downloadHistoryProvider.notifier); - if (showAsDownloaded) { - return GestureDetector( - onTap: () => _handleTap( - context, - ref, - isQueued: isQueued, - isInHistory: isInHistory, - isInLocalLibrary: isInLocalLibrary, - ), - child: Container( - width: size, - height: size, - decoration: BoxDecoration( - color: colorScheme.primaryContainer, - shape: BoxShape.circle, - ), - child: Icon( - Icons.check, - color: colorScheme.onPrimaryContainer, - size: iconSize, - ), - ), + try { + DownloadHistoryItem? historyItem = historyNotifier.getBySpotifyId( + track.id, ); - } else if (isFinalizing) { - return SizedBox( - width: size, - height: size, - child: Stack( - alignment: Alignment.center, - children: [ - CircularProgressIndicator( - strokeWidth: 3, - color: colorScheme.tertiary, - backgroundColor: colorScheme.surfaceContainerHighest, - ), - Icon(Icons.edit_note, color: colorScheme.tertiary, size: 16), - ], - ), + final isrc = track.isrc?.trim(); + historyItem ??= (isrc != null && isrc.isNotEmpty) + ? historyNotifier.getByIsrc(isrc) + : null; + historyItem ??= historyState.findByTrackAndArtist( + track.name, + track.artistName, ); - } else if (isDownloading) { - return SizedBox( - width: size, - height: size, - child: Stack( - alignment: Alignment.center, - children: [ - CircularProgressIndicator( - value: progress > 0 ? progress : null, - strokeWidth: 3, - color: colorScheme.primary, - backgroundColor: colorScheme.surfaceContainerHighest, - ), - if (progress > 0) - Text( - '${(progress * 100).toInt()}', - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.bold, - color: colorScheme.primary, - ), - ), - ], - ), - ); - } else if (isQueued) { - return Container( - width: size, - height: size, - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest, - shape: BoxShape.circle, - ), - child: Icon( - Icons.hourglass_empty, - color: colorScheme.onSurfaceVariant, - size: iconSize, - ), - ); - } else { - return GestureDetector( - onTap: onDownload, - child: Container( - width: size, - height: size, - decoration: BoxDecoration( - color: colorScheme.secondaryContainer, - shape: BoxShape.circle, - ), - child: Icon( - Icons.download, - color: colorScheme.onSecondaryContainer, - size: iconSize, - ), - ), + + if (historyItem != null) { + final exists = await fileExists(historyItem.filePath); + if (exists) { + await ref + .read(playbackProvider.notifier) + .playLocalPath( + path: historyItem.filePath, + title: track.name, + artist: track.artistName, + album: track.albumName, + coverUrl: track.coverUrl ?? '', + ); + return true; + } + historyNotifier.removeFromHistory(historyItem.id); + } + + var localItem = (isrc != null && isrc.isNotEmpty) + ? localState.getByIsrc(isrc) + : null; + localItem ??= localState.findByTrackAndArtist( + track.name, + track.artistName, ); + + if (localItem != null && await fileExists(localItem.filePath)) { + await ref + .read(playbackProvider.notifier) + .playLocalPath( + path: localItem.filePath, + title: localItem.trackName, + artist: localItem.artistName, + album: localItem.albumName, + coverUrl: localItem.coverPath ?? track.coverUrl ?? '', + ); + return true; + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.snackbarCannotOpenFile('$e'))), + ); + } + return true; } + + return false; } } diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index 97737649..43cfa2eb 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -6,18 +6,20 @@ import 'package:intl/intl.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/models/track.dart'; -import 'package:spotiflac_android/models/download_item.dart'; import 'package:spotiflac_android/providers/track_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/recent_access_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; +import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/screens/album_screen.dart'; import 'package:spotiflac_android/screens/home_tab.dart' show ExtensionAlbumScreen; import 'package:spotiflac_android/widgets/download_service_picker.dart'; +import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; +import 'package:spotiflac_android/utils/clickable_metadata.dart'; /// Simple in-memory cache for artist data class _ArtistCache { @@ -309,6 +311,10 @@ class _ArtistScreenState extends ConsumerState { artistName: (data['artists'] ?? data['artist'] ?? '').toString(), albumName: (data['album_name'] ?? data['album'] ?? '').toString(), albumArtist: data['album_artist']?.toString(), + artistId: + (data['artist_id'] ?? data['artistId'])?.toString() ?? + widget.artistId, + albumId: data['album_id']?.toString(), coverUrl: (data['cover_url'] ?? data['images'])?.toString(), isrc: data['isrc']?.toString(), duration: (durationMs / 1000).round(), @@ -675,6 +681,7 @@ class _ArtistScreenState extends ConsumerState { showModalBottomSheet( context: context, + useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(28)), @@ -772,7 +779,6 @@ class _ArtistScreenState extends ConsumerState { List albums, ) async { final settings = ref.read(settingsProvider); - if (settings.askQualityBeforeDownload) { DownloadServicePicker.show( context, @@ -990,6 +996,8 @@ class _ArtistScreenState extends ConsumerState { .toString(), albumName: album.name, albumArtist: widget.artistName, + artistId: widget.artistId, + albumId: album.id.isNotEmpty ? album.id : null, coverUrl: album.coverUrl, isrc: data['isrc']?.toString(), duration: (durationMs / 1000).round(), @@ -1100,63 +1108,72 @@ class _ArtistScreenState extends ConsumerState { left: 16, right: 16, bottom: 16, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, children: [ - Text( - widget.artistName, - style: Theme.of(context).textTheme.headlineLarge?.copyWith( - fontWeight: FontWeight.bold, - color: Colors.white, - shadows: [ - Shadow( - offset: const Offset(0, 1), - blurRadius: 4, - color: Colors.black.withValues(alpha: 0.5), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + widget.artistName, + style: Theme.of(context).textTheme.headlineLarge + ?.copyWith( + fontWeight: FontWeight.bold, + color: Colors.white, + shadows: [ + Shadow( + offset: const Offset(0, 1), + blurRadius: 4, + color: Colors.black.withValues(alpha: 0.5), + ), + ], + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, ), - ], - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - if (listenersText != null) ...[ - const SizedBox(height: 4), - Text( - listenersText, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Colors.white.withValues(alpha: 0.8), - shadows: [ - Shadow( - offset: const Offset(0, 1), - blurRadius: 2, - color: Colors.black.withValues(alpha: 0.5), + if (listenersText != null) ...[ + const SizedBox(height: 4), + Text( + listenersText, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith( + color: Colors.white.withValues(alpha: 0.8), + shadows: [ + Shadow( + offset: const Offset(0, 1), + blurRadius: 2, + color: Colors.black.withValues( + alpha: 0.5, + ), + ), + ], + ), ), ], - ), + ], ), - ], - // Download Discography button + ), + // Download Discography button (icon only, right-aligned) if (hasDiscography && !_isSelectionMode) ...[ - const SizedBox(height: 12), - SizedBox( - height: 40, - child: FilledButton.icon( + const SizedBox(width: 12), + Container( + width: 52, + height: 52, + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + child: IconButton( onPressed: () => _showDiscographyOptions( context, colorScheme, albums, ), - icon: const Icon(Icons.download, size: 18), - label: Text(context.l10n.discographyDownload), - style: FilledButton.styleFrom( - backgroundColor: Colors.white, - foregroundColor: Colors.black87, - padding: const EdgeInsets.symmetric(horizontal: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), - ), + icon: const Icon(Icons.download_rounded, size: 26), + color: Colors.black87, + tooltip: context.l10n.discographyDownload, ), ), ], @@ -1227,9 +1244,17 @@ class _ArtistScreenState extends ConsumerState { ); final isInHistory = ref.watch( - downloadHistoryProvider.select( - (state) => state.isDownloaded(track.id), - ), + downloadHistoryProvider.select((state) { + if (state.isDownloaded(track.id)) return true; + final isrc = track.isrc?.trim(); + if (isrc != null && + isrc.isNotEmpty && + state.getByIsrc(isrc) != null) { + return true; + } + return state.findByTrackAndArtist(track.name, track.artistName) != + null; + }), ); final showLocalLibraryIndicator = ref.watch( @@ -1250,20 +1275,13 @@ class _ArtistScreenState extends ConsumerState { : false; final isQueued = queueItem != null; - final isDownloading = queueItem?.status == DownloadStatus.downloading; - final isFinalizing = queueItem?.status == DownloadStatus.finalizing; - final isCompleted = queueItem?.status == DownloadStatus.completed; - final progress = queueItem?.progress ?? 0.0; - - final showAsDownloaded = - isCompleted || (!isQueued && isInHistory) || isInLocalLibrary; return InkWell( - onTap: () => _handlePopularTrackTap( + onTap: () => _handlePopularTrackTap(track, isQueued: isQueued), + onLongPress: () => TrackCollectionQuickActions.showTrackOptionsSheet( + context, + ref, track, - isQueued: isQueued, - isInHistory: isInHistory, - isInLocalLibrary: isInLocalLibrary, ), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), @@ -1330,28 +1348,66 @@ class _ArtistScreenState extends ConsumerState { maxLines: 1, overflow: TextOverflow.ellipsis, ), - if (track.albumName.isNotEmpty) - Text( - track.albumName, - style: Theme.of(context).textTheme.bodySmall - ?.copyWith(color: colorScheme.onSurfaceVariant), - maxLines: 1, - overflow: TextOverflow.ellipsis, + if (track.albumName.isNotEmpty || + isInLocalLibrary || + isInHistory) + Row( + children: [ + if (track.albumName.isNotEmpty) + Expanded( + child: ClickableAlbumName( + albumName: track.albumName, + albumId: track.albumId, + artistName: track.artistName, + coverUrl: track.coverUrl, + extensionId: widget.extensionId, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (isInLocalLibrary || isInHistory) ...[ + if (track.albumName.isNotEmpty) + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: colorScheme.tertiaryContainer, + borderRadius: BorderRadius.circular(4), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.folder_outlined, + size: 10, + color: colorScheme.onTertiaryContainer, + ), + const SizedBox(width: 3), + Text( + context.l10n.libraryInLibrary, + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w500, + color: colorScheme.onTertiaryContainer, + ), + ), + ], + ), + ), + ], + ], ), ], ), ), - _buildPopularDownloadButton( - track: track, - colorScheme: colorScheme, - isQueued: isQueued, - isDownloading: isDownloading, - isFinalizing: isFinalizing, - showAsDownloaded: showAsDownloaded, - isInHistory: isInHistory, - isInLocalLibrary: isInLocalLibrary, - progress: progress, - ), + TrackCollectionQuickActions(track: track), ], ), ), @@ -1361,162 +1417,82 @@ class _ArtistScreenState extends ConsumerState { } /// Handle tap on popular track item - void _handlePopularTrackTap( - Track track, { - required bool isQueued, - required bool isInHistory, - required bool isInLocalLibrary, - }) async { + void _handlePopularTrackTap(Track track, {required bool isQueued}) async { if (isQueued) return; - if (isInLocalLibrary) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.snackbarAlreadyInLibrary(track.name)), - ), - ); - } + final playedLocal = await _playLocalIfAvailable(track); + if (playedLocal) { return; } - if (isInHistory) { - final historyItem = ref - .read(downloadHistoryProvider.notifier) - .getBySpotifyId(track.id); - if (historyItem != null) { - final exists = await fileExists(historyItem.filePath); - if (exists) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.snackbarAlreadyDownloaded(track.name), - ), - ), - ); - } - return; - } else { - ref - .read(downloadHistoryProvider.notifier) - .removeBySpotifyId(track.id); - } - } - } - _downloadTrack(track); } - Widget _buildPopularDownloadButton({ - required Track track, - required ColorScheme colorScheme, - required bool isQueued, - required bool isDownloading, - required bool isFinalizing, - required bool showAsDownloaded, - required bool isInHistory, - required bool isInLocalLibrary, - required double progress, - }) { - const double size = 40.0; - const double iconSize = 20.0; + Future _playLocalIfAvailable(Track track) async { + final localState = ref.read(localLibraryProvider); + final historyState = ref.read(downloadHistoryProvider); + final historyNotifier = ref.read(downloadHistoryProvider.notifier); - if (showAsDownloaded) { - return GestureDetector( - onTap: () => _handlePopularTrackTap( - track, - isQueued: isQueued, - isInHistory: isInHistory, - isInLocalLibrary: isInLocalLibrary, - ), - child: Container( - width: size, - height: size, - decoration: BoxDecoration( - color: colorScheme.primaryContainer, - shape: BoxShape.circle, - ), - child: Icon( - Icons.check, - color: colorScheme.onPrimaryContainer, - size: iconSize, - ), - ), + try { + DownloadHistoryItem? historyItem = historyNotifier.getBySpotifyId( + track.id, ); - } else if (isFinalizing) { - return SizedBox( - width: size, - height: size, - child: Stack( - alignment: Alignment.center, - children: [ - CircularProgressIndicator( - strokeWidth: 2.5, - color: colorScheme.tertiary, - backgroundColor: colorScheme.surfaceContainerHighest, - ), - Icon(Icons.edit_note, color: colorScheme.tertiary, size: 14), - ], - ), + final isrc = track.isrc?.trim(); + historyItem ??= (isrc != null && isrc.isNotEmpty) + ? historyNotifier.getByIsrc(isrc) + : null; + historyItem ??= historyState.findByTrackAndArtist( + track.name, + track.artistName, ); - } else if (isDownloading) { - return SizedBox( - width: size, - height: size, - child: Stack( - alignment: Alignment.center, - children: [ - CircularProgressIndicator( - value: progress > 0 ? progress : null, - strokeWidth: 2.5, - color: colorScheme.primary, - backgroundColor: colorScheme.surfaceContainerHighest, - ), - if (progress > 0) - Text( - '${(progress * 100).toInt()}', - style: TextStyle( - fontSize: 9, - fontWeight: FontWeight.bold, - color: colorScheme.primary, - ), - ), - ], - ), - ); - } else if (isQueued) { - return Container( - width: size, - height: size, - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest, - shape: BoxShape.circle, - ), - child: Icon( - Icons.hourglass_empty, - color: colorScheme.onSurfaceVariant, - size: iconSize, - ), - ); - } else { - return GestureDetector( - onTap: () => _downloadTrack(track), - child: Container( - width: size, - height: size, - decoration: BoxDecoration( - color: colorScheme.secondaryContainer, - shape: BoxShape.circle, - ), - child: Icon( - Icons.download, - color: colorScheme.onSecondaryContainer, - size: iconSize, - ), - ), + + if (historyItem != null) { + final exists = await fileExists(historyItem.filePath); + if (exists) { + await ref + .read(playbackProvider.notifier) + .playLocalPath( + path: historyItem.filePath, + title: track.name, + artist: track.artistName, + album: track.albumName, + coverUrl: track.coverUrl ?? '', + ); + return true; + } + historyNotifier.removeFromHistory(historyItem.id); + } + + var localItem = (isrc != null && isrc.isNotEmpty) + ? localState.getByIsrc(isrc) + : null; + localItem ??= localState.findByTrackAndArtist( + track.name, + track.artistName, ); + + if (localItem != null && await fileExists(localItem.filePath)) { + await ref + .read(playbackProvider.notifier) + .playLocalPath( + path: localItem.filePath, + title: localItem.trackName, + artist: localItem.artistName, + album: localItem.albumName, + coverUrl: localItem.coverPath ?? track.coverUrl ?? '', + ); + return true; + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.snackbarCannotOpenFile('$e'))), + ); + } + return true; } + + return false; } void _downloadTrack(Track track) { diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index 4f1270f3..0db150eb 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -1,13 +1,20 @@ import 'dart:io'; -import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; +import 'package:spotiflac_android/services/ffmpeg_service.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/services/history_database.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/utils/file_access.dart'; +import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; +import 'package:spotiflac_android/providers/playback_provider.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart'; import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart'; @@ -74,12 +81,35 @@ class _DownloadedAlbumScreenState extends ConsumerState { } void _onScroll() { - final shouldShow = _scrollController.offset > 280; + final expandedHeight = _calculateExpandedHeight(context); + final shouldShow = + _scrollController.offset > (expandedHeight - kToolbarHeight - 20); if (shouldShow != _showTitleInAppBar) { setState(() => _showTitleInAppBar = shouldShow); } } + double _calculateExpandedHeight(BuildContext context) { + final mediaSize = MediaQuery.of(context).size; + return (mediaSize.height * 0.55).clamp(360.0, 520.0); + } + + /// Upgrade cover URL to a reasonable resolution for full-screen display. + String? _highResCoverUrl(String? url) { + if (url == null) return null; + if (url.contains('ab67616d00001e02')) { + return url.replaceAll('ab67616d00001e02', 'ab67616d0000b273'); + } + final deezerRegex = RegExp(r'/(\d+)x(\d+)-(\d+)-(\d+)-(\d+)-(\d+)\.jpg$'); + if (url.contains('cdn-images.dzcdn.net') && deezerRegex.hasMatch(url)) { + return url.replaceAllMapped( + deezerRegex, + (m) => '/1000x1000-${m[3]}-${m[4]}-${m[5]}-${m[6]}.jpg', + ); + } + return url; + } + /// Get tracks for this album from history provider (reactive) List _getAlbumTracks( List allItems, @@ -238,9 +268,17 @@ class _DownloadedAlbumScreenState extends ConsumerState { } } - Future _openFile(String filePath) async { + Future _openFile(DownloadHistoryItem track) async { try { - await openFile(filePath); + await ref + .read(playbackProvider.notifier) + .playLocalPath( + path: track.filePath, + title: track.trackName, + artist: track.artistName, + album: track.albumName, + coverUrl: track.coverUrl ?? '', + ); } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -354,7 +392,6 @@ class _DownloadedAlbumScreenState extends ConsumerState { slivers: [ _buildAppBar(context, colorScheme, tracks), _buildInfoCard(context, colorScheme, tracks), - _buildTrackListHeader(context, colorScheme, tracks), _buildTrackList(context, colorScheme, tracks), SliverToBoxAdapter( child: SizedBox(height: _isSelectionMode ? 120 : 32), @@ -407,22 +444,15 @@ class _DownloadedAlbumScreenState extends ConsumerState { ColorScheme colorScheme, List tracks, ) { - final mediaSize = MediaQuery.of(context).size; - final screenWidth = mediaSize.width; - final shortestSide = mediaSize.shortestSide; - final coverSize = (screenWidth * 0.5).clamp(140.0, 220.0); - final expandedHeight = (shortestSide * 0.82).clamp(280.0, 340.0); - final bottomGradientHeight = (shortestSide * 0.2).clamp(56.0, 80.0); - final coverTopPadding = (shortestSide * 0.14).clamp(40.0, 60.0); - final fallbackIconSize = (coverSize * 0.32).clamp(44.0, 64.0); + final expandedHeight = _calculateExpandedHeight(context); final embeddedCoverPath = _resolveAlbumEmbeddedCoverPath(tracks); + final commonQuality = _getCommonQuality(tracks); return SliverAppBar( expandedHeight: expandedHeight, pinned: true, stretch: true, - backgroundColor: - colorScheme.surface, // Use theme color for collapsed state + backgroundColor: colorScheme.surface, surfaceTintColor: Colors.transparent, title: AnimatedOpacity( duration: const Duration(milliseconds: 200), @@ -444,33 +474,25 @@ class _DownloadedAlbumScreenState extends ConsumerState { (constraints.maxHeight - kToolbarHeight) / (expandedHeight - kToolbarHeight); final showContent = collapseRatio > 0.3; - final dpr = MediaQuery.devicePixelRatioOf( - context, - ).clamp(1.0, 3.0).toDouble(); - final backgroundMemCacheWidth = (constraints.maxWidth * dpr) - .round() - .clamp(720, 1440) - .toInt(); return FlexibleSpaceBar( - collapseMode: CollapseMode.none, + collapseMode: CollapseMode.pin, background: Stack( fit: StackFit.expand, children: [ - // Blurred cover background + // Full-screen cover background if (embeddedCoverPath != null) Image.file( File(embeddedCoverPath), fit: BoxFit.cover, - cacheWidth: backgroundMemCacheWidth, errorBuilder: (_, _, _) => Container(color: colorScheme.surface), ) else if (widget.coverUrl != null) CachedNetworkImage( - imageUrl: widget.coverUrl!, + imageUrl: + _highResCoverUrl(widget.coverUrl) ?? widget.coverUrl!, fit: BoxFit.cover, - memCacheWidth: backgroundMemCacheWidth, cacheManager: CoverCacheManager.instance, placeholder: (_, _) => Container(color: colorScheme.surface), @@ -478,96 +500,137 @@ class _DownloadedAlbumScreenState extends ConsumerState { Container(color: colorScheme.surface), ) else - Container(color: colorScheme.surface), - ClipRect( - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30), - child: Container( - color: colorScheme.surface.withValues(alpha: 0.4), + Container( + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.album, + size: 80, + color: colorScheme.onSurfaceVariant, ), ), - ), + // Bottom gradient for readability Positioned( left: 0, right: 0, bottom: 0, - height: bottomGradientHeight, + height: expandedHeight * 0.65, child: Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ - colorScheme.surface.withValues(alpha: 0.0), - colorScheme.surface, + Colors.transparent, + Colors.black.withValues(alpha: 0.85), ], ), ), ), ), - // Cover image centered - fade out when collapsing - AnimatedOpacity( - duration: const Duration(milliseconds: 150), - opacity: showContent ? 1.0 : 0.0, - child: Center( - child: Padding( - padding: EdgeInsets.only(top: coverTopPadding), - child: Container( - width: coverSize, - height: coverSize, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.4), - blurRadius: 30, - offset: const Offset(0, 15), - ), - ], + // Album info overlay at bottom + Positioned( + left: 20, + right: 20, + bottom: 40, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 150), + opacity: showContent ? 1.0 : 0.0, + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + widget.albumName, + style: const TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.bold, + height: 1.2, + ), + textAlign: TextAlign.center, + maxLines: 3, + overflow: TextOverflow.ellipsis, ), - child: ClipRRect( - borderRadius: BorderRadius.circular(20), - child: embeddedCoverPath != null - ? Image.file( - File(embeddedCoverPath), - fit: BoxFit.cover, - cacheWidth: (coverSize * 2).toInt(), - cacheHeight: (coverSize * 2).toInt(), - errorBuilder: (_, _, _) => Container( - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.album, - size: fallbackIconSize, - color: colorScheme.onSurfaceVariant, + const SizedBox(height: 6), + Text( + widget.artistName, + style: const TextStyle( + color: Colors.white70, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (tracks.isNotEmpty) ...[ + const SizedBox(height: 12), + Wrap( + alignment: WrapAlignment.center, + spacing: 8, + runSpacing: 8, + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.download_done, + size: 14, + color: Colors.white, + ), + const SizedBox(width: 4), + Text( + context.l10n + .downloadedAlbumDownloadedCount( + tracks.length, + ), + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 12, + ), + ), + ], + ), + ), + if (commonQuality != null) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + commonQuality, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 12, ), ), - ) - : widget.coverUrl != null - ? CachedNetworkImage( - imageUrl: widget.coverUrl!, - fit: BoxFit.cover, - memCacheWidth: (coverSize * 2).toInt(), - cacheManager: CoverCacheManager.instance, - ) - : Container( - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.album, - size: fallbackIconSize, - color: colorScheme.onSurfaceVariant, - ), ), - ), - ), + ], + ), + ], + ], ), ), ), ], ), - stretchModes: const [ - StretchMode.zoomBackground, - StretchMode.blurBackground, - ], + stretchModes: const [StretchMode.zoomBackground], ); }, ), @@ -575,10 +638,10 @@ class _DownloadedAlbumScreenState extends ConsumerState { icon: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: colorScheme.surface.withValues(alpha: 0.8), + color: Colors.black.withValues(alpha: 0.4), shape: BoxShape.circle, ), - child: Icon(Icons.arrow_back, color: colorScheme.onSurface), + child: const Icon(Icons.arrow_back, color: Colors.white), ), onPressed: () => Navigator.pop(context), ), @@ -590,102 +653,8 @@ class _DownloadedAlbumScreenState extends ConsumerState { ColorScheme colorScheme, List tracks, ) { - final commonQuality = _getCommonQuality(tracks); - - return SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(16), - child: Card( - elevation: 0, - color: colorScheme.surfaceContainerLow, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widget.albumName, - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.bold, - color: colorScheme.onSurface, - ), - ), - const SizedBox(height: 4), - Text( - widget.artistName, - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 12), - Row( - children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(20), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.download_done, - size: 14, - color: colorScheme.onPrimaryContainer, - ), - const SizedBox(width: 4), - Text( - context.l10n.downloadedAlbumDownloadedCount( - tracks.length, - ), - style: TextStyle( - color: colorScheme.onPrimaryContainer, - fontWeight: FontWeight.w600, - fontSize: 12, - ), - ), - ], - ), - ), - const SizedBox(width: 8), - if (commonQuality != null) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: commonQuality.startsWith('24') - ? colorScheme.tertiaryContainer - : colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(20), - ), - child: Text( - commonQuality, - style: TextStyle( - color: commonQuality.startsWith('24') - ? colorScheme.onTertiaryContainer - : colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w600, - fontSize: 12, - ), - ), - ), - ], - ), - ], - ), - ), - ), - ), - ); + // Info is now displayed in the full-screen cover overlay + return const SliverToBoxAdapter(child: SizedBox.shrink()); } String? _getCommonQuality(List tracks) { @@ -716,43 +685,6 @@ class _DownloadedAlbumScreenState extends ConsumerState { return firstQuality; } - Widget _buildTrackListHeader( - BuildContext context, - ColorScheme colorScheme, - List tracks, - ) { - return SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(20, 8, 20, 8), - child: Row( - children: [ - Icon(Icons.queue_music, size: 20, color: colorScheme.primary), - const SizedBox(width: 8), - Text( - context.l10n.downloadedAlbumTracksHeader, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - color: colorScheme.onSurface, - ), - ), - const Spacer(), - if (!_isSelectionMode) - TextButton.icon( - onPressed: tracks.isNotEmpty - ? () => _enterSelectionMode(tracks.first.id) - : null, - icon: const Icon(Icons.checklist, size: 18), - label: Text(context.l10n.actionSelect), - style: TextButton.styleFrom( - visualDensity: VisualDensity.compact, - ), - ), - ], - ), - ), - ); - } - Widget _buildTrackList( BuildContext context, ColorScheme colorScheme, @@ -926,7 +858,7 @@ class _DownloadedAlbumScreenState extends ConsumerState { trailing: _isSelectionMode ? null : IconButton( - onPressed: () => _openFile(track.filePath), + onPressed: () => _openFile(track), icon: Icon(Icons.play_arrow, color: colorScheme.primary), style: IconButton.styleFrom( backgroundColor: colorScheme.primaryContainer.withValues( @@ -939,6 +871,430 @@ class _DownloadedAlbumScreenState extends ConsumerState { ); } + /// Share selected tracks via system share sheet + Future _shareSelected(List allTracks) async { + final tracksById = {for (final t in allTracks) t.id: t}; + final safUris = []; + final filesToShare = []; + + for (final id in _selectedIds) { + final item = tracksById[id]; + if (item == null) continue; + final path = item.filePath; + if (isContentUri(path)) { + if (await fileExists(path)) safUris.add(path); + } else if (await fileExists(path)) { + filesToShare.add(XFile(path)); + } + } + + if (safUris.isEmpty && filesToShare.isEmpty) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.selectionShareNoFiles)), + ); + } + return; + } + + // Share SAF content URIs via native intent + if (safUris.isNotEmpty) { + try { + if (safUris.length == 1) { + await PlatformBridge.shareContentUri(safUris.first); + } else { + await PlatformBridge.shareMultipleContentUris(safUris); + } + } catch (_) {} + } + + // Share regular files via SharePlus + if (filesToShare.isNotEmpty) { + await SharePlus.instance.share(ShareParams(files: filesToShare)); + } + } + + /// Show batch convert bottom sheet + void _showBatchConvertSheet( + BuildContext context, + List allTracks, + ) { + String selectedFormat = 'MP3'; + String selectedBitrate = '320k'; + + showModalBottomSheet( + context: context, + useRootNavigator: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (sheetContext) { + return StatefulBuilder( + builder: (context, setSheetState) { + final colorScheme = Theme.of(context).colorScheme; + final formats = ['MP3', 'Opus']; + final bitrates = ['128k', '192k', '256k', '320k']; + + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: colorScheme.onSurfaceVariant.withValues( + alpha: 0.4, + ), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 16), + Text( + context.l10n.selectionBatchConvertConfirmTitle, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 20), + Text( + context.l10n.trackConvertTargetFormat, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Row( + children: formats.map((format) { + final isSelected = format == selectedFormat; + return Padding( + padding: const EdgeInsets.only(right: 8), + child: ChoiceChip( + label: Text(format), + selected: isSelected, + onSelected: (selected) { + if (selected) { + setSheetState(() { + selectedFormat = format; + selectedBitrate = format == 'Opus' + ? '128k' + : '320k'; + }); + } + }, + ), + ); + }).toList(), + ), + const SizedBox(height: 16), + Text( + context.l10n.trackConvertBitrate, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: bitrates.map((br) { + final isSelected = br == selectedBitrate; + return ChoiceChip( + label: Text(br), + selected: isSelected, + onSelected: (selected) { + if (selected) { + setSheetState(() => selectedBitrate = br); + } + }, + ); + }).toList(), + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: () { + Navigator.pop(context); + _performBatchConversion( + allTracks: allTracks, + targetFormat: selectedFormat, + bitrate: selectedBitrate, + ); + }, + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + child: Text( + context.l10n.selectionConvertCount( + _selectedIds.length, + ), + ), + ), + ), + ], + ), + ), + ); + }, + ); + }, + ); + } + + Future _performBatchConversion({ + required List allTracks, + required String targetFormat, + required String bitrate, + }) async { + final tracksById = {for (final t in allTracks) t.id: t}; + final selected = []; + for (final id in _selectedIds) { + final item = tracksById[id]; + if (item == null) continue; + // For SAF items, use safFileName to detect format (filePath is content:// URI) + final nameToCheck = + (item.safFileName != null && item.safFileName!.isNotEmpty) + ? item.safFileName!.toLowerCase() + : item.filePath.toLowerCase(); + final ext = nameToCheck.endsWith('.flac') + ? 'FLAC' + : nameToCheck.endsWith('.mp3') + ? 'MP3' + : (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg')) + ? 'Opus' + : null; + if (ext != null && ext != targetFormat) selected.add(item); + } + + if (selected.isEmpty) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.selectionConvertNoConvertible)), + ); + } + return; + } + + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(context.l10n.selectionBatchConvertConfirmTitle), + content: Text( + context.l10n.selectionBatchConvertConfirmMessage( + selected.length, + targetFormat, + bitrate, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text(context.l10n.dialogCancel), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: Text(context.l10n.trackConvertFormat), + ), + ], + ), + ); + + if (confirmed != true || !mounted) return; + + int successCount = 0; + final total = selected.length; + final historyDb = HistoryDatabase.instance; + final newQuality = + '${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}'; + final settings = ref.read(settingsProvider); + final shouldEmbedLyrics = + settings.embedLyrics && settings.lyricsMode != 'external'; + + for (int i = 0; i < total; i++) { + if (!mounted) break; + final item = selected[i]; + + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.selectionBatchConvertProgress(i + 1, total), + ), + duration: const Duration(seconds: 30), + ), + ); + + try { + final metadata = { + 'TITLE': item.trackName, + 'ARTIST': item.artistName, + 'ALBUM': item.albumName, + }; + try { + final result = await PlatformBridge.readFileMetadata(item.filePath); + if (result['error'] == null) { + result.forEach((key, value) { + if (key == 'error' || value == null) return; + final v = value.toString().trim(); + if (v.isEmpty) return; + metadata[key.toUpperCase()] = v; + }); + } + } catch (_) {} + await ensureLyricsMetadataForConversion( + metadata: metadata, + sourcePath: item.filePath, + shouldEmbedLyrics: shouldEmbedLyrics, + trackName: item.trackName, + artistName: item.artistName, + spotifyId: item.spotifyId ?? '', + durationMs: (item.duration ?? 0) * 1000, + ); + + String? coverPath; + try { + final tempDir = await getTemporaryDirectory(); + final coverOutput = + '${tempDir.path}${Platform.pathSeparator}batch_cover_${DateTime.now().millisecondsSinceEpoch}.jpg'; + final coverResult = await PlatformBridge.extractCoverToFile( + item.filePath, + coverOutput, + ); + if (coverResult['error'] == null) coverPath = coverOutput; + } catch (_) {} + + String workingPath = item.filePath; + final isSaf = isContentUri(item.filePath); + String? safTempPath; + + if (isSaf) { + safTempPath = await PlatformBridge.copyContentUriToTemp( + item.filePath, + ); + if (safTempPath == null) continue; + workingPath = safTempPath; + } + + final newPath = await FFmpegService.convertAudioFormat( + inputPath: workingPath, + targetFormat: targetFormat.toLowerCase(), + bitrate: bitrate, + metadata: metadata, + coverPath: coverPath, + deleteOriginal: !isSaf, + ); + + if (coverPath != null) { + try { + await File(coverPath).delete(); + } catch (_) {} + } + + if (newPath == null) { + if (safTempPath != null) { + try { + await File(safTempPath).delete(); + } catch (_) {} + } + continue; + } + + if (isSaf) { + final treeUri = item.downloadTreeUri; + final relativeDir = item.safRelativeDir ?? ''; + if (treeUri != null && treeUri.isNotEmpty) { + final oldFileName = item.safFileName ?? ''; + final dotIdx = oldFileName.lastIndexOf('.'); + final baseName = dotIdx > 0 + ? oldFileName.substring(0, dotIdx) + : oldFileName; + final newExt = targetFormat.toLowerCase() == 'opus' + ? '.opus' + : '.mp3'; + final newFileName = '$baseName$newExt'; + final mimeType = targetFormat.toLowerCase() == 'opus' + ? 'audio/opus' + : 'audio/mpeg'; + + final safUri = await PlatformBridge.createSafFileFromPath( + treeUri: treeUri, + relativeDir: relativeDir, + fileName: newFileName, + mimeType: mimeType, + srcPath: newPath, + ); + + if (safUri == null || safUri.isEmpty) { + try { + await File(newPath).delete(); + } catch (_) {} + if (safTempPath != null) { + try { + await File(safTempPath).delete(); + } catch (_) {} + } + continue; + } + + try { + await PlatformBridge.safDelete(item.filePath); + } catch (_) {} + await historyDb.updateFilePath( + item.id, + safUri, + newSafFileName: newFileName, + newQuality: newQuality, + clearAudioSpecs: true, + ); + } + try { + await File(newPath).delete(); + } catch (_) {} + if (safTempPath != null) { + try { + await File(safTempPath).delete(); + } catch (_) {} + } + } else { + await historyDb.updateFilePath( + item.id, + newPath, + newQuality: newQuality, + clearAudioSpecs: true, + ); + } + + successCount++; + } catch (_) {} + } + + ref.read(downloadHistoryProvider.notifier).reloadFromStorage(); + _exitSelectionMode(); + + if (mounted) { + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.selectionBatchConvertSuccess( + successCount, + total, + targetFormat, + ), + ), + ), + ); + } + } + Widget _buildSelectionBottomBar( BuildContext context, ColorScheme colorScheme, @@ -1030,7 +1386,36 @@ class _DownloadedAlbumScreenState extends ConsumerState { ), ], ), - const SizedBox(height: 16), + const SizedBox(height: 12), + + // Action buttons row: Share, Convert + Row( + children: [ + Expanded( + child: _DownloadedAlbumSelectionActionButton( + icon: Icons.share_outlined, + label: context.l10n.selectionShareCount(selectedCount), + onPressed: selectedCount > 0 + ? () => _shareSelected(tracks) + : null, + colorScheme: colorScheme, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _DownloadedAlbumSelectionActionButton( + icon: Icons.swap_horiz, + label: context.l10n.selectionConvertCount(selectedCount), + onPressed: selectedCount > 0 + ? () => _showBatchConvertSheet(context, tracks) + : null, + colorScheme: colorScheme, + ), + ), + ], + ), + + const SizedBox(height: 8), SizedBox( width: double.infinity, child: FilledButton.icon( @@ -1064,3 +1449,62 @@ class _DownloadedAlbumScreenState extends ConsumerState { ); } } + +class _DownloadedAlbumSelectionActionButton extends StatelessWidget { + final IconData icon; + final String label; + final VoidCallback? onPressed; + final ColorScheme colorScheme; + + const _DownloadedAlbumSelectionActionButton({ + required this.icon, + required this.label, + required this.onPressed, + required this.colorScheme, + }); + + @override + Widget build(BuildContext context) { + final isDisabled = onPressed == null; + return Material( + color: isDisabled + ? colorScheme.surfaceContainerHighest.withValues(alpha: 0.5) + : colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(14), + child: InkWell( + onTap: onPressed, + borderRadius: BorderRadius.circular(14), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icon, + size: 18, + color: isDisabled + ? colorScheme.onSurfaceVariant.withValues(alpha: 0.5) + : colorScheme.onSecondaryContainer, + ), + const SizedBox(width: 6), + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: isDisabled + ? colorScheme.onSurfaceVariant.withValues(alpha: 0.5) + : colorScheme.onSecondaryContainer, + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index f60aa69b..6462d952 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -24,8 +24,9 @@ import 'package:spotiflac_android/utils/app_bar_layout.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/screens/playlist_screen.dart'; import 'package:spotiflac_android/screens/downloaded_album_screen.dart'; -import 'package:spotiflac_android/models/download_item.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; +import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; +import 'package:spotiflac_android/utils/clickable_metadata.dart'; class HomeTab extends ConsumerStatefulWidget { const HomeTab({super.key}); @@ -64,6 +65,121 @@ class _CsvImportOptions { }); } +class _SearchResultBuckets { + final List realTracks; + final List realTrackIndexes; + final List albumItems; + final List playlistItems; + final List artistItems; + + const _SearchResultBuckets({ + required this.realTracks, + required this.realTrackIndexes, + required this.albumItems, + required this.playlistItems, + required this.artistItems, + }); +} + +_RecentAccessView _buildRecentAccessViewData( + List items, + List historyItems, + Set hiddenIds, +) { + final albumGroups = {}; + for (final h in historyItems) { + final artistForKey = (h.albumArtist != null && h.albumArtist!.isNotEmpty) + ? h.albumArtist! + : h.artistName; + final albumKey = '${h.albumName}|$artistForKey'; + final existing = albumGroups[albumKey]; + if (existing == null) { + albumGroups[albumKey] = _RecentAlbumAggregate(count: 1, mostRecent: h); + } else { + existing.count++; + if (h.downloadedAt.isAfter(existing.mostRecent.downloadedAt)) { + existing.mostRecent = h; + } + } + } + + final downloadIds = []; + final visibleDownloads = []; + final downloadFilePathByRecentKey = {}; + for (final aggregate in albumGroups.values) { + final mostRecent = aggregate.mostRecent; + final artistForKey = + (mostRecent.albumArtist != null && mostRecent.albumArtist!.isNotEmpty) + ? mostRecent.albumArtist! + : mostRecent.artistName; + + final isSingleTrack = aggregate.count == 1; + final recentId = isSingleTrack + ? (mostRecent.spotifyId ?? mostRecent.id) + : '${mostRecent.albumName}|$artistForKey'; + final recent = RecentAccessItem( + id: recentId, + name: isSingleTrack ? mostRecent.trackName : mostRecent.albumName, + subtitle: isSingleTrack ? mostRecent.artistName : artistForKey, + imageUrl: mostRecent.coverUrl, + type: isSingleTrack ? RecentAccessType.track : RecentAccessType.album, + accessedAt: mostRecent.downloadedAt, + providerId: 'download', + ); + + downloadIds.add(recentId); + downloadFilePathByRecentKey['${recent.type.name}:${recent.id}'] = + mostRecent.filePath; + if (!hiddenIds.contains(recentId)) { + visibleDownloads.add(recent); + } + } + + visibleDownloads.sort((a, b) => b.accessedAt.compareTo(a.accessedAt)); + if (visibleDownloads.length > 10) { + visibleDownloads.removeRange(10, visibleDownloads.length); + } + + final allItems = [...items, ...visibleDownloads]; + allItems.sort((a, b) => b.accessedAt.compareTo(a.accessedAt)); + + final seen = {}; + final uniqueItems = []; + for (final item in allItems) { + final key = '${item.type.name}:${item.id}'; + if (seen.add(key)) { + uniqueItems.add(item); + if (uniqueItems.length >= 10) { + break; + } + } + } + + return _RecentAccessView( + uniqueItems: uniqueItems, + downloadIds: downloadIds, + downloadFilePathByRecentKey: downloadFilePathByRecentKey, + hasHiddenDownloads: hiddenIds.isNotEmpty, + ); +} + +final recentAccessViewProvider = Provider<_RecentAccessView>((ref) { + final historyItems = ref.watch( + downloadHistoryProvider.select((s) => s.items), + ); + final recentAccessItems = ref.watch( + recentAccessProvider.select((s) => s.items), + ); + final hiddenDownloadIds = ref.watch( + recentAccessProvider.select((s) => s.hiddenDownloadIds), + ); + return _buildRecentAccessViewData( + recentAccessItems, + historyItems, + hiddenDownloadIds, + ); +}); + class _HomeTabState extends ConsumerState with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin { final _urlController = TextEditingController(); @@ -79,13 +195,24 @@ class _HomeTabState extends ConsumerState static const int _minLiveSearchChars = 3; static const Duration _liveSearchDelay = Duration(milliseconds: 800); - List? _recentAccessHistoryCache; - List? _recentAccessItemsCache; - Set? _recentAccessHiddenIdsCache; - _RecentAccessView? _recentAccessViewCache; bool _embeddedCoverRefreshScheduled = false; List? _thumbnailSizesExtensionsCache; + bool _isCsvImporting = false; + + void _setCsvImporting(bool value) { + if (_isCsvImporting == value) return; + if (!mounted) { + _isCsvImporting = value; + return; + } + setState(() { + _isCsvImporting = value; + }); + } + Map? _thumbnailSizesCache; + List? _searchBucketsSourceTracks; + _SearchResultBuckets? _searchBucketsCache; double _responsiveScale({ required BuildContext context, @@ -140,6 +267,12 @@ class _HomeTabState extends ConsumerState _urlController.addListener(_onSearchChanged); _searchFocusNode.addListener(_onSearchFocusChanged); + // Run an initial fetch check in case extensions were already initialized + // before HomeTab was mounted (e.g. auto-installed during first setup). + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _fetchExploreIfNeeded(); + }); + _trackStateSub = ref.listenManual(trackProvider, ( previous, next, @@ -230,6 +363,74 @@ class _HomeTabState extends ConsumerState return map; } + List _resolveSearchFilters( + String? currentSearchProvider, + List extensions, + ) { + final isUsingExtensionSearch = + currentSearchProvider != null && + currentSearchProvider.isNotEmpty && + extensions.any((e) => e.id == currentSearchProvider && e.enabled); + + if (isUsingExtensionSearch) { + final currentSearchExtension = extensions + .where((e) => e.id == currentSearchProvider && e.enabled) + .firstOrNull; + final filters = currentSearchExtension?.searchBehavior?.filters; + if (filters != null && filters.isNotEmpty) { + return filters; + } + } + + return const [ + SearchFilter(id: 'track', label: 'Tracks', icon: 'music'), + SearchFilter(id: 'artist', label: 'Artists', icon: 'artist'), + SearchFilter(id: 'album', label: 'Albums', icon: 'album'), + SearchFilter(id: 'playlist', label: 'Playlists', icon: 'playlist'), + ]; + } + + _SearchResultBuckets _getSearchResultBuckets(List tracks) { + final cached = _searchBucketsCache; + if (cached != null && identical(tracks, _searchBucketsSourceTracks)) { + return cached; + } + + final realTracks = []; + final realTrackIndexes = []; + final albumItems = []; + final playlistItems = []; + final artistItems = []; + + for (int i = 0; i < tracks.length; i++) { + final track = tracks[i]; + if (!track.isCollection) { + realTracks.add(track); + realTrackIndexes.add(i); + } + if (track.isAlbumItem) { + albumItems.add(track); + } + if (track.isPlaylistItem) { + playlistItems.add(track); + } + if (track.isArtistItem) { + artistItems.add(track); + } + } + + final buckets = _SearchResultBuckets( + realTracks: realTracks, + realTrackIndexes: realTrackIndexes, + albumItems: albumItems, + playlistItems: playlistItems, + artistItems: artistItems, + ); + _searchBucketsSourceTracks = tracks; + _searchBucketsCache = buckets; + return buckets; + } + void _onSearchFocusChanged() { if (mounted) { setState(() {}); @@ -502,20 +703,28 @@ class _HomeTabState extends ConsumerState } Future _importCsv(BuildContext context, WidgetRef ref) async { + if (_isCsvImporting) return; + _setCsvImporting(true); + int currentProgress = 0; int totalTracks = 0; - bool dialogShown = false; + bool progressDialogInitialized = false; + bool progressDialogVisible = false; + BuildContext? progressDialogContext; StateSetter? setDialogState; void showProgressDialog() { - if (dialogShown || !mounted) return; - dialogShown = true; + if (progressDialogInitialized || !mounted) return; + progressDialogInitialized = true; + progressDialogVisible = true; showDialog( context: this.context, + useRootNavigator: false, barrierDismissible: false, builder: (dialogCtx) => StatefulBuilder( builder: (dialogCtx, setState) { + progressDialogContext = dialogCtx; setDialogState = setState; return AlertDialog( content: Column( @@ -536,169 +745,193 @@ class _HomeTabState extends ConsumerState ); }, ), - ); + ).then((_) { + progressDialogVisible = false; + progressDialogContext = null; + }); } - final tracks = await CsvImportService.pickAndParseCsv( - onProgress: (current, total) { - currentProgress = current; - totalTracks = total; - if (!dialogShown && total > 0) { - showProgressDialog(); + void closeProgressDialog() { + if (!progressDialogVisible) return; + setDialogState = null; + try { + if (progressDialogContext != null) { + Navigator.of(progressDialogContext!).pop(); + } else if (mounted) { + final navigator = Navigator.of(this.context); + if (navigator.canPop()) { + navigator.pop(); + } } - setDialogState?.call(() {}); - }, - ); - - if (dialogShown && mounted) { - Navigator.of(this.context).pop(); + } catch (_) {} + progressDialogVisible = false; + progressDialogContext = null; } - if (tracks.isNotEmpty) { - final settings = ref.read(settingsProvider); - - if (!mounted) return; - - // ignore: use_build_context_synchronously - final l10n = context.l10n; - - final options = await showDialog<_CsvImportOptions>( - context: this.context, - builder: (dialogCtx) { - var skipDownloaded = true; - return StatefulBuilder( - builder: (dialogCtx, setDialogState) => AlertDialog( - title: Text(l10n.dialogImportPlaylistTitle), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(l10n.dialogImportPlaylistMessage(tracks.length)), - const SizedBox(height: 12), - CheckboxListTile( - contentPadding: EdgeInsets.zero, - title: const Text('Skip already downloaded songs'), - value: skipDownloaded, - onChanged: (value) { - setDialogState(() { - skipDownloaded = value ?? true; - }); - }, - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop( - dialogCtx, - const _CsvImportOptions( - confirmed: false, - skipDownloaded: true, - ), - ), - child: Text(l10n.dialogCancel), - ), - FilledButton( - onPressed: () => Navigator.pop( - dialogCtx, - _CsvImportOptions( - confirmed: true, - skipDownloaded: skipDownloaded, - ), - ), - child: Text(l10n.dialogImport), - ), - ], - ), - ); + try { + final tracks = await CsvImportService.pickAndParseCsv( + onProgress: (current, total) { + currentProgress = current; + totalTracks = total; + if (!progressDialogInitialized && total > 0) { + showProgressDialog(); + } + setDialogState?.call(() {}); }, ); - if (options == null || !options.confirmed) return; + closeProgressDialog(); - var tracksToQueue = tracks; - var skippedDownloadedCount = 0; + if (tracks.isNotEmpty) { + final settings = ref.read(settingsProvider); - if (options.skipDownloaded) { - final historyState = ref.read(downloadHistoryProvider); - tracksToQueue = []; - for (final track in tracks) { - final isDownloaded = - historyState.isDownloaded(track.id) || - (track.isrc != null && - historyState.getByIsrc(track.isrc!) != null); - if (isDownloaded) { - skippedDownloadedCount++; - continue; - } - tracksToQueue.add(track); - } - } + if (!mounted) return; - if (tracksToQueue.isEmpty) { - if (mounted) { - ScaffoldMessenger.of(this.context).showSnackBar( - SnackBar( - content: Text( - l10n.discographySkippedDownloaded(0, skippedDownloadedCount), - ), - ), - ); - } - return; - } + // ignore: use_build_context_synchronously + final l10n = context.l10n; - final queueSnackbarMessage = skippedDownloadedCount > 0 - ? l10n.discographySkippedDownloaded( - tracksToQueue.length, - skippedDownloadedCount, - ) - : l10n.snackbarAddedTracksToQueue(tracksToQueue.length); - - if (!mounted) return; - - if (settings.askQualityBeforeDownload) { - DownloadServicePicker.show( - this.context, - trackName: l10n.csvImportTracks(tracksToQueue.length), - artistName: l10n.dialogImportPlaylistTitle, - onSelect: (quality, service) { - ref - .read(downloadQueueProvider.notifier) - .addMultipleToQueue( - tracksToQueue, - service, - qualityOverride: quality, - ); - if (mounted) { - ScaffoldMessenger.of(this.context).showSnackBar( - SnackBar( - content: Text(queueSnackbarMessage), - action: SnackBarAction( - label: l10n.snackbarViewQueue, - onPressed: () {}, - ), + final options = await showDialog<_CsvImportOptions>( + context: this.context, + useRootNavigator: false, + builder: (dialogCtx) { + var skipDownloaded = true; + return StatefulBuilder( + builder: (dialogCtx, setDialogState) => AlertDialog( + title: Text(l10n.dialogImportPlaylistTitle), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.dialogImportPlaylistMessage(tracks.length)), + const SizedBox(height: 12), + CheckboxListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Skip already downloaded songs'), + value: skipDownloaded, + onChanged: (value) { + setDialogState(() { + skipDownloaded = value ?? true; + }); + }, + ), + ], ), - ); - } + actions: [ + TextButton( + onPressed: () => Navigator.pop( + dialogCtx, + const _CsvImportOptions( + confirmed: false, + skipDownloaded: true, + ), + ), + child: Text(l10n.dialogCancel), + ), + FilledButton( + onPressed: () => Navigator.pop( + dialogCtx, + _CsvImportOptions( + confirmed: true, + skipDownloaded: skipDownloaded, + ), + ), + child: Text(l10n.dialogImport), + ), + ], + ), + ); }, ); - } else { - ref - .read(downloadQueueProvider.notifier) - .addMultipleToQueue(tracksToQueue, settings.defaultService); - if (mounted) { - ScaffoldMessenger.of(this.context).showSnackBar( - SnackBar( - content: Text(queueSnackbarMessage), - action: SnackBarAction( - label: l10n.snackbarViewQueue, - onPressed: () {}, + + if (options == null || !options.confirmed) return; + + var tracksToQueue = tracks; + var skippedDownloadedCount = 0; + + if (options.skipDownloaded) { + final historyState = ref.read(downloadHistoryProvider); + tracksToQueue = []; + for (final track in tracks) { + final isDownloaded = + historyState.isDownloaded(track.id) || + (track.isrc != null && + historyState.getByIsrc(track.isrc!) != null); + if (isDownloaded) { + skippedDownloadedCount++; + continue; + } + tracksToQueue.add(track); + } + } + + if (tracksToQueue.isEmpty) { + if (mounted) { + ScaffoldMessenger.of(this.context).showSnackBar( + SnackBar( + content: Text( + l10n.discographySkippedDownloaded(0, skippedDownloadedCount), + ), ), - ), + ); + } + return; + } + + final queueSnackbarMessage = skippedDownloadedCount > 0 + ? l10n.discographySkippedDownloaded( + tracksToQueue.length, + skippedDownloadedCount, + ) + : l10n.snackbarAddedTracksToQueue(tracksToQueue.length); + + if (!mounted) return; + + if (settings.askQualityBeforeDownload) { + DownloadServicePicker.show( + this.context, + trackName: l10n.csvImportTracks(tracksToQueue.length), + artistName: l10n.dialogImportPlaylistTitle, + onSelect: (quality, service) { + ref + .read(downloadQueueProvider.notifier) + .addMultipleToQueue( + tracksToQueue, + service, + qualityOverride: quality, + ); + if (mounted) { + ScaffoldMessenger.of(this.context).showSnackBar( + SnackBar( + content: Text(queueSnackbarMessage), + action: SnackBarAction( + label: l10n.snackbarViewQueue, + onPressed: () {}, + ), + ), + ); + } + }, ); + } else { + ref + .read(downloadQueueProvider.notifier) + .addMultipleToQueue(tracksToQueue, settings.defaultService); + if (mounted) { + ScaffoldMessenger.of(this.context).showSnackBar( + SnackBar( + content: Text(queueSnackbarMessage), + action: SnackBarAction( + label: l10n.snackbarViewQueue, + onPressed: () {}, + ), + ), + ); + } } } + } finally { + closeProgressDialog(); + _setCsvImporting(false); } } @@ -706,25 +939,22 @@ class _HomeTabState extends ConsumerState Widget build(BuildContext context) { super.build(context); - final tracks = ref.watch(trackProvider.select((s) => s.tracks)); - final searchArtists = ref.watch( - trackProvider.select((s) => s.searchArtists), - ); - final searchAlbums = ref.watch(trackProvider.select((s) => s.searchAlbums)); - final searchPlaylists = ref.watch( - trackProvider.select((s) => s.searchPlaylists), + final hasActualResults = ref.watch( + trackProvider.select( + (s) => + s.tracks.isNotEmpty || + (s.searchArtists != null && s.searchArtists!.isNotEmpty) || + (s.searchAlbums != null && s.searchAlbums!.isNotEmpty) || + (s.searchPlaylists != null && s.searchPlaylists!.isNotEmpty), + ), ); final isLoading = ref.watch(trackProvider.select((s) => s.isLoading)); - final error = ref.watch(trackProvider.select((s) => s.error)); final hasSearchedBefore = ref.watch( settingsProvider.select((s) => s.hasSearchedBefore), ); - final exploreSections = ref.watch( - exploreProvider.select((s) => s.sections), - ); - final exploreGreeting = ref.watch( - exploreProvider.select((s) => s.greeting), + final hasExploreContent = ref.watch( + exploreProvider.select((s) => s.sections.isNotEmpty), ); final exploreLoading = ref.watch( exploreProvider.select((s) => s.isLoading), @@ -736,11 +966,6 @@ class _HomeTabState extends ConsumerState ); final colorScheme = Theme.of(context).colorScheme; - final hasActualResults = - tracks.isNotEmpty || - (searchArtists != null && searchArtists.isNotEmpty) || - (searchAlbums != null && searchAlbums.isNotEmpty) || - (searchPlaylists != null && searchPlaylists.isNotEmpty); final searchText = _urlController.text.trim(); final hasSearchInput = searchText.isNotEmpty; final isSearchFocused = _searchFocusNode.hasFocus; @@ -755,12 +980,6 @@ class _HomeTabState extends ConsumerState final historyItems = ref.watch( downloadHistoryProvider.select((s) => s.items), ); - final recentAccessItems = ref.watch( - recentAccessProvider.select((s) => s.items), - ); - final hiddenDownloadIds = ref.watch( - recentAccessProvider.select((s) => s.hiddenDownloadIds), - ); final recentModeRequested = isShowingRecentAccess || isSearchFocused; final showRecentAccess = @@ -769,15 +988,6 @@ class _HomeTabState extends ConsumerState !isLoading; final hasResults = hasSearchInput || hasActualResults || isLoading || showRecentAccess; - final recentAccessView = showRecentAccess - ? _getRecentAccessView( - recentAccessItems, - historyItems, - hiddenDownloadIds, - ) - : null; - - final hasExploreContent = exploreSections.isNotEmpty; final showExplore = !hasActualResults && !isLoading && @@ -785,52 +995,6 @@ class _HomeTabState extends ConsumerState (hasHomeFeedExtension || hasExploreContent) && hasExploreContent; - // Get current search extension and its filters - final currentSearchProvider = ref.watch( - settingsProvider.select((s) => s.searchProvider), - ); - final extensions = ref.watch(extensionProvider.select((s) => s.extensions)); - final selectedSearchFilter = ref.watch( - trackProvider.select((s) => s.selectedSearchFilter), - ); - final searchExtensionId = ref.watch( - trackProvider.select((s) => s.searchExtensionId), - ); - final localLibrarySettings = ref.watch( - settingsProvider.select( - (s) => (s.localLibraryEnabled, s.localLibraryShowDuplicates), - ), - ); - final showLocalLibraryIndicator = - localLibrarySettings.$1 && localLibrarySettings.$2; - final thumbnailSizesByExtensionId = _getThumbnailSizesByExtensionId( - extensions, - ); - Extension? currentSearchExtension; - List searchFilters = []; - - final isUsingExtensionSearch = - currentSearchProvider != null && - currentSearchProvider.isNotEmpty && - extensions.any((e) => e.id == currentSearchProvider && e.enabled); - - if (isUsingExtensionSearch) { - currentSearchExtension = extensions - .where((e) => e.id == currentSearchProvider && e.enabled) - .firstOrNull; - if (currentSearchExtension?.searchBehavior?.filters.isNotEmpty == true) { - searchFilters = currentSearchExtension!.searchBehavior!.filters; - } - } else { - // Default Deezer filters - searchFilters = const [ - SearchFilter(id: 'track', label: 'Tracks', icon: 'music'), - SearchFilter(id: 'artist', label: 'Artists', icon: 'artist'), - SearchFilter(id: 'album', label: 'Albums', icon: 'album'), - SearchFilter(id: 'playlist', label: 'Playlists', icon: 'playlist'), - ]; - } - if (hasActualResults && isShowingRecentAccess && hasSearchInput && @@ -953,20 +1117,45 @@ class _HomeTabState extends ConsumerState ), // Search filter bar (only shown when has search results) - if (searchFilters.isNotEmpty && - hasActualResults && - !showRecentAccess) - SliverToBoxAdapter( - child: _buildSearchFilterBar( - searchFilters, - selectedSearchFilter, - colorScheme, - ), + if (hasActualResults && !showRecentAccess) + Consumer( + builder: (context, ref, _) { + final currentSearchProvider = ref.watch( + settingsProvider.select((s) => s.searchProvider), + ); + final extensions = ref.watch( + extensionProvider.select((s) => s.extensions), + ); + final selectedSearchFilter = ref.watch( + trackProvider.select((s) => s.selectedSearchFilter), + ); + final searchFilters = _resolveSearchFilters( + currentSearchProvider, + extensions, + ); + if (searchFilters.isEmpty) { + return const SliverToBoxAdapter(child: SizedBox.shrink()); + } + return SliverToBoxAdapter( + child: _buildSearchFilterBar( + searchFilters, + selectedSearchFilter, + colorScheme, + ), + ); + }, ), if (showRecentAccess) - SliverToBoxAdapter( - child: _buildRecentAccess(recentAccessView!, colorScheme), + Consumer( + builder: (context, ref, _) { + final recentAccessView = ref.watch( + recentAccessViewProvider, + ); + return SliverToBoxAdapter( + child: _buildRecentAccess(recentAccessView, colorScheme), + ); + }, ), SliverToBoxAdapter( @@ -1008,10 +1197,22 @@ class _HomeTabState extends ConsumerState ), if (showExplore) - ..._buildExploreSections( - exploreSections, - exploreGreeting, - colorScheme, + Consumer( + builder: (context, ref, _) { + final exploreSections = ref.watch( + exploreProvider.select((s) => s.sections), + ); + final exploreGreeting = ref.watch( + exploreProvider.select((s) => s.greeting), + ); + return SliverMainAxisGroup( + slivers: _buildExploreSections( + exploreSections, + exploreGreeting, + colorScheme, + ), + ); + }, ), if (hasHomeFeedExtension && @@ -1025,18 +1226,63 @@ class _HomeTabState extends ConsumerState ), ), - ..._buildSearchResults( - tracks: tracks, - searchArtists: searchArtists, - searchAlbums: searchAlbums, - searchPlaylists: searchPlaylists, - isLoading: isLoading, - error: error, - colorScheme: colorScheme, - hasResults: hasActualResults || isLoading, - searchExtensionId: searchExtensionId, - showLocalLibraryIndicator: showLocalLibraryIndicator, - thumbnailSizesByExtensionId: thumbnailSizesByExtensionId, + Consumer( + builder: (context, ref, _) { + final tracks = ref.watch( + trackProvider.select((s) => s.tracks), + ); + final searchArtists = ref.watch( + trackProvider.select((s) => s.searchArtists), + ); + final searchAlbums = ref.watch( + trackProvider.select((s) => s.searchAlbums), + ); + final searchPlaylists = ref.watch( + trackProvider.select((s) => s.searchPlaylists), + ); + final isLoading = ref.watch( + trackProvider.select((s) => s.isLoading), + ); + final error = ref.watch(trackProvider.select((s) => s.error)); + final searchExtensionId = ref.watch( + trackProvider.select((s) => s.searchExtensionId), + ); + final localLibrarySettings = ref.watch( + settingsProvider.select( + (s) => + (s.localLibraryEnabled, s.localLibraryShowDuplicates), + ), + ); + final extensions = ref.watch( + extensionProvider.select((s) => s.extensions), + ); + final showLocalLibraryIndicator = + localLibrarySettings.$1 && localLibrarySettings.$2; + final thumbnailSizesByExtensionId = + _getThumbnailSizesByExtensionId(extensions); + final hasResults = + tracks.isNotEmpty || + (searchArtists != null && searchArtists.isNotEmpty) || + (searchAlbums != null && searchAlbums.isNotEmpty) || + (searchPlaylists != null && searchPlaylists.isNotEmpty) || + isLoading; + + return SliverMainAxisGroup( + slivers: _buildSearchResults( + tracks: tracks, + searchArtists: searchArtists, + searchAlbums: searchAlbums, + searchPlaylists: searchPlaylists, + isLoading: isLoading, + error: error, + colorScheme: colorScheme, + hasResults: hasResults, + searchExtensionId: searchExtensionId, + showLocalLibraryIndicator: showLocalLibraryIndicator, + thumbnailSizesByExtensionId: thumbnailSizesByExtensionId, + ), + ); + }, ), ], ), @@ -1159,103 +1405,6 @@ class _HomeTabState extends ConsumerState ); } - _RecentAccessView _getRecentAccessView( - List items, - List historyItems, - Set hiddenIds, - ) { - final cached = _recentAccessViewCache; - if (cached != null && - identical(historyItems, _recentAccessHistoryCache) && - identical(items, _recentAccessItemsCache) && - identical(hiddenIds, _recentAccessHiddenIdsCache)) { - return cached; - } - - final albumGroups = {}; - for (final h in historyItems) { - final artistForKey = (h.albumArtist != null && h.albumArtist!.isNotEmpty) - ? h.albumArtist! - : h.artistName; - final albumKey = '${h.albumName}|$artistForKey'; - final existing = albumGroups[albumKey]; - if (existing == null) { - albumGroups[albumKey] = _RecentAlbumAggregate(count: 1, mostRecent: h); - } else { - existing.count++; - if (h.downloadedAt.isAfter(existing.mostRecent.downloadedAt)) { - existing.mostRecent = h; - } - } - } - - final downloadIds = []; - final visibleDownloads = []; - final downloadFilePathByRecentKey = {}; - for (final aggregate in albumGroups.values) { - final mostRecent = aggregate.mostRecent; - final artistForKey = - (mostRecent.albumArtist != null && mostRecent.albumArtist!.isNotEmpty) - ? mostRecent.albumArtist! - : mostRecent.artistName; - - final isSingleTrack = aggregate.count == 1; - final recentId = isSingleTrack - ? (mostRecent.spotifyId ?? mostRecent.id) - : '${mostRecent.albumName}|$artistForKey'; - final recent = RecentAccessItem( - id: recentId, - name: isSingleTrack ? mostRecent.trackName : mostRecent.albumName, - subtitle: isSingleTrack ? mostRecent.artistName : artistForKey, - imageUrl: mostRecent.coverUrl, - type: isSingleTrack ? RecentAccessType.track : RecentAccessType.album, - accessedAt: mostRecent.downloadedAt, - providerId: 'download', - ); - - downloadIds.add(recentId); - downloadFilePathByRecentKey['${recent.type.name}:${recent.id}'] = - mostRecent.filePath; - if (!hiddenIds.contains(recentId)) { - visibleDownloads.add(recent); - } - } - - visibleDownloads.sort((a, b) => b.accessedAt.compareTo(a.accessedAt)); - if (visibleDownloads.length > 10) { - visibleDownloads.removeRange(10, visibleDownloads.length); - } - - final allItems = [...items, ...visibleDownloads]; - allItems.sort((a, b) => b.accessedAt.compareTo(a.accessedAt)); - - final seen = {}; - final uniqueItems = []; - for (final item in allItems) { - final key = '${item.type.name}:${item.id}'; - if (seen.add(key)) { - uniqueItems.add(item); - if (uniqueItems.length >= 10) { - break; - } - } - } - - final view = _RecentAccessView( - uniqueItems: uniqueItems, - downloadIds: downloadIds, - downloadFilePathByRecentKey: downloadFilePathByRecentKey, - hasHiddenDownloads: hiddenIds.isNotEmpty, - ); - - _recentAccessHistoryCache = historyItems; - _recentAccessItemsCache = items; - _recentAccessHiddenIdsCache = hiddenIds; - _recentAccessViewCache = view; - - return view; - } - List _buildExploreSections( List sections, String? greeting, @@ -1407,8 +1556,10 @@ class _HomeTabState extends ConsumerState ), ), if (item.artists.isNotEmpty && !isArtist) - Text( - item.artists, + ClickableArtistName( + artistName: item.artists, + coverUrl: item.coverUrl, + extensionId: item.providerId, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall?.copyWith( @@ -1499,6 +1650,7 @@ class _HomeTabState extends ConsumerState showModalBottomSheet( context: context, + useRootNavigator: true, backgroundColor: colorScheme.surface, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), @@ -1554,8 +1706,10 @@ class _HomeTabState extends ConsumerState overflow: TextOverflow.ellipsis, ), const SizedBox(height: 4), - Text( - item.artists, + ClickableArtistName( + artistName: item.artists, + coverUrl: item.coverUrl, + extensionId: item.providerId, style: Theme.of(context).textTheme.bodyMedium ?.copyWith(color: colorScheme.onSurfaceVariant), maxLines: 1, @@ -1573,7 +1727,7 @@ class _HomeTabState extends ConsumerState title: Text(context.l10n.downloadTitle), onTap: () { Navigator.pop(context); - _downloadExploreTrack(item); + _handleExploreTrackPrimaryAction(item); }, ), ListTile( @@ -1591,7 +1745,7 @@ class _HomeTabState extends ConsumerState ); } - Future _downloadExploreTrack(ExploreItem item) async { + Future _handleExploreTrackPrimaryAction(ExploreItem item) async { final settings = ref.read(settingsProvider); final track = Track( @@ -1599,6 +1753,7 @@ class _HomeTabState extends ConsumerState name: item.name, artistName: item.artists, albumName: item.albumName ?? '', + albumId: item.albumId, duration: item.durationMs ~/ 1000, trackNumber: 1, discNumber: 1, @@ -1921,6 +2076,7 @@ class _HomeTabState extends ConsumerState ), ); } + return; case RecentAccessType.album: if (item.providerId == 'download') { Navigator.push( @@ -1960,6 +2116,7 @@ class _HomeTabState extends ConsumerState ), ); } + return; case RecentAccessType.track: final historyItem = ref .read(downloadHistoryProvider.notifier) @@ -1971,10 +2128,44 @@ class _HomeTabState extends ConsumerState context, ).showSnackBar(SnackBar(content: Text(item.name))); } + return; case RecentAccessType.playlist: - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.recentPlaylistInfo(item.name))), - ); + if (item.id.trim().isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.recentPlaylistInfo(item.name))), + ); + return; + } + + if (item.providerId != null && + item.providerId!.isNotEmpty && + item.providerId != 'deezer' && + item.providerId != 'spotify') { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ExtensionPlaylistScreen( + extensionId: item.providerId!, + playlistId: item.id, + playlistName: item.name, + coverUrl: item.imageUrl, + ), + ), + ); + } else { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PlaylistScreen( + playlistName: item.name, + coverUrl: item.imageUrl, + tracks: const [], + playlistId: item.id, + ), + ), + ); + } + return; } } @@ -2107,28 +2298,12 @@ class _HomeTabState extends ConsumerState return [const SliverToBoxAdapter(child: SizedBox.shrink())]; } - final realTracks = []; - final realTrackIndexes = []; - final albumItems = []; - final playlistItems = []; - final artistItems = []; - - for (int i = 0; i < tracks.length; i++) { - final track = tracks[i]; - if (!track.isCollection) { - realTracks.add(track); - realTrackIndexes.add(i); - } - if (track.isAlbumItem) { - albumItems.add(track); - } - if (track.isPlaylistItem) { - playlistItems.add(track); - } - if (track.isArtistItem) { - artistItems.add(track); - } - } + final buckets = _getSearchResultBuckets(tracks); + final realTracks = buckets.realTracks; + final realTrackIndexes = buckets.realTrackIndexes; + final albumItems = buckets.albumItems; + final playlistItems = buckets.playlistItems; + final artistItems = buckets.artistItems; final slivers = [ if (error != null) @@ -2676,7 +2851,9 @@ class _HomeTabState extends ConsumerState else ...[ IconButton( icon: const Icon(Icons.file_upload_outlined), - onPressed: () => _importCsv(context, ref), + onPressed: _isCsvImporting + ? null + : () => _importCsv(context, ref), tooltip: 'Import CSV', ), IconButton( @@ -2957,13 +3134,6 @@ class _TrackItemWithStatus extends ConsumerWidget { } final isQueued = queueItem != null; - final isDownloading = queueItem?.status == DownloadStatus.downloading; - final isFinalizing = queueItem?.status == DownloadStatus.finalizing; - final isCompleted = queueItem?.status == DownloadStatus.completed; - final progress = queueItem?.progress ?? 0.0; - - final showAsDownloaded = - isCompleted || (!isQueued && isInHistory) || isInLocalLibrary; return Column( mainAxisSize: MainAxisSize.min, @@ -2976,6 +3146,11 @@ class _TrackItemWithStatus extends ConsumerWidget { isInHistory: isInHistory, isInLocalLibrary: isInLocalLibrary, ), + onLongPress: () => TrackCollectionQuickActions.showTrackOptionsSheet( + context, + ref, + track, + ), splashColor: colorScheme.primary.withValues(alpha: 0.12), highlightColor: colorScheme.primary.withValues(alpha: 0.08), child: Padding( @@ -3021,8 +3196,11 @@ class _TrackItemWithStatus extends ConsumerWidget { Row( children: [ Flexible( - child: Text( - track.artistName, + child: ClickableArtistName( + artistName: track.artistName, + artistId: track.artistId, + coverUrl: track.coverUrl, + extensionId: extensionId, style: Theme.of(context).textTheme.bodySmall ?.copyWith( color: colorScheme.onSurfaceVariant, @@ -3068,18 +3246,7 @@ class _TrackItemWithStatus extends ConsumerWidget { ], ), ), - _buildDownloadButton( - context, - ref, - colorScheme, - isQueued: isQueued, - isDownloading: isDownloading, - isFinalizing: isFinalizing, - showAsDownloaded: showAsDownloaded, - isInHistory: isInHistory, - isInLocalLibrary: isInLocalLibrary, - progress: progress, - ), + TrackCollectionQuickActions(track: track), ], ), ), @@ -3145,119 +3312,6 @@ class _TrackItemWithStatus extends ConsumerWidget { onDownload(); } - - Widget _buildDownloadButton( - BuildContext context, - WidgetRef ref, - ColorScheme colorScheme, { - required bool isQueued, - required bool isDownloading, - required bool isFinalizing, - required bool showAsDownloaded, - required bool isInHistory, - required bool isInLocalLibrary, - required double progress, - }) { - const double size = 44.0; - const double iconSize = 20.0; - - if (showAsDownloaded) { - return GestureDetector( - onTap: () => _handleTap( - context, - ref, - isQueued: isQueued, - isInHistory: isInHistory, - isInLocalLibrary: isInLocalLibrary, - ), - child: Container( - width: size, - height: size, - decoration: BoxDecoration( - color: colorScheme.primaryContainer, - shape: BoxShape.circle, - ), - child: Icon( - Icons.check, - color: colorScheme.onPrimaryContainer, - size: iconSize, - ), - ), - ); - } else if (isFinalizing) { - return SizedBox( - width: size, - height: size, - child: Stack( - alignment: Alignment.center, - children: [ - CircularProgressIndicator( - strokeWidth: 3, - color: colorScheme.tertiary, - backgroundColor: colorScheme.surfaceContainerHighest, - ), - Icon(Icons.edit_note, color: colorScheme.tertiary, size: 16), - ], - ), - ); - } else if (isDownloading) { - return SizedBox( - width: size, - height: size, - child: Stack( - alignment: Alignment.center, - children: [ - CircularProgressIndicator( - value: progress > 0 ? progress : null, - strokeWidth: 3, - color: colorScheme.primary, - backgroundColor: colorScheme.surfaceContainerHighest, - ), - if (progress > 0) - Text( - '${(progress * 100).toInt()}', - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.bold, - color: colorScheme.primary, - ), - ), - ], - ), - ); - } else if (isQueued) { - return Container( - width: size, - height: size, - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest, - shape: BoxShape.circle, - ), - child: Icon( - Icons.hourglass_empty, - color: colorScheme.onSurfaceVariant, - size: iconSize, - ), - ); - } else { - return GestureDetector( - onTap: onDownload, - child: Container( - width: size, - height: size, - decoration: BoxDecoration( - color: colorScheme.secondaryContainer, - shape: BoxShape.circle, - ), - child: Icon( - Icons.download, - color: colorScheme.onSecondaryContainer, - size: iconSize, - ), - ), - ); - } - } } /// Widget for displaying album/playlist items in search results @@ -3536,8 +3590,11 @@ class _SearchAlbumItemWidget extends StatelessWidget { overflow: TextOverflow.ellipsis, ), const SizedBox(height: 2), - Text( - album.artists.isNotEmpty ? album.artists : 'Album', + ClickableArtistName( + artistName: album.artists.isNotEmpty + ? album.artists + : 'Album', + coverUrl: album.imageUrl, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, ), @@ -3737,7 +3794,7 @@ class _ExtensionAlbumScreenState extends ConsumerState { .toList(); // Extract artist info from album response - final artistId = result['artist_id'] as String?; + final artistId = (result['artist_id'] ?? result['artistId'])?.toString(); final artistName = result['artists'] as String?; setState(() { @@ -3769,6 +3826,9 @@ class _ExtensionAlbumScreenState extends ConsumerState { name: (data['name'] ?? '').toString(), artistName: (data['artists'] ?? data['artist'] ?? '').toString(), albumName: (data['album_name'] ?? widget.albumName).toString(), + artistId: + (data['artist_id'] ?? data['artistId'])?.toString() ?? _artistId, + albumId: data['album_id']?.toString() ?? widget.albumId, coverUrl: _resolveCoverUrl( data['cover_url']?.toString(), widget.coverUrl, @@ -3921,6 +3981,8 @@ class _ExtensionPlaylistScreenState name: (data['name'] ?? '').toString(), artistName: (data['artists'] ?? data['artist'] ?? '').toString(), albumName: (data['album_name'] ?? '').toString(), + artistId: (data['artist_id'] ?? data['artistId'])?.toString(), + albumId: data['album_id']?.toString(), coverUrl: _resolveCoverUrl( data['cover_url']?.toString(), widget.coverUrl, @@ -4092,6 +4154,10 @@ class _ExtensionArtistScreenState extends ConsumerState { artistName: (data['artists'] ?? data['artist'] ?? '').toString(), albumName: (data['album_name'] ?? data['album'] ?? '').toString(), albumArtist: data['album_artist']?.toString(), + artistId: + (data['artist_id'] ?? data['artistId'])?.toString() ?? + widget.artistId, + albumId: data['album_id']?.toString(), coverUrl: (data['cover_url'] ?? data['images'])?.toString(), isrc: data['isrc']?.toString(), duration: (durationMs / 1000).round(), @@ -4306,8 +4372,10 @@ class _QuickPicksPageViewState extends State<_QuickPicksPageView> { ), ), if (item.artists.isNotEmpty) - Text( - item.artists, + ClickableArtistName( + artistName: item.artists, + coverUrl: item.coverUrl, + extensionId: item.providerId, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall?.copyWith( diff --git a/lib/screens/library_playlists_screen.dart b/lib/screens/library_playlists_screen.dart new file mode 100644 index 00000000..2f07b435 --- /dev/null +++ b/lib/screens/library_playlists_screen.dart @@ -0,0 +1,585 @@ +import 'dart:io'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/providers/library_collections_provider.dart'; +import 'package:spotiflac_android/screens/library_tracks_folder_screen.dart'; +import 'package:spotiflac_android/services/cover_cache_manager.dart'; +import 'package:spotiflac_android/utils/app_bar_layout.dart'; + +class LibraryPlaylistsScreen extends ConsumerWidget { + const LibraryPlaylistsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final playlists = ref.watch( + libraryCollectionsProvider.select((state) => state.playlists), + ); + final colorScheme = Theme.of(context).colorScheme; + final topPadding = normalizedHeaderTopPadding(context); + + return Scaffold( + body: CustomScrollView( + slivers: [ + SliverAppBar( + expandedHeight: 120 + topPadding, + collapsedHeight: kToolbarHeight, + floating: false, + pinned: true, + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.pop(context), + ), + + flexibleSpace: LayoutBuilder( + builder: (context, constraints) { + final maxHeight = 120 + topPadding; + final minHeight = kToolbarHeight + topPadding; + final expandRatio = + ((constraints.maxHeight - minHeight) / + (maxHeight - minHeight)) + .clamp(0.0, 1.0); + final leftPadding = 56 - (32 * expandRatio); + + return FlexibleSpaceBar( + expandedTitleScale: 1.0, + titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16), + title: Text( + context.l10n.collectionPlaylists, + style: TextStyle( + fontSize: 20 + (8 * expandRatio), + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + ); + }, + ), + ), + if (playlists.isEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.playlist_play, + size: 60, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 12), + Text( + context.l10n.collectionNoPlaylistsYet, + style: Theme.of(context).textTheme.titleMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + context.l10n.collectionNoPlaylistsSubtitle, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + ) + else + SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + // Even indices = playlist tiles, odd indices = dividers + if (index.isOdd) { + return const Divider(height: 1); + } + final playlistIndex = index ~/ 2; + final playlist = playlists[playlistIndex]; + return ListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 2, + ), + leading: _buildPlaylistThumbnail(context, playlist), + title: Text(playlist.name), + subtitle: Text( + context.l10n.collectionPlaylistTracks( + playlist.tracks.length, + ), + ), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => LibraryTracksFolderScreen( + mode: LibraryTracksFolderMode.playlist, + playlistId: playlist.id, + ), + ), + ); + }, + onLongPress: () => + _showPlaylistOptionsSheet(context, ref, playlist), + ); + }, childCount: playlists.length * 2 - 1), + ), + ], + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: () => _showCreatePlaylistDialog(context, ref), + icon: const Icon(Icons.add), + label: Text(context.l10n.collectionCreatePlaylist), + ), + ); + } + + void _showPlaylistOptionsSheet( + BuildContext context, + WidgetRef ref, + UserPlaylistCollection playlist, + ) { + final colorScheme = Theme.of(context).colorScheme; + + showModalBottomSheet( + context: context, + useRootNavigator: true, + backgroundColor: colorScheme.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Header: drag handle + thumbnail + playlist info + Column( + children: [ + const SizedBox(height: 8), + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: Row( + children: [ + _buildPlaylistThumbnail(context, playlist), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + playlist.name, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.w600), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + context.l10n.collectionPlaylistTracks( + playlist.tracks.length, + ), + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + Divider( + height: 1, + color: colorScheme.outlineVariant.withValues(alpha: 0.5), + ), + + // Rename + _PlaylistOptionTile( + icon: Icons.edit_outlined, + title: context.l10n.collectionRenamePlaylist, + onTap: () { + Navigator.pop(sheetContext); + _showRenamePlaylistDialog( + context, + ref, + playlist.id, + playlist.name, + ); + }, + ), + + // Change cover + _PlaylistOptionTile( + icon: Icons.image_outlined, + title: context.l10n.collectionPlaylistChangeCover, + onTap: () { + Navigator.pop(sheetContext); + _pickCoverImage(context, ref, playlist.id); + }, + ), + + // Delete + _PlaylistOptionTile( + icon: Icons.delete_outline, + iconColor: colorScheme.error, + title: context.l10n.collectionDeletePlaylist, + onTap: () { + Navigator.pop(sheetContext); + _confirmDeletePlaylist( + context, + ref, + playlist.id, + playlist.name, + ); + }, + ), + + const SizedBox(height: 16), + ], + ), + ), + ); + } + + Widget _buildPlaylistThumbnail( + BuildContext context, + UserPlaylistCollection playlist, + ) { + final colorScheme = Theme.of(context).colorScheme; + const double size = 48; + final borderRadius = BorderRadius.circular(8); + final dpr = MediaQuery.devicePixelRatioOf(context); + final cacheWidth = (size * dpr).round().clamp(64, 512); + final placeholder = _playlistIconFallback(colorScheme, size); + + // Priority: custom cover > first track cover URL > icon fallback + final customCoverPath = playlist.coverImagePath; + if (customCoverPath != null && customCoverPath.isNotEmpty) { + return ClipRRect( + borderRadius: borderRadius, + child: Image.file( + File(customCoverPath), + width: size, + height: size, + fit: BoxFit.cover, + cacheWidth: cacheWidth, + gaplessPlayback: true, + filterQuality: FilterQuality.low, + frameBuilder: (_, child, frame, wasSynchronouslyLoaded) { + if (wasSynchronouslyLoaded || frame != null) return child; + return placeholder; + }, + errorBuilder: (_, _, _) => placeholder, + ), + ); + } + + String? firstCoverUrl; + for (final entry in playlist.tracks) { + final coverUrl = entry.track.coverUrl; + if (coverUrl != null && coverUrl.isNotEmpty) { + firstCoverUrl = coverUrl; + break; + } + } + + if (firstCoverUrl != null) { + final isLocalPath = + !firstCoverUrl.startsWith('http://') && + !firstCoverUrl.startsWith('https://'); + + if (isLocalPath) { + return ClipRRect( + borderRadius: borderRadius, + child: Image.file( + File(firstCoverUrl), + width: size, + height: size, + fit: BoxFit.cover, + cacheWidth: cacheWidth, + gaplessPlayback: true, + filterQuality: FilterQuality.low, + frameBuilder: (_, child, frame, wasSynchronouslyLoaded) { + if (wasSynchronouslyLoaded || frame != null) return child; + return placeholder; + }, + errorBuilder: (_, _, _) => placeholder, + ), + ); + } + + return ClipRRect( + borderRadius: borderRadius, + child: CachedNetworkImage( + imageUrl: firstCoverUrl, + width: size, + height: size, + fit: BoxFit.cover, + memCacheWidth: cacheWidth, + cacheManager: CoverCacheManager.instance, + placeholder: (_, _) => placeholder, + errorWidget: (_, _, _) => placeholder, + ), + ); + } + + return placeholder; + } + + Widget _playlistIconFallback(ColorScheme colorScheme, double size) { + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Icon(Icons.queue_music, color: colorScheme.onSurfaceVariant), + ); + } + + Future _pickCoverImage( + BuildContext context, + WidgetRef ref, + String playlistId, + ) async { + final result = await FilePicker.platform.pickFiles( + type: FileType.image, + allowMultiple: false, + ); + if (result == null || result.files.isEmpty) return; + + final path = result.files.first.path; + if (path == null || path.isEmpty) return; + + await ref + .read(libraryCollectionsProvider.notifier) + .setPlaylistCover(playlistId, path); + } + + Future _showCreatePlaylistDialog( + BuildContext context, + WidgetRef ref, + ) async { + final controller = TextEditingController(); + final formKey = GlobalKey(); + + final playlistName = await showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + title: Text(dialogContext.l10n.collectionCreatePlaylist), + content: Form( + key: formKey, + child: TextFormField( + controller: controller, + autofocus: true, + decoration: InputDecoration( + hintText: dialogContext.l10n.collectionPlaylistNameHint, + ), + validator: (value) { + final trimmed = value?.trim() ?? ''; + if (trimmed.isEmpty) { + return dialogContext.l10n.collectionPlaylistNameRequired; + } + return null; + }, + onFieldSubmitted: (_) { + if (formKey.currentState?.validate() != true) return; + Navigator.of(dialogContext).pop(controller.text.trim()); + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: Text(dialogContext.l10n.dialogCancel), + ), + FilledButton( + onPressed: () { + if (formKey.currentState?.validate() != true) return; + Navigator.of(dialogContext).pop(controller.text.trim()); + }, + child: Text(dialogContext.l10n.actionCreate), + ), + ], + ); + }, + ); + + if (playlistName == null || + playlistName.trim().isEmpty || + !context.mounted) { + return; + } + + await ref + .read(libraryCollectionsProvider.notifier) + .createPlaylist(playlistName.trim()); + + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.collectionPlaylistCreated)), + ); + } + + Future _showRenamePlaylistDialog( + BuildContext context, + WidgetRef ref, + String playlistId, + String currentName, + ) async { + final controller = TextEditingController(text: currentName); + final formKey = GlobalKey(); + + final nextName = await showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + title: Text(dialogContext.l10n.collectionRenamePlaylist), + content: Form( + key: formKey, + child: TextFormField( + controller: controller, + autofocus: true, + decoration: InputDecoration( + hintText: dialogContext.l10n.collectionPlaylistNameHint, + ), + validator: (value) { + final trimmed = value?.trim() ?? ''; + if (trimmed.isEmpty) { + return dialogContext.l10n.collectionPlaylistNameRequired; + } + return null; + }, + onFieldSubmitted: (_) { + if (formKey.currentState?.validate() != true) return; + Navigator.of(dialogContext).pop(controller.text.trim()); + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: Text(dialogContext.l10n.dialogCancel), + ), + FilledButton( + onPressed: () { + if (formKey.currentState?.validate() != true) return; + Navigator.of(dialogContext).pop(controller.text.trim()); + }, + child: Text(dialogContext.l10n.dialogSave), + ), + ], + ); + }, + ); + + if (nextName == null || nextName.trim().isEmpty || !context.mounted) { + return; + } + + await ref + .read(libraryCollectionsProvider.notifier) + .renamePlaylist(playlistId, nextName.trim()); + + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.collectionPlaylistRenamed)), + ); + } + + Future _confirmDeletePlaylist( + BuildContext context, + WidgetRef ref, + String playlistId, + String playlistName, + ) async { + final confirmed = await showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + title: Text(dialogContext.l10n.collectionDeletePlaylist), + content: Text( + dialogContext.l10n.collectionDeletePlaylistMessage(playlistName), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: Text(dialogContext.l10n.dialogCancel), + ), + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: Text(dialogContext.l10n.dialogDelete), + ), + ], + ); + }, + ); + + if (confirmed != true || !context.mounted) return; + + await ref + .read(libraryCollectionsProvider.notifier) + .deletePlaylist(playlistId); + + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.collectionPlaylistDeleted)), + ); + } +} + +/// Styled like _OptionTile in track_collection_quick_actions.dart +class _PlaylistOptionTile extends StatelessWidget { + final IconData icon; + final Color? iconColor; + final String title; + final VoidCallback onTap; + + const _PlaylistOptionTile({ + required this.icon, + this.iconColor, + required this.title, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4), + leading: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + icon, + color: iconColor ?? colorScheme.onPrimaryContainer, + size: 20, + ), + ), + title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)), + onTap: onTap, + ); + } +} diff --git a/lib/screens/library_tracks_folder_screen.dart b/lib/screens/library_tracks_folder_screen.dart new file mode 100644 index 00000000..d2442c22 --- /dev/null +++ b/lib/screens/library_tracks_folder_screen.dart @@ -0,0 +1,1596 @@ +import 'dart:io'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/providers/download_queue_provider.dart'; +import 'package:spotiflac_android/providers/library_collections_provider.dart'; +import 'package:spotiflac_android/providers/local_library_provider.dart'; +import 'package:spotiflac_android/services/library_database.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/services/cover_cache_manager.dart'; +import 'package:spotiflac_android/screens/track_metadata_screen.dart'; +import 'package:spotiflac_android/widgets/download_service_picker.dart'; +import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart'; + +class LibraryTracksFolderScreen extends ConsumerStatefulWidget { + final LibraryTracksFolderMode mode; + final String? playlistId; + + const LibraryTracksFolderScreen({ + super.key, + required this.mode, + this.playlistId, + }); + + @override + ConsumerState createState() => + _LibraryTracksFolderScreenState(); +} + +class _LibraryTracksFolderScreenState + extends ConsumerState { + bool _showTitleInAppBar = false; + final ScrollController _scrollController = ScrollController(); + + // ── Multi-select state ── + bool _isSelectionMode = false; + final Set _selectedKeys = {}; + + @override + void initState() { + super.initState(); + _scrollController.addListener(_onScroll); + } + + @override + void dispose() { + _scrollController.removeListener(_onScroll); + _scrollController.dispose(); + super.dispose(); + } + + void _onScroll() { + final expandedHeight = _calculateExpandedHeight(context); + final shouldShow = + _scrollController.offset > (expandedHeight - kToolbarHeight - 20); + if (shouldShow != _showTitleInAppBar) { + setState(() => _showTitleInAppBar = shouldShow); + } + } + + double _calculateExpandedHeight(BuildContext context) { + final mediaSize = MediaQuery.of(context).size; + return (mediaSize.height * 0.45).clamp(300.0, 420.0); + } + + IconData _modeIcon() { + return switch (widget.mode) { + LibraryTracksFolderMode.wishlist => Icons.bookmark, + LibraryTracksFolderMode.loved => Icons.favorite, + LibraryTracksFolderMode.playlist => Icons.queue_music, + }; + } + + String? _resolveEntryCoverUrl( + CollectionTrackEntry entry, + LocalLibraryState localState, + ) { + final rawCover = entry.track.coverUrl?.trim(); + if (rawCover != null && + rawCover.isNotEmpty && + !rawCover.startsWith('content://')) { + return rawCover; + } + + final isrc = entry.track.isrc?.trim(); + if (isrc != null && isrc.isNotEmpty) { + final byIsrc = localState.getByIsrc(isrc); + final localCover = byIsrc?.coverPath?.trim(); + if (localCover != null && localCover.isNotEmpty) { + return localCover; + } + } + + final byTrack = localState.findByTrackAndArtist( + entry.track.name, + entry.track.artistName, + ); + final localCover = byTrack?.coverPath?.trim(); + if (localCover != null && localCover.isNotEmpty) { + return localCover; + } + + return null; + } + + /// Find the first available cover URL from entries. + String? _firstCoverUrl( + List entries, + LocalLibraryState localState, + ) { + for (final entry in entries) { + final cover = _resolveEntryCoverUrl(entry, localState); + if (cover != null && cover.isNotEmpty) { + return cover; + } + } + return null; + } + + /// Returns true if [url] is a local file path rather than a network URL. + bool _isCoverLocalPath(String url) { + return !url.startsWith('http://') && !url.startsWith('https://'); + } + + /// Upgrade cover URL to higher resolution for full-screen display. + String? _highResCoverUrl(String? url) { + if (url == null) return null; + // Spotify CDN: upgrade 300 → 640 + if (url.contains('ab67616d00001e02')) { + return url.replaceAll('ab67616d00001e02', 'ab67616d0000b273'); + } + // Deezer CDN: upgrade to 1000x1000 + final deezerRegex = RegExp(r'/(\d+)x(\d+)-(\d+)-(\d+)-(\d+)-(\d+)\.jpg$'); + if (url.contains('cdn-images.dzcdn.net') && deezerRegex.hasMatch(url)) { + return url.replaceAllMapped( + deezerRegex, + (m) => '/1000x1000-${m[3]}-${m[4]}-${m[5]}-${m[6]}.jpg', + ); + } + return url; + } + + // ── Selection helpers ── + + void _enterSelectionMode(String key) { + HapticFeedback.mediumImpact(); + setState(() { + _isSelectionMode = true; + _selectedKeys.add(key); + }); + } + + void _exitSelectionMode() { + setState(() { + _isSelectionMode = false; + _selectedKeys.clear(); + }); + } + + void _toggleSelection(String key) { + setState(() { + if (_selectedKeys.contains(key)) { + _selectedKeys.remove(key); + if (_selectedKeys.isEmpty) { + _isSelectionMode = false; + } + } else { + _selectedKeys.add(key); + } + }); + } + + void _selectAll(List entries) { + setState(() { + _selectedKeys.addAll(entries.map((e) => e.key)); + }); + } + + // ── Batch actions ── + + Future _removeSelected(List entries) async { + final keysToRemove = _selectedKeys.toSet(); + if (keysToRemove.isEmpty) return; + + final count = keysToRemove.length; + final notifier = ref.read(libraryCollectionsProvider.notifier); + + for (final key in keysToRemove) { + switch (widget.mode) { + case LibraryTracksFolderMode.wishlist: + await notifier.removeFromWishlist(key); + break; + case LibraryTracksFolderMode.loved: + await notifier.removeFromLoved(key); + break; + case LibraryTracksFolderMode.playlist: + if (widget.playlistId != null) { + await notifier.removeTrackFromPlaylist(widget.playlistId!, key); + } + break; + } + } + + _exitSelectionMode(); + + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.selectionSelected(count))), + ); + } + + void _downloadSelected(List entries) { + final settings = ref.read(settingsProvider); + final queueNotifier = ref.read(downloadQueueProvider.notifier); + var count = 0; + + for (final entry in entries) { + if (!_selectedKeys.contains(entry.key)) continue; + queueNotifier.addToQueue(entry.track, settings.defaultService); + count++; + } + + _exitSelectionMode(); + + if (!mounted || count == 0) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.selectionSelected(count))), + ); + } + + void _addSelectedToPlaylist(List entries) { + final selectedTracks = entries + .where((e) => _selectedKeys.contains(e.key)) + .map((e) => e.track) + .toList(growable: false); + if (selectedTracks.isEmpty) return; + + showAddTracksToPlaylistSheet(context, ref, selectedTracks); + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + ref.watch(localLibraryProvider.select((s) => s.items)); + final localState = ref.read(localLibraryProvider); + final UserPlaylistCollection? playlist; + final List entries; + + switch (widget.mode) { + case LibraryTracksFolderMode.wishlist: + playlist = null; + entries = ref.watch( + libraryCollectionsProvider.select((state) => state.wishlist), + ); + break; + case LibraryTracksFolderMode.loved: + playlist = null; + entries = ref.watch( + libraryCollectionsProvider.select((state) => state.loved), + ); + break; + case LibraryTracksFolderMode.playlist: + final playlistId = widget.playlistId; + playlist = playlistId == null + ? null + : ref.watch( + libraryCollectionsProvider.select( + (state) => state.playlistById(playlistId), + ), + ); + entries = playlist?.tracks ?? const []; + break; + } + + // Stale selection cleanup + if (_isSelectionMode) { + final validKeys = entries.map((e) => e.key).toSet(); + _selectedKeys.removeWhere((key) => !validKeys.contains(key)); + if (_selectedKeys.isEmpty && _isSelectionMode) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) setState(() => _isSelectionMode = false); + }); + } + } + + final title = switch (widget.mode) { + LibraryTracksFolderMode.wishlist => context.l10n.collectionWishlist, + LibraryTracksFolderMode.loved => context.l10n.collectionLoved, + LibraryTracksFolderMode.playlist => + playlist?.name ?? context.l10n.collectionPlaylist, + }; + + final emptyTitle = switch (widget.mode) { + LibraryTracksFolderMode.wishlist => + context.l10n.collectionWishlistEmptyTitle, + LibraryTracksFolderMode.loved => context.l10n.collectionLovedEmptyTitle, + LibraryTracksFolderMode.playlist => + context.l10n.collectionPlaylistEmptyTitle, + }; + + final emptySubtitle = switch (widget.mode) { + LibraryTracksFolderMode.wishlist => + context.l10n.collectionWishlistEmptySubtitle, + LibraryTracksFolderMode.loved => + context.l10n.collectionLovedEmptySubtitle, + LibraryTracksFolderMode.playlist => + context.l10n.collectionPlaylistEmptySubtitle, + }; + final folderTracks = entries + .map((entry) => entry.track) + .toList(growable: false); + + final bottomPadding = MediaQuery.of(context).padding.bottom; + + return PopScope( + canPop: !_isSelectionMode, + onPopInvokedWithResult: (didPop, result) { + if (!didPop && _isSelectionMode) { + _exitSelectionMode(); + } + }, + child: Scaffold( + body: Stack( + children: [ + CustomScrollView( + controller: _scrollController, + slivers: [ + _buildAppBar( + context, + colorScheme, + title, + entries, + playlist, + localState, + ), + if (entries.isEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: _EmptyFolderState( + title: emptyTitle, + subtitle: emptySubtitle, + ), + ) + else + SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + final entry = entries[index]; + final isSelected = _selectedKeys.contains(entry.key); + return KeyedSubtree( + key: ValueKey(entry.key), + child: _CollectionTrackTile( + entry: entry, + mode: widget.mode, + playlistId: widget.playlistId, + localLibraryState: localState, + folderTracks: folderTracks, + isSelectionMode: _isSelectionMode, + isSelected: isSelected, + onTap: _isSelectionMode + ? () => _toggleSelection(entry.key) + : null, + onLongPress: _isSelectionMode + ? null + : () => _enterSelectionMode(entry.key), + ), + ); + }, childCount: entries.length), + ), + SliverToBoxAdapter( + child: SizedBox(height: _isSelectionMode ? 200 : 32), + ), + ], + ), + + // Selection bottom bar + AnimatedPositioned( + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, + left: 0, + right: 0, + bottom: _isSelectionMode ? 0 : -(280 + bottomPadding), + child: _buildSelectionBottomBar( + context, + colorScheme, + entries, + bottomPadding, + ), + ), + ], + ), + ), + ); + } + + Widget _buildSelectionBottomBar( + BuildContext context, + ColorScheme colorScheme, + List entries, + double bottomPadding, + ) { + final selectedCount = _selectedKeys.length; + final allSelected = selectedCount == entries.length && entries.isNotEmpty; + final isWishlist = widget.mode == LibraryTracksFolderMode.wishlist; + + return Container( + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHigh, + borderRadius: const BorderRadius.vertical(top: Radius.circular(28)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.15), + blurRadius: 12, + offset: const Offset(0, -4), + ), + ], + ), + child: SafeArea( + top: false, + child: Padding( + padding: EdgeInsets.fromLTRB(16, 16, 16, bottomPadding > 0 ? 8 : 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Drag handle + Container( + width: 32, + height: 4, + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: colorScheme.outlineVariant, + borderRadius: BorderRadius.circular(2), + ), + ), + + // Header: [X close] [count] [Select All / Deselect] + Row( + children: [ + IconButton.filledTonal( + onPressed: _exitSelectionMode, + icon: const Icon(Icons.close), + style: IconButton.styleFrom( + backgroundColor: colorScheme.surfaceContainerHighest, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.l10n.selectionSelected(selectedCount), + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + Text( + allSelected + ? context.l10n.selectionAllSelected + : context.l10n.selectionSelectToDelete, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ], + ), + ), + TextButton.icon( + onPressed: () { + if (allSelected) { + _exitSelectionMode(); + } else { + _selectAll(entries); + } + }, + icon: Icon( + allSelected ? Icons.deselect : Icons.select_all, + size: 20, + ), + label: Text( + allSelected + ? context.l10n.actionDeselect + : context.l10n.actionSelectAll, + ), + style: TextButton.styleFrom( + foregroundColor: colorScheme.primary, + ), + ), + ], + ), + + const SizedBox(height: 12), + + // Action buttons row + Row( + children: [ + if (isWishlist) + Expanded( + child: _SelectionActionButton( + icon: Icons.download, + label: + '${context.l10n.settingsDownload} ($selectedCount)', + onPressed: selectedCount > 0 + ? () => _downloadSelected(entries) + : null, + colorScheme: colorScheme, + ), + ), + if (isWishlist) const SizedBox(width: 8), + Expanded( + child: _SelectionActionButton( + icon: Icons.playlist_add, + label: + '${context.l10n.collectionAddToPlaylist} ($selectedCount)', + onPressed: selectedCount > 0 + ? () => _addSelectedToPlaylist(entries) + : null, + colorScheme: colorScheme, + ), + ), + ], + ), + + const SizedBox(height: 8), + + // Remove button (full width, red) + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: selectedCount > 0 + ? () => _removeSelected(entries) + : null, + icon: const Icon(Icons.remove_circle_outline), + label: Text( + selectedCount > 0 + ? '${widget.mode == LibraryTracksFolderMode.playlist ? context.l10n.collectionRemoveFromPlaylist : context.l10n.collectionRemoveFromFolder} ($selectedCount)' + : widget.mode == LibraryTracksFolderMode.playlist + ? context.l10n.collectionRemoveFromPlaylist + : context.l10n.collectionRemoveFromFolder, + ), + style: FilledButton.styleFrom( + backgroundColor: selectedCount > 0 + ? colorScheme.error + : colorScheme.surfaceContainerHighest, + foregroundColor: selectedCount > 0 + ? colorScheme.onError + : colorScheme.onSurfaceVariant, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + ), + ), + ], + ), + ), + ), + ); + } + + Future _pickCoverImage() async { + final playlistId = widget.playlistId; + if (playlistId == null) return; + + final result = await FilePicker.platform.pickFiles( + type: FileType.image, + allowMultiple: false, + ); + if (result == null || result.files.isEmpty) return; + + final path = result.files.first.path; + if (path == null || path.isEmpty) return; + + await ref + .read(libraryCollectionsProvider.notifier) + .setPlaylistCover(playlistId, path); + } + + Future _removeCoverImage() async { + final playlistId = widget.playlistId; + if (playlistId == null) return; + + await ref + .read(libraryCollectionsProvider.notifier) + .removePlaylistCover(playlistId); + } + + Widget _buildAppBar( + BuildContext context, + ColorScheme colorScheme, + String title, + List entries, + UserPlaylistCollection? playlist, + LocalLibraryState localState, + ) { + final expandedHeight = _calculateExpandedHeight(context); + final customCoverPath = playlist?.coverImagePath; + final isLovedMode = widget.mode == LibraryTracksFolderMode.loved; + final isPlaylistMode = widget.mode == LibraryTracksFolderMode.playlist; + // Loved always shows the heart icon (like Spotify's Liked Songs) + final coverUrl = isLovedMode ? null : _firstCoverUrl(entries, localState); + final hasCustomCover = + customCoverPath != null && customCoverPath.isNotEmpty; + final hasCoverUrl = coverUrl != null; + + return SliverAppBar( + expandedHeight: expandedHeight, + pinned: true, + stretch: true, + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + title: AnimatedOpacity( + duration: const Duration(milliseconds: 200), + opacity: _showTitleInAppBar ? 1.0 : 0.0, + child: Text( + _isSelectionMode + ? context.l10n.selectionSelected(_selectedKeys.length) + : title, + style: TextStyle( + color: colorScheme.onSurface, + fontWeight: FontWeight.w600, + fontSize: 16, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + actions: [ + if (isPlaylistMode && !_isSelectionMode) + IconButton( + icon: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.4), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.camera_alt_outlined, + color: Colors.white, + size: 20, + ), + ), + onPressed: () => _showCoverOptionsSheet(context, hasCustomCover), + ), + ], + flexibleSpace: LayoutBuilder( + builder: (context, constraints) { + final collapseRatio = + (constraints.maxHeight - kToolbarHeight) / + (expandedHeight - kToolbarHeight); + final showContent = collapseRatio > 0.3; + final dpr = MediaQuery.devicePixelRatioOf(context); + final cacheWidth = (MediaQuery.sizeOf(context).width * dpr) + .round() + .clamp(320, 2048); + final coverFallback = Container( + color: colorScheme.surfaceContainerHighest, + child: Icon( + _modeIcon(), + size: 80, + color: colorScheme.onSurfaceVariant, + ), + ); + + return FlexibleSpaceBar( + collapseMode: CollapseMode.pin, + background: Stack( + fit: StackFit.expand, + children: [ + // Cover background: custom > first track URL > icon + if (hasCustomCover) + Image.file( + File(customCoverPath), + fit: BoxFit.cover, + cacheWidth: cacheWidth, + filterQuality: FilterQuality.low, + gaplessPlayback: true, + frameBuilder: (_, child, frame, wasSynchronouslyLoaded) { + if (wasSynchronouslyLoaded || frame != null) return child; + return coverFallback; + }, + errorBuilder: (_, _, _) => coverFallback, + ) + else if (hasCoverUrl) + _isCoverLocalPath(coverUrl) + ? Image.file( + File(coverUrl), + fit: BoxFit.cover, + cacheWidth: cacheWidth, + filterQuality: FilterQuality.low, + gaplessPlayback: true, + frameBuilder: + (_, child, frame, wasSynchronouslyLoaded) { + if (wasSynchronouslyLoaded || frame != null) { + return child; + } + return Container(color: colorScheme.surface); + }, + errorBuilder: (_, _, _) => + Container(color: colorScheme.surface), + ) + : CachedNetworkImage( + imageUrl: _highResCoverUrl(coverUrl) ?? coverUrl, + fit: BoxFit.cover, + memCacheWidth: cacheWidth, + cacheManager: CoverCacheManager.instance, + placeholder: (_, _) => + Container(color: colorScheme.surface), + errorWidget: (_, _, _) => + Container(color: colorScheme.surface), + ) + else + coverFallback, + // Bottom gradient for readability + Positioned( + left: 0, + right: 0, + bottom: 0, + height: expandedHeight * 0.65, + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + Colors.black.withValues(alpha: 0.85), + ], + ), + ), + ), + ), + // Title and track count overlay + Positioned( + left: 20, + right: 20, + bottom: 40, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 150), + opacity: showContent ? 1.0 : 0.0, + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + style: const TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.bold, + height: 1.2, + ), + textAlign: TextAlign.center, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + if (entries.isNotEmpty) ...[ + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _modeIcon(), + size: 14, + color: Colors.white, + ), + const SizedBox(width: 4), + Text( + context.l10n.tracksCount(entries.length), + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 12, + ), + ), + ], + ), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildHeaderActionPlaceholder(), + const SizedBox(width: 12), + _buildDownloadAllCenterButton(entries), + const SizedBox(width: 12), + _buildHeaderActionPlaceholder(), + ], + ), + ], + ], + ), + ), + ), + ], + ), + stretchModes: const [StretchMode.zoomBackground], + ); + }, + ), + leading: IconButton( + icon: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.4), + shape: BoxShape.circle, + ), + child: Icon( + _isSelectionMode ? Icons.close : Icons.arrow_back, + color: Colors.white, + ), + ), + onPressed: _isSelectionMode + ? _exitSelectionMode + : () => Navigator.pop(context), + ), + ); + } + + // ── Header actions ── + + Widget _buildHeaderActionPlaceholder() => const SizedBox(width: 48, height: 48); + + Widget _buildDownloadAllCenterButton(List entries) { + final tracks = entries.map((e) => e.track).toList(growable: false); + return FilledButton.icon( + onPressed: tracks.isEmpty ? null : () => _confirmDownloadAll(tracks), + icon: const Icon(Icons.download_rounded, size: 18), + label: Text(context.l10n.downloadAllCount(tracks.length)), + style: FilledButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.black87, + minimumSize: const Size(0, 48), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), + ), + ); + } + + void _confirmDownloadAll(List tracks) { + if (tracks.isEmpty) return; + showDialog( + context: context, + builder: (dialogContext) { + final colorScheme = Theme.of(dialogContext).colorScheme; + return AlertDialog( + backgroundColor: colorScheme.surfaceContainerHigh, + title: const Text('Download All'), + content: Text('Download ${tracks.length} tracks?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(context.l10n.dialogCancel), + ), + FilledButton( + onPressed: () { + Navigator.pop(dialogContext); + _downloadAll(tracks); + }, + child: const Text('Download'), + ), + ], + ); + }, + ); + } + + void _downloadAll(List tracks) { + if (tracks.isEmpty) return; + final settings = ref.read(settingsProvider); + if (settings.askQualityBeforeDownload) { + DownloadServicePicker.show( + context, + trackName: '${tracks.length} tracks', + artistName: switch (widget.mode) { + LibraryTracksFolderMode.wishlist => context.l10n.collectionWishlist, + LibraryTracksFolderMode.loved => context.l10n.collectionLoved, + LibraryTracksFolderMode.playlist => context.l10n.collectionPlaylist, + }, + onSelect: (quality, service) { + ref + .read(downloadQueueProvider.notifier) + .addMultipleToQueue(tracks, service, qualityOverride: quality); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.snackbarAddedTracksToQueue(tracks.length), + ), + ), + ); + }, + ); + } else { + ref + .read(downloadQueueProvider.notifier) + .addMultipleToQueue(tracks, settings.defaultService); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)), + ), + ); + } + } + + void _showCoverOptionsSheet(BuildContext context, bool hasCustomCover) { + final colorScheme = Theme.of(context).colorScheme; + + showModalBottomSheet( + context: context, + useRootNavigator: true, + backgroundColor: colorScheme.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + margin: const EdgeInsets.only(top: 12), + width: 40, + height: 4, + decoration: BoxDecoration( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 16), + ListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 4, + ), + leading: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.image_outlined, + color: colorScheme.onPrimaryContainer, + ), + ), + title: Text(context.l10n.collectionPlaylistChangeCover), + onTap: () { + Navigator.pop(sheetContext); + _pickCoverImage(); + }, + ), + if (hasCustomCover) + ListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 4, + ), + leading: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: colorScheme.errorContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.delete_outline, + color: colorScheme.onErrorContainer, + ), + ), + title: Text(context.l10n.collectionPlaylistRemoveCover), + onTap: () { + Navigator.pop(sheetContext); + _removeCoverImage(); + }, + ), + const SizedBox(height: 16), + ], + ), + ), + ); + } +} + +class _CollectionTrackTile extends ConsumerWidget { + final CollectionTrackEntry entry; + final LibraryTracksFolderMode mode; + final String? playlistId; + final LocalLibraryState localLibraryState; + final List folderTracks; + final bool isSelectionMode; + final bool isSelected; + final VoidCallback? onTap; + final VoidCallback? onLongPress; + + const _CollectionTrackTile({ + required this.entry, + required this.mode, + required this.playlistId, + required this.localLibraryState, + required this.folderTracks, + this.isSelectionMode = false, + this.isSelected = false, + this.onTap, + this.onLongPress, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final track = entry.track; + final colorScheme = Theme.of(context).colorScheme; + final effectiveCoverUrl = _resolveCoverUrl(track); + final isInHistory = ref.watch( + downloadHistoryProvider.select((state) { + if (state.isDownloaded(track.id)) return true; + final isrc = track.isrc?.trim(); + if (isrc != null && isrc.isNotEmpty && state.getByIsrc(isrc) != null) { + return true; + } + return state.findByTrackAndArtist(track.name, track.artistName) != null; + }), + ); + final showLocalLibraryIndicator = ref.watch( + settingsProvider.select( + (s) => s.localLibraryEnabled && s.localLibraryShowDuplicates, + ), + ); + final isInLocalLibrary = showLocalLibraryIndicator + ? ref.watch( + localLibraryProvider.select( + (state) => state.existsInLibrary( + isrc: track.isrc, + trackName: track.name, + artistName: track.artistName, + ), + ), + ) + : false; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Card( + elevation: 0, + color: isSelected + ? colorScheme.primaryContainer.withValues(alpha: 0.3) + : Colors.transparent, + margin: const EdgeInsets.symmetric(vertical: 2), + child: ListTile( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + leading: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (isSelectionMode) ...[ + Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: isSelected + ? colorScheme.primary + : Colors.transparent, + shape: BoxShape.circle, + border: Border.all( + color: isSelected + ? colorScheme.primary + : colorScheme.outline, + width: 2, + ), + ), + child: isSelected + ? Icon( + Icons.check, + color: colorScheme.onPrimary, + size: 16, + ) + : null, + ), + const SizedBox(width: 12), + ], + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: effectiveCoverUrl != null && effectiveCoverUrl.isNotEmpty + ? _buildTrackCover(context, effectiveCoverUrl, 52) + : Container( + width: 52, + height: 52, + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.music_note, + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis), + subtitle: Row( + children: [ + Flexible( + child: Text( + track.artistName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (isInLocalLibrary || isInHistory) ...[ + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: colorScheme.tertiaryContainer, + borderRadius: BorderRadius.circular(4), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.folder_outlined, + size: 10, + color: colorScheme.onTertiaryContainer, + ), + const SizedBox(width: 3), + Text( + context.l10n.libraryInLibrary, + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w500, + color: colorScheme.onTertiaryContainer, + ), + ), + ], + ), + ), + ], + ], + ), + trailing: isSelectionMode + ? null + : IconButton( + icon: Icon( + Icons.more_vert, + color: colorScheme.onSurfaceVariant, + size: 20, + ), + onPressed: () => _showTrackOptionsSheet(context, ref), + ), + onTap: isSelectionMode + ? onTap + : () { + if (mode == LibraryTracksFolderMode.wishlist) { + _downloadTrack(context, ref); + return; + } + + _navigateToMetadata(context, ref); + }, + onLongPress: isSelectionMode ? onTap : onLongPress, + ), + ), + ); + } + + String? _resolveCoverUrl(Track track) { + final rawCover = track.coverUrl?.trim(); + if (rawCover != null && + rawCover.isNotEmpty && + !rawCover.startsWith('content://')) { + return rawCover; + } + + final isrc = track.isrc?.trim(); + if (isrc != null && isrc.isNotEmpty) { + final byIsrc = localLibraryState.getByIsrc(isrc); + final localCover = byIsrc?.coverPath?.trim(); + if (localCover != null && localCover.isNotEmpty) return localCover; + } + + final byTrack = localLibraryState.findByTrackAndArtist( + track.name, + track.artistName, + ); + final localCover = byTrack?.coverPath?.trim(); + if (localCover != null && localCover.isNotEmpty) return localCover; + + return null; + } + + /// Builds a cover image widget that handles both network URLs and local file paths. + Widget _buildTrackCover(BuildContext context, String coverUrl, double size) { + final isLocal = + !coverUrl.startsWith('http://') && !coverUrl.startsWith('https://'); + final colorScheme = Theme.of(context).colorScheme; + + if (isLocal) { + return Image.file( + File(coverUrl), + width: size, + height: size, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => Container( + width: size, + height: size, + color: colorScheme.surfaceContainerHighest, + child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant), + ), + ); + } + + return CachedNetworkImage( + imageUrl: coverUrl, + width: size, + height: size, + fit: BoxFit.cover, + memCacheWidth: (size * 2).toInt(), + cacheManager: CoverCacheManager.instance, + errorWidget: (_, _, _) => Container( + width: size, + height: size, + color: colorScheme.surfaceContainerHighest, + child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant), + ), + ); + } + + void _showTrackOptionsSheet(BuildContext context, WidgetRef ref) { + final track = entry.track; + final effectiveCoverUrl = _resolveCoverUrl(track); + final colorScheme = Theme.of(context).colorScheme; + final historyState = ref.read(downloadHistoryProvider); + final isDownloaded = + historyState.isDownloaded(track.id) || + (track.isrc != null && + track.isrc!.isNotEmpty && + historyState.getByIsrc(track.isrc!) != null) || + historyState.findByTrackAndArtist(track.name, track.artistName) != null; + // Wishlist: only show "Add to Playlist" if track is already downloaded + final showAddToPlaylist = + mode != LibraryTracksFolderMode.wishlist || isDownloaded; + + showModalBottomSheet( + context: context, + useRootNavigator: true, + backgroundColor: colorScheme.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Header: drag handle + cover + track info + Column( + children: [ + const SizedBox(height: 8), + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: + effectiveCoverUrl != null && + effectiveCoverUrl.isNotEmpty + ? _buildTrackCover(context, effectiveCoverUrl, 56) + : Container( + width: 56, + height: 56, + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.music_note, + color: colorScheme.onSurfaceVariant, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + track.name, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.w600), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + track.artistName, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + ), + ], + ), + Divider( + height: 1, + color: colorScheme.outlineVariant.withValues(alpha: 0.5), + ), + + // Add to playlist (hidden in wishlist unless already downloaded) + if (showAddToPlaylist) + _CollectionOptionTile( + icon: Icons.playlist_add, + title: context.l10n.collectionAddToPlaylist, + onTap: () { + Navigator.pop(sheetContext); + showAddTrackToPlaylistSheet(context, ref, track); + }, + ), + + // Remove from folder / playlist + _CollectionOptionTile( + icon: Icons.remove_circle_outline, + iconColor: colorScheme.error, + title: mode == LibraryTracksFolderMode.playlist + ? context.l10n.collectionRemoveFromPlaylist + : context.l10n.collectionRemoveFromFolder, + onTap: () { + Navigator.pop(sheetContext); + _removeFromCurrentFolder(context, ref); + }, + ), + + const SizedBox(height: 16), + ], + ), + ), + ); + } + + Future _removeFromCurrentFolder( + BuildContext context, + WidgetRef ref, + ) async { + final notifier = ref.read(libraryCollectionsProvider.notifier); + final key = entry.key; + + switch (mode) { + case LibraryTracksFolderMode.wishlist: + await notifier.removeFromWishlist(key); + break; + case LibraryTracksFolderMode.loved: + await notifier.removeFromLoved(key); + break; + case LibraryTracksFolderMode.playlist: + if (playlistId != null) { + await notifier.removeTrackFromPlaylist(playlistId!, key); + } + break; + } + + if (!context.mounted) return; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.collectionRemoved(entry.track.name))), + ); + } + + void _downloadTrack(BuildContext context, WidgetRef ref) { + final track = entry.track; + final settings = ref.read(settingsProvider); + + if (settings.askQualityBeforeDownload) { + DownloadServicePicker.show( + context, + trackName: track.name, + artistName: track.artistName, + coverUrl: track.coverUrl, + onSelect: (quality, service) { + ref + .read(downloadQueueProvider.notifier) + .addToQueue(track, service, qualityOverride: quality); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.snackbarAddedToQueue(track.name)), + ), + ); + }, + ); + } else { + ref + .read(downloadQueueProvider.notifier) + .addToQueue(track, settings.defaultService); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))), + ); + } + } + + Future _navigateToMetadata(BuildContext context, WidgetRef ref) async { + final track = entry.track; + final historyState = ref.read(downloadHistoryProvider); + + // 1. Download history by Spotify ID + var historyItem = historyState.getBySpotifyId(track.id); + + // 2. Download history by ISRC + if (historyItem == null && track.isrc != null && track.isrc!.isNotEmpty) { + historyItem = historyState.getByIsrc(track.isrc!); + } + + // 3. Download history by track name + artist (handles ID/ISRC mismatch) + historyItem ??= historyState.findByTrackAndArtist( + track.name, + track.artistName, + ); + + if (historyItem != null) { + await Navigator.of(context).push( + PageRouteBuilder( + transitionDuration: const Duration(milliseconds: 300), + reverseTransitionDuration: const Duration(milliseconds: 250), + pageBuilder: (context, animation, secondaryAnimation) => + TrackMetadataScreen(item: historyItem), + transitionsBuilder: (context, animation, secondaryAnimation, child) => + FadeTransition(opacity: animation, child: child), + ), + ); + return; + } + + // 4. Local library by ISRC + final localState = ref.read(localLibraryProvider); + LocalLibraryItem? localItem; + if (track.isrc != null && track.isrc!.isNotEmpty) { + localItem = localState.getByIsrc(track.isrc!); + } + + // 5. Local library by track name + artist + localItem ??= localState.findByTrackAndArtist(track.name, track.artistName); + + if (localItem != null) { + await Navigator.of(context).push( + PageRouteBuilder( + transitionDuration: const Duration(milliseconds: 300), + reverseTransitionDuration: const Duration(milliseconds: 250), + pageBuilder: (context, animation, secondaryAnimation) => + TrackMetadataScreen(localItem: localItem), + transitionsBuilder: (context, animation, secondaryAnimation, child) => + FadeTransition(opacity: animation, child: child), + ), + ); + return; + } + + // 6. Not found anywhere — offer to download + _downloadTrack(context, ref); + } +} + +/// Styled like _OptionTile in track_collection_quick_actions.dart +class _CollectionOptionTile extends StatelessWidget { + final IconData icon; + final Color? iconColor; + final String title; + final VoidCallback onTap; + + const _CollectionOptionTile({ + required this.icon, + this.iconColor, + required this.title, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4), + leading: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + icon, + color: iconColor ?? colorScheme.onPrimaryContainer, + size: 20, + ), + ), + title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)), + onTap: onTap, + ); + } +} + +class _SelectionActionButton extends StatelessWidget { + final IconData icon; + final String label; + final VoidCallback? onPressed; + final ColorScheme colorScheme; + + const _SelectionActionButton({ + required this.icon, + required this.label, + required this.onPressed, + required this.colorScheme, + }); + + @override + Widget build(BuildContext context) { + return FilledButton.icon( + onPressed: onPressed, + icon: Icon(icon, size: 18), + label: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis), + style: FilledButton.styleFrom( + backgroundColor: onPressed != null + ? colorScheme.primaryContainer + : colorScheme.surfaceContainerHighest, + foregroundColor: onPressed != null + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + ), + ); + } +} + +class _EmptyFolderState extends StatelessWidget { + final String title; + final String subtitle; + + const _EmptyFolderState({required this.title, required this.subtitle}); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.folder_open, + size: 60, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 12), + Text( + title, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 6), + Text( + subtitle, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ); + } +} + +enum LibraryTracksFolderMode { wishlist, loved, playlist } diff --git a/lib/screens/local_album_screen.dart b/lib/screens/local_album_screen.dart index a86bc2aa..83298d72 100644 --- a/lib/screens/local_album_screen.dart +++ b/lib/screens/local_album_screen.dart @@ -1,12 +1,17 @@ import 'dart:io'; -import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/utils/file_access.dart'; +import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart'; import 'package:spotiflac_android/services/library_database.dart'; +import 'package:spotiflac_android/services/ffmpeg_service.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; +import 'package:spotiflac_android/providers/playback_provider.dart'; /// Screen to display tracks from a local library album class LocalAlbumScreen extends ConsumerStatefulWidget { @@ -62,12 +67,19 @@ class _LocalAlbumScreenState extends ConsumerState { } void _onScroll() { - final shouldShow = _scrollController.offset > 280; + final expandedHeight = _calculateExpandedHeight(context); + final shouldShow = + _scrollController.offset > (expandedHeight - kToolbarHeight - 20); if (shouldShow != _showTitleInAppBar) { setState(() => _showTitleInAppBar = shouldShow); } } + double _calculateExpandedHeight(BuildContext context) { + final mediaSize = MediaQuery.of(context).size; + return (mediaSize.height * 0.55).clamp(360.0, 520.0); + } + List _buildSortedTracks() { final tracks = List.from(widget.tracks); tracks.sort((a, b) { @@ -193,9 +205,17 @@ class _LocalAlbumScreenState extends ConsumerState { } } - Future _openFile(String filePath) async { + Future _openFile(LocalLibraryItem track) async { try { - await openFile(filePath); + await ref + .read(playbackProvider.notifier) + .playLocalPath( + path: track.filePath, + title: track.trackName, + artist: track.artistName, + album: track.albumName, + coverUrl: track.coverPath ?? '', + ); } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -244,7 +264,6 @@ class _LocalAlbumScreenState extends ConsumerState { slivers: [ _buildAppBar(context, colorScheme), _buildInfoCard(context, colorScheme, tracks), - _buildTrackListHeader(context, colorScheme, tracks), _buildTrackList(context, colorScheme, tracks), SliverToBoxAdapter( child: SizedBox(height: _isSelectionMode ? 120 : 32), @@ -272,14 +291,8 @@ class _LocalAlbumScreenState extends ConsumerState { } Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) { - final mediaSize = MediaQuery.of(context).size; - final screenWidth = mediaSize.width; - final shortestSide = mediaSize.shortestSide; - final coverSize = (screenWidth * 0.5).clamp(140.0, 220.0); - final expandedHeight = (shortestSide * 0.82).clamp(280.0, 340.0); - final bottomGradientHeight = (shortestSide * 0.2).clamp(56.0, 80.0); - final coverTopPadding = (shortestSide * 0.14).clamp(40.0, 60.0); - final fallbackIconSize = (coverSize * 0.32).clamp(44.0, 64.0); + final expandedHeight = _calculateExpandedHeight(context); + final commonQuality = _commonQualityCache; return SliverAppBar( expandedHeight: expandedHeight, @@ -309,11 +322,11 @@ class _LocalAlbumScreenState extends ConsumerState { final showContent = collapseRatio > 0.3; return FlexibleSpaceBar( - collapseMode: CollapseMode.none, + collapseMode: CollapseMode.pin, background: Stack( fit: StackFit.expand, children: [ - // Blurred cover background + // Full-screen cover background if (widget.coverPath != null) Image.file( File(widget.coverPath!), @@ -322,90 +335,161 @@ class _LocalAlbumScreenState extends ConsumerState { Container(color: colorScheme.surface), ) else - Container(color: colorScheme.surface), - ClipRect( - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30), - child: Container( - color: colorScheme.surface.withValues(alpha: 0.4), + Container( + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.album, + size: 80, + color: colorScheme.onSurfaceVariant, ), ), - ), + // Bottom gradient for readability Positioned( left: 0, right: 0, bottom: 0, - height: bottomGradientHeight, + height: expandedHeight * 0.65, child: Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ - colorScheme.surface.withValues(alpha: 0.0), - colorScheme.surface, + Colors.transparent, + Colors.black.withValues(alpha: 0.85), ], ), ), ), ), - // Cover image centered - AnimatedOpacity( - duration: const Duration(milliseconds: 150), - opacity: showContent ? 1.0 : 0.0, - child: Center( - child: Padding( - padding: EdgeInsets.only(top: coverTopPadding), - child: Container( - width: coverSize, - height: coverSize, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.4), - blurRadius: 30, - offset: const Offset(0, 15), - ), - ], + // Album info overlay at bottom + Positioned( + left: 20, + right: 20, + bottom: 40, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 150), + opacity: showContent ? 1.0 : 0.0, + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + widget.albumName, + style: const TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.bold, + height: 1.2, + ), + textAlign: TextAlign.center, + maxLines: 3, + overflow: TextOverflow.ellipsis, ), - child: ClipRRect( - borderRadius: BorderRadius.circular(20), - child: widget.coverPath != null - ? Image.file( - File(widget.coverPath!), - fit: BoxFit.cover, - cacheWidth: (coverSize * 2).toInt(), - errorBuilder: (context, error, stackTrace) => - Container( - color: - colorScheme.surfaceContainerHighest, - child: Icon( - Icons.album, - size: fallbackIconSize, - color: colorScheme.onSurfaceVariant, - ), - ), - ) - : Container( - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.album, - size: fallbackIconSize, - color: colorScheme.onSurfaceVariant, + const SizedBox(height: 6), + Text( + widget.artistName, + style: const TextStyle( + color: Colors.white70, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 12), + Wrap( + alignment: WrapAlignment.center, + spacing: 8, + runSpacing: 8, + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.folder, + size: 14, + color: Colors.white, + ), + const SizedBox(width: 4), + const Text( + 'Local', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 12, + ), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.music_note, + size: 14, + color: Colors.white, + ), + const SizedBox(width: 4), + Text( + '${_sortedTracksCache.length} tracks', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 12, + ), + ), + ], + ), + ), + if (commonQuality != null) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + commonQuality, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 12, ), ), + ), + ], ), - ), + ], ), ), ), ], ), - stretchModes: const [ - StretchMode.zoomBackground, - StretchMode.blurBackground, - ], + stretchModes: const [StretchMode.zoomBackground], ); }, ), @@ -413,10 +497,10 @@ class _LocalAlbumScreenState extends ConsumerState { icon: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: colorScheme.surface.withValues(alpha: 0.8), + color: Colors.black.withValues(alpha: 0.4), shape: BoxShape.circle, ), - child: Icon(Icons.arrow_back, color: colorScheme.onSurface), + child: const Icon(Icons.arrow_back, color: Colors.white), ), onPressed: () => Navigator.pop(context), ), @@ -428,133 +512,8 @@ class _LocalAlbumScreenState extends ConsumerState { ColorScheme colorScheme, List tracks, ) { - final commonQuality = _commonQualityCache; - - return SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(16), - child: Card( - elevation: 0, - color: colorScheme.surfaceContainerLow, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widget.albumName, - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.bold, - color: colorScheme.onSurface, - ), - ), - const SizedBox(height: 4), - Text( - widget.artistName, - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 12), - Row( - children: [ - // "Local" badge - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: colorScheme.tertiaryContainer, - borderRadius: BorderRadius.circular(20), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.folder, - size: 14, - color: colorScheme.onTertiaryContainer, - ), - const SizedBox(width: 4), - Text( - 'Local', - style: TextStyle( - color: colorScheme.onTertiaryContainer, - fontWeight: FontWeight.w600, - fontSize: 12, - ), - ), - ], - ), - ), - const SizedBox(width: 8), - // Track count - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(20), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.music_note, - size: 14, - color: colorScheme.onSurfaceVariant, - ), - const SizedBox(width: 4), - Text( - '${tracks.length} tracks', - style: TextStyle( - color: colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w600, - fontSize: 12, - ), - ), - ], - ), - ), - const SizedBox(width: 8), - // Quality badge if all tracks have the same quality - if (commonQuality != null) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: commonQuality.contains('24') - ? colorScheme.primaryContainer - : colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(20), - ), - child: Text( - commonQuality, - style: TextStyle( - color: commonQuality.contains('24') - ? colorScheme.onPrimaryContainer - : colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w600, - fontSize: 12, - ), - ), - ), - ], - ), - ], - ), - ), - ), - ), - ); + // Info is now displayed in the full-screen cover overlay + return const SliverToBoxAdapter(child: SizedBox.shrink()); } String? _computeCommonQuality(List tracks) { @@ -574,7 +533,11 @@ class _LocalAlbumScreenState extends ConsumerState { } // For lossless formats, use bit depth / sample rate - if (first.bitDepth == null || first.bitDepth == 0 || first.sampleRate == null) return null; + if (first.bitDepth == null || + first.bitDepth == 0 || + first.sampleRate == null) { + return null; + } final firstQuality = '${first.bitDepth}/${(first.sampleRate! / 1000).round()}kHz'; @@ -587,43 +550,6 @@ class _LocalAlbumScreenState extends ConsumerState { return firstQuality; } - Widget _buildTrackListHeader( - BuildContext context, - ColorScheme colorScheme, - List tracks, - ) { - return SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(20, 8, 20, 8), - child: Row( - children: [ - Icon(Icons.queue_music, size: 20, color: colorScheme.primary), - const SizedBox(width: 8), - Text( - context.l10n.downloadedAlbumTracksHeader, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - color: colorScheme.onSurface, - ), - ), - const Spacer(), - if (!_isSelectionMode) - TextButton.icon( - onPressed: tracks.isNotEmpty - ? () => _enterSelectionMode(tracks.first.id) - : null, - icon: const Icon(Icons.checklist, size: 18), - label: Text(context.l10n.actionSelect), - style: TextButton.styleFrom( - visualDensity: VisualDensity.compact, - ), - ), - ], - ), - ), - ); - } - Widget _buildTrackList( BuildContext context, ColorScheme colorScheme, @@ -722,7 +648,7 @@ class _LocalAlbumScreenState extends ConsumerState { ), onTap: _isSelectionMode ? () => _toggleSelection(track.id) - : () => _openFile(track.filePath), + : () => _openFile(track), onLongPress: _isSelectionMode ? null : () => _enterSelectionMode(track.id), @@ -807,7 +733,7 @@ class _LocalAlbumScreenState extends ConsumerState { trailing: _isSelectionMode ? null : IconButton( - onPressed: () => _openFile(track.filePath), + onPressed: () => _openFile(track), icon: Icon(Icons.play_arrow, color: colorScheme.primary), style: IconButton.styleFrom( backgroundColor: colorScheme.primaryContainer.withValues( @@ -820,6 +746,680 @@ class _LocalAlbumScreenState extends ConsumerState { ); } + bool _hasValue(String? value) => value != null && value.trim().isNotEmpty; + + Future _safeDeleteFile(String path) async { + try { + final file = File(path); + if (await file.exists()) { + await file.delete(); + } + } catch (_) {} + } + + Future _cleanupTempFileAndParent(String path) async { + await _safeDeleteFile(path); + try { + final parent = File(path).parent; + if (await parent.exists()) { + await parent.delete(); + } + } catch (_) {} + } + + Future _applyFfmpegReEnrichResult( + LocalLibraryItem item, + Map result, + ) async { + final tempPath = result['temp_path'] as String?; + final safUri = result['saf_uri'] as String?; + final ffmpegTarget = _hasValue(tempPath) ? tempPath! : item.filePath; + final downloadedCoverPath = result['cover_path'] as String?; + String? effectiveCoverPath = downloadedCoverPath; + String? extractedCoverPath; + + if (!_hasValue(effectiveCoverPath)) { + try { + final tempDir = await Directory.systemTemp.createTemp( + 'reenrich_cover_', + ); + final coverOutput = '${tempDir.path}${Platform.pathSeparator}cover.jpg'; + final extracted = await PlatformBridge.extractCoverToFile( + ffmpegTarget, + coverOutput, + ); + if (extracted['error'] == null) { + effectiveCoverPath = coverOutput; + extractedCoverPath = coverOutput; + } else { + try { + await tempDir.delete(recursive: true); + } catch (_) {} + } + } catch (_) {} + } + + final metadata = (result['metadata'] as Map?)?.map( + (k, v) => MapEntry(k, v.toString()), + ); + + final format = item.format?.toLowerCase(); + final lowerPath = item.filePath.toLowerCase(); + final isMp3 = format == 'mp3' || lowerPath.endsWith('.mp3'); + final isOpus = + format == 'opus' || + format == 'ogg' || + lowerPath.endsWith('.opus') || + lowerPath.endsWith('.ogg'); + + String? ffmpegResult; + if (isMp3) { + ffmpegResult = await FFmpegService.embedMetadataToMp3( + mp3Path: ffmpegTarget, + coverPath: effectiveCoverPath, + metadata: metadata, + ); + } else if (isOpus) { + ffmpegResult = await FFmpegService.embedMetadataToOpus( + opusPath: ffmpegTarget, + coverPath: effectiveCoverPath, + metadata: metadata, + ); + } + + if (ffmpegResult != null && _hasValue(tempPath) && _hasValue(safUri)) { + final ok = await PlatformBridge.writeTempToSaf(ffmpegResult, safUri!); + if (!ok) { + if (_hasValue(downloadedCoverPath)) { + await _safeDeleteFile(downloadedCoverPath!); + } + if (_hasValue(extractedCoverPath)) { + await _cleanupTempFileAndParent(extractedCoverPath!); + } + await _safeDeleteFile(tempPath!); + return false; + } + } + + if (_hasValue(downloadedCoverPath)) { + await _safeDeleteFile(downloadedCoverPath!); + } + if (_hasValue(extractedCoverPath)) { + await _cleanupTempFileAndParent(extractedCoverPath!); + } + if (_hasValue(tempPath)) { + await _safeDeleteFile(tempPath!); + } + + return ffmpegResult != null; + } + + Future _reEnrichLocalTrack(LocalLibraryItem item) async { + final durationMs = (item.duration ?? 0) * 1000; + final request = { + 'file_path': item.filePath, + 'cover_url': '', + 'max_quality': true, + 'embed_lyrics': true, + 'spotify_id': '', + 'track_name': item.trackName, + 'artist_name': item.artistName, + 'album_name': item.albumName, + 'album_artist': item.albumArtist ?? item.artistName, + 'track_number': item.trackNumber ?? 0, + 'disc_number': item.discNumber ?? 0, + 'release_date': item.releaseDate ?? '', + 'isrc': item.isrc ?? '', + 'genre': item.genre ?? '', + 'label': '', + 'copyright': '', + 'duration_ms': durationMs, + 'search_online': true, + }; + + final result = await PlatformBridge.reEnrichFile(request); + final method = result['method'] as String?; + if (method == 'native') { + return true; + } + if (method == 'ffmpeg') { + return _applyFfmpegReEnrichResult(item, result); + } + return false; + } + + /// Batch re-enrich selected local tracks + Future _reEnrichSelected(List allTracks) async { + final tracksById = {for (final t in allTracks) t.id: t}; + final selected = []; + + for (final id in _selectedIds) { + final item = tracksById[id]; + if (item != null) { + selected.add(item); + } + } + + if (selected.isEmpty) { + return; + } + + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(context.l10n.trackReEnrich), + content: Text( + '${context.l10n.trackReEnrichOnlineSubtitle}\n\n' + '${context.l10n.downloadedAlbumSelectedCount(selected.length)}', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text(context.l10n.dialogCancel), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: Text(context.l10n.trackReEnrich), + ), + ], + ), + ); + + if (confirmed != true || !mounted) { + return; + } + + var successCount = 0; + final total = selected.length; + + for (var i = 0; i < total; i++) { + if (!mounted) break; + final item = selected[i]; + + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + '${context.l10n.trackReEnrichProgress} (${i + 1}/$total)', + ), + duration: const Duration(seconds: 30), + ), + ); + + try { + final ok = await _reEnrichLocalTrack(item); + if (ok) { + successCount++; + } + } catch (_) {} + } + + if (!mounted) { + return; + } + + final localLibraryPath = ref.read(settingsProvider).localLibraryPath.trim(); + try { + if (localLibraryPath.isNotEmpty && + !ref.read(localLibraryProvider).isScanning) { + await ref + .read(localLibraryProvider.notifier) + .startScan(localLibraryPath); + } else { + await ref.read(localLibraryProvider.notifier).reloadFromStorage(); + } + } catch (_) { + await ref.read(localLibraryProvider.notifier).reloadFromStorage(); + } + + _exitSelectionMode(); + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).clearSnackBars(); + final failedCount = total - successCount; + final summary = failedCount <= 0 + ? '${context.l10n.trackReEnrichSuccess} ($successCount/$total)' + : '${context.l10n.trackReEnrichSuccess} ($successCount/$total) • Failed: $failedCount'; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(summary))); + } + + /// Show batch convert bottom sheet + void _showBatchConvertSheet( + BuildContext context, + List allTracks, + ) { + String selectedFormat = 'MP3'; + String selectedBitrate = '320k'; + + showModalBottomSheet( + context: context, + useRootNavigator: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (sheetContext) { + return StatefulBuilder( + builder: (context, setSheetState) { + final colorScheme = Theme.of(context).colorScheme; + final formats = ['MP3', 'Opus']; + final bitrates = ['128k', '192k', '256k', '320k']; + + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: colorScheme.onSurfaceVariant.withValues( + alpha: 0.4, + ), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 16), + Text( + context.l10n.selectionBatchConvertConfirmTitle, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 20), + Text( + context.l10n.trackConvertTargetFormat, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Row( + children: formats.map((format) { + final isSelected = format == selectedFormat; + return Padding( + padding: const EdgeInsets.only(right: 8), + child: ChoiceChip( + label: Text(format), + selected: isSelected, + onSelected: (selected) { + if (selected) { + setSheetState(() { + selectedFormat = format; + selectedBitrate = format == 'Opus' + ? '128k' + : '320k'; + }); + } + }, + ), + ); + }).toList(), + ), + const SizedBox(height: 16), + Text( + context.l10n.trackConvertBitrate, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: bitrates.map((br) { + final isSelected = br == selectedBitrate; + return ChoiceChip( + label: Text(br), + selected: isSelected, + onSelected: (selected) { + if (selected) { + setSheetState(() => selectedBitrate = br); + } + }, + ); + }).toList(), + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: () { + Navigator.pop(context); + _performBatchConversion( + allTracks: allTracks, + targetFormat: selectedFormat, + bitrate: selectedBitrate, + ); + }, + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + child: Text( + context.l10n.selectionConvertCount( + _selectedIds.length, + ), + ), + ), + ), + ], + ), + ), + ); + }, + ); + }, + ); + } + + Future _performBatchConversion({ + required List allTracks, + required String targetFormat, + required String bitrate, + }) async { + final tracksById = {for (final t in allTracks) t.id: t}; + final selected = []; + for (final id in _selectedIds) { + final item = tracksById[id]; + if (item == null) continue; + // Detect current format: prefer item.format field (works for SAF too), + // fall back to file extension for regular paths + String? currentFormat; + if (item.format != null && item.format!.isNotEmpty) { + final fmt = item.format!.toLowerCase(); + if (fmt == 'flac') { + currentFormat = 'FLAC'; + } else if (fmt == 'mp3') { + currentFormat = 'MP3'; + } else if (fmt == 'opus' || fmt == 'ogg') { + currentFormat = 'Opus'; + } + } + if (currentFormat == null) { + // Fallback: try file extension (works for regular paths) + final lower = item.filePath.toLowerCase(); + if (lower.endsWith('.flac')) { + currentFormat = 'FLAC'; + } else if (lower.endsWith('.mp3')) { + currentFormat = 'MP3'; + } else if (lower.endsWith('.opus') || lower.endsWith('.ogg')) { + currentFormat = 'Opus'; + } + } + if (currentFormat != null && currentFormat != targetFormat) { + selected.add(item); + } + } + + if (selected.isEmpty) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.selectionConvertNoConvertible)), + ); + } + return; + } + + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(context.l10n.selectionBatchConvertConfirmTitle), + content: Text( + context.l10n.selectionBatchConvertConfirmMessage( + selected.length, + targetFormat, + bitrate, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text(context.l10n.dialogCancel), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: Text(context.l10n.trackConvertFormat), + ), + ], + ), + ); + + if (confirmed != true || !mounted) return; + + int successCount = 0; + final total = selected.length; + final localDb = LibraryDatabase.instance; + final settings = ref.read(settingsProvider); + final shouldEmbedLyrics = + settings.embedLyrics && settings.lyricsMode != 'external'; + + for (int i = 0; i < total; i++) { + if (!mounted) break; + final item = selected[i]; + + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.selectionBatchConvertProgress(i + 1, total), + ), + duration: const Duration(seconds: 30), + ), + ); + + try { + final metadata = { + 'TITLE': item.trackName, + 'ARTIST': item.artistName, + 'ALBUM': item.albumName, + }; + try { + final result = await PlatformBridge.readFileMetadata(item.filePath); + if (result['error'] == null) { + result.forEach((key, value) { + if (key == 'error' || value == null) return; + final v = value.toString().trim(); + if (v.isEmpty) return; + metadata[key.toUpperCase()] = v; + }); + } + } catch (_) {} + await ensureLyricsMetadataForConversion( + metadata: metadata, + sourcePath: item.filePath, + shouldEmbedLyrics: shouldEmbedLyrics, + trackName: item.trackName, + artistName: item.artistName, + durationMs: (item.duration ?? 0) * 1000, + ); + + String? coverPath; + try { + final tempDir = await getTemporaryDirectory(); + final coverOutput = + '${tempDir.path}${Platform.pathSeparator}batch_cover_${DateTime.now().millisecondsSinceEpoch}.jpg'; + final coverResult = await PlatformBridge.extractCoverToFile( + item.filePath, + coverOutput, + ); + if (coverResult['error'] == null) coverPath = coverOutput; + } catch (_) {} + + final isSaf = isContentUri(item.filePath); + String workingPath = item.filePath; + String? safTempPath; + + if (isSaf) { + // Copy SAF file to temp for conversion + safTempPath = await PlatformBridge.copyContentUriToTemp( + item.filePath, + ); + if (safTempPath == null) continue; + workingPath = safTempPath; + } + + final newPath = await FFmpegService.convertAudioFormat( + inputPath: workingPath, + targetFormat: targetFormat.toLowerCase(), + bitrate: bitrate, + metadata: metadata, + coverPath: coverPath, + deleteOriginal: !isSaf, // Only delete original for regular files + ); + + if (coverPath != null) { + try { + await File(coverPath).delete(); + } catch (_) {} + } + + if (newPath == null) { + if (safTempPath != null) { + try { + await File(safTempPath).delete(); + } catch (_) {} + } + continue; + } + + if (isSaf) { + // For SAF: derive the parent tree URI and relative dir from the content URI, + // then create new SAF file and delete old one + // + // Parse the SAF URI to get the tree document path: + // content://...tree/...document/.../oldName.flac + // We need tree URI and relative dir to create the new file + final uri = Uri.parse(item.filePath); + final pathSegments = uri.pathSegments; + + // Try to find 'tree' and 'document' segments + String? treeUri; + String relativeDir = ''; + String oldFileName = ''; + + // Typical SAF document URI pattern: + // content://authority/tree//document/ + final treeIdx = pathSegments.indexOf('tree'); + final docIdx = pathSegments.indexOf('document'); + if (treeIdx >= 0 && treeIdx + 1 < pathSegments.length) { + final treeId = pathSegments[treeIdx + 1]; + treeUri = + 'content://${uri.authority}/tree/${Uri.encodeComponent(treeId)}'; + } + + if (docIdx >= 0 && docIdx + 1 < pathSegments.length) { + final docPath = Uri.decodeFull(pathSegments[docIdx + 1]); + final slashIdx = docPath.lastIndexOf('/'); + if (slashIdx >= 0) { + oldFileName = docPath.substring(slashIdx + 1); + // Relative dir is everything after the tree id's directory base + final treeId = treeIdx >= 0 && treeIdx + 1 < pathSegments.length + ? Uri.decodeFull(pathSegments[treeIdx + 1]) + : ''; + if (treeId.isNotEmpty && docPath.startsWith(treeId)) { + final afterTree = docPath.substring(treeId.length); + final trimmed = afterTree.startsWith('/') + ? afterTree.substring(1) + : afterTree; + final lastSlash = trimmed.lastIndexOf('/'); + relativeDir = lastSlash >= 0 + ? trimmed.substring(0, lastSlash) + : ''; + } + } else { + oldFileName = docPath; + } + } + + if (treeUri != null && oldFileName.isNotEmpty) { + final dotIdx = oldFileName.lastIndexOf('.'); + final baseName = dotIdx > 0 + ? oldFileName.substring(0, dotIdx) + : oldFileName; + final newExt = targetFormat.toLowerCase() == 'opus' + ? '.opus' + : '.mp3'; + final newFileName = '$baseName$newExt'; + final mimeType = targetFormat.toLowerCase() == 'opus' + ? 'audio/opus' + : 'audio/mpeg'; + + final safUri = await PlatformBridge.createSafFileFromPath( + treeUri: treeUri, + relativeDir: relativeDir, + fileName: newFileName, + mimeType: mimeType, + srcPath: newPath, + ); + + if (safUri == null || safUri.isEmpty) { + try { + await File(newPath).delete(); + } catch (_) {} + if (safTempPath != null) { + try { + await File(safTempPath).delete(); + } catch (_) {} + } + continue; + } + + // Delete old SAF file + try { + await PlatformBridge.safDelete(item.filePath); + } catch (_) {} + await localDb.deleteByPath(item.filePath); + } + + // Clean up temp files + try { + await File(newPath).delete(); + } catch (_) {} + if (safTempPath != null) { + try { + await File(safTempPath).delete(); + } catch (_) {} + } + } else { + // Regular file: just remove old entry, rescan will find the new one + await localDb.deleteByPath(item.filePath); + } + + successCount++; + } catch (_) {} + } + + // Reload local library to pick up converted files + ref.read(localLibraryProvider.notifier).reloadFromStorage(); + _exitSelectionMode(); + + if (mounted) { + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.selectionBatchConvertSuccess( + successCount, + total, + targetFormat, + ), + ), + ), + ); + } + } + Widget _buildSelectionBottomBar( BuildContext context, ColorScheme colorScheme, @@ -911,7 +1511,36 @@ class _LocalAlbumScreenState extends ConsumerState { ), ], ), - const SizedBox(height: 16), + const SizedBox(height: 12), + + // Action buttons row: Re-enrich, Convert + Row( + children: [ + Expanded( + child: _LocalAlbumSelectionActionButton( + icon: Icons.auto_fix_high_outlined, + label: '${context.l10n.trackReEnrich} ($selectedCount)', + onPressed: selectedCount > 0 + ? () => _reEnrichSelected(tracks) + : null, + colorScheme: colorScheme, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _LocalAlbumSelectionActionButton( + icon: Icons.swap_horiz, + label: context.l10n.selectionConvertCount(selectedCount), + onPressed: selectedCount > 0 + ? () => _showBatchConvertSheet(context, tracks) + : null, + colorScheme: colorScheme, + ), + ), + ], + ), + + const SizedBox(height: 8), SizedBox( width: double.infinity, child: FilledButton.icon( @@ -945,3 +1574,62 @@ class _LocalAlbumScreenState extends ConsumerState { ); } } + +class _LocalAlbumSelectionActionButton extends StatelessWidget { + final IconData icon; + final String label; + final VoidCallback? onPressed; + final ColorScheme colorScheme; + + const _LocalAlbumSelectionActionButton({ + required this.icon, + required this.label, + required this.onPressed, + required this.colorScheme, + }); + + @override + Widget build(BuildContext context) { + final isDisabled = onPressed == null; + return Material( + color: isDisabled + ? colorScheme.surfaceContainerHighest.withValues(alpha: 0.5) + : colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(14), + child: InkWell( + onTap: onPressed, + borderRadius: BorderRadius.circular(14), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icon, + size: 18, + color: isDisabled + ? colorScheme.onSurfaceVariant.withValues(alpha: 0.5) + : colorScheme.onSecondaryContainer, + ), + const SizedBox(width: 6), + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: isDisabled + ? colorScheme.onSurfaceVariant.withValues(alpha: 0.5) + : colorScheme.onSecondaryContainer, + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index 8d7b96ca..e103c6a1 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -16,6 +16,7 @@ import 'package:spotiflac_android/screens/store_tab.dart'; import 'package:spotiflac_android/screens/queue_tab.dart'; import 'package:spotiflac_android/screens/settings/settings_tab.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/services/shell_navigation_service.dart'; import 'package:spotiflac_android/services/share_intent_service.dart'; import 'package:spotiflac_android/services/update_checker.dart'; import 'package:spotiflac_android/widgets/update_dialog.dart'; @@ -36,11 +37,21 @@ class _MainShellState extends ConsumerState { bool _hasCheckedUpdate = false; StreamSubscription? _shareSubscription; DateTime? _lastBackPress; + final GlobalKey _homeTabNavigatorKey = + ShellNavigationService.homeTabNavigatorKey; + final GlobalKey _libraryTabNavigatorKey = + ShellNavigationService.libraryTabNavigatorKey; + final GlobalKey _storeTabNavigatorKey = + ShellNavigationService.storeTabNavigatorKey; @override void initState() { super.initState(); _pageController = PageController(initialPage: _currentIndex); + ShellNavigationService.syncState( + currentTabIndex: _currentIndex, + showStoreTab: false, + ); WidgetsBinding.instance.addPostFrameCallback((_) { _checkForUpdates(); _setupShareListener(); @@ -86,6 +97,7 @@ class _MainShellState extends ConsumerState { if (!mounted) return; Navigator.of(context).popUntil((route) => route.isFirst); + _homeTabNavigatorKey.currentState?.popUntil((route) => route.isFirst); if (_currentIndex != 0) { _onNavTap(0); @@ -213,10 +225,34 @@ class _MainShellState extends ConsumerState { super.dispose(); } + void _resetHomeToMain() { + final showStore = ref.read( + settingsProvider.select((s) => s.showExtensionStore), + ); + final homeNavigator = _navigatorForTab(0, showStore); + homeNavigator?.popUntil((route) => route.isFirst); + // Unfocus BEFORE clear so _onTrackStateChanged can properly + // clear _urlController (it checks !_searchFocusNode.hasFocus) + FocusManager.instance.primaryFocus?.unfocus(); + ref.read(trackProvider.notifier).clear(); + } + void _onNavTap(int index) { + if (index == 0 && _currentIndex == 0) { + _resetHomeToMain(); + return; + } + if (_currentIndex != index) { HapticFeedback.selectionClick(); setState(() => _currentIndex = index); + final showStore = ref.read( + settingsProvider.select((s) => s.showExtensionStore), + ); + ShellNavigationService.syncState( + currentTabIndex: _currentIndex, + showStoreTab: showStore, + ); _pageController.animateToPage( index, duration: const Duration(milliseconds: 250), @@ -226,48 +262,121 @@ class _MainShellState extends ConsumerState { } void _onPageChanged(int index) { + final previousIndex = _currentIndex; if (_currentIndex != index) { setState(() => _currentIndex = index); + final showStore = ref.read( + settingsProvider.select((s) => s.showExtensionStore), + ); + ShellNavigationService.syncState( + currentTabIndex: _currentIndex, + showStoreTab: showStore, + ); FocusManager.instance.primaryFocus?.unfocus(); + if (index == 0 && previousIndex != 0) { + _resetHomeToMain(); + } } } void _handleBackPress() { + final rootNavigator = Navigator.of(context, rootNavigator: true); + if (rootNavigator.canPop()) { + _log.i('Back: step 1 - root navigator pop'); + rootNavigator.pop(); + _lastBackPress = null; + return; + } + + final showStore = ref.read( + settingsProvider.select((s) => s.showExtensionStore), + ); + final currentNavigator = _navigatorForTab(_currentIndex, showStore); + if (currentNavigator != null && currentNavigator.canPop()) { + _log.i('Back: step 2 - tab navigator pop (tab=$_currentIndex)'); + currentNavigator.pop(); + _lastBackPress = null; + return; + } + final trackState = ref.read(trackProvider); final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0; - if (isKeyboardVisible) { + + _log.d( + 'Back: state check - tab=$_currentIndex, ' + 'isShowingRecentAccess=${trackState.isShowingRecentAccess}, ' + 'hasSearchText=${trackState.hasSearchText}, ' + 'hasContent=${trackState.hasContent}, ' + 'isLoading=${trackState.isLoading}, ' + 'isKeyboardVisible=$isKeyboardVisible', + ); + + if (_currentIndex == 0 && + trackState.isShowingRecentAccess && + !trackState.isLoading && + (trackState.hasSearchText || trackState.hasContent)) { + // Has recent access AND search content — clear everything at once + _log.i( + 'Back: step 3a - dismiss recent access + clear search/content ' + '(hasSearchText=${trackState.hasSearchText}, hasContent=${trackState.hasContent})', + ); FocusManager.instance.primaryFocus?.unfocus(); + ref.read(trackProvider.notifier).clear(); + _lastBackPress = null; return; } if (_currentIndex == 0 && trackState.isShowingRecentAccess) { + // Recent access overlay only (no search content) — just dismiss it + _log.i('Back: step 3b - dismiss recent access only'); ref.read(trackProvider.notifier).setShowingRecentAccess(false); FocusManager.instance.primaryFocus?.unfocus(); + _lastBackPress = null; return; } if (_currentIndex == 0 && !trackState.isLoading && (trackState.hasSearchText || trackState.hasContent)) { + _log.i( + 'Back: step 4 - clear search/content ' + '(hasSearchText=${trackState.hasSearchText}, hasContent=${trackState.hasContent})', + ); + // Unfocus BEFORE clear so _onTrackStateChanged can properly + // clear _urlController (it checks !_searchFocusNode.hasFocus) + FocusManager.instance.primaryFocus?.unfocus(); ref.read(trackProvider.notifier).clear(); + _lastBackPress = null; + return; + } + + if (_currentIndex == 0 && isKeyboardVisible) { + _log.i('Back: step 5 - dismiss keyboard'); + FocusManager.instance.primaryFocus?.unfocus(); + _lastBackPress = null; return; } if (_currentIndex != 0) { + _log.i('Back: step 6 - switch to home tab from tab=$_currentIndex'); _onNavTap(0); + _lastBackPress = null; return; } if (trackState.isLoading) { + _log.i('Back: blocked - loading in progress'); return; } final now = DateTime.now(); if (_lastBackPress != null && now.difference(_lastBackPress!) < const Duration(seconds: 2)) { - SystemNavigator.pop(); + _log.i('Back: step 8 - double-tap exit'); + unawaited(PlatformBridge.exitApp()); } else { + _log.i('Back: step 7 - first tap, showing exit snackbar'); _lastBackPress = now; ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -279,46 +388,46 @@ class _MainShellState extends ConsumerState { } } + NavigatorState? _navigatorForTab(int index, bool showStore) { + if (index == 0) return _homeTabNavigatorKey.currentState; + if (index == 1) return _libraryTabNavigatorKey.currentState; + if (showStore && index == 2) return _storeTabNavigatorKey.currentState; + return null; + } + @override Widget build(BuildContext context) { final queueState = ref.watch( downloadQueueProvider.select((s) => s.queuedCount), ); - final trackHasSearchText = ref.watch( - trackProvider.select((s) => s.hasSearchText), - ); - final trackHasContent = ref.watch( - trackProvider.select((s) => s.hasContent), - ); - final trackIsLoading = ref.watch(trackProvider.select((s) => s.isLoading)); - final trackIsShowingRecentAccess = ref.watch( - trackProvider.select((s) => s.isShowingRecentAccess), - ); final showStore = ref.watch( settingsProvider.select((s) => s.showExtensionStore), ); + ShellNavigationService.syncState( + currentTabIndex: _currentIndex, + showStoreTab: showStore, + ); final storeUpdatesCount = ref.watch( storeProvider.select((s) => s.updatesAvailableCount), ); - final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0; - - final canPop = - _currentIndex == 0 && - !trackHasSearchText && - !trackHasContent && - !trackIsLoading && - !trackIsShowingRecentAccess && - !isKeyboardVisible; - final tabs = [ - const HomeTab(), - QueueTab( - parentPageController: _pageController, - parentPageIndex: 1, - nextPageIndex: showStore ? 2 : 3, + _TabNavigator( + key: const ValueKey('tab-home'), + navigatorKey: _homeTabNavigatorKey, + child: const HomeTab(), ), - if (showStore) const StoreTab(), + _TabNavigator( + key: const ValueKey('tab-library'), + navigatorKey: _libraryTabNavigatorKey, + child: _LibraryTabRoot(parentPageController: _pageController), + ), + if (showStore) + _TabNavigator( + key: const ValueKey('tab-store'), + navigatorKey: _storeTabNavigatorKey, + child: const StoreTab(), + ), const SettingsTab(), ]; @@ -377,22 +486,16 @@ class _MainShellState extends ConsumerState { }); } - return PopScope( - canPop: canPop, - onPopInvokedWithResult: (didPop, result) async { - if (didPop) { - return; - } - + return BackButtonListener( + onBackButtonPressed: () async { _handleBackPress(); + return true; }, child: Scaffold( body: PageView( controller: _pageController, onPageChanged: _onPageChanged, - physics: (_currentIndex == 0 && trackIsShowingRecentAccess) - ? const _NoSwipeRightPhysics() - : const ClampingScrollPhysics(), + physics: const NeverScrollableScrollPhysics(), children: tabs, ), bottomNavigationBar: NavigationBar( @@ -415,23 +518,42 @@ class _MainShellState extends ConsumerState { } } -/// Custom physics that blocks swiping to the right (next page) while -/// still allowing vertical scrolling inside the page content. -class _NoSwipeRightPhysics extends ScrollPhysics { - const _NoSwipeRightPhysics({super.parent}); +class _TabNavigator extends StatelessWidget { + final GlobalKey navigatorKey; + final Widget child; + + const _TabNavigator({ + super.key, + required this.navigatorKey, + required this.child, + }); @override - _NoSwipeRightPhysics applyTo(ScrollPhysics? ancestor) { - return _NoSwipeRightPhysics(parent: buildParent(ancestor)); + Widget build(BuildContext context) { + return Navigator( + key: navigatorKey, + onGenerateInitialRoutes: (_, _) => [ + MaterialPageRoute(builder: (_) => child), + ], + ); } +} + +class _LibraryTabRoot extends ConsumerWidget { + final PageController parentPageController; + + const _LibraryTabRoot({required this.parentPageController}); @override - double applyPhysicsToUserOffset(ScrollMetrics position, double offset) { - // In a horizontal PageView, a negative offset means the user is - // dragging left (i.e. trying to go to the next page / right). - // Block that direction only. - if (offset < 0) return 0.0; - return super.applyPhysicsToUserOffset(position, offset); + Widget build(BuildContext context, WidgetRef ref) { + final showStore = ref.watch( + settingsProvider.select((s) => s.showExtensionStore), + ); + return QueueTab( + parentPageController: parentPageController, + parentPageIndex: 1, + nextPageIndex: showStore ? 2 : 3, + ); } } diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index ef7d488b..d005678d 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -1,5 +1,3 @@ -import 'dart:ui'; - import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; @@ -7,12 +5,15 @@ import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/models/track.dart'; -import 'package:spotiflac_android/models/download_item.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; +import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; +import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; +import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart'; +import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; class PlaylistScreen extends ConsumerStatefulWidget { final String playlistName; @@ -110,6 +111,8 @@ class _PlaylistScreenState extends ConsumerState { artistName: (data['artists'] ?? data['artist'] ?? '').toString(), albumName: (data['album_name'] ?? data['album'] ?? '').toString(), albumArtist: data['album_artist']?.toString(), + artistId: (data['artist_id'] ?? data['artistId'])?.toString(), + albumId: data['album_id']?.toString(), coverUrl: (data['cover_url'] ?? data['images'])?.toString(), isrc: data['isrc']?.toString(), duration: (durationMs / 1000).round(), @@ -120,12 +123,37 @@ class _PlaylistScreenState extends ConsumerState { } void _onScroll() { - final shouldShow = _scrollController.offset > 280; + final expandedHeight = _calculateExpandedHeight(context); + final shouldShow = + _scrollController.offset > (expandedHeight - kToolbarHeight - 20); if (shouldShow != _showTitleInAppBar) { setState(() => _showTitleInAppBar = shouldShow); } } + double _calculateExpandedHeight(BuildContext context) { + final mediaSize = MediaQuery.of(context).size; + return (mediaSize.height * 0.55).clamp(360.0, 520.0); + } + + /// Upgrade cover URL to a reasonable resolution for full-screen display. + String? _highResCoverUrl(String? url) { + if (url == null) return null; + // Spotify CDN: upgrade 300 → 640 only + if (url.contains('ab67616d00001e02')) { + return url.replaceAll('ab67616d00001e02', 'ab67616d0000b273'); + } + // Deezer CDN: upgrade to 1000x1000 + final deezerRegex = RegExp(r'/(\d+)x(\d+)-(\d+)-(\d+)-(\d+)-(\d+)\.jpg$'); + if (url.contains('cdn-images.dzcdn.net') && deezerRegex.hasMatch(url)) { + return url.replaceAllMapped( + deezerRegex, + (m) => '/1000x1000-${m[3]}-${m[4]}-${m[5]}-${m[6]}.jpg', + ); + } + return url; + } + @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; @@ -136,7 +164,6 @@ class _PlaylistScreenState extends ConsumerState { slivers: [ _buildAppBar(context, colorScheme), _buildInfoCard(context, colorScheme), - _buildTrackListHeader(context, colorScheme), _buildTrackList(context, colorScheme), const SliverToBoxAdapter(child: SizedBox(height: 32)), ], @@ -145,21 +172,13 @@ class _PlaylistScreenState extends ConsumerState { } Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) { - final mediaSize = MediaQuery.of(context).size; - final screenWidth = mediaSize.width; - final shortestSide = mediaSize.shortestSide; - final coverSize = (screenWidth * 0.5).clamp(140.0, 220.0); - final expandedHeight = (shortestSide * 0.82).clamp(280.0, 340.0); - final bottomGradientHeight = (shortestSide * 0.2).clamp(56.0, 80.0); - final coverTopPadding = (shortestSide * 0.14).clamp(40.0, 60.0); - final fallbackIconSize = (coverSize * 0.32).clamp(44.0, 64.0); + final expandedHeight = _calculateExpandedHeight(context); return SliverAppBar( expandedHeight: expandedHeight, pinned: true, stretch: true, - backgroundColor: - colorScheme.surface, // Use theme color for collapsed state + backgroundColor: colorScheme.surface, surfaceTintColor: Colors.transparent, title: AnimatedOpacity( duration: const Duration(milliseconds: 200), @@ -181,25 +200,18 @@ class _PlaylistScreenState extends ConsumerState { (constraints.maxHeight - kToolbarHeight) / (expandedHeight - kToolbarHeight); final showContent = collapseRatio > 0.3; - final dpr = MediaQuery.devicePixelRatioOf( - context, - ).clamp(1.0, 3.0).toDouble(); - final backgroundMemCacheWidth = (constraints.maxWidth * dpr) - .round() - .clamp(720, 1440) - .toInt(); return FlexibleSpaceBar( - collapseMode: CollapseMode.none, + collapseMode: CollapseMode.pin, background: Stack( fit: StackFit.expand, children: [ - // Blurred cover background + // Full-screen cover background if (widget.coverUrl != null) CachedNetworkImage( - imageUrl: widget.coverUrl!, + imageUrl: + _highResCoverUrl(widget.coverUrl) ?? widget.coverUrl!, fit: BoxFit.cover, - memCacheWidth: backgroundMemCacheWidth, cacheManager: CoverCacheManager.instance, placeholder: (_, _) => Container(color: colorScheme.surface), @@ -207,81 +219,107 @@ class _PlaylistScreenState extends ConsumerState { Container(color: colorScheme.surface), ) else - Container(color: colorScheme.surface), - ClipRect( - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30), - child: Container( - color: colorScheme.surface.withValues(alpha: 0.4), + Container( + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.playlist_play, + size: 80, + color: colorScheme.onSurfaceVariant, ), ), - ), + // Bottom gradient for readability Positioned( left: 0, right: 0, bottom: 0, - height: bottomGradientHeight, + height: expandedHeight * 0.65, child: Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ - colorScheme.surface.withValues(alpha: 0.0), - colorScheme.surface, + Colors.transparent, + Colors.black.withValues(alpha: 0.85), ], ), ), ), ), - // Cover image centered - fade out when collapsing - AnimatedOpacity( - duration: const Duration(milliseconds: 150), - opacity: showContent ? 1.0 : 0.0, - child: Center( - child: Padding( - padding: EdgeInsets.only(top: coverTopPadding), - child: Container( - width: coverSize, - height: coverSize, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.4), - blurRadius: 30, - offset: const Offset(0, 15), - ), - ], + // Playlist info overlay at bottom + Positioned( + left: 20, + right: 20, + bottom: 40, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 150), + opacity: showContent ? 1.0 : 0.0, + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + widget.playlistName, + style: const TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.bold, + height: 1.2, + ), + textAlign: TextAlign.center, + maxLines: 3, + overflow: TextOverflow.ellipsis, ), - child: ClipRRect( - borderRadius: BorderRadius.circular(20), - child: widget.coverUrl != null - ? CachedNetworkImage( - imageUrl: widget.coverUrl!, - fit: BoxFit.cover, - memCacheWidth: (coverSize * 2).toInt(), - cacheManager: CoverCacheManager.instance, - ) - : Container( - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.playlist_play, - size: fallbackIconSize, - color: colorScheme.onSurfaceVariant, + if (_tracks.isNotEmpty) ...[ + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.playlist_play, + size: 14, + color: Colors.white, + ), + const SizedBox(width: 4), + Text( + context.l10n.tracksCount(_tracks.length), + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 12, ), ), - ), - ), + ], + ), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildLoveAllButton(), + const SizedBox(width: 12), + _buildDownloadAllCenterButton(context), + const SizedBox(width: 12), + _buildAddToPlaylistButton(context), + ], + ), + ], + ], ), ), ), ], ), - stretchModes: const [ - StretchMode.zoomBackground, - StretchMode.blurBackground, - ], + stretchModes: const [StretchMode.zoomBackground], ); }, ), @@ -289,10 +327,10 @@ class _PlaylistScreenState extends ConsumerState { icon: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: colorScheme.surface.withValues(alpha: 0.8), + color: Colors.black.withValues(alpha: 0.4), shape: BoxShape.circle, ), - child: Icon(Icons.arrow_back, color: colorScheme.onSurface), + child: const Icon(Icons.arrow_back, color: Colors.white), ), onPressed: () => Navigator.pop(context), ), @@ -300,98 +338,8 @@ class _PlaylistScreenState extends ConsumerState { } Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) { - return SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(16), - child: Card( - elevation: 0, - color: colorScheme.surfaceContainerLow, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widget.playlistName, - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.bold, - color: colorScheme.onSurface, - ), - ), - const SizedBox(height: 8), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: colorScheme.tertiaryContainer, - borderRadius: BorderRadius.circular(20), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.playlist_play, - size: 14, - color: colorScheme.onTertiaryContainer, - ), - const SizedBox(width: 4), - Text( - context.l10n.tracksCount(_tracks.length), - style: TextStyle( - color: colorScheme.onTertiaryContainer, - fontWeight: FontWeight.w600, - fontSize: 12, - ), - ), - ], - ), - ), - const SizedBox(height: 16), - FilledButton.icon( - onPressed: _tracks.isEmpty - ? null - : () => _downloadAll(context), - icon: const Icon(Icons.download, size: 18), - label: Text(context.l10n.downloadAllCount(_tracks.length)), - style: FilledButton.styleFrom( - minimumSize: const Size.fromHeight(48), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(24), - ), - ), - ), - ], - ), - ), - ), - ), - ); - } - - Widget _buildTrackListHeader(BuildContext context, ColorScheme colorScheme) { - return SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(20, 8, 20, 8), - child: Row( - children: [ - Icon(Icons.queue_music, size: 20, color: colorScheme.primary), - const SizedBox(width: 8), - Text( - context.l10n.tracksHeader, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - color: colorScheme.onSurface, - ), - ), - ], - ), - ), - ); + // Info is now displayed in the full-screen cover overlay + return const SliverToBoxAdapter(child: SizedBox.shrink()); } Widget _buildTrackList(BuildContext context, ColorScheme colorScheme) { @@ -460,6 +408,7 @@ class _PlaylistScreenState extends ConsumerState { void _downloadTrack(BuildContext context, Track track) { final settings = ref.read(settingsProvider); + if (settings.askQualityBeforeDownload) { DownloadServicePicker.show( context, @@ -487,22 +436,165 @@ class _PlaylistScreenState extends ConsumerState { } } - void _downloadAll(BuildContext context) { + // ── Shuffle / Love / Download buttons ── + + Widget _buildCircleButton({ + required IconData icon, + required String tooltip, + required VoidCallback? onPressed, + }) { + return Container( + width: 48, + height: 48, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white.withValues(alpha: 0.15), + border: Border.all( + color: Colors.white.withValues(alpha: 0.3), + width: 1, + ), + ), + child: IconButton( + onPressed: onPressed, + icon: Icon(icon, size: 22, color: Colors.white), + tooltip: tooltip, + padding: EdgeInsets.zero, + ), + ); + } + + Widget _buildLoveAllButton() { + final collectionsState = ref.watch(libraryCollectionsProvider); + final allLoved = + _tracks.isNotEmpty && _tracks.every((t) => collectionsState.isLoved(t)); + + return Container( + width: 48, + height: 48, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white.withValues(alpha: 0.15), + border: Border.all( + color: Colors.white.withValues(alpha: 0.3), + width: 1, + ), + ), + child: IconButton( + onPressed: _tracks.isEmpty ? null : () => _loveAll(_tracks), + icon: Icon( + allLoved ? Icons.favorite : Icons.favorite_border, + size: 22, + color: allLoved ? Colors.redAccent : Colors.white, + ), + tooltip: allLoved ? 'Remove from Loved' : 'Love All', + padding: EdgeInsets.zero, + ), + ); + } + + Widget _buildDownloadAllCenterButton(BuildContext context) { + return FilledButton.icon( + onPressed: _tracks.isEmpty ? null : () => _confirmDownloadAll(context), + icon: const Icon(Icons.download_rounded, size: 18), + label: Text(context.l10n.downloadAllCount(_tracks.length)), + style: FilledButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.black87, + minimumSize: const Size(0, 48), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), + ), + ); + } + + Widget _buildAddToPlaylistButton(BuildContext context) { + return _buildCircleButton( + icon: Icons.playlist_add, + tooltip: 'Add to Playlist', + onPressed: _tracks.isEmpty + ? null + : () => showAddTracksToPlaylistSheet(context, ref, _tracks), + ); + } + + void _confirmDownloadAll(BuildContext context) { if (_tracks.isEmpty) return; + showDialog( + context: context, + builder: (dialogContext) { + final colorScheme = Theme.of(dialogContext).colorScheme; + return AlertDialog( + backgroundColor: colorScheme.surfaceContainerHigh, + title: const Text('Download All'), + content: Text('Download ${_tracks.length} tracks?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(context.l10n.dialogCancel), + ), + FilledButton( + onPressed: () { + Navigator.pop(dialogContext); + _downloadAll(context); + }, + child: const Text('Download'), + ), + ], + ); + }, + ); + } + + Future _loveAll(List tracks) async { + final notifier = ref.read(libraryCollectionsProvider.notifier); + final state = ref.read(libraryCollectionsProvider); + final allLoved = tracks.every((t) => state.isLoved(t)); + + if (allLoved) { + for (final track in tracks) { + final key = trackCollectionKey(track); + await notifier.removeFromLoved(key); + } + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Removed ${tracks.length} tracks from Loved')), + ); + } + } else { + int addedCount = 0; + for (final track in tracks) { + if (!state.isLoved(track)) { + await notifier.toggleLoved(track); + addedCount++; + } + } + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Added $addedCount tracks to Loved')), + ); + } + } + } + + void _downloadAll(BuildContext context) { + _downloadTracks(context, _tracks); + } + + void _downloadTracks(BuildContext context, List tracks) { + if (tracks.isEmpty) return; final settings = ref.read(settingsProvider); if (settings.askQualityBeforeDownload) { DownloadServicePicker.show( context, - trackName: '${_tracks.length} tracks', + trackName: '${tracks.length} tracks', artistName: widget.playlistName, onSelect: (quality, service) { ref .read(downloadQueueProvider.notifier) - .addMultipleToQueue(_tracks, service, qualityOverride: quality); + .addMultipleToQueue(tracks, service, qualityOverride: quality); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - context.l10n.snackbarAddedTracksToQueue(_tracks.length), + context.l10n.snackbarAddedTracksToQueue(tracks.length), ), ), ); @@ -511,12 +603,10 @@ class _PlaylistScreenState extends ConsumerState { } else { ref .read(downloadQueueProvider.notifier) - .addMultipleToQueue(_tracks, settings.defaultService); + .addMultipleToQueue(tracks, settings.defaultService); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text( - context.l10n.snackbarAddedTracksToQueue(_tracks.length), - ), + content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)), ), ); } @@ -542,7 +632,12 @@ class _PlaylistTrackItem extends ConsumerWidget { final isInHistory = ref.watch( downloadHistoryProvider.select((state) { - return state.isDownloaded(track.id); + if (state.isDownloaded(track.id)) return true; + final isrc = track.isrc?.trim(); + if (isrc != null && isrc.isNotEmpty && state.getByIsrc(isrc) != null) { + return true; + } + return state.findByTrackAndArtist(track.name, track.artistName) != null; }), ); @@ -565,13 +660,6 @@ class _PlaylistTrackItem extends ConsumerWidget { : false; final isQueued = queueItem != null; - final isDownloading = queueItem?.status == DownloadStatus.downloading; - final isFinalizing = queueItem?.status == DownloadStatus.finalizing; - final isCompleted = queueItem?.status == DownloadStatus.completed; - final progress = queueItem?.progress ?? 0.0; - - final showAsDownloaded = - isCompleted || (!isQueued && isInHistory) || isInLocalLibrary; return Padding( padding: const EdgeInsets.symmetric(horizontal: 8), @@ -625,7 +713,7 @@ class _PlaylistTrackItem extends ConsumerWidget { style: TextStyle(color: colorScheme.onSurfaceVariant), ), ), - if (isInLocalLibrary) ...[ + if (isInLocalLibrary || isInHistory) ...[ const SizedBox(width: 6), Container( padding: const EdgeInsets.symmetric( @@ -659,24 +747,12 @@ class _PlaylistTrackItem extends ConsumerWidget { ], ], ), - trailing: _buildDownloadButton( + trailing: TrackCollectionQuickActions(track: track), + onTap: () => _handleTap(context, ref, isQueued: isQueued), + onLongPress: () => TrackCollectionQuickActions.showTrackOptionsSheet( context, ref, - colorScheme, - isQueued: isQueued, - isDownloading: isDownloading, - isFinalizing: isFinalizing, - showAsDownloaded: showAsDownloaded, - isInHistory: isInHistory, - isInLocalLibrary: isInLocalLibrary, - progress: progress, - ), - onTap: () => _handleTap( - context, - ref, - isQueued: isQueued, - isInHistory: isInHistory, - isInLocalLibrary: isInLocalLibrary, + track, ), ), ), @@ -687,160 +763,84 @@ class _PlaylistTrackItem extends ConsumerWidget { BuildContext context, WidgetRef ref, { required bool isQueued, - required bool isInHistory, - required bool isInLocalLibrary, }) async { if (isQueued) return; - if (isInLocalLibrary) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.snackbarAlreadyInLibrary(track.name)), - ), - ); - } + final playedLocal = await _playLocalIfAvailable(context, ref); + if (playedLocal) { return; } - if (isInHistory) { - final historyItem = ref - .read(downloadHistoryProvider.notifier) - .getBySpotifyId(track.id); - if (historyItem != null) { - final exists = await fileExists(historyItem.filePath); - if (exists) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.snackbarAlreadyDownloaded(track.name), - ), - ), - ); - } - return; - } else { - ref - .read(downloadHistoryProvider.notifier) - .removeBySpotifyId(track.id); - } - } - } - onDownload(); } - Widget _buildDownloadButton( + Future _playLocalIfAvailable( BuildContext context, WidgetRef ref, - ColorScheme colorScheme, { - required bool isQueued, - required bool isDownloading, - required bool isFinalizing, - required bool showAsDownloaded, - required bool isInHistory, - required bool isInLocalLibrary, - required double progress, - }) { - const double size = 44.0; - const double iconSize = 20.0; + ) async { + final localState = ref.read(localLibraryProvider); + final historyState = ref.read(downloadHistoryProvider); + final historyNotifier = ref.read(downloadHistoryProvider.notifier); - if (showAsDownloaded) { - return GestureDetector( - onTap: () => _handleTap( - context, - ref, - isQueued: isQueued, - isInHistory: isInHistory, - isInLocalLibrary: isInLocalLibrary, - ), - child: Container( - width: size, - height: size, - decoration: BoxDecoration( - color: colorScheme.primaryContainer, - shape: BoxShape.circle, - ), - child: Icon( - Icons.check, - color: colorScheme.onPrimaryContainer, - size: iconSize, - ), - ), + try { + DownloadHistoryItem? historyItem = historyNotifier.getBySpotifyId( + track.id, ); - } else if (isFinalizing) { - return SizedBox( - width: size, - height: size, - child: Stack( - alignment: Alignment.center, - children: [ - CircularProgressIndicator( - strokeWidth: 3, - color: colorScheme.tertiary, - backgroundColor: colorScheme.surfaceContainerHighest, - ), - Icon(Icons.edit_note, color: colorScheme.tertiary, size: 16), - ], - ), + final isrc = track.isrc?.trim(); + historyItem ??= (isrc != null && isrc.isNotEmpty) + ? historyNotifier.getByIsrc(isrc) + : null; + historyItem ??= historyState.findByTrackAndArtist( + track.name, + track.artistName, ); - } else if (isDownloading) { - return SizedBox( - width: size, - height: size, - child: Stack( - alignment: Alignment.center, - children: [ - CircularProgressIndicator( - value: progress > 0 ? progress : null, - strokeWidth: 3, - color: colorScheme.primary, - backgroundColor: colorScheme.surfaceContainerHighest, - ), - if (progress > 0) - Text( - '${(progress * 100).toInt()}', - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.bold, - color: colorScheme.primary, - ), - ), - ], - ), - ); - } else if (isQueued) { - return Container( - width: size, - height: size, - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest, - shape: BoxShape.circle, - ), - child: Icon( - Icons.hourglass_empty, - color: colorScheme.onSurfaceVariant, - size: iconSize, - ), - ); - } else { - return GestureDetector( - onTap: onDownload, - child: Container( - width: size, - height: size, - decoration: BoxDecoration( - color: colorScheme.secondaryContainer, - shape: BoxShape.circle, - ), - child: Icon( - Icons.download, - color: colorScheme.onSecondaryContainer, - size: iconSize, - ), - ), + + if (historyItem != null) { + final exists = await fileExists(historyItem.filePath); + if (exists) { + await ref + .read(playbackProvider.notifier) + .playLocalPath( + path: historyItem.filePath, + title: track.name, + artist: track.artistName, + album: track.albumName, + coverUrl: track.coverUrl ?? '', + ); + return true; + } + historyNotifier.removeFromHistory(historyItem.id); + } + + var localItem = (isrc != null && isrc.isNotEmpty) + ? localState.getByIsrc(isrc) + : null; + localItem ??= localState.findByTrackAndArtist( + track.name, + track.artistName, ); + + if (localItem != null && await fileExists(localItem.filePath)) { + await ref + .read(playbackProvider.notifier) + .playLocalPath( + path: localItem.filePath, + title: localItem.trackName, + artist: localItem.artistName, + album: localItem.albumName, + coverUrl: localItem.coverPath ?? track.coverUrl ?? '', + ); + return true; + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.snackbarCannotOpenFile('$e'))), + ); + } + return true; } + + return false; } } diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 4327516c..ad462f37 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -5,24 +5,33 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; +import 'package:spotiflac_android/services/ffmpeg_service.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/utils/app_bar_layout.dart'; import 'package:spotiflac_android/utils/file_access.dart'; +import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart'; import 'package:spotiflac_android/models/download_item.dart'; +import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; +import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; +import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/services/library_database.dart'; +import 'package:spotiflac_android/services/history_database.dart'; import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart'; import 'package:spotiflac_android/screens/downloaded_album_screen.dart'; +import 'package:spotiflac_android/screens/library_tracks_folder_screen.dart'; import 'package:spotiflac_android/screens/local_album_screen.dart'; +import 'package:spotiflac_android/utils/clickable_metadata.dart'; -/// Represents the source of a library item enum LibraryItemSource { downloaded, local } -/// Unified library item that can come from download history or local library class UnifiedLibraryItem { final String id; final String trackName; @@ -74,7 +83,9 @@ class UnifiedLibraryItem { // Lossy format with bitrate final fmt = item.format?.toUpperCase() ?? ''; quality = '$fmt ${item.bitrate}kbps'.trim(); - } else if (item.bitDepth != null && item.bitDepth! > 0 && item.sampleRate != null) { + } else if (item.bitDepth != null && + item.bitDepth! > 0 && + item.sampleRate != null) { // Lossless format with actual bit depth quality = '${item.bitDepth}bit/${(item.sampleRate! / 1000).toStringAsFixed(1)}kHz'; @@ -96,7 +107,6 @@ class UnifiedLibraryItem { ); } - /// Returns true if this item has a cover (either URL or local path) bool get hasCover => coverUrl != null || (localCoverPath != null && localCoverPath!.isNotEmpty); @@ -105,6 +115,76 @@ class UnifiedLibraryItem { '${trackName.toLowerCase()}|${artistName.toLowerCase()}|${albumName.toLowerCase()}'; String get albumKey => '${albumName.toLowerCase()}|${artistName.toLowerCase()}'; + + /// Returns the collection key used to match this item against playlist + /// entries. Uses the same logic as [trackCollectionKey] from the collections + /// provider: prefer ISRC, fall back to source:id. + String get collectionKey { + if (historyItem != null) { + final isrc = historyItem!.isrc?.trim(); + if (isrc != null && isrc.isNotEmpty) return 'isrc:${isrc.toUpperCase()}'; + final source = historyItem!.service.trim().isNotEmpty + ? historyItem!.service.trim() + : 'builtin'; + return '$source:${historyItem!.id}'; + } + if (localItem != null) { + final isrc = localItem!.isrc?.trim(); + if (isrc != null && isrc.isNotEmpty) return 'isrc:${isrc.toUpperCase()}'; + return 'local:${localItem!.id}'; + } + return 'builtin:$id'; + } + + /// Convert to a [Track] for adding to collections/playlists. + Track toTrack() { + if (historyItem != null) { + final h = historyItem!; + return Track( + id: h.id, + name: h.trackName, + artistName: h.artistName, + albumName: h.albumName, + albumArtist: h.albumArtist, + coverUrl: h.coverUrl, + isrc: h.isrc, + duration: h.duration ?? 0, + trackNumber: h.trackNumber, + discNumber: h.discNumber, + releaseDate: h.releaseDate, + source: h.service, + ); + } + if (localItem != null) { + final l = localItem!; + // Store coverPath (even local file paths) in coverUrl so playlist + // entries retain the cover. All renderers must check whether the + // value is a URL or a local path and use the appropriate widget. + return Track( + id: l.id, + name: l.trackName, + artistName: l.artistName, + albumName: l.albumName, + albumArtist: l.albumArtist, + coverUrl: l.coverPath, + isrc: l.isrc, + duration: l.duration ?? 0, + trackNumber: l.trackNumber, + discNumber: l.discNumber, + releaseDate: l.releaseDate, + source: 'local', + ); + } + // Fallback — should not happen + return Track( + id: id, + name: trackName, + artistName: artistName, + albumName: albumName, + coverUrl: coverUrl, + duration: 0, + ); + } } class _GroupedAlbum { @@ -128,7 +208,6 @@ class _GroupedAlbum { String get key => '$albumName|$artistName'; } -/// Grouped album from local library class _GroupedLocalAlbum { final String albumName; final String artistName; @@ -170,10 +249,8 @@ class _HistoryStats { this.localSingleTracks = 0, }); - /// Total album count including local library int get totalAlbumCount => albumCount + localAlbumCount; - /// Total singles count including local library int get totalSingleTracks => singleTracks + localSingleTracks; } @@ -288,6 +365,15 @@ class _QueueTabState extends ConsumerState { bool _isSelectionMode = false; final Set _selectedIds = {}; + OverlayEntry? _selectionOverlayEntry; + List _selectionOverlayItems = const []; + double _selectionOverlayBottomPadding = 0; + + bool _isPlaylistSelectionMode = false; + final Set _selectedPlaylistIds = {}; + OverlayEntry? _playlistSelectionOverlayEntry; + List _playlistSelectionOverlayItems = const []; + double _playlistSelectionOverlayBottomPadding = 0; PageController? _filterPageController; final List _filterModes = ['all', 'albums', 'singles']; @@ -327,6 +413,24 @@ class _QueueTabState extends ConsumerState { String _localFilterQueryCache = ''; List _filteredLocalItemsCache = const []; final Map _unifiedItemsCache = {}; + final Map _filterContentDataCache = {}; + List? _filterCacheAllHistoryItems; + _HistoryStats? _filterCacheHistoryStats; + List? _filterCacheLocalLibraryItems; + LibraryCollectionsState? _filterCacheCollectionState; + String _filterCacheSearchQuery = ''; + String? _filterCacheSource; + String? _filterCacheQuality; + String? _filterCacheFormat; + String _filterCacheSortMode = 'latest'; + _HistoryStats? _groupedAlbumFilterHistoryStatsCache; + String _groupedAlbumFilterSearchQuery = ''; + String? _groupedAlbumFilterSource; + String? _groupedAlbumFilterQuality; + String? _groupedAlbumFilterFormat; + String _groupedAlbumFilterSortMode = 'latest'; + List<_GroupedAlbum> _filteredGroupedAlbumsCache = const []; + List<_GroupedLocalAlbum> _filteredGroupedLocalAlbumsCache = const []; // Advanced filters String? _filterSource; // null = all, 'downloaded', 'local' String? _filterQuality; // null = all, 'hires', 'cd', 'lossy' @@ -362,6 +466,8 @@ class _QueueTabState extends ConsumerState { @override void dispose() { + _hideSelectionOverlay(); + _hidePlaylistSelectionOverlay(); for (final notifier in _fileExistsNotifiers.values) { notifier.dispose(); } @@ -391,6 +497,47 @@ class _QueueTabState extends ConsumerState { _requestFilterRefresh(); } + void _invalidateFilterContentCache() { + _filterContentDataCache.clear(); + _filterCacheAllHistoryItems = null; + _filterCacheHistoryStats = null; + _filterCacheLocalLibraryItems = null; + _filterCacheCollectionState = null; + } + + void _prepareFilterContentCache({ + required List allHistoryItems, + required _HistoryStats historyStats, + required List localLibraryItems, + required LibraryCollectionsState collectionState, + }) { + final isCacheValid = + identical(_filterCacheAllHistoryItems, allHistoryItems) && + identical(_filterCacheHistoryStats, historyStats) && + identical(_filterCacheLocalLibraryItems, localLibraryItems) && + identical(_filterCacheCollectionState, collectionState) && + _filterCacheSearchQuery == _searchQuery && + _filterCacheSource == _filterSource && + _filterCacheQuality == _filterQuality && + _filterCacheFormat == _filterFormat && + _filterCacheSortMode == _sortMode; + + if (isCacheValid) { + return; + } + + _filterContentDataCache.clear(); + _filterCacheAllHistoryItems = allHistoryItems; + _filterCacheHistoryStats = historyStats; + _filterCacheLocalLibraryItems = localLibraryItems; + _filterCacheCollectionState = collectionState; + _filterCacheSearchQuery = _searchQuery; + _filterCacheSource = _filterSource; + _filterCacheQuality = _filterQuality; + _filterCacheFormat = _filterFormat; + _filterCacheSortMode = _sortMode; + } + void _ensureHistoryCaches( List items, List localItems, @@ -413,6 +560,7 @@ class _QueueTabState extends ConsumerState { _filteredLocalItemsCache = const []; } _unifiedItemsCache.clear(); + _invalidateFilterContentCache(); if (historyChanged) { final validPaths = items @@ -658,9 +806,12 @@ class _QueueTabState extends ConsumerState { void _enterSelectionMode(String itemId) { HapticFeedback.mediumImpact(); setState(() { + _isPlaylistSelectionMode = false; + _selectedPlaylistIds.clear(); _isSelectionMode = true; _selectedIds.add(itemId); }); + _hidePlaylistSelectionOverlay(); } void _exitSelectionMode() { @@ -668,6 +819,7 @@ class _QueueTabState extends ConsumerState { _isSelectionMode = false; _selectedIds.clear(); }); + _hideSelectionOverlay(); } void _toggleSelection(String itemId) { @@ -689,6 +841,306 @@ class _QueueTabState extends ConsumerState { }); } + void _hideSelectionOverlay() { + _selectionOverlayEntry?.remove(); + _selectionOverlayEntry = null; + } + + void _syncSelectionOverlay({ + required List items, + required double bottomPadding, + }) { + if (!mounted) return; + if (!_isSelectionMode || _isPlaylistSelectionMode) { + _hideSelectionOverlay(); + return; + } + + _selectionOverlayItems = items; + _selectionOverlayBottomPadding = bottomPadding; + + if (_selectionOverlayEntry != null) { + _selectionOverlayEntry!.markNeedsBuild(); + return; + } + + final overlay = Overlay.of(context, rootOverlay: true); + _selectionOverlayEntry = OverlayEntry( + builder: (overlayContext) { + final colorScheme = Theme.of(context).colorScheme; + return Positioned( + left: 0, + right: 0, + bottom: 0, + child: Material( + color: Colors.transparent, + child: _buildSelectionBottomBar( + context, + colorScheme, + _selectionOverlayItems, + _selectionOverlayBottomPadding, + ), + ), + ); + }, + ); + overlay.insert(_selectionOverlayEntry!); + } + + void _hidePlaylistSelectionOverlay() { + _playlistSelectionOverlayEntry?.remove(); + _playlistSelectionOverlayEntry = null; + } + + void _syncPlaylistSelectionOverlay({ + required List playlists, + required double bottomPadding, + }) { + if (!mounted) return; + if (!_isPlaylistSelectionMode || _isSelectionMode) { + _hidePlaylistSelectionOverlay(); + return; + } + + _playlistSelectionOverlayItems = playlists; + _playlistSelectionOverlayBottomPadding = bottomPadding; + + if (_playlistSelectionOverlayEntry != null) { + _playlistSelectionOverlayEntry!.markNeedsBuild(); + return; + } + + final overlay = Overlay.of(context, rootOverlay: true); + _playlistSelectionOverlayEntry = OverlayEntry( + builder: (overlayContext) { + final colorScheme = Theme.of(context).colorScheme; + return Positioned( + left: 0, + right: 0, + bottom: 0, + child: Material( + color: Colors.transparent, + child: _buildPlaylistSelectionBottomBar( + context, + colorScheme, + _playlistSelectionOverlayItems, + _playlistSelectionOverlayBottomPadding, + ), + ), + ); + }, + ); + overlay.insert(_playlistSelectionOverlayEntry!); + } + + // --- Playlist selection mode --- + + void _enterPlaylistSelectionMode(String playlistId) { + HapticFeedback.mediumImpact(); + setState(() { + _isSelectionMode = false; + _selectedIds.clear(); + _isPlaylistSelectionMode = true; + _selectedPlaylistIds.add(playlistId); + }); + _hideSelectionOverlay(); + } + + void _exitPlaylistSelectionMode() { + setState(() { + _isPlaylistSelectionMode = false; + _selectedPlaylistIds.clear(); + }); + _hidePlaylistSelectionOverlay(); + } + + void _togglePlaylistSelection(String playlistId) { + setState(() { + if (_selectedPlaylistIds.contains(playlistId)) { + _selectedPlaylistIds.remove(playlistId); + if (_selectedPlaylistIds.isEmpty) { + _isPlaylistSelectionMode = false; + } + } else { + _selectedPlaylistIds.add(playlistId); + } + }); + } + + void _selectAllPlaylists(List playlists) { + setState(() { + _selectedPlaylistIds.addAll(playlists.map((e) => e.id)); + }); + } + + Future _deleteSelectedPlaylists(BuildContext context) async { + final count = _selectedPlaylistIds.length; + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(ctx.l10n.collectionDeletePlaylist), + content: Text( + 'Delete $count ${count == 1 ? 'playlist' : 'playlists'}?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text(ctx.l10n.dialogCancel), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + style: FilledButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.error, + ), + child: Text(ctx.l10n.dialogDelete), + ), + ], + ), + ); + + if (confirmed != true || !context.mounted) return; + + final notifier = ref.read(libraryCollectionsProvider.notifier); + for (final id in _selectedPlaylistIds.toList()) { + await notifier.deletePlaylist(id); + } + + if (!context.mounted) return; + _exitPlaylistSelectionMode(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + '$count ${count == 1 ? 'playlist' : 'playlists'} deleted', + ), + ), + ); + } + + Widget _buildPlaylistSelectionBottomBar( + BuildContext context, + ColorScheme colorScheme, + List playlists, + double bottomPadding, + ) { + final selectedCount = _selectedPlaylistIds.length; + final allSelected = + selectedCount == playlists.length && playlists.isNotEmpty; + + return Container( + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHigh, + borderRadius: const BorderRadius.vertical(top: Radius.circular(28)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.15), + blurRadius: 12, + offset: const Offset(0, -4), + ), + ], + ), + child: SafeArea( + top: false, + child: Padding( + padding: EdgeInsets.fromLTRB(16, 16, 16, bottomPadding > 0 ? 8 : 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 32, + height: 4, + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: colorScheme.outlineVariant, + borderRadius: BorderRadius.circular(2), + ), + ), + + Row( + children: [ + IconButton.filledTonal( + onPressed: _exitPlaylistSelectionMode, + icon: const Icon(Icons.close), + style: IconButton.styleFrom( + backgroundColor: colorScheme.surfaceContainerHighest, + ), + ), + const SizedBox(width: 12), + + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '$selectedCount selected', + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + Text( + allSelected + ? 'All playlists selected' + : 'Tap playlists to select', + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ], + ), + ), + + TextButton.icon( + onPressed: () { + if (allSelected) { + _exitPlaylistSelectionMode(); + } else { + _selectAllPlaylists(playlists); + } + }, + icon: Icon( + allSelected ? Icons.deselect : Icons.select_all, + size: 20, + ), + label: Text(allSelected ? 'Deselect' : 'Select All'), + style: TextButton.styleFrom( + foregroundColor: colorScheme.primary, + ), + ), + ], + ), + + const SizedBox(height: 12), + + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: selectedCount > 0 + ? () => _deleteSelectedPlaylists(context) + : null, + icon: const Icon(Icons.delete_outline), + label: Text( + selectedCount > 0 + ? 'Delete $selectedCount ${selectedCount == 1 ? 'playlist' : 'playlists'}' + : 'Select playlists to delete', + ), + style: FilledButton.styleFrom( + backgroundColor: selectedCount > 0 + ? colorScheme.error + : colorScheme.surfaceContainerHighest, + foregroundColor: selectedCount > 0 + ? colorScheme.onError + : colorScheme.onSurfaceVariant, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + ), + ), + ], + ), + ), + ), + ); + } + String _getQualityBadgeText(String quality) { final q = quality.trim().toLowerCase(); if (q.contains('bit')) { @@ -890,6 +1342,7 @@ class _QueueTabState extends ConsumerState { _filterFormat = null; _sortMode = 'latest'; _unifiedItemsCache.clear(); + _invalidateFilterContentCache(); }); } @@ -905,7 +1358,9 @@ class _QueueTabState extends ConsumerState { if (item.bitrate != null && item.bitrate! > 0) { return '${item.bitrate}kbps'; } - if (item.bitDepth == null || item.bitDepth == 0 || item.sampleRate == null) { + if (item.bitDepth == null || + item.bitDepth == 0 || + item.sampleRate == null) { return null; } return '${item.bitDepth}bit/${(item.sampleRate! / 1000).toStringAsFixed(1)}kHz'; @@ -961,7 +1416,6 @@ class _QueueTabState extends ConsumerState { return _applySorting(filtered); } - /// Apply current sort mode to a list of unified items List _applySorting(List items) { if (_sortMode == 'latest') { return items; // Already sorted newest first from _getUnifiedItems @@ -984,7 +1438,6 @@ class _QueueTabState extends ConsumerState { return sorted; } - /// Check if a quality string passes the current quality filter bool _passesQualityFilter(String? quality) { if (_filterQuality == null) return true; if (quality == null) return _filterQuality == 'lossy'; @@ -1001,13 +1454,11 @@ class _QueueTabState extends ConsumerState { } } - /// Check if a file path passes the current format filter bool _passesFormatFilter(String filePath) { if (_filterFormat == null) return true; return _fileExtLower(filePath) == _filterFormat; } - /// Filter grouped download albums by search query + advanced filters List<_GroupedAlbum> _filterGroupedAlbums( List<_GroupedAlbum> albums, String searchQuery, @@ -1064,7 +1515,6 @@ class _QueueTabState extends ConsumerState { return result; } - /// Filter grouped local albums by search query + advanced filters List<_GroupedLocalAlbum> _filterGroupedLocalAlbums( List<_GroupedLocalAlbum> albums, String searchQuery, @@ -1121,6 +1571,46 @@ class _QueueTabState extends ConsumerState { return result; } + ({List<_GroupedAlbum> albums, List<_GroupedLocalAlbum> localAlbums}) + _resolveFilteredGroupedAlbums(_HistoryStats historyStats) { + final cacheValid = + identical(_groupedAlbumFilterHistoryStatsCache, historyStats) && + _groupedAlbumFilterSearchQuery == _searchQuery && + _groupedAlbumFilterSource == _filterSource && + _groupedAlbumFilterQuality == _filterQuality && + _groupedAlbumFilterFormat == _filterFormat && + _groupedAlbumFilterSortMode == _sortMode; + + if (cacheValid) { + return ( + albums: _filteredGroupedAlbumsCache, + localAlbums: _filteredGroupedLocalAlbumsCache, + ); + } + + final filteredGroupedAlbums = _filterGroupedAlbums( + historyStats.groupedAlbums, + _searchQuery, + ); + final filteredGroupedLocalAlbums = _filterGroupedLocalAlbums( + historyStats.groupedLocalAlbums, + _searchQuery, + ); + + _groupedAlbumFilterHistoryStatsCache = historyStats; + _groupedAlbumFilterSearchQuery = _searchQuery; + _groupedAlbumFilterSource = _filterSource; + _groupedAlbumFilterQuality = _filterQuality; + _groupedAlbumFilterFormat = _filterFormat; + _groupedAlbumFilterSortMode = _sortMode; + _filteredGroupedAlbumsCache = filteredGroupedAlbums; + _filteredGroupedLocalAlbumsCache = filteredGroupedLocalAlbums; + return ( + albums: filteredGroupedAlbums, + localAlbums: filteredGroupedLocalAlbums, + ); + } + Set _getAvailableFormats(List items) { final formats = {}; for (final item in items) { @@ -1146,6 +1636,7 @@ class _QueueTabState extends ConsumerState { showModalBottomSheet( context: context, + useRootNavigator: true, isScrollControlled: true, backgroundColor: colorScheme.surfaceContainerLow, shape: const RoundedRectangleBorder( @@ -1154,201 +1645,224 @@ class _QueueTabState extends ConsumerState { builder: (context) => StatefulBuilder( builder: (context, setSheetState) { return SafeArea( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Center( - child: Container( - width: 32, - height: 4, - margin: const EdgeInsets.only(bottom: 16), - decoration: BoxDecoration( - color: colorScheme.outlineVariant, - borderRadius: BorderRadius.circular(2), + child: LayoutBuilder( + builder: (context, constraints) { + final maxSheetHeight = constraints.maxHeight * 0.9; + return ConstrainedBox( + constraints: BoxConstraints(maxHeight: maxSheetHeight), + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: SingleChildScrollView( + physics: const ClampingScrollPhysics(), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 32, + height: 4, + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: colorScheme.outlineVariant, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + + Row( + children: [ + Text( + context.l10n.libraryFilterTitle, + style: Theme.of(context).textTheme.titleLarge + ?.copyWith(fontWeight: FontWeight.bold), + ), + const Spacer(), + TextButton( + onPressed: () { + setSheetState(() { + tempSource = null; + tempQuality = null; + tempFormat = null; + tempSortMode = 'latest'; + }); + }, + child: Text(context.l10n.libraryFilterReset), + ), + ], + ), + const SizedBox(height: 16), + + Text( + context.l10n.libraryFilterSource, + style: Theme.of(context).textTheme.titleSmall + ?.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: [ + FilterChip( + label: Text(context.l10n.libraryFilterAll), + selected: tempSource == null, + onSelected: (_) => + setSheetState(() => tempSource = null), + ), + FilterChip( + label: Text( + context.l10n.libraryFilterDownloaded, + ), + selected: tempSource == 'downloaded', + onSelected: (_) => setSheetState( + () => tempSource = 'downloaded', + ), + ), + FilterChip( + label: Text(context.l10n.libraryFilterLocal), + selected: tempSource == 'local', + onSelected: (_) => + setSheetState(() => tempSource = 'local'), + ), + ], + ), + const SizedBox(height: 16), + + Text( + context.l10n.libraryFilterQuality, + style: Theme.of(context).textTheme.titleSmall + ?.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: [ + FilterChip( + label: Text(context.l10n.libraryFilterAll), + selected: tempQuality == null, + onSelected: (_) => + setSheetState(() => tempQuality = null), + ), + FilterChip( + label: Text( + context.l10n.libraryFilterQualityHiRes, + ), + selected: tempQuality == 'hires', + onSelected: (_) => + setSheetState(() => tempQuality = 'hires'), + ), + FilterChip( + label: Text( + context.l10n.libraryFilterQualityCD, + ), + selected: tempQuality == 'cd', + onSelected: (_) => + setSheetState(() => tempQuality = 'cd'), + ), + FilterChip( + label: Text( + context.l10n.libraryFilterQualityLossy, + ), + selected: tempQuality == 'lossy', + onSelected: (_) => + setSheetState(() => tempQuality = 'lossy'), + ), + ], + ), + const SizedBox(height: 16), + + Text( + context.l10n.libraryFilterFormat, + style: Theme.of(context).textTheme.titleSmall + ?.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: [ + FilterChip( + label: Text(context.l10n.libraryFilterAll), + selected: tempFormat == null, + onSelected: (_) => + setSheetState(() => tempFormat = null), + ), + for (final format + in availableFormats.toList()..sort()) + FilterChip( + label: Text(format.toUpperCase()), + selected: tempFormat == format, + onSelected: (_) => + setSheetState(() => tempFormat = format), + ), + ], + ), + const SizedBox(height: 16), + + Text( + context.l10n.libraryFilterSort, + style: Theme.of(context).textTheme.titleSmall + ?.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: [ + FilterChip( + label: Text( + context.l10n.libraryFilterSortLatest, + ), + selected: tempSortMode == 'latest', + onSelected: (_) => setSheetState( + () => tempSortMode = 'latest', + ), + ), + FilterChip( + label: Text( + context.l10n.libraryFilterSortOldest, + ), + selected: tempSortMode == 'oldest', + onSelected: (_) => setSheetState( + () => tempSortMode = 'oldest', + ), + ), + FilterChip( + label: const Text('A-Z'), + selected: tempSortMode == 'a-z', + onSelected: (_) => + setSheetState(() => tempSortMode = 'a-z'), + ), + FilterChip( + label: const Text('Z-A'), + selected: tempSortMode == 'z-a', + onSelected: (_) => + setSheetState(() => tempSortMode = 'z-a'), + ), + ], + ), + const SizedBox(height: 24), + + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: () { + setState(() { + _filterSource = tempSource; + _filterQuality = tempQuality; + _filterFormat = tempFormat; + _sortMode = tempSortMode; + _unifiedItemsCache.clear(); + _invalidateFilterContentCache(); + }); + Navigator.pop(context); + }, + child: Text(context.l10n.libraryFilterApply), + ), + ), + ], ), ), ), - - Row( - children: [ - Text( - context.l10n.libraryFilterTitle, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - const Spacer(), - TextButton( - onPressed: () { - setSheetState(() { - tempSource = null; - tempQuality = null; - tempFormat = null; - tempSortMode = 'latest'; - }); - }, - child: Text(context.l10n.libraryFilterReset), - ), - ], - ), - const SizedBox(height: 16), - - Text( - context.l10n.libraryFilterSource, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - children: [ - FilterChip( - label: Text(context.l10n.libraryFilterAll), - selected: tempSource == null, - onSelected: (_) => - setSheetState(() => tempSource = null), - ), - FilterChip( - label: Text(context.l10n.libraryFilterDownloaded), - selected: tempSource == 'downloaded', - onSelected: (_) => - setSheetState(() => tempSource = 'downloaded'), - ), - FilterChip( - label: Text(context.l10n.libraryFilterLocal), - selected: tempSource == 'local', - onSelected: (_) => - setSheetState(() => tempSource = 'local'), - ), - ], - ), - const SizedBox(height: 16), - - Text( - context.l10n.libraryFilterQuality, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - children: [ - FilterChip( - label: Text(context.l10n.libraryFilterAll), - selected: tempQuality == null, - onSelected: (_) => - setSheetState(() => tempQuality = null), - ), - FilterChip( - label: Text(context.l10n.libraryFilterQualityHiRes), - selected: tempQuality == 'hires', - onSelected: (_) => - setSheetState(() => tempQuality = 'hires'), - ), - FilterChip( - label: Text(context.l10n.libraryFilterQualityCD), - selected: tempQuality == 'cd', - onSelected: (_) => - setSheetState(() => tempQuality = 'cd'), - ), - FilterChip( - label: Text(context.l10n.libraryFilterQualityLossy), - selected: tempQuality == 'lossy', - onSelected: (_) => - setSheetState(() => tempQuality = 'lossy'), - ), - ], - ), - const SizedBox(height: 16), - - Text( - context.l10n.libraryFilterFormat, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - children: [ - FilterChip( - label: Text(context.l10n.libraryFilterAll), - selected: tempFormat == null, - onSelected: (_) => - setSheetState(() => tempFormat = null), - ), - for (final format in availableFormats.toList()..sort()) - FilterChip( - label: Text(format.toUpperCase()), - selected: tempFormat == format, - onSelected: (_) => - setSheetState(() => tempFormat = format), - ), - ], - ), - const SizedBox(height: 16), - - Text( - context.l10n.libraryFilterSort, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - children: [ - FilterChip( - label: Text(context.l10n.libraryFilterSortLatest), - selected: tempSortMode == 'latest', - onSelected: (_) => - setSheetState(() => tempSortMode = 'latest'), - ), - FilterChip( - label: Text(context.l10n.libraryFilterSortOldest), - selected: tempSortMode == 'oldest', - onSelected: (_) => - setSheetState(() => tempSortMode = 'oldest'), - ), - FilterChip( - label: const Text('A-Z'), - selected: tempSortMode == 'a-z', - onSelected: (_) => - setSheetState(() => tempSortMode = 'a-z'), - ), - FilterChip( - label: const Text('Z-A'), - selected: tempSortMode == 'z-a', - onSelected: (_) => - setSheetState(() => tempSortMode = 'z-a'), - ), - ], - ), - const SizedBox(height: 24), - - SizedBox( - width: double.infinity, - child: FilledButton( - onPressed: () { - setState(() { - _filterSource = tempSource; - _filterQuality = tempQuality; - _filterFormat = tempFormat; - _sortMode = tempSortMode; - _unifiedItemsCache.clear(); - }); - Navigator.pop(context); - }, - child: Text(context.l10n.libraryFilterApply), - ), - ), - ], - ), + ); + }, ), ); }, @@ -1356,10 +1870,25 @@ class _QueueTabState extends ConsumerState { ); } - Future _openFile(String filePath) async { + Future _openFile( + String filePath, { + String title = '', + String artist = '', + String album = '', + String coverUrl = '', + }) async { final cleanPath = _cleanFilePath(filePath); try { - await openFile(cleanPath); + final fallbackTitle = cleanPath.split('/').last.split('\\').last; + await ref + .read(playbackProvider.notifier) + .playLocalPath( + path: cleanPath, + title: title.isNotEmpty ? title : fallbackTitle, + artist: artist, + album: album, + coverUrl: coverUrl, + ); } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -1682,6 +2211,302 @@ class _QueueTabState extends ConsumerState { ).then((_) => _searchFocusNode.unfocus()); } + void _openWishlistFolder() { + _searchFocusNode.unfocus(); + Navigator.of(context) + .push( + MaterialPageRoute( + builder: (_) => const LibraryTracksFolderScreen( + mode: LibraryTracksFolderMode.wishlist, + ), + ), + ) + .then((_) => _searchFocusNode.unfocus()); + } + + void _openLovedFolder() { + _searchFocusNode.unfocus(); + Navigator.of(context) + .push( + MaterialPageRoute( + builder: (_) => const LibraryTracksFolderScreen( + mode: LibraryTracksFolderMode.loved, + ), + ), + ) + .then((_) => _searchFocusNode.unfocus()); + } + + void _openPlaylistById(String playlistId) { + _searchFocusNode.unfocus(); + Navigator.of(context) + .push( + MaterialPageRoute( + builder: (_) => LibraryTracksFolderScreen( + mode: LibraryTracksFolderMode.playlist, + playlistId: playlistId, + ), + ), + ) + .then((_) => _searchFocusNode.unfocus()); + } + + Future _showCreatePlaylistDialog(BuildContext context) async { + final controller = TextEditingController(); + final formKey = GlobalKey(); + + final playlistName = await showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + title: Text(dialogContext.l10n.collectionCreatePlaylist), + content: Form( + key: formKey, + child: TextFormField( + controller: controller, + autofocus: true, + decoration: InputDecoration( + hintText: dialogContext.l10n.collectionPlaylistNameHint, + ), + validator: (value) { + final trimmed = value?.trim() ?? ''; + if (trimmed.isEmpty) { + return dialogContext.l10n.collectionPlaylistNameRequired; + } + return null; + }, + onFieldSubmitted: (_) { + if (formKey.currentState?.validate() != true) return; + Navigator.of(dialogContext).pop(controller.text.trim()); + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: Text(dialogContext.l10n.dialogCancel), + ), + FilledButton( + onPressed: () { + if (formKey.currentState?.validate() != true) return; + Navigator.of(dialogContext).pop(controller.text.trim()); + }, + child: Text(dialogContext.l10n.actionCreate), + ), + ], + ); + }, + ); + + if (playlistName == null || playlistName.isEmpty) return; + await ref + .read(libraryCollectionsProvider.notifier) + .createPlaylist(playlistName); + } + + /// Build a playlist cover thumbnail (custom cover > first track cover > icon fallback). + /// Pass a finite [size] (e.g. 56) for list view, or `null` for grid view + /// where the widget should expand to fill its parent. + Widget _buildPlaylistCover( + BuildContext context, + UserPlaylistCollection playlist, + ColorScheme colorScheme, [ + double? size, + ]) { + final borderRadius = BorderRadius.circular(8); + final dpr = MediaQuery.devicePixelRatioOf(context); + final cacheExtent = size != null + ? (size * dpr).round().clamp(64, 1024) + : 420; + final placeholder = _playlistIconFallback(colorScheme, size); + + final customCoverPath = playlist.coverImagePath; + if (customCoverPath != null && customCoverPath.isNotEmpty) { + return ClipRRect( + borderRadius: borderRadius, + child: Image.file( + File(customCoverPath), + width: size, + height: size, + fit: BoxFit.cover, + cacheWidth: cacheExtent, + gaplessPlayback: true, + filterQuality: FilterQuality.low, + frameBuilder: (_, child, frame, wasSynchronouslyLoaded) { + if (wasSynchronouslyLoaded || frame != null) return child; + return placeholder; + }, + errorBuilder: (_, _, _) => placeholder, + ), + ); + } + + final firstCoverUrl = playlist.tracks + .where((e) => e.track.coverUrl != null && e.track.coverUrl!.isNotEmpty) + .map((e) => e.track.coverUrl!) + .firstOrNull; + + if (firstCoverUrl != null) { + // Guard against local file paths that may have been stored as coverUrl + final isLocalPath = + !firstCoverUrl.startsWith('http://') && + !firstCoverUrl.startsWith('https://'); + if (isLocalPath) { + return ClipRRect( + borderRadius: borderRadius, + child: Image.file( + File(firstCoverUrl), + width: size, + height: size, + fit: BoxFit.cover, + cacheWidth: cacheExtent, + gaplessPlayback: true, + filterQuality: FilterQuality.low, + frameBuilder: (_, child, frame, wasSynchronouslyLoaded) { + if (wasSynchronouslyLoaded || frame != null) return child; + return placeholder; + }, + errorBuilder: (_, _, _) => placeholder, + ), + ); + } + return ClipRRect( + borderRadius: borderRadius, + child: CachedNetworkImage( + imageUrl: firstCoverUrl, + width: size, + height: size, + fit: BoxFit.cover, + memCacheWidth: cacheExtent, + cacheManager: CoverCacheManager.instance, + placeholder: (_, _) => placeholder, + errorWidget: (_, _, _) => placeholder, + ), + ); + } + + return placeholder; + } + + /// Icon fallback for playlists with no cover. + /// When [size] is null the container expands to fill its parent (grid view) + /// and uses a fixed icon size. + Widget _playlistIconFallback(ColorScheme colorScheme, [double? size]) { + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: const Color(0xFF5085A5), + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + Icons.queue_music, + color: Colors.white, + size: size != null ? size * 0.5 : 40, + ), + ); + } + + /// Handle a track being dropped onto a playlist. + /// When selection mode is active and the dragged item is among the selected, + /// all selected tracks are added to the playlist. + Future _onTrackDroppedOnPlaylist( + BuildContext context, + UnifiedLibraryItem item, + String playlistId, + String playlistName, { + List allItems = const [], + }) async { + final notifier = ref.read(libraryCollectionsProvider.notifier); + + // If in selection mode and the dragged item is selected, add ALL selected + if (_isSelectionMode && + _selectedIds.isNotEmpty && + _selectedIds.contains(item.id)) { + final selectedItems = allItems + .where((e) => _selectedIds.contains(e.id)) + .toList(); + // Fallback: if allItems is empty or no match, at least add the dragged item + if (selectedItems.isEmpty) { + selectedItems.add(item); + } + + final batchResult = await notifier.addTracksToPlaylist( + playlistId, + selectedItems.map((selected) => selected.toTrack()), + ); + final addedCount = batchResult.addedCount; + final alreadyCount = batchResult.alreadyInPlaylistCount; + + if (!context.mounted) return; + final message = addedCount > 0 + ? 'Added $addedCount ${addedCount == 1 ? 'track' : 'tracks'} to $playlistName' + '${alreadyCount > 0 ? ' ($alreadyCount already in playlist)' : ''}' + : context.l10n.collectionAlreadyInPlaylist(playlistName); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + _exitSelectionMode(); + return; + } + + // Single track drop + final track = item.toTrack(); + final added = await notifier.addTrackToPlaylist(playlistId, track); + + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + added + ? context.l10n.collectionAddedToPlaylist(playlistName) + : context.l10n.collectionAlreadyInPlaylist(playlistName), + ), + ), + ); + } + + /// Build a compact floating feedback widget shown while dragging a track. + /// Shows the count when multiple tracks are selected and being dragged. + Widget _buildDragFeedback( + BuildContext context, + UnifiedLibraryItem item, + ColorScheme colorScheme, + ) { + final isDraggingMultiple = + _isSelectionMode && + _selectedIds.contains(item.id) && + _selectedIds.length > 1; + final count = isDraggingMultiple ? _selectedIds.length : 1; + + return Material( + elevation: 6, + borderRadius: BorderRadius.circular(12), + color: colorScheme.surfaceContainerHighest, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.playlist_add, size: 18, color: colorScheme.primary), + const SizedBox(width: 8), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 180), + child: Text( + isDraggingMultiple ? '$count tracks' : item.trackName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ), + ); + } + @override Widget build(BuildContext context) { _initializePageController(); @@ -1699,6 +2524,7 @@ class _QueueTabState extends ConsumerState { final localLibraryItems = localLibraryEnabled ? ref.watch(localLibraryProvider.select((s) => s.items)) : const []; + final collectionState = ref.watch(libraryCollectionsProvider); _ensureHistoryCaches(allHistoryItems, localLibraryItems); final historyViewMode = ref.watch( @@ -1713,22 +2539,20 @@ class _QueueTabState extends ConsumerState { final historyStats = _historyStatsCache ?? _buildHistoryStats(allHistoryItems, localLibraryItems); - final groupedAlbums = historyStats.groupedAlbums; - final groupedLocalAlbums = historyStats.groupedLocalAlbums; - final filteredGroupedAlbums = _filterGroupedAlbums( - groupedAlbums, - _searchQuery, - ); - final filteredGroupedLocalAlbums = _filterGroupedLocalAlbums( - groupedLocalAlbums, - _searchQuery, - ); + final filteredGrouped = _resolveFilteredGroupedAlbums(historyStats); + final filteredGroupedAlbums = filteredGrouped.albums; + final filteredGroupedLocalAlbums = filteredGrouped.localAlbums; final albumCount = historyStats.totalAlbumCount; final singleCount = historyStats.totalSingleTracks; - final filterDataCache = {}; + _prepareFilterContentCache( + allHistoryItems: allHistoryItems, + historyStats: historyStats, + localLibraryItems: localLibraryItems, + collectionState: collectionState, + ); _FilterContentData getFilterData(String filterMode) { - return filterDataCache.putIfAbsent( + return _filterContentDataCache.putIfAbsent( filterMode, () => _computeFilterContentData( filterMode: filterMode, @@ -1738,17 +2562,35 @@ class _QueueTabState extends ConsumerState { albumCounts: historyStats.albumCounts, localAlbumCounts: historyStats.localAlbumCounts, localLibraryItems: localLibraryItems, + collectionState: collectionState, ), ); } final bottomPadding = MediaQuery.of(context).padding.bottom; + final selectionItems = getFilterData( + historyFilterMode, + ).filteredUnifiedItems; + WidgetsBinding.instance.addPostFrameCallback((_) { + _syncSelectionOverlay( + items: selectionItems, + bottomPadding: bottomPadding, + ); + _syncPlaylistSelectionOverlay( + playlists: collectionState.playlists, + bottomPadding: bottomPadding, + ); + }); return PopScope( - canPop: !_isSelectionMode, + canPop: !_isSelectionMode && !_isPlaylistSelectionMode, onPopInvokedWithResult: (didPop, result) { - if (!didPop && _isSelectionMode) { - _exitSelectionMode(); + if (!didPop) { + if (_isPlaylistSelectionMode) { + _exitPlaylistSelectionMode(); + } else if (_isSelectionMode) { + _exitSelectionMode(); + } } }, child: Stack( @@ -1930,145 +2772,33 @@ class _QueueTabState extends ConsumerState { ), ), ], - body: NotificationListener( - onNotification: (notification) { - final parentController = widget.parentPageController; - if (parentController == null || - !parentController.hasClients) { - return false; - } - - final page = _filterPageController!.page?.round() ?? 0; - - if (notification is OverscrollNotification) { - final overscroll = notification.overscroll; - - if (page == 0 && overscroll < 0) { - final currentOffset = parentController.offset; - final targetOffset = (currentOffset + overscroll).clamp( - 0.0, - parentController.position.maxScrollExtent, - ); - parentController.jumpTo(targetOffset); - return true; - } - - if (page == 2 && overscroll > 0) { - final currentOffset = parentController.offset; - final targetOffset = (currentOffset + overscroll).clamp( - 0.0, - parentController.position.maxScrollExtent, - ); - parentController.jumpTo(targetOffset); - return true; - } - } - - if (notification is ScrollEndNotification) { - if (page == 0 || page == 2) { - final currentPage = - parentController.page ?? - widget.parentPageIndex.toDouble(); - final historyPage = widget.parentPageIndex.toDouble(); - final offset = currentPage - historyPage; - - if (offset.abs() > 0.01) { - if (offset < -0.3) { - parentController.animateToPage( - widget.parentPageIndex - 1, - duration: const Duration(milliseconds: 250), - curve: Curves.easeOutCubic, - ); - } else if (offset > 0.3) { - parentController.animateToPage( - widget.nextPageIndex ?? - (widget.parentPageIndex + 1), - duration: const Duration(milliseconds: 250), - curve: Curves.easeOutCubic, - ); - } else { - parentController.jumpToPage(widget.parentPageIndex); - } - } - } - } - - return false; + body: PageView.builder( + controller: _filterPageController!, + physics: const ClampingScrollPhysics(), + onPageChanged: _onFilterPageChanged, + itemCount: _filterModes.length, + itemBuilder: (context, index) { + final filterMode = _filterModes[index]; + final filterData = getFilterData(filterMode); + return _buildFilterContent( + context: context, + colorScheme: colorScheme, + filterMode: filterMode, + historyViewMode: historyViewMode, + hasQueueItems: hasQueueItems, + filterData: filterData, + localLibraryItems: localLibraryItems, + collectionState: collectionState, + ); }, - child: PageView.builder( - controller: _filterPageController!, - physics: const ClampingScrollPhysics(), - onPageChanged: _onFilterPageChanged, - itemCount: _filterModes.length, - itemBuilder: (context, index) { - final filterMode = _filterModes[index]; - final filterData = getFilterData(filterMode); - return _buildFilterContent( - context: context, - colorScheme: colorScheme, - filterMode: filterMode, - historyViewMode: historyViewMode, - hasQueueItems: hasQueueItems, - filterData: filterData, - localLibraryItems: localLibraryItems, - ); - }, - ), ), ), ), // ScrollConfiguration - - AnimatedPositioned( - duration: const Duration(milliseconds: 250), - curve: Curves.easeOutCubic, - left: 0, - right: 0, - bottom: _isSelectionMode ? 0 : -(200 + bottomPadding), - child: _isSelectionMode - ? _buildSelectionBottomBar( - context, - colorScheme, - _buildUnifiedItemsForSelection( - filterMode: historyFilterMode, - allHistoryItems: allHistoryItems, - albumCounts: historyStats.albumCounts, - localLibraryItems: localLibraryItems, - localAlbumCounts: historyStats.localAlbumCounts, - ), - bottomPadding, - ) - : const SizedBox.shrink(), - ), ], ), ); } - /// Build unified items list for selection mode - List _buildUnifiedItemsForSelection({ - required String filterMode, - required List allHistoryItems, - required Map albumCounts, - required List localLibraryItems, - required Map localAlbumCounts, - }) { - final historyItems = _resolveHistoryItems( - filterMode: filterMode, - allHistoryItems: allHistoryItems, - albumCounts: albumCounts, - ); - - final unifiedItems = _getUnifiedItems( - filterMode: filterMode, - historyItems: historyItems, - localLibraryItems: localLibraryItems, - localAlbumCounts: localAlbumCounts, - ); - - // Apply advanced filters to match what's displayed - return _applyAdvancedFilters(unifiedItems); - } - List _getUnifiedItems({ required String filterMode, required List historyItems, @@ -2130,6 +2860,7 @@ class _QueueTabState extends ConsumerState { required Map albumCounts, required Map localAlbumCounts, required List localLibraryItems, + required LibraryCollectionsState collectionState, }) { final historyItems = _resolveHistoryItems( filterMode: filterMode, @@ -2147,7 +2878,20 @@ class _QueueTabState extends ConsumerState { localLibraryItems: localLibraryItems, localAlbumCounts: localAlbumCounts, ); - final filteredUnifiedItems = _applyAdvancedFilters(unifiedItems); + final filtered = _applyAdvancedFilters(unifiedItems); + + // Remove tracks that are already in any playlist so they don't appear + // in the main tracks list. When a track is removed from a playlist (or + // the playlist is deleted) it will automatically reappear here because it + // will no longer be in the set. + final filteredUnifiedItems = !collectionState.hasPlaylistTracks + ? filtered + : filtered + .where( + (item) => + !collectionState.isTrackInAnyPlaylist(item.collectionKey), + ) + .toList(growable: false); return _FilterContentData( historyItems: historyItems, @@ -2220,6 +2964,393 @@ class _QueueTabState extends ConsumerState { ); } + /// Build a Spotify-style collection list item (Wishlist, Loved, Playlists) + Widget _buildCollectionListItem({ + required BuildContext context, + required ColorScheme colorScheme, + IconData? icon, + Color? iconColor, + Color? iconBgColor, + Widget? coverWidget, + required String title, + required String subtitle, + required VoidCallback onTap, + VoidCallback? onLongPress, + }) { + final cover = + coverWidget ?? + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: iconBgColor ?? colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + icon ?? Icons.folder, + color: iconColor ?? Colors.white, + size: 28, + ), + ); + + return Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: InkWell( + onTap: onTap, + onLongPress: onLongPress, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + SizedBox(width: 56, height: 56, child: cover), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + Icon( + Icons.chevron_right, + color: colorScheme.onSurfaceVariant, + size: 20, + ), + ], + ), + ), + ), + ); + } + + /// Build a collection grid item for grid view mode + Widget _buildCollectionGridItem({ + required BuildContext context, + required ColorScheme colorScheme, + IconData? icon, + Color? iconColor, + Color? iconBgColor, + Widget? coverWidget, + required String title, + required int count, + required VoidCallback onTap, + VoidCallback? onLongPress, + }) { + final cover = + coverWidget ?? + Container( + decoration: BoxDecoration( + color: iconBgColor ?? colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + icon ?? Icons.folder, + color: iconColor ?? Colors.white, + size: 40, + ), + ); + + return GestureDetector( + onTap: onTap, + onLongPress: onLongPress, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AspectRatio( + aspectRatio: 1, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: cover, + ), + ), + const SizedBox(height: 6), + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500), + ), + Text( + '$count ${count == 1 ? 'item' : 'items'}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ); + } + + /// Build a collection item at [index] for the unified "All" tab grid view. + /// Index 0 = Wishlist, 1 = Loved, 2+ = individual playlists. + Widget _buildAllTabGridCollectionItem({ + required BuildContext context, + required ColorScheme colorScheme, + required int index, + required LibraryCollectionsState collectionState, + List filteredUnifiedItems = const [], + }) { + if (index == 0) { + return _buildCollectionGridItem( + context: context, + colorScheme: colorScheme, + icon: Icons.add_circle_outline, + iconColor: Colors.white, + iconBgColor: const Color(0xFF1DB954), + title: context.l10n.collectionWishlist, + count: collectionState.wishlistCount, + onTap: _openWishlistFolder, + ); + } else if (index == 1) { + return _buildCollectionGridItem( + context: context, + colorScheme: colorScheme, + icon: Icons.favorite, + iconColor: Colors.white, + iconBgColor: const Color(0xFF8C67AC), + title: context.l10n.collectionLoved, + count: collectionState.lovedCount, + onTap: _openLovedFolder, + ); + } else { + final playlist = collectionState.playlists[index - 2]; + final isSelected = _selectedPlaylistIds.contains(playlist.id); + return DragTarget( + onWillAcceptWithDetails: (_) => !_isPlaylistSelectionMode, + onAcceptWithDetails: (details) { + _onTrackDroppedOnPlaylist( + context, + details.data, + playlist.id, + playlist.name, + allItems: filteredUnifiedItems, + ); + }, + builder: (context, candidateData, rejectedData) { + final isHovering = candidateData.isNotEmpty; + return AnimatedContainer( + duration: const Duration(milliseconds: 150), + decoration: isHovering + ? BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: colorScheme.primary, width: 2), + color: colorScheme.primary.withValues(alpha: 0.1), + ) + : null, + child: Stack( + children: [ + _buildCollectionGridItem( + context: context, + colorScheme: colorScheme, + coverWidget: _buildPlaylistCover( + context, + playlist, + colorScheme, + ), + title: playlist.name, + count: playlist.tracks.length, + onTap: _isPlaylistSelectionMode + ? () => _togglePlaylistSelection(playlist.id) + : () => _openPlaylistById(playlist.id), + onLongPress: _isPlaylistSelectionMode + ? () => _togglePlaylistSelection(playlist.id) + : () => _enterPlaylistSelectionMode(playlist.id), + ), + if (_isPlaylistSelectionMode) + Positioned( + left: 0, + top: 0, + right: 0, + child: IgnorePointer( + child: AspectRatio( + aspectRatio: 1, + child: Container( + decoration: BoxDecoration( + color: isSelected + ? colorScheme.primary.withValues(alpha: 0.3) + : Colors.transparent, + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + ), + if (_isPlaylistSelectionMode) + Positioned( + top: 4, + right: 4, + child: IgnorePointer( + child: Container( + decoration: BoxDecoration( + color: isSelected + ? colorScheme.primary + : colorScheme.surface.withValues(alpha: 0.85), + shape: BoxShape.circle, + border: Border.all( + color: isSelected + ? colorScheme.primary + : colorScheme.outline, + width: 2, + ), + ), + child: isSelected + ? Icon( + Icons.check, + size: 16, + color: colorScheme.onPrimary, + ) + : const SizedBox(width: 16, height: 16), + ), + ), + ), + ], + ), + ); + }, + ); + } + } + + /// Build a collection item at [index] for the unified "All" tab list view. + /// Index 0 = Wishlist, 1 = Loved, 2+ = individual playlists. + Widget _buildAllTabListCollectionItem({ + required BuildContext context, + required ColorScheme colorScheme, + required int index, + required LibraryCollectionsState collectionState, + List filteredUnifiedItems = const [], + }) { + if (index == 0) { + return _buildCollectionListItem( + context: context, + colorScheme: colorScheme, + icon: Icons.add_circle_outline, + iconColor: Colors.white, + iconBgColor: const Color(0xFF1DB954), + title: context.l10n.collectionWishlist, + subtitle: + '${context.l10n.collectionFoldersTitle} • ${collectionState.wishlistCount} ${collectionState.wishlistCount == 1 ? 'track' : 'tracks'}', + onTap: _openWishlistFolder, + ); + } else if (index == 1) { + return _buildCollectionListItem( + context: context, + colorScheme: colorScheme, + icon: Icons.favorite, + iconColor: Colors.white, + iconBgColor: const Color(0xFF8C67AC), + title: context.l10n.collectionLoved, + subtitle: + '${context.l10n.collectionFoldersTitle} • ${collectionState.lovedCount} ${collectionState.lovedCount == 1 ? 'track' : 'tracks'}', + onTap: _openLovedFolder, + ); + } else { + final playlist = collectionState.playlists[index - 2]; + final isSelected = _selectedPlaylistIds.contains(playlist.id); + return DragTarget( + onWillAcceptWithDetails: (_) => !_isPlaylistSelectionMode, + onAcceptWithDetails: (details) { + _onTrackDroppedOnPlaylist( + context, + details.data, + playlist.id, + playlist.name, + allItems: filteredUnifiedItems, + ); + }, + builder: (context, candidateData, rejectedData) { + final isHovering = candidateData.isNotEmpty; + return AnimatedContainer( + duration: const Duration(milliseconds: 150), + decoration: isHovering + ? BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: colorScheme.primary, width: 2), + color: colorScheme.primary.withValues(alpha: 0.1), + ) + : null, + child: Row( + children: [ + if (_isPlaylistSelectionMode) + GestureDetector( + onTap: () => _togglePlaylistSelection(playlist.id), + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.only(left: 8), + child: Container( + decoration: BoxDecoration( + color: isSelected + ? colorScheme.primary + : Colors.transparent, + shape: BoxShape.circle, + border: Border.all( + color: isSelected + ? colorScheme.primary + : colorScheme.outline, + width: 2, + ), + ), + child: isSelected + ? Icon( + Icons.check, + size: 18, + color: colorScheme.onPrimary, + ) + : const SizedBox(width: 18, height: 18), + ), + ), + ), + Expanded( + child: _buildCollectionListItem( + context: context, + colorScheme: colorScheme, + coverWidget: _buildPlaylistCover( + context, + playlist, + colorScheme, + 56, + ), + title: playlist.name, + subtitle: + '${playlist.tracks.length} ${playlist.tracks.length == 1 ? 'track' : 'tracks'}', + onTap: _isPlaylistSelectionMode + ? () => _togglePlaylistSelection(playlist.id) + : () => _openPlaylistById(playlist.id), + onLongPress: _isPlaylistSelectionMode + ? () => _togglePlaylistSelection(playlist.id) + : () => _enterPlaylistSelectionMode(playlist.id), + ), + ), + ], + ), + ); + }, + ); + } + } + Widget _buildFilterContent({ required BuildContext context, required ColorScheme colorScheme, @@ -2228,6 +3359,7 @@ class _QueueTabState extends ConsumerState { required bool hasQueueItems, required _FilterContentData filterData, required List localLibraryItems, + required LibraryCollectionsState collectionState, }) { final historyItems = filterData.historyItems; final showFilteringIndicator = filterData.showFilteringIndicator; @@ -2275,10 +3407,9 @@ class _QueueTabState extends ConsumerState { ), if (!_isSelectionMode && filteredUnifiedItems.isNotEmpty) TextButton.icon( - onPressed: () => - _enterSelectionMode(filteredUnifiedItems.first.id), - icon: const Icon(Icons.checklist, size: 18), - label: Text(context.l10n.actionSelect), + onPressed: () => _showCreatePlaylistDialog(context), + icon: const Icon(Icons.add, size: 20), + label: Text(context.l10n.collectionCreatePlaylist), style: TextButton.styleFrom( visualDensity: VisualDensity.compact, ), @@ -2288,6 +3419,8 @@ class _QueueTabState extends ConsumerState { ), ), + // Collection folders as list items (Spotify-style) in "All" tab + // are now rendered inline with tracks below (unified sliver) if ((filteredGroupedAlbums.isNotEmpty || filteredGroupedLocalAlbums.isNotEmpty) && filterMode == 'albums') @@ -2435,45 +3568,119 @@ class _QueueTabState extends ConsumerState { ), ), - // Unified list for 'all' filter (merged downloaded + local) - if (filteredUnifiedItems.isNotEmpty && filterMode == 'all') - historyViewMode == 'grid' - ? SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 16), - sliver: SliverGrid( - gridDelegate: - const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - mainAxisSpacing: 8, - crossAxisSpacing: 8, - childAspectRatio: 0.75, - ), - delegate: SliverChildBuilderDelegate((context, index) { - final item = filteredUnifiedItems[index]; + // Unified list/grid for 'all' filter: collection items + tracks combined + if (filterMode == 'all') ...[ + if (historyViewMode == 'grid') + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverGrid( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + childAspectRatio: 0.75, + ), + delegate: SliverChildBuilderDelegate( + (context, index) { + final collectionCount = + 2 + collectionState.playlists.length; + if (index < collectionCount) { + return _buildAllTabGridCollectionItem( + context: context, + colorScheme: colorScheme, + index: index, + collectionState: collectionState, + filteredUnifiedItems: filteredUnifiedItems, + ); + } + final trackIndex = index - collectionCount; + if (trackIndex < filteredUnifiedItems.length) { + final item = filteredUnifiedItems[trackIndex]; return KeyedSubtree( key: ValueKey(item.id), - child: _buildUnifiedGridItem( + child: LongPressDraggable( + data: item, + feedback: _buildDragFeedback( + context, + item, + colorScheme, + ), + childWhenDragging: Opacity( + opacity: 0.4, + child: _buildUnifiedGridItem( + context, + item, + colorScheme, + ), + ), + child: _buildUnifiedGridItem( + context, + item, + colorScheme, + ), + ), + ); + } + return const SizedBox.shrink(); + }, + childCount: + 2 + + collectionState.playlists.length + + filteredUnifiedItems.length, + ), + ), + ) + else + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final collectionCount = 2 + collectionState.playlists.length; + if (index < collectionCount) { + return _buildAllTabListCollectionItem( + context: context, + colorScheme: colorScheme, + index: index, + collectionState: collectionState, + filteredUnifiedItems: filteredUnifiedItems, + ); + } + final trackIndex = index - collectionCount; + if (trackIndex < filteredUnifiedItems.length) { + final item = filteredUnifiedItems[trackIndex]; + return KeyedSubtree( + key: ValueKey(item.id), + child: LongPressDraggable( + data: item, + feedback: _buildDragFeedback( + context, + item, + colorScheme, + ), + childWhenDragging: Opacity( + opacity: 0.4, + child: _buildUnifiedLibraryItem( + context, + item, + colorScheme, + ), + ), + child: _buildUnifiedLibraryItem( context, item, colorScheme, ), - ); - }, childCount: filteredUnifiedItems.length), - ), - ) - : SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - final item = filteredUnifiedItems[index]; - return KeyedSubtree( - key: ValueKey(item.id), - child: _buildUnifiedLibraryItem( - context, - item, - colorScheme, ), ); - }, childCount: filteredUnifiedItems.length), - ), + } + return const SizedBox.shrink(); + }, + childCount: + 2 + + collectionState.playlists.length + + filteredUnifiedItems.length, + ), + ), + ], // Singles filter - show unified items (downloaded + local singles) if (filterMode == 'singles') @@ -2510,10 +3717,9 @@ class _QueueTabState extends ConsumerState { ), if (!_isSelectionMode && filteredUnifiedItems.isNotEmpty) TextButton.icon( - onPressed: () => - _enterSelectionMode(filteredUnifiedItems.first.id), - icon: const Icon(Icons.checklist, size: 18), - label: Text(context.l10n.actionSelect), + onPressed: () => _showCreatePlaylistDialog(context), + icon: const Icon(Icons.add, size: 20), + label: Text(context.l10n.collectionCreatePlaylist), style: TextButton.styleFrom( visualDensity: VisualDensity.compact, ), @@ -2803,8 +4009,9 @@ class _QueueTabState extends ConsumerState { context, ).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), ), - Text( - album.artistName, + ClickableArtistName( + artistName: album.artistName, + coverUrl: album.coverUrl, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall?.copyWith( @@ -2909,8 +4116,8 @@ class _QueueTabState extends ConsumerState { context, ).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), ), - Text( - album.artistName, + ClickableArtistName( + artistName: album.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall?.copyWith( @@ -2922,6 +4129,828 @@ class _QueueTabState extends ConsumerState { ); } + bool _hasTextValue(String? value) => value != null && value.trim().isNotEmpty; + + List _selectedItemsFromAll( + List allItems, + ) { + final itemsById = {for (final item in allItems) item.id: item}; + return _selectedIds + .map((id) => itemsById[id]) + .whereType() + .toList(growable: false); + } + + bool _isLocalOnlySelection(List allItems) { + final selectedItems = _selectedItemsFromAll(allItems); + return selectedItems.isNotEmpty && + selectedItems.every((item) => item.localItem != null); + } + + Future _safeDeleteTempFile(String path) async { + try { + final file = File(path); + if (await file.exists()) { + await file.delete(); + } + } catch (_) {} + } + + Future _cleanupTempFileAndParentDir(String path) async { + await _safeDeleteTempFile(path); + try { + final parent = File(path).parent; + if (await parent.exists()) { + await parent.delete(); + } + } catch (_) {} + } + + Future _applyQueueFfmpegReEnrichResult( + LocalLibraryItem item, + Map result, + ) async { + final tempPath = result['temp_path'] as String?; + final safUri = result['saf_uri'] as String?; + final ffmpegTarget = _hasTextValue(tempPath) ? tempPath! : item.filePath; + final downloadedCoverPath = result['cover_path'] as String?; + String? effectiveCoverPath = downloadedCoverPath; + String? extractedCoverPath; + + if (!_hasTextValue(effectiveCoverPath)) { + try { + final tempDir = await Directory.systemTemp.createTemp( + 'reenrich_cover_', + ); + final coverOutput = '${tempDir.path}${Platform.pathSeparator}cover.jpg'; + final extracted = await PlatformBridge.extractCoverToFile( + ffmpegTarget, + coverOutput, + ); + if (extracted['error'] == null) { + effectiveCoverPath = coverOutput; + extractedCoverPath = coverOutput; + } else { + try { + await tempDir.delete(recursive: true); + } catch (_) {} + } + } catch (_) {} + } + + final metadata = (result['metadata'] as Map?)?.map( + (k, v) => MapEntry(k, v.toString()), + ); + + final format = item.format?.toLowerCase(); + final lowerPath = item.filePath.toLowerCase(); + final isMp3 = format == 'mp3' || lowerPath.endsWith('.mp3'); + final isOpus = + format == 'opus' || + format == 'ogg' || + lowerPath.endsWith('.opus') || + lowerPath.endsWith('.ogg'); + + String? ffmpegResult; + if (isMp3) { + ffmpegResult = await FFmpegService.embedMetadataToMp3( + mp3Path: ffmpegTarget, + coverPath: effectiveCoverPath, + metadata: metadata, + ); + } else if (isOpus) { + ffmpegResult = await FFmpegService.embedMetadataToOpus( + opusPath: ffmpegTarget, + coverPath: effectiveCoverPath, + metadata: metadata, + ); + } + + if (ffmpegResult != null && + _hasTextValue(tempPath) && + _hasTextValue(safUri)) { + final ok = await PlatformBridge.writeTempToSaf(ffmpegResult, safUri!); + if (!ok) { + if (_hasTextValue(downloadedCoverPath)) { + await _safeDeleteTempFile(downloadedCoverPath!); + } + if (_hasTextValue(extractedCoverPath)) { + await _cleanupTempFileAndParentDir(extractedCoverPath!); + } + await _safeDeleteTempFile(tempPath!); + return false; + } + } + + if (_hasTextValue(downloadedCoverPath)) { + await _safeDeleteTempFile(downloadedCoverPath!); + } + if (_hasTextValue(extractedCoverPath)) { + await _cleanupTempFileAndParentDir(extractedCoverPath!); + } + if (_hasTextValue(tempPath)) { + await _safeDeleteTempFile(tempPath!); + } + + return ffmpegResult != null; + } + + Future _reEnrichQueueLocalTrack(LocalLibraryItem item) async { + final durationMs = (item.duration ?? 0) * 1000; + final request = { + 'file_path': item.filePath, + 'cover_url': '', + 'max_quality': true, + 'embed_lyrics': true, + 'spotify_id': '', + 'track_name': item.trackName, + 'artist_name': item.artistName, + 'album_name': item.albumName, + 'album_artist': item.albumArtist ?? item.artistName, + 'track_number': item.trackNumber ?? 0, + 'disc_number': item.discNumber ?? 0, + 'release_date': item.releaseDate ?? '', + 'isrc': item.isrc ?? '', + 'genre': item.genre ?? '', + 'label': '', + 'copyright': '', + 'duration_ms': durationMs, + 'search_online': true, + }; + + final result = await PlatformBridge.reEnrichFile(request); + final method = result['method'] as String?; + if (method == 'native') { + return true; + } + if (method == 'ffmpeg') { + return _applyQueueFfmpegReEnrichResult(item, result); + } + return false; + } + + Future _reEnrichSelectedLocalFromQueue( + List allItems, + ) async { + final selectedItems = _selectedItemsFromAll(allItems); + final selectedLocalItems = selectedItems + .map((item) => item.localItem) + .whereType() + .toList(growable: false); + + if (selectedLocalItems.isEmpty) { + return; + } + + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(context.l10n.trackReEnrich), + content: Text( + '${context.l10n.trackReEnrichOnlineSubtitle}\n\n' + '${context.l10n.downloadedAlbumSelectedCount(selectedLocalItems.length)}', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text(context.l10n.dialogCancel), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: Text(context.l10n.trackReEnrich), + ), + ], + ), + ); + + if (confirmed != true || !mounted) { + return; + } + + var successCount = 0; + final total = selectedLocalItems.length; + + for (var i = 0; i < total; i++) { + if (!mounted) break; + final item = selectedLocalItems[i]; + + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + '${context.l10n.trackReEnrichProgress} (${i + 1}/$total)', + ), + duration: const Duration(seconds: 30), + ), + ); + + try { + final ok = await _reEnrichQueueLocalTrack(item); + if (ok) { + successCount++; + } + } catch (_) {} + } + + if (!mounted) { + return; + } + + final localLibraryPath = ref.read(settingsProvider).localLibraryPath.trim(); + try { + if (localLibraryPath.isNotEmpty && + !ref.read(localLibraryProvider).isScanning) { + await ref + .read(localLibraryProvider.notifier) + .startScan(localLibraryPath); + } else { + await ref.read(localLibraryProvider.notifier).reloadFromStorage(); + } + } catch (_) { + await ref.read(localLibraryProvider.notifier).reloadFromStorage(); + } + + _exitSelectionMode(); + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).clearSnackBars(); + final failedCount = total - successCount; + final summary = failedCount <= 0 + ? '${context.l10n.trackReEnrichSuccess} ($successCount/$total)' + : '${context.l10n.trackReEnrichSuccess} ($successCount/$total) • Failed: $failedCount'; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(summary))); + } + + /// Share selected tracks via system share sheet + Future _shareSelected(List allItems) async { + final itemsById = {for (final item in allItems) item.id: item}; + final safUris = []; + final filesToShare = []; + + for (final id in _selectedIds) { + final item = itemsById[id]; + if (item == null) continue; + final path = item.filePath; + if (isContentUri(path)) { + if (await fileExists(path)) safUris.add(path); + } else if (await fileExists(path)) { + filesToShare.add(XFile(path)); + } + } + + if (safUris.isEmpty && filesToShare.isEmpty) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.selectionShareNoFiles)), + ); + } + return; + } + + // Share SAF content URIs via native intent + if (safUris.isNotEmpty) { + try { + if (safUris.length == 1) { + await PlatformBridge.shareContentUri(safUris.first); + } else { + await PlatformBridge.shareMultipleContentUris(safUris); + } + } catch (_) {} + } + + // Share regular files via SharePlus + if (filesToShare.isNotEmpty) { + await SharePlus.instance.share(ShareParams(files: filesToShare)); + } + } + + /// Show batch convert bottom sheet for selected tracks + Future _showBatchConvertSheet( + BuildContext context, + List allItems, + ) async { + String selectedFormat = 'MP3'; + String selectedBitrate = '320k'; + var didStartConversion = false; + + _hideSelectionOverlay(); + _hidePlaylistSelectionOverlay(); + + await showModalBottomSheet( + context: context, + useRootNavigator: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (sheetContext) { + return StatefulBuilder( + builder: (context, setSheetState) { + final colorScheme = Theme.of(context).colorScheme; + final formats = ['MP3', 'Opus']; + final bitrates = ['128k', '192k', '256k', '320k']; + + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: colorScheme.onSurfaceVariant.withValues( + alpha: 0.4, + ), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 16), + Text( + context.l10n.selectionBatchConvertConfirmTitle, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 20), + Text( + context.l10n.trackConvertTargetFormat, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Row( + children: formats.map((format) { + final isSelected = format == selectedFormat; + return Padding( + padding: const EdgeInsets.only(right: 8), + child: ChoiceChip( + label: Text(format), + selected: isSelected, + onSelected: (selected) { + if (selected) { + setSheetState(() { + selectedFormat = format; + selectedBitrate = format == 'Opus' + ? '128k' + : '320k'; + }); + } + }, + ), + ); + }).toList(), + ), + const SizedBox(height: 16), + Text( + context.l10n.trackConvertBitrate, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: bitrates.map((br) { + final isSelected = br == selectedBitrate; + return ChoiceChip( + label: Text(br), + selected: isSelected, + onSelected: (selected) { + if (selected) { + setSheetState(() => selectedBitrate = br); + } + }, + ); + }).toList(), + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: () { + didStartConversion = true; + Navigator.pop(context); + _performBatchConversion( + allItems: allItems, + targetFormat: selectedFormat, + bitrate: selectedBitrate, + ); + }, + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + child: Text( + context.l10n.selectionConvertCount( + _selectedIds.length, + ), + ), + ), + ), + ], + ), + ), + ); + }, + ); + }, + ); + + if (!mounted || didStartConversion) return; + if (_isSelectionMode) { + _syncSelectionOverlay( + items: allItems, + bottomPadding: MediaQuery.of(this.context).padding.bottom, + ); + } else if (_isPlaylistSelectionMode) { + _syncPlaylistSelectionOverlay( + playlists: ref.read(libraryCollectionsProvider).playlists, + bottomPadding: MediaQuery.of(this.context).padding.bottom, + ); + } + } + + /// Perform batch conversion on selected tracks + Future _performBatchConversion({ + required List allItems, + required String targetFormat, + required String bitrate, + }) async { + final itemsById = {for (final item in allItems) item.id: item}; + final selectedItems = []; + for (final id in _selectedIds) { + final item = itemsById[id]; + if (item == null) continue; + // Detect format: use safFileName for download history SAF items, + // item.localItem?.format for local library items, file extension as fallback + String nameToCheck; + if (item.historyItem?.safFileName != null && + item.historyItem!.safFileName!.isNotEmpty) { + nameToCheck = item.historyItem!.safFileName!.toLowerCase(); + } else if (item.localItem?.format != null && + item.localItem!.format!.isNotEmpty) { + // Synthesize a fake extension to keep detection unified + nameToCheck = '.${item.localItem!.format!.toLowerCase()}'; + } else { + nameToCheck = item.filePath.toLowerCase(); + } + final ext = nameToCheck.endsWith('.flac') + ? 'FLAC' + : nameToCheck.endsWith('.mp3') + ? 'MP3' + : (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg')) + ? 'Opus' + : null; + if (ext != null && ext != targetFormat) { + selectedItems.add(item); + } + } + + if (selectedItems.isEmpty) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.selectionConvertNoConvertible)), + ); + } + return; + } + + // Confirm + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(context.l10n.selectionBatchConvertConfirmTitle), + content: Text( + context.l10n.selectionBatchConvertConfirmMessage( + selectedItems.length, + targetFormat, + bitrate, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text(context.l10n.dialogCancel), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: Text(context.l10n.trackConvertFormat), + ), + ], + ), + ); + + if (confirmed != true || !mounted) return; + + int successCount = 0; + final total = selectedItems.length; + final historyDb = HistoryDatabase.instance; + final newQuality = + '${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}'; + final settings = ref.read(settingsProvider); + final shouldEmbedLyrics = + settings.embedLyrics && settings.lyricsMode != 'external'; + + for (int i = 0; i < total; i++) { + if (!mounted) break; + final item = selectedItems[i]; + + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.selectionBatchConvertProgress(i + 1, total), + ), + duration: const Duration(seconds: 30), + ), + ); + + try { + // Read metadata from file + final metadata = { + 'TITLE': item.trackName, + 'ARTIST': item.artistName, + 'ALBUM': item.albumName, + }; + try { + final result = await PlatformBridge.readFileMetadata(item.filePath); + if (result['error'] == null) { + result.forEach((key, value) { + if (key == 'error' || value == null) return; + final v = value.toString().trim(); + if (v.isEmpty) return; + metadata[key.toUpperCase()] = v; + }); + } + } catch (_) {} + await ensureLyricsMetadataForConversion( + metadata: metadata, + sourcePath: item.filePath, + shouldEmbedLyrics: shouldEmbedLyrics, + trackName: item.trackName, + artistName: item.artistName, + spotifyId: item.historyItem?.spotifyId ?? '', + durationMs: + ((item.historyItem?.duration ?? item.localItem?.duration) ?? 0) * + 1000, + ); + + // Extract cover art + String? coverPath; + try { + final tempDir = await getTemporaryDirectory(); + final coverOutput = + '${tempDir.path}${Platform.pathSeparator}batch_cover_${DateTime.now().millisecondsSinceEpoch}.jpg'; + final coverResult = await PlatformBridge.extractCoverToFile( + item.filePath, + coverOutput, + ); + if (coverResult['error'] == null) { + coverPath = coverOutput; + } + } catch (_) {} + + // Handle SAF vs regular file + String workingPath = item.filePath; + final isSaf = isContentUri(item.filePath); + String? safTempPath; + + if (isSaf) { + safTempPath = await PlatformBridge.copyContentUriToTemp( + item.filePath, + ); + if (safTempPath == null) continue; + workingPath = safTempPath; + } + + // Convert + final newPath = await FFmpegService.convertAudioFormat( + inputPath: workingPath, + targetFormat: targetFormat.toLowerCase(), + bitrate: bitrate, + metadata: metadata, + coverPath: coverPath, + deleteOriginal: !isSaf, + ); + + // Cleanup cover temp + if (coverPath != null) { + try { + await File(coverPath).delete(); + } catch (_) {} + } + + if (newPath == null) { + if (safTempPath != null) { + try { + await File(safTempPath).delete(); + } catch (_) {} + } + continue; + } + + // Handle SAF write-back + if (isSaf && item.historyItem != null) { + final hi = item.historyItem!; + final treeUri = hi.downloadTreeUri; + final relativeDir = hi.safRelativeDir ?? ''; + if (treeUri != null && treeUri.isNotEmpty) { + final oldFileName = hi.safFileName ?? ''; + final dotIdx = oldFileName.lastIndexOf('.'); + final baseName = dotIdx > 0 + ? oldFileName.substring(0, dotIdx) + : oldFileName; + final newExt = targetFormat.toLowerCase() == 'opus' + ? '.opus' + : '.mp3'; + final newFileName = '$baseName$newExt'; + final mimeType = targetFormat.toLowerCase() == 'opus' + ? 'audio/opus' + : 'audio/mpeg'; + + final safUri = await PlatformBridge.createSafFileFromPath( + treeUri: treeUri, + relativeDir: relativeDir, + fileName: newFileName, + mimeType: mimeType, + srcPath: newPath, + ); + + if (safUri == null || safUri.isEmpty) { + try { + await File(newPath).delete(); + } catch (_) {} + if (safTempPath != null) { + try { + await File(safTempPath).delete(); + } catch (_) {} + } + continue; + } + + // Delete old SAF file + try { + await PlatformBridge.safDelete(item.filePath); + } catch (_) {} + + // Update history + await historyDb.updateFilePath( + hi.id, + safUri, + newSafFileName: newFileName, + newQuality: newQuality, + clearAudioSpecs: true, + ); + } + // Cleanup temp files + try { + await File(newPath).delete(); + } catch (_) {} + if (safTempPath != null) { + try { + await File(safTempPath).delete(); + } catch (_) {} + } + } else if (isSaf && item.localItem != null) { + // Local library SAF item: parse content URI to derive tree and dir + final uri = Uri.parse(item.filePath); + final pathSegments = uri.pathSegments; + + String? treeUri; + String relativeDir = ''; + String oldFileName = ''; + + final treeIdx = pathSegments.indexOf('tree'); + final docIdx = pathSegments.indexOf('document'); + if (treeIdx >= 0 && treeIdx + 1 < pathSegments.length) { + final treeId = pathSegments[treeIdx + 1]; + treeUri = + 'content://${uri.authority}/tree/${Uri.encodeComponent(treeId)}'; + } + if (docIdx >= 0 && docIdx + 1 < pathSegments.length) { + final docPath = Uri.decodeFull(pathSegments[docIdx + 1]); + final slashIdx = docPath.lastIndexOf('/'); + if (slashIdx >= 0) { + oldFileName = docPath.substring(slashIdx + 1); + final treeId = treeIdx >= 0 && treeIdx + 1 < pathSegments.length + ? Uri.decodeFull(pathSegments[treeIdx + 1]) + : ''; + if (treeId.isNotEmpty && docPath.startsWith(treeId)) { + final afterTree = docPath.substring(treeId.length); + final trimmed = afterTree.startsWith('/') + ? afterTree.substring(1) + : afterTree; + final lastSlash = trimmed.lastIndexOf('/'); + relativeDir = lastSlash >= 0 + ? trimmed.substring(0, lastSlash) + : ''; + } + } else { + oldFileName = docPath; + } + } + + if (treeUri != null && oldFileName.isNotEmpty) { + final dotIdx = oldFileName.lastIndexOf('.'); + final baseName = dotIdx > 0 + ? oldFileName.substring(0, dotIdx) + : oldFileName; + final newExt = targetFormat.toLowerCase() == 'opus' + ? '.opus' + : '.mp3'; + final newFileName = '$baseName$newExt'; + final mimeType = targetFormat.toLowerCase() == 'opus' + ? 'audio/opus' + : 'audio/mpeg'; + + final safUri = await PlatformBridge.createSafFileFromPath( + treeUri: treeUri, + relativeDir: relativeDir, + fileName: newFileName, + mimeType: mimeType, + srcPath: newPath, + ); + + if (safUri == null || safUri.isEmpty) { + try { + await File(newPath).delete(); + } catch (_) {} + if (safTempPath != null) { + try { + await File(safTempPath).delete(); + } catch (_) {} + } + continue; + } + + try { + await PlatformBridge.safDelete(item.filePath); + } catch (_) {} + await LibraryDatabase.instance.deleteByPath(item.filePath); + } + + // Cleanup temp files + try { + await File(newPath).delete(); + } catch (_) {} + if (safTempPath != null) { + try { + await File(safTempPath).delete(); + } catch (_) {} + } + } else if (item.historyItem != null) { + // Regular file - update history path + await historyDb.updateFilePath( + item.historyItem!.id, + newPath, + newQuality: newQuality, + clearAudioSpecs: true, + ); + } else if (item.localItem != null) { + // Regular local library file - delete old db entry, rescan picks up new file + await LibraryDatabase.instance.deleteByPath(item.filePath); + } + + successCount++; + } catch (_) { + // Continue to next item on error + } + } + + // Reload history and local library to reflect path changes in UI + ref.read(downloadHistoryProvider.notifier).reloadFromStorage(); + ref.read(localLibraryProvider.notifier).reloadFromStorage(); + + _exitSelectionMode(); + + if (mounted) { + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.selectionBatchConvertSuccess( + successCount, + total, + targetFormat, + ), + ), + ), + ); + } + } + /// Bottom action bar for selection mode (Material Design 3 style) Widget _buildSelectionBottomBar( BuildContext context, @@ -2932,6 +4961,7 @@ class _QueueTabState extends ConsumerState { final selectedCount = _selectedIds.length; final allSelected = selectedCount == unifiedItems.length && unifiedItems.isNotEmpty; + final localOnlySelection = _isLocalOnlySelection(unifiedItems); return Container( decoration: BoxDecoration( @@ -3013,7 +5043,42 @@ class _QueueTabState extends ConsumerState { ], ), - const SizedBox(height: 16), + const SizedBox(height: 12), + + // Action buttons row: Share/Re-enrich, Convert, Delete + Row( + children: [ + Expanded( + child: _SelectionActionButton( + icon: localOnlySelection + ? Icons.auto_fix_high_outlined + : Icons.share_outlined, + label: localOnlySelection + ? '${context.l10n.trackReEnrich} ($selectedCount)' + : context.l10n.selectionShareCount(selectedCount), + onPressed: selectedCount > 0 + ? () => localOnlySelection + ? _reEnrichSelectedLocalFromQueue(unifiedItems) + : _shareSelected(unifiedItems) + : null, + colorScheme: colorScheme, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _SelectionActionButton( + icon: Icons.swap_horiz, + label: context.l10n.selectionConvertCount(selectedCount), + onPressed: selectedCount > 0 + ? () => _showBatchConvertSheet(context, unifiedItems) + : null, + colorScheme: colorScheme, + ), + ), + ], + ), + + const SizedBox(height: 8), SizedBox( width: double.infinity, @@ -3084,8 +5149,10 @@ class _QueueTabState extends ConsumerState { ), ), const SizedBox(height: 2), - Text( - item.track.artistName, + ClickableArtistName( + artistName: item.track.artistName, + artistId: item.track.artistId, + coverUrl: item.track.coverUrl, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall?.copyWith( @@ -3231,7 +5298,13 @@ class _QueueTabState extends ConsumerState { children: [ if (fileExists) IconButton( - onPressed: () => _openFile(item.filePath!), + onPressed: () => _openFile( + item.filePath!, + title: item.track.name, + artist: item.track.artistName, + album: item.track.albumName, + coverUrl: item.track.coverUrl ?? '', + ), icon: Icon(Icons.play_arrow, color: colorScheme.primary), tooltip: 'Play', style: IconButton.styleFrom( @@ -3537,7 +5610,13 @@ class _QueueTabState extends ConsumerState { ? () => _navigateToHistoryMetadataScreen(item.historyItem!) : item.localItem != null ? () => _navigateToLocalMetadataScreen(item.localItem!) - : () => _openFile(item.filePath), + : () => _openFile( + item.filePath, + title: item.trackName, + artist: item.artistName, + album: item.albumName, + coverUrl: item.coverUrl ?? item.localCoverPath ?? '', + ), onLongPress: _isSelectionMode ? null : () => _enterSelectionMode(item.id), @@ -3589,8 +5668,9 @@ class _QueueTabState extends ConsumerState { ), ), const SizedBox(height: 2), - Text( - item.artistName, + ClickableArtistName( + artistName: item.artistName, + coverUrl: item.coverUrl, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall?.copyWith( @@ -3621,14 +5701,17 @@ class _QueueTabState extends ConsumerState { ), ), const SizedBox(width: 8), - Text( - dateStr, - style: Theme.of(context).textTheme.labelSmall - ?.copyWith( - color: colorScheme.onSurfaceVariant.withValues( - alpha: 0.7, + Flexible( + child: Text( + dateStr, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: colorScheme.onSurfaceVariant + .withValues(alpha: 0.7), ), - ), + ), ), if (item.quality != null && item.quality!.isNotEmpty) ...[ @@ -3673,7 +5756,14 @@ class _QueueTabState extends ConsumerState { children: [ if (fileExists) IconButton( - onPressed: () => _openFile(item.filePath), + onPressed: () => _openFile( + item.filePath, + title: item.trackName, + artist: item.artistName, + album: item.albumName, + coverUrl: + item.coverUrl ?? item.localCoverPath ?? '', + ), icon: Icon( Icons.play_arrow, color: colorScheme.primary, @@ -3718,7 +5808,13 @@ class _QueueTabState extends ConsumerState { ? () => _navigateToHistoryMetadataScreen(item.historyItem!) : item.localItem != null ? () => _navigateToLocalMetadataScreen(item.localItem!) - : () => _openFile(item.filePath), + : () => _openFile( + item.filePath, + title: item.trackName, + artist: item.artistName, + album: item.albumName, + coverUrl: item.coverUrl ?? item.localCoverPath ?? '', + ), onLongPress: _isSelectionMode ? null : () => _enterSelectionMode(item.id), child: Stack( children: [ @@ -3793,7 +5889,16 @@ class _QueueTabState extends ConsumerState { builder: (context, fileExists, child) { return fileExists ? GestureDetector( - onTap: () => _openFile(item.filePath), + onTap: () => _openFile( + item.filePath, + title: item.trackName, + artist: item.artistName, + album: item.albumName, + coverUrl: + item.coverUrl ?? + item.localCoverPath ?? + '', + ), child: Container( padding: const EdgeInsets.all(6), decoration: BoxDecoration( @@ -3844,8 +5949,9 @@ class _QueueTabState extends ConsumerState { context, ).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500), ), - Text( - item.artistName, + ClickableArtistName( + artistName: item.artistName, + coverUrl: item.coverUrl, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.labelSmall?.copyWith( @@ -3971,3 +6077,63 @@ class _FilterChip extends StatelessWidget { ); } } + +/// Reusable action button for selection mode bottom bar +class _SelectionActionButton extends StatelessWidget { + final IconData icon; + final String label; + final VoidCallback? onPressed; + final ColorScheme colorScheme; + + const _SelectionActionButton({ + required this.icon, + required this.label, + required this.onPressed, + required this.colorScheme, + }); + + @override + Widget build(BuildContext context) { + final isDisabled = onPressed == null; + return Material( + color: isDisabled + ? colorScheme.surfaceContainerHighest.withValues(alpha: 0.5) + : colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(14), + child: InkWell( + onTap: onPressed, + borderRadius: BorderRadius.circular(14), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icon, + size: 18, + color: isDisabled + ? colorScheme.onSurfaceVariant.withValues(alpha: 0.5) + : colorScheme.onSecondaryContainer, + ), + const SizedBox(width: 6), + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: isDisabled + ? colorScheme.onSurfaceVariant.withValues(alpha: 0.5) + : colorScheme.onSecondaryContainer, + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index d85d447f..a348ecac 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -6,6 +6,8 @@ import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/providers/track_provider.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; +import 'package:spotiflac_android/utils/clickable_metadata.dart'; class SearchScreen extends ConsumerStatefulWidget { final String query; @@ -61,9 +63,10 @@ class _SearchScreenState extends ConsumerState { @override Widget build(BuildContext context) { - final trackState = ref.watch(trackProvider); + final tracks = ref.watch(trackProvider.select((s) => s.tracks)); + final isLoading = ref.watch(trackProvider.select((s) => s.isLoading)); + final error = ref.watch(trackProvider.select((s) => s.error)); final colorScheme = Theme.of(context).colorScheme; - final tracks = trackState.tracks; return Scaffold( appBar: AppBar( @@ -86,15 +89,11 @@ class _SearchScreenState extends ConsumerState { ), body: Column( children: [ - if (trackState.isLoading) - LinearProgressIndicator(color: colorScheme.primary), - if (trackState.error != null) + if (isLoading) LinearProgressIndicator(color: colorScheme.primary), + if (error != null) Padding( padding: const EdgeInsets.all(16.0), - child: Text( - trackState.error!, - style: TextStyle(color: colorScheme.error), - ), + child: Text(error, style: TextStyle(color: colorScheme.error)), ), Expanded( child: tracks.isEmpty @@ -159,14 +158,19 @@ class _SearchScreenState extends ConsumerState { subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - track.artistName, + ClickableArtistName( + artistName: track.artistName, + artistId: track.artistId, + coverUrl: track.coverUrl, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant), ), - Text( - track.albumName, + ClickableAlbumName( + albumName: track.albumName, + albumId: track.albumId, + artistName: track.artistName, + coverUrl: track.coverUrl, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall?.copyWith( @@ -175,9 +179,20 @@ class _SearchScreenState extends ConsumerState { ), ], ), - trailing: IconButton( - icon: Icon(Icons.download, color: colorScheme.primary), - onPressed: () => _downloadTrack(track), + onLongPress: () => TrackCollectionQuickActions.showTrackOptionsSheet( + context, + ref, + track, + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.download_rounded), + tooltip: 'Download', + onPressed: () => _downloadTrack(track), + ), + ], ), onTap: () => _downloadTrack(track), ); diff --git a/lib/screens/settings/about_page.dart b/lib/screens/settings/about_page.dart index ba8d7bc8..5b5bc299 100644 --- a/lib/screens/settings/about_page.dart +++ b/lib/screens/settings/about_page.dart @@ -149,6 +149,12 @@ class AboutPage extends StatelessWidget { subtitle: 'Partner lyrics proxy for Apple Music and QQ Music sources', onTap: () => _launchUrl('https://lyrics.paxsenix.org'), + showDivider: true, + ), + _ContributorItem( + name: 'Ruubiiiii', + description: 'Provided Qobuz & Deezer API for the project', + githubUsername: 'Ruubiiiii', showDivider: false, ), ], diff --git a/lib/screens/settings/appearance_settings_page.dart b/lib/screens/settings/appearance_settings_page.dart index 17a174a6..4778e95f 100644 --- a/lib/screens/settings/appearance_settings_page.dart +++ b/lib/screens/settings/appearance_settings_page.dart @@ -763,6 +763,7 @@ class _LanguageSelector extends StatelessWidget { final colorScheme = Theme.of(context).colorScheme; showModalBottomSheet( context: context, + useRootNavigator: true, backgroundColor: colorScheme.surface, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), diff --git a/lib/screens/settings/donate_page.dart b/lib/screens/settings/donate_page.dart index c71cd4db..4f20d118 100644 --- a/lib/screens/settings/donate_page.dart +++ b/lib/screens/settings/donate_page.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:spotiflac_android/constants/app_info.dart'; import 'package:spotiflac_android/utils/app_bar_layout.dart'; @@ -165,6 +166,7 @@ class _RecentDonorsCard extends StatelessWidget { @override Widget build(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; + const donorNames = []; // Match SettingsGroup color logic final cardColor = isDark @@ -207,17 +209,39 @@ class _RecentDonorsCard extends StatelessWidget { ), ), const SizedBox(height: 16), - _DonorTile(name: 'J', colorScheme: colorScheme), - _DonorTile(name: 'Julian', colorScheme: colorScheme), - _DonorTile(name: 'matt_3050', colorScheme: colorScheme), - _DonorTile(name: 'Daniel', colorScheme: colorScheme), - _DonorTile(name: '283Fabio', colorScheme: colorScheme), - _DonorTile(name: 'laflame', colorScheme: colorScheme), - _DonorTile( - name: 'Elias el Autentico', - colorScheme: colorScheme, - showDivider: false, - ), + if (donorNames.isEmpty) + Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Column( + children: [ + Icon( + Icons.emoji_events_outlined, + size: 32, + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), + ), + const SizedBox(height: 8), + Text( + 'No supporters yet — be the first!', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.6), + ), + ), + ], + ), + ), + ) + else + Wrap( + spacing: 8, + runSpacing: 8, + children: donorNames + .map( + (name) => + _SupporterChip(name: name, colorScheme: colorScheme), + ) + .toList(), + ), ], ), ), @@ -273,6 +297,19 @@ class _DonateLinksCard extends StatelessWidget { url: AppInfo.githubSponsorsUrl, colorScheme: colorScheme, ), + Divider( + height: 1, + thickness: 1, + indent: 74, + endIndent: 16, + color: colorScheme.outlineVariant.withValues(alpha: 0.3), + ), + _CryptoWalletItem( + title: 'USDT (TRC20)', + walletAddress: 'TL7iAqjq9M8BwVMi9AtHvuAGHtdwEvsDta', + color: const Color(0xFF26A17B), + colorScheme: colorScheme, + ), ], ), ); @@ -348,55 +385,166 @@ class _DonateCardItem extends StatelessWidget { } } -class _DonorTile extends StatelessWidget { - final String name; +class _CryptoWalletItem extends StatelessWidget { + final String title; + final String walletAddress; + final Color color; final ColorScheme colorScheme; - final bool showDivider; - const _DonorTile({ - required this.name, + const _CryptoWalletItem({ + required this.title, + required this.walletAddress, + required this.color, required this.colorScheme, - this.showDivider = true, }); @override Widget build(BuildContext context) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsets.symmetric(vertical: 10), - child: Row( - children: [ - CircleAvatar( - radius: 18, - backgroundColor: colorScheme.primaryContainer, + return InkWell( + onTap: () { + Clipboard.setData(ClipboardData(text: walletAddress)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('$title address copied to clipboard'), + behavior: SnackBarBehavior.floating, + duration: const Duration(seconds: 2), + ), + ); + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(12), + ), + child: const Center( child: Text( - name.isNotEmpty ? name[0].toUpperCase() : '?', + '\$', style: TextStyle( - fontSize: 14, + color: Colors.white, + fontSize: 22, fontWeight: FontWeight.bold, - color: colorScheme.onPrimaryContainer, ), ), ), - const SizedBox(width: 12), - Text( - name, - style: Theme.of( - context, - ).textTheme.bodyLarge?.copyWith(color: colorScheme.onSurface), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + ), + ), + const SizedBox(height: 2), + Text( + walletAddress, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontSize: 11, + ), + overflow: TextOverflow.ellipsis, + ), + ], ), - ], - ), + ), + Icon( + Icons.copy_rounded, + size: 18, + color: colorScheme.onSurfaceVariant, + ), + ], ), - if (showDivider) - Divider( - height: 1, - thickness: 1, - color: colorScheme.outlineVariant.withValues(alpha: 0.3), - ), - ], + ), + ); + } +} + +int _cr(String v) { + int r = 0x1F; + for (final c in v.codeUnits) { r = (r * 31 + c) & 0x7FFFFFFF; } + return r; +} +// Highlighted supporters (hashes of names): none for now. +const _cv = {}; + +class _SupporterChip extends StatelessWidget { + final String name; + final ColorScheme colorScheme; + + const _SupporterChip({required this.name, required this.colorScheme}); + + @override + Widget build(BuildContext context) { + final e = _cv.contains(_cr(name)); + const goldChipColor = Color(0xFFFFF8DC); + const goldAccentColor = Color(0xFFB8860B); + const goldDarkChipColor = Color(0xFF3A3000); + + final chipColor = e + ? goldChipColor + : colorScheme.secondaryContainer; + final accentColor = e + ? goldAccentColor + : colorScheme.primary; + final isDark = Theme.of(context).brightness == Brightness.dark; + final effectiveChipColor = e && isDark + ? goldDarkChipColor + : chipColor; + + return Material( + color: effectiveChipColor, + borderRadius: BorderRadius.circular(20), + child: Container( + decoration: e + ? BoxDecoration( + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: accentColor.withValues(alpha: 0.4), + width: 1, + ), + ) + : null, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + CircleAvatar( + radius: 10, + backgroundColor: accentColor.withValues(alpha: 0.2), + child: e + ? Icon(Icons.star_rounded, size: 12, color: accentColor) + : Text( + name.isNotEmpty ? name[0].toUpperCase() : '?', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: accentColor, + ), + ), + ), + const SizedBox(width: 8), + Text( + name, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: e + ? accentColor + : colorScheme.onSecondaryContainer, + fontWeight: e ? FontWeight.w600 : FontWeight.w500, + ), + ), + ], + ), + ), ); } } diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index 125cc626..9d154404 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -23,7 +23,207 @@ class DownloadSettingsPage extends ConsumerStatefulWidget { } class _DownloadSettingsPageState extends ConsumerState { - static const _builtInServices = ['tidal', 'qobuz', 'amazon']; + static const _builtInServices = ['tidal', 'qobuz', 'amazon', 'deezer']; + static const _songLinkRegions = [ + 'AD', + 'AE', + 'AG', + 'AL', + 'AM', + 'AO', + 'AR', + 'AT', + 'AU', + 'AZ', + 'BA', + 'BB', + 'BD', + 'BE', + 'BF', + 'BG', + 'BH', + 'BI', + 'BJ', + 'BN', + 'BO', + 'BR', + 'BS', + 'BT', + 'BW', + 'BZ', + 'CA', + 'CD', + 'CG', + 'CH', + 'CI', + 'CL', + 'CM', + 'CO', + 'CR', + 'CV', + 'CW', + 'CY', + 'CZ', + 'DE', + 'DJ', + 'DK', + 'DM', + 'DO', + 'DZ', + 'EC', + 'EE', + 'EG', + 'ES', + 'ET', + 'FI', + 'FJ', + 'FM', + 'FR', + 'GA', + 'GB', + 'GD', + 'GE', + 'GH', + 'GM', + 'GN', + 'GQ', + 'GR', + 'GT', + 'GW', + 'GY', + 'HK', + 'HN', + 'HR', + 'HT', + 'HU', + 'ID', + 'IE', + 'IL', + 'IN', + 'IQ', + 'IS', + 'IT', + 'JM', + 'JO', + 'JP', + 'KE', + 'KG', + 'KH', + 'KI', + 'KM', + 'KN', + 'KR', + 'KW', + 'KZ', + 'LA', + 'LB', + 'LC', + 'LI', + 'LK', + 'LR', + 'LS', + 'LT', + 'LU', + 'LV', + 'LY', + 'MA', + 'MC', + 'MD', + 'ME', + 'MG', + 'MH', + 'MK', + 'ML', + 'MN', + 'MO', + 'MR', + 'MT', + 'MU', + 'MV', + 'MW', + 'MX', + 'MY', + 'MZ', + 'NA', + 'NE', + 'NG', + 'NI', + 'NL', + 'NO', + 'NP', + 'NR', + 'NZ', + 'OM', + 'PA', + 'PE', + 'PG', + 'PH', + 'PK', + 'PL', + 'PS', + 'PT', + 'PW', + 'PY', + 'QA', + 'RO', + 'RS', + 'RW', + 'SA', + 'SB', + 'SC', + 'SE', + 'SG', + 'SI', + 'SK', + 'SL', + 'SM', + 'SN', + 'SR', + 'ST', + 'SV', + 'SZ', + 'TD', + 'TG', + 'TH', + 'TJ', + 'TL', + 'TN', + 'TO', + 'TR', + 'TT', + 'TV', + 'TW', + 'TZ', + 'UA', + 'UG', + 'US', + 'UY', + 'UZ', + 'VC', + 'VE', + 'VN', + 'VU', + 'WS', + 'XK', + 'ZA', + 'ZM', + 'ZW', + ]; + static const _songLinkRegionNames = { + 'US': 'United States', + 'GB': 'United Kingdom', + 'FR': 'France', + 'DE': 'Germany', + 'JP': 'Japan', + 'KR': 'South Korea', + 'IN': 'India', + 'ID': 'Indonesia', + 'BR': 'Brazil', + 'MX': 'Mexico', + 'AU': 'Australia', + 'CA': 'Canada', + 'XK': 'Kosovo', + }; int _androidSdkVersion = 0; bool _hasAllFilesAccess = false; bool _artistFolderFiltersExpanded = false; @@ -261,6 +461,33 @@ class _DownloadSettingsPageState extends ConsumerState { ), ), ], + SettingsItem( + title: context.l10n.youtubeOpusBitrateTitle, + subtitle: '${settings.youtubeOpusBitrate}kbps (128/256)', + onTap: () => _showYoutubeBitratePicker( + context: context, + title: context.l10n.youtubeOpusBitrateTitle, + currentValue: settings.youtubeOpusBitrate, + options: const [128, 256], + onSave: (value) => ref + .read(settingsProvider.notifier) + .setYoutubeOpusBitrate(value), + ), + ), + SettingsItem( + title: context.l10n.youtubeMp3BitrateTitle, + subtitle: '${settings.youtubeMp3Bitrate}kbps (128/256/320)', + onTap: () => _showYoutubeBitratePicker( + context: context, + title: context.l10n.youtubeMp3BitrateTitle, + currentValue: settings.youtubeMp3Bitrate, + options: const [128, 256, 320], + onSave: (value) => ref + .read(settingsProvider.notifier) + .setYoutubeMp3Bitrate(value), + ), + showDivider: false, + ), ], ), ), @@ -271,73 +498,93 @@ class _DownloadSettingsPageState extends ConsumerState { SliverToBoxAdapter( child: SettingsGroup( children: [ - SettingsItem( - icon: Icons.lyrics_outlined, - title: context.l10n.lyricsMode, - subtitle: _getLyricsModeLabel(context, settings.lyricsMode), - onTap: () => _showLyricsModePicker( - context, - ref, - settings.lyricsMode, - ), + SettingsSwitchItem( + icon: Icons.subtitles_outlined, + title: context.l10n.optionsEmbedLyrics, + subtitle: settings.embedMetadata + ? context.l10n.optionsEmbedLyricsSubtitle + : 'Disabled while Embed Metadata is turned off', + value: settings.embedLyrics, + enabled: settings.embedMetadata, + onChanged: (value) => ref + .read(settingsProvider.notifier) + .setEmbedLyrics(value), + showDivider: settings.embedMetadata && settings.embedLyrics, ), - SettingsItem( - icon: Icons.source_outlined, - title: 'Lyrics Providers', - subtitle: _getLyricsProvidersSubtitle(settings.lyricsProviders), - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (_) => const LyricsProviderPriorityPage(), + if (settings.embedMetadata && settings.embedLyrics) ...[ + SettingsItem( + icon: Icons.lyrics_outlined, + title: context.l10n.lyricsMode, + subtitle: _getLyricsModeLabel( + context, + settings.lyricsMode, + ), + onTap: () => _showLyricsModePicker( + context, + ref, + settings.lyricsMode, ), ), - ), - SettingsSwitchItem( - icon: Icons.translate_outlined, - title: 'Netease: Include Translation', - subtitle: settings.lyricsIncludeTranslationNetease - ? 'Append translated lyrics when available' - : 'Use original lyrics only', - value: settings.lyricsIncludeTranslationNetease, - onChanged: (value) => ref - .read(settingsProvider.notifier) - .setLyricsIncludeTranslationNetease(value), - ), - SettingsSwitchItem( - icon: Icons.text_fields_outlined, - title: 'Netease: Include Romanization', - subtitle: settings.lyricsIncludeRomanizationNetease - ? 'Append romanized lyrics when available' - : 'Disabled', - value: settings.lyricsIncludeRomanizationNetease, - onChanged: (value) => ref - .read(settingsProvider.notifier) - .setLyricsIncludeRomanizationNetease(value), - ), - SettingsSwitchItem( - icon: Icons.record_voice_over_outlined, - title: 'Apple/QQ Multi-Person Word-by-Word', - subtitle: settings.lyricsMultiPersonWordByWord - ? 'Enable v1/v2 speaker and [bg:] tags' - : 'Simplified word-by-word formatting', - value: settings.lyricsMultiPersonWordByWord, - onChanged: (value) => ref - .read(settingsProvider.notifier) - .setLyricsMultiPersonWordByWord(value), - ), - SettingsItem( - icon: Icons.language_outlined, - title: 'Musixmatch Language', - subtitle: settings.musixmatchLanguage.isEmpty - ? 'Auto (original)' - : settings.musixmatchLanguage.toUpperCase(), - onTap: () => _showMusixmatchLanguagePicker( - context, - ref, - settings.musixmatchLanguage, + SettingsItem( + icon: Icons.source_outlined, + title: 'Lyrics Providers', + subtitle: _getLyricsProvidersSubtitle( + settings.lyricsProviders, + ), + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const LyricsProviderPriorityPage(), + ), + ), ), - showDivider: false, - ), + SettingsSwitchItem( + icon: Icons.translate_outlined, + title: 'Netease: Include Translation', + subtitle: settings.lyricsIncludeTranslationNetease + ? 'Append translated lyrics when available' + : 'Use original lyrics only', + value: settings.lyricsIncludeTranslationNetease, + onChanged: (value) => ref + .read(settingsProvider.notifier) + .setLyricsIncludeTranslationNetease(value), + ), + SettingsSwitchItem( + icon: Icons.text_fields_outlined, + title: 'Netease: Include Romanization', + subtitle: settings.lyricsIncludeRomanizationNetease + ? 'Append romanized lyrics when available' + : 'Disabled', + value: settings.lyricsIncludeRomanizationNetease, + onChanged: (value) => ref + .read(settingsProvider.notifier) + .setLyricsIncludeRomanizationNetease(value), + ), + SettingsSwitchItem( + icon: Icons.record_voice_over_outlined, + title: 'Apple/QQ Multi-Person Word-by-Word', + subtitle: settings.lyricsMultiPersonWordByWord + ? 'Enable v1/v2 speaker and [bg:] tags' + : 'Simplified word-by-word formatting', + value: settings.lyricsMultiPersonWordByWord, + onChanged: (value) => ref + .read(settingsProvider.notifier) + .setLyricsMultiPersonWordByWord(value), + ), + SettingsItem( + icon: Icons.language_outlined, + title: 'Musixmatch Language', + subtitle: settings.musixmatchLanguage.isEmpty + ? 'Auto (original)' + : settings.musixmatchLanguage.toUpperCase(), + onTap: () => _showMusixmatchLanguagePicker( + context, + ref, + settings.musixmatchLanguage, + ), + showDivider: false, + ), + ], ], ), ), @@ -504,6 +751,29 @@ class _DownloadSettingsPageState extends ConsumerState { settings.downloadNetworkMode, ), ), + SettingsItem( + icon: Icons.public, + title: 'SongLink Region', + subtitle: _getSongLinkRegionLabel(settings.songLinkRegion), + onTap: () => _showSongLinkRegionPicker( + context, + ref, + settings.songLinkRegion, + ), + ), + SettingsSwitchItem( + icon: Icons.security_outlined, + title: 'Network compatibility mode', + subtitle: settings.networkCompatibilityMode + ? 'Enabled: try HTTP + accept invalid TLS certificates (unsafe)' + : 'Off: strict HTTPS certificate validation (recommended)', + value: settings.networkCompatibilityMode, + onChanged: (value) { + ref + .read(settingsProvider.notifier) + .setNetworkCompatibilityMode(value); + }, + ), SettingsSwitchItem( icon: Icons.file_download_outlined, title: context.l10n.settingsAutoExportFailed, @@ -603,6 +873,7 @@ class _DownloadSettingsPageState extends ConsumerState { ) { showModalBottomSheet( context: context, + useRootNavigator: true, builder: (context) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, @@ -737,6 +1008,7 @@ class _DownloadSettingsPageState extends ConsumerState { showModalBottomSheet( context: context, + useRootNavigator: true, isScrollControlled: true, backgroundColor: colorScheme.surface, shape: const RoundedRectangleBorder( @@ -954,6 +1226,7 @@ class _DownloadSettingsPageState extends ConsumerState { settings.storageMode == 'saf' && settings.downloadTreeUri.isNotEmpty; showModalBottomSheet( context: context, + useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(28)), @@ -1033,6 +1306,7 @@ class _DownloadSettingsPageState extends ConsumerState { final colorScheme = Theme.of(context).colorScheme; showModalBottomSheet( context: context, + useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(28)), @@ -1180,6 +1454,14 @@ class _DownloadSettingsPageState extends ConsumerState { } } + String _getSongLinkRegionLabel(String code) { + final normalized = code.trim().toUpperCase(); + final effective = normalized.isEmpty ? 'US' : normalized; + final name = _songLinkRegionNames[effective]; + if (name == null) return effective; + return '$effective - $name'; + } + void _showLyricsModePicker( BuildContext context, WidgetRef ref, @@ -1188,6 +1470,7 @@ class _DownloadSettingsPageState extends ConsumerState { final colorScheme = Theme.of(context).colorScheme; showModalBottomSheet( context: context, + useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(28)), @@ -1253,6 +1536,7 @@ class _DownloadSettingsPageState extends ConsumerState { } static const _providerDisplayNames = { + 'spotify_api': 'Spotify Lyrics API', 'lrclib': 'LRCLIB', 'netease': 'Netease', 'musixmatch': 'Musixmatch', @@ -1262,9 +1546,7 @@ class _DownloadSettingsPageState extends ConsumerState { String _getLyricsProvidersSubtitle(List providers) { if (providers.isEmpty) return 'None enabled'; - return providers - .map((p) => _providerDisplayNames[p] ?? p) - .join(' > '); + return providers.map((p) => _providerDisplayNames[p] ?? p).join(' > '); } String _normalizeMusixmatchLanguage(String value) { @@ -1272,6 +1554,68 @@ class _DownloadSettingsPageState extends ConsumerState { return normalized.replaceAll(RegExp(r'[^a-z0-9\-_]'), ''); } + void _showYoutubeBitratePicker({ + required BuildContext context, + required String title, + required int currentValue, + required List options, + required void Function(int value) onSave, + }) { + final colorScheme = Theme.of(context).colorScheme; + + showModalBottomSheet( + context: context, + useRootNavigator: true, + backgroundColor: colorScheme.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 8), + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.fromLTRB(24, 12, 24, 8), + child: Row( + children: [ + Expanded( + child: Text( + title, + style: Theme.of(sheetContext).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + ), + ], + ), + ), + for (final bitrate in options) + ListTile( + title: Text('$bitrate kbps'), + trailing: bitrate == currentValue + ? Icon(Icons.check, color: colorScheme.primary) + : null, + onTap: () { + onSave(bitrate); + Navigator.pop(sheetContext); + }, + ), + const SizedBox(height: 8), + ], + ), + ), + ); + } + void _showMusixmatchLanguagePicker( BuildContext context, WidgetRef ref, @@ -1282,6 +1626,7 @@ class _DownloadSettingsPageState extends ConsumerState { showModalBottomSheet( context: context, + useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(28)), @@ -1300,9 +1645,9 @@ class _DownloadSettingsPageState extends ConsumerState { children: [ Text( 'Musixmatch Language', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - ), + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), ), const SizedBox(height: 8), Text( @@ -1331,7 +1676,9 @@ class _DownloadSettingsPageState extends ConsumerState { const SizedBox(width: 8), TextButton( onPressed: () { - ref.read(settingsProvider.notifier).setMusixmatchLanguage(''); + ref + .read(settingsProvider.notifier) + .setMusixmatchLanguage(''); Navigator.pop(context); }, child: const Text('Auto'), @@ -1378,6 +1725,7 @@ class _DownloadSettingsPageState extends ConsumerState { final colorScheme = Theme.of(context).colorScheme; showModalBottomSheet( context: context, + useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(28)), @@ -1462,6 +1810,7 @@ class _DownloadSettingsPageState extends ConsumerState { final colorScheme = Theme.of(context).colorScheme; showModalBottomSheet( context: context, + useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(28)), @@ -1524,6 +1873,75 @@ class _DownloadSettingsPageState extends ConsumerState { ); } + void _showSongLinkRegionPicker( + BuildContext context, + WidgetRef ref, + String current, + ) { + final colorScheme = Theme.of(context).colorScheme; + final normalizedCurrent = current.trim().toUpperCase(); + showModalBottomSheet( + context: context, + useRootNavigator: true, + backgroundColor: colorScheme.surfaceContainerHigh, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + builder: (context) => SafeArea( + child: SizedBox( + height: MediaQuery.of(context).size.height * 0.7, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), + child: Text( + 'SongLink Region', + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), + child: Text( + 'Used as userCountry for SongLink API lookup.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + Expanded( + child: ListView.builder( + itemCount: _songLinkRegions.length, + itemBuilder: (context, index) { + final code = _songLinkRegions[index]; + final isSelected = code == normalizedCurrent; + final displayName = _songLinkRegionNames[code]; + return ListTile( + title: Text(code), + subtitle: displayName != null ? Text(displayName) : null, + trailing: isSelected + ? Icon(Icons.check, color: colorScheme.primary) + : null, + onTap: () { + ref + .read(settingsProvider.notifier) + .setSongLinkRegion(code); + Navigator.pop(context); + }, + ); + }, + ), + ), + ], + ), + ), + ), + ); + } + void _showFolderOrganizationPicker( BuildContext context, WidgetRef ref, @@ -1532,6 +1950,7 @@ class _DownloadSettingsPageState extends ConsumerState { final colorScheme = Theme.of(context).colorScheme; showModalBottomSheet( context: context, + useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, isScrollControlled: true, shape: const RoundedRectangleBorder( @@ -1632,16 +2051,13 @@ class _ServiceSelector extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final extState = ref.watch(extensionProvider); + final builtInServiceIds = ['tidal', 'qobuz', 'amazon', 'deezer', 'youtube']; final extensionProviders = extState.extensions .where((e) => e.enabled && e.hasDownloadProvider) .toList(); - final isExtensionService = ![ - 'tidal', - 'qobuz', - 'amazon', - ].contains(currentService); + final isExtensionService = !builtInServiceIds.contains(currentService); final isCurrentExtensionEnabled = isExtensionService ? extensionProviders.any((e) => e.id == currentService) : true; @@ -1654,47 +2070,56 @@ class _ServiceSelector extends ConsumerWidget { children: [ Row( children: [ - _ServiceChip( - icon: Icons.music_note, - label: 'Tidal', - isSelected: effectiveService == 'tidal', - onTap: () => onChanged('tidal'), + Expanded( + child: _ServiceChip( + icon: Icons.music_note, + label: 'Tidal', + isSelected: effectiveService == 'tidal', + onTap: () => onChanged('tidal'), + ), ), const SizedBox(width: 8), - _ServiceChip( - icon: Icons.album, - label: 'Qobuz', - isSelected: effectiveService == 'qobuz', - onTap: () => onChanged('qobuz'), + Expanded( + child: _ServiceChip( + icon: Icons.album, + label: 'Qobuz', + isSelected: effectiveService == 'qobuz', + onTap: () => onChanged('qobuz'), + ), ), const SizedBox(width: 8), - _ServiceChip( - icon: Icons.shopping_bag_outlined, - label: 'Amazon', - isSelected: effectiveService == 'amazon', - onTap: () => onChanged('amazon'), + Expanded( + child: _ServiceChip( + icon: Icons.shopping_bag_outlined, + label: 'Amazon', + isSelected: effectiveService == 'amazon', + onTap: () => onChanged('amazon'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: _ServiceChip( + icon: Icons.smart_display, + label: 'YouTube', + isSelected: effectiveService == 'youtube', + onTap: () => onChanged('youtube'), + ), ), ], ), if (extensionProviders.isNotEmpty) ...[ const SizedBox(height: 8), - Row( + Wrap( + spacing: 8, + runSpacing: 8, children: [ - for (int i = 0; i < extensionProviders.length; i++) ...[ - if (i > 0) const SizedBox(width: 8), - Expanded( - child: _ServiceChip( - icon: Icons.extension, - label: extensionProviders[i].displayName, - isSelected: effectiveService == extensionProviders[i].id, - onTap: () => onChanged(extensionProviders[i].id), - ), + for (final extension in extensionProviders) + _ServiceChip( + icon: Icons.extension, + label: extension.displayName, + isSelected: effectiveService == extension.id, + onTap: () => onChanged(extension.id), ), - ], - for (int i = extensionProviders.length; i < 3; i++) ...[ - const SizedBox(width: 8), - const Expanded(child: SizedBox()), - ], ], ), ], @@ -1728,38 +2153,35 @@ class _ServiceChip extends StatelessWidget { ) : colorScheme.surfaceContainerHigh; - return Expanded( - child: Material( - color: isSelected ? colorScheme.primaryContainer : unselectedColor, + return Material( + color: isSelected ? colorScheme.primaryContainer : unselectedColor, + borderRadius: BorderRadius.circular(12), + child: InkWell( + onTap: onTap, borderRadius: BorderRadius.circular(12), - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(12), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 14), - child: Column( - children: [ - Icon( - icon, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 12), + child: Column( + children: [ + Icon( + icon, + color: isSelected + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 6), + Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant, ), - const SizedBox(height: 6), - Text( - label, - style: TextStyle( - fontSize: 12, - fontWeight: isSelected - ? FontWeight.w600 - : FontWeight.normal, - color: isSelected - ? colorScheme.onPrimaryContainer - : colorScheme.onSurfaceVariant, - ), - ), - ], - ), + overflow: TextOverflow.ellipsis, + ), + ], ), ), ), diff --git a/lib/screens/settings/extensions_page.dart b/lib/screens/settings/extensions_page.dart index 5c13458b..d339e24d 100644 --- a/lib/screens/settings/extensions_page.dart +++ b/lib/screens/settings/extensions_page.dart @@ -20,12 +20,15 @@ class ExtensionsPage extends ConsumerStatefulWidget { } class _ExtensionsPageState extends ConsumerState { - static final RegExp _platformExceptionPattern = - RegExp(r'PlatformException\([^,]+,\s*([^,]+(?:,[^,]+)?),'); - static final RegExp _platformExceptionSimplePattern = - RegExp(r'PlatformException\([^,]+,\s*(.+?),\s*null'); - static final RegExp _trailingNullsPattern = - RegExp(r',\s*null\s*,\s*null\)?$'); + static final RegExp _platformExceptionPattern = RegExp( + r'PlatformException\([^,]+,\s*([^,]+(?:,[^,]+)?),', + ); + static final RegExp _platformExceptionSimplePattern = RegExp( + r'PlatformException\([^,]+,\s*(.+?),\s*null', + ); + static final RegExp _trailingNullsPattern = RegExp( + r',\s*null\s*,\s*null\)?$', + ); static final RegExp _leadingCommaPattern = RegExp(r'^\s*,\s*'); @override @@ -40,11 +43,13 @@ class _ExtensionsPageState extends ConsumerState { final appDir = await getApplicationDocumentsDirectory(); final extensionsDir = '${appDir.path}/extensions'; final dataDir = '${appDir.path}/extension_data'; - + await Directory(extensionsDir).create(recursive: true); await Directory(dataDir).create(recursive: true); - - await ref.read(extensionProvider.notifier).initialize(extensionsDir, dataDir); + + await ref + .read(extensionProvider.notifier) + .initialize(extensionsDir, dataDir); } } @@ -59,67 +64,205 @@ class _ExtensionsPageState extends ConsumerState { child: Scaffold( body: CustomScrollView( slivers: [ - SliverAppBar( - expandedHeight: 120 + topPadding, - collapsedHeight: kToolbarHeight, - floating: false, - pinned: true, - backgroundColor: colorScheme.surface, - surfaceTintColor: Colors.transparent, - leading: IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () => Navigator.pop(context), - ), - flexibleSpace: LayoutBuilder( - builder: (context, constraints) { - final maxHeight = 120 + topPadding; - final minHeight = kToolbarHeight + topPadding; - final expandRatio = ((constraints.maxHeight - minHeight) / - (maxHeight - minHeight)) - .clamp(0.0, 1.0); - final leftPadding = 56 - (32 * expandRatio); - return FlexibleSpaceBar( - expandedTitleScale: 1.0, - titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16), - title: Text( - context.l10n.extensionsTitle, - style: TextStyle( - fontSize: 20 + (8 * expandRatio), - fontWeight: FontWeight.bold, - color: colorScheme.onSurface, + SliverAppBar( + expandedHeight: 120 + topPadding, + collapsedHeight: kToolbarHeight, + floating: false, + pinned: true, + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.pop(context), + ), + flexibleSpace: LayoutBuilder( + builder: (context, constraints) { + final maxHeight = 120 + topPadding; + final minHeight = kToolbarHeight + topPadding; + final expandRatio = + ((constraints.maxHeight - minHeight) / + (maxHeight - minHeight)) + .clamp(0.0, 1.0); + final leftPadding = 56 - (32 * expandRatio); + return FlexibleSpaceBar( + expandedTitleScale: 1.0, + titlePadding: EdgeInsets.only( + left: leftPadding, + bottom: 16, ), - ), - ); - }, - ), - ), - - if (extState.isLoading) - const SliverToBoxAdapter( - child: Padding( - padding: EdgeInsets.all(32), - child: Center(child: CircularProgressIndicator()), + title: Text( + context.l10n.extensionsTitle, + style: TextStyle( + fontSize: 20 + (8 * expandRatio), + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + ); + }, ), ), - if (extState.error != null) + if (extState.isLoading) + const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.all(32), + child: Center(child: CircularProgressIndicator()), + ), + ), + + if (extState.error != null) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(16), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.errorContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon(Icons.error_outline, color: colorScheme.error), + const SizedBox(width: 12), + Expanded( + child: Text( + extState.error!, + style: TextStyle( + color: colorScheme.onErrorContainer, + ), + ), + ), + ], + ), + ), + ), + ), + + SliverToBoxAdapter( + child: SettingsSectionHeader( + title: context.l10n.extensionsProviderPrioritySection, + ), + ), + SliverToBoxAdapter( + child: SettingsGroup( + children: [ + _DownloadPriorityItem(), + _MetadataPriorityItem(), + _SearchProviderSelector(), + ], + ), + ), + + SliverToBoxAdapter( + child: SettingsSectionHeader( + title: context.l10n.extensionsInstalledSection, + ), + ), + + if (extState.extensions.isEmpty && !extState.isLoading) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest.withValues( + alpha: 0.3, + ), + borderRadius: BorderRadius.circular(16), + ), + child: Column( + children: [ + Icon( + Icons.extension_outlined, + size: 48, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 12), + Text( + context.l10n.extensionsNoExtensions, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(color: colorScheme.onSurfaceVariant), + ), + const SizedBox(height: 4), + Text( + context.l10n.extensionsNoExtensionsSubtitle, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: colorScheme.onSurfaceVariant), + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ), + + if (extState.extensions.isNotEmpty) + SliverToBoxAdapter( + child: SettingsGroup( + children: extState.extensions.asMap().entries.map((entry) { + final index = entry.key; + final ext = entry.value; + return _ExtensionItem( + extension: ext, + showDivider: index < extState.extensions.length - 1, + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => + ExtensionDetailPage(extensionId: ext.id), + ), + ), + onToggle: (enabled) => ref + .read(extensionProvider.notifier) + .setExtensionEnabled(ext.id, enabled), + ); + }).toList(), + ), + ), + SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.all(16), + child: FilledButton.icon( + onPressed: _installExtension, + icon: const Icon(Icons.add), + label: Text(context.l10n.extensionsInstallButton), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + ), + ), + ), + + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 32), child: Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: colorScheme.errorContainer, + color: colorScheme.tertiaryContainer.withValues(alpha: 0.3), borderRadius: BorderRadius.circular(12), ), child: Row( children: [ - Icon(Icons.error_outline, color: colorScheme.error), + Icon( + Icons.info_outline, + size: 20, + color: colorScheme.tertiary, + ), const SizedBox(width: 12), Expanded( child: Text( - extState.error!, - style: TextStyle(color: colorScheme.onErrorContainer), + context.l10n.extensionsInfoTip, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: colorScheme.onTertiaryContainer, + ), ), ), ], @@ -127,131 +270,9 @@ class _ExtensionsPageState extends ConsumerState { ), ), ), - - SliverToBoxAdapter( - child: SettingsSectionHeader(title: context.l10n.extensionsProviderPrioritySection), - ), - SliverToBoxAdapter( - child: SettingsGroup( - children: [ - _DownloadPriorityItem(), - _MetadataPriorityItem(), - _SearchProviderSelector(), - ], - ), - ), - - SliverToBoxAdapter( - child: SettingsSectionHeader(title: context.l10n.extensionsInstalledSection), - ), - - if (extState.extensions.isEmpty && !extState.isLoading) - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Container( - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(16), - ), - child: Column( - children: [ - Icon( - Icons.extension_outlined, - size: 48, - color: colorScheme.onSurfaceVariant, - ), - const SizedBox(height: 12), - Text( - context.l10n.extensionsNoExtensions, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 4), - Text( - context.l10n.extensionsNoExtensionsSubtitle, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - textAlign: TextAlign.center, - ), - ], - ), - ), - ), - ), - - if (extState.extensions.isNotEmpty) - SliverToBoxAdapter( - child: SettingsGroup( - children: extState.extensions.asMap().entries.map((entry) { - final index = entry.key; - final ext = entry.value; - return _ExtensionItem( - extension: ext, - showDivider: index < extState.extensions.length - 1, - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (_) => ExtensionDetailPage(extensionId: ext.id), - ), - ), - onToggle: (enabled) => ref - .read(extensionProvider.notifier) - .setExtensionEnabled(ext.id, enabled), - ); - }).toList(), - ), - ), - - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(16), - child: FilledButton.icon( - onPressed: _installExtension, - icon: const Icon(Icons.add), - label: Text(context.l10n.extensionsInstallButton), - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - ), - ), - ), - ), - - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 32), - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: colorScheme.tertiaryContainer.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(12), - ), - child: Row( - children: [ - Icon(Icons.info_outline, size: 20, color: colorScheme.tertiary), - const SizedBox(width: 12), - Expanded( - child: Text( - context.l10n.extensionsInfoTip, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onTertiaryContainer, - ), - ), - ), - ], - ), - ), - ), - ), - ], + ], + ), ), - ), ); } @@ -267,9 +288,7 @@ class _ExtensionsPageState extends ConsumerState { if (!file.path!.endsWith('.spotiflac-ext')) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.snackbarSelectExtFile), - ), + SnackBar(content: Text(context.l10n.snackbarSelectExtFile)), ); } return; @@ -287,12 +306,12 @@ class _ExtensionsPageState extends ConsumerState { } else { message = _getFriendlyErrorMessage(extState.error); } - + ref.read(extensionProvider.notifier).clearError(); - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(message)), - ); + + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); } } } @@ -301,9 +320,9 @@ class _ExtensionsPageState extends ConsumerState { /// Parse error message to be more user-friendly String _getFriendlyErrorMessage(String? error) { if (error == null) return 'Failed to install extension'; - + String message = error; - + if (message.contains('PlatformException')) { final match = _platformExceptionPattern.firstMatch(message); if (match != null) { @@ -315,10 +334,10 @@ class _ExtensionsPageState extends ConsumerState { } } } - + message = message.replaceAll(_trailingNullsPattern, ''); message = message.replaceAll(_leadingCommaPattern, ''); - + return message; } } @@ -359,7 +378,9 @@ class _ExtensionItem extends StatelessWidget { : colorScheme.primaryContainer, borderRadius: BorderRadius.circular(12), ), - child: extension.iconPath != null && extension.iconPath!.isNotEmpty + child: + extension.iconPath != null && + extension.iconPath!.isNotEmpty ? ClipRRect( borderRadius: BorderRadius.circular(12), child: Image.file( @@ -396,7 +417,8 @@ class _ExtensionItem extends StatelessWidget { const SizedBox(height: 2), Text( hasError - ? extension.errorMessage ?? context.l10n.extensionsErrorLoading + ? extension.errorMessage ?? + context.l10n.extensionsErrorLoading : 'v${extension.version} ${context.l10n.extensionsAuthor(extension.author)}', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: hasError @@ -435,17 +457,16 @@ class _DownloadPriorityItem extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final extState = ref.watch(extensionProvider); final colorScheme = Theme.of(context).colorScheme; - - final hasDownloadExtensions = extState.extensions - .any((e) => e.enabled && e.hasDownloadProvider); - + + final hasDownloadExtensions = extState.extensions.any( + (e) => e.enabled && e.hasDownloadProvider, + ); + return InkWell( - onTap: hasDownloadExtensions + onTap: hasDownloadExtensions ? () => Navigator.push( context, - MaterialPageRoute( - builder: (_) => const ProviderPriorityPage(), - ), + MaterialPageRoute(builder: (_) => const ProviderPriorityPage()), ) : null, child: Padding( @@ -454,8 +475,8 @@ class _DownloadPriorityItem extends ConsumerWidget { children: [ Icon( Icons.download, - color: hasDownloadExtensions - ? colorScheme.onSurfaceVariant + color: hasDownloadExtensions + ? colorScheme.onSurfaceVariant : colorScheme.outline, ), const SizedBox(width: 16), @@ -466,14 +487,12 @@ class _DownloadPriorityItem extends ConsumerWidget { Text( context.l10n.extensionsDownloadPriority, style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: hasDownloadExtensions - ? null - : colorScheme.outline, + color: hasDownloadExtensions ? null : colorScheme.outline, ), ), const SizedBox(height: 2), Text( - hasDownloadExtensions + hasDownloadExtensions ? context.l10n.extensionsDownloadPrioritySubtitle : context.l10n.extensionsNoDownloadProvider, style: Theme.of(context).textTheme.bodySmall?.copyWith( @@ -485,8 +504,8 @@ class _DownloadPriorityItem extends ConsumerWidget { ), Icon( Icons.chevron_right, - color: hasDownloadExtensions - ? colorScheme.onSurfaceVariant + color: hasDownloadExtensions + ? colorScheme.onSurfaceVariant : colorScheme.outline, ), ], @@ -503,12 +522,13 @@ class _MetadataPriorityItem extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final extState = ref.watch(extensionProvider); final colorScheme = Theme.of(context).colorScheme; - - final hasMetadataExtensions = extState.extensions - .any((e) => e.enabled && e.hasMetadataProvider); - + + final hasMetadataExtensions = extState.extensions.any( + (e) => e.enabled && e.hasMetadataProvider, + ); + return InkWell( - onTap: hasMetadataExtensions + onTap: hasMetadataExtensions ? () => Navigator.push( context, MaterialPageRoute( @@ -522,8 +542,8 @@ class _MetadataPriorityItem extends ConsumerWidget { children: [ Icon( Icons.search, - color: hasMetadataExtensions - ? colorScheme.onSurfaceVariant + color: hasMetadataExtensions + ? colorScheme.onSurfaceVariant : colorScheme.outline, ), const SizedBox(width: 16), @@ -534,14 +554,12 @@ class _MetadataPriorityItem extends ConsumerWidget { Text( context.l10n.extensionsMetadataPriority, style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: hasMetadataExtensions - ? null - : colorScheme.outline, + color: hasMetadataExtensions ? null : colorScheme.outline, ), ), const SizedBox(height: 2), Text( - hasMetadataExtensions + hasMetadataExtensions ? context.l10n.extensionsMetadataPrioritySubtitle : context.l10n.extensionsNoMetadataProvider, style: Theme.of(context).textTheme.bodySmall?.copyWith( @@ -553,8 +571,8 @@ class _MetadataPriorityItem extends ConsumerWidget { ), Icon( Icons.chevron_right, - color: hasMetadataExtensions - ? colorScheme.onSurfaceVariant + color: hasMetadataExtensions + ? colorScheme.onSurfaceVariant : colorScheme.outline, ), ], @@ -572,32 +590,40 @@ class _SearchProviderSelector extends ConsumerWidget { final settings = ref.watch(settingsProvider); final extState = ref.watch(extensionProvider); final colorScheme = Theme.of(context).colorScheme; - + final searchProviders = extState.extensions .where((e) => e.enabled && e.hasCustomSearch) .toList(); - + String currentProviderName = context.l10n.extensionDefaultProvider; - if (settings.searchProvider != null && settings.searchProvider!.isNotEmpty) { - final ext = searchProviders.where((e) => e.id == settings.searchProvider).firstOrNull; + if (settings.searchProvider != null && + settings.searchProvider!.isNotEmpty) { + final ext = searchProviders + .where((e) => e.id == settings.searchProvider) + .firstOrNull; currentProviderName = ext?.displayName ?? settings.searchProvider!; } - + return Column( mainAxisSize: MainAxisSize.min, children: [ InkWell( - onTap: searchProviders.isEmpty - ? null - : () => _showSearchProviderPicker(context, ref, settings, searchProviders), + onTap: searchProviders.isEmpty + ? null + : () => _showSearchProviderPicker( + context, + ref, + settings, + searchProviders, + ), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( children: [ Icon( Icons.manage_search, - color: searchProviders.isEmpty - ? colorScheme.outline + color: searchProviders.isEmpty + ? colorScheme.outline : colorScheme.onSurfaceVariant, ), const SizedBox(width: 16), @@ -608,14 +634,14 @@ class _SearchProviderSelector extends ConsumerWidget { Text( context.l10n.extensionsSearchProvider, style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: searchProviders.isEmpty - ? colorScheme.outline + color: searchProviders.isEmpty + ? colorScheme.outline : null, ), ), const SizedBox(height: 2), Text( - searchProviders.isEmpty + searchProviders.isEmpty ? context.l10n.extensionsNoCustomSearch : currentProviderName, style: Theme.of(context).textTheme.bodySmall?.copyWith( @@ -627,8 +653,8 @@ class _SearchProviderSelector extends ConsumerWidget { ), Icon( Icons.chevron_right, - color: searchProviders.isEmpty - ? colorScheme.outline + color: searchProviders.isEmpty + ? colorScheme.outline : colorScheme.onSurfaceVariant, ), ], @@ -646,9 +672,10 @@ class _SearchProviderSelector extends ConsumerWidget { List searchProviders, ) { final colorScheme = Theme.of(context).colorScheme; - + showModalBottomSheet( context: context, + useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(28)), @@ -662,9 +689,9 @@ class _SearchProviderSelector extends ConsumerWidget { padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), child: Text( ctx.l10n.extensionsSearchProvider, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - ), + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), ), ), Padding( @@ -680,7 +707,9 @@ class _SearchProviderSelector extends ConsumerWidget { leading: Icon(Icons.music_note, color: colorScheme.primary), title: Text(ctx.l10n.extensionDefaultProvider), subtitle: Text(ctx.l10n.extensionDefaultProviderSubtitle), - trailing: (settings.searchProvider == null || settings.searchProvider!.isEmpty) + trailing: + (settings.searchProvider == null || + settings.searchProvider!.isEmpty) ? Icon(Icons.check_circle, color: colorScheme.primary) : Icon(Icons.circle_outlined, color: colorScheme.outline), onTap: () { @@ -688,18 +717,23 @@ class _SearchProviderSelector extends ConsumerWidget { Navigator.pop(ctx); }, ), - ...searchProviders.map((ext) => ListTile( - leading: Icon(Icons.extension, color: colorScheme.secondary), - title: Text(ext.displayName), - subtitle: Text(ext.searchBehavior?.placeholder ?? ctx.l10n.extensionsCustomSearch), - trailing: settings.searchProvider == ext.id - ? Icon(Icons.check_circle, color: colorScheme.primary) - : Icon(Icons.circle_outlined, color: colorScheme.outline), - onTap: () { - ref.read(settingsProvider.notifier).setSearchProvider(ext.id); - Navigator.pop(ctx); - }, - )), + ...searchProviders.map( + (ext) => ListTile( + leading: Icon(Icons.extension, color: colorScheme.secondary), + title: Text(ext.displayName), + subtitle: Text( + ext.searchBehavior?.placeholder ?? + ctx.l10n.extensionsCustomSearch, + ), + trailing: settings.searchProvider == ext.id + ? Icon(Icons.check_circle, color: colorScheme.primary) + : Icon(Icons.circle_outlined, color: colorScheme.outline), + onTap: () { + ref.read(settingsProvider.notifier).setSearchProvider(ext.id); + Navigator.pop(ctx); + }, + ), + ), const SizedBox(height: 16), ], ), diff --git a/lib/screens/settings/library_settings_page.dart b/lib/screens/settings/library_settings_page.dart index 696baee9..d8d32dda 100644 --- a/lib/screens/settings/library_settings_page.dart +++ b/lib/screens/settings/library_settings_page.dart @@ -656,14 +656,8 @@ class _LibraryHeroCard extends StatelessWidget { const SizedBox(height: 4), Text( isScanning - ? context.l10n - .libraryTracksCount(scannedFiles) - .replaceAll(scannedFiles.toString(), '') - .trim() - : context.l10n - .libraryTracksCount(displayCount) - .replaceAll(displayCount.toString(), '') - .trim(), + ? context.l10n.libraryTracksUnit(scannedFiles) + : context.l10n.libraryTracksUnit(displayCount), style: TextStyle( fontSize: 16, color: colorScheme.onSurfaceVariant, diff --git a/lib/screens/settings/lyrics_provider_priority_page.dart b/lib/screens/settings/lyrics_provider_priority_page.dart index 58abaea3..a33e8a50 100644 --- a/lib/screens/settings/lyrics_provider_priority_page.dart +++ b/lib/screens/settings/lyrics_provider_priority_page.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; -import 'package:spotiflac_android/utils/app_bar_layout.dart'; +import 'package:spotiflac_android/widgets/priority_settings_scaffold.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; class LyricsProviderPriorityPage extends ConsumerStatefulWidget { @@ -16,6 +16,7 @@ class _LyricsProviderPriorityPageState extends ConsumerState { static const _allProviderIds = [ 'lrclib', + 'spotify_api', 'netease', 'musixmatch', 'apple_music', @@ -26,9 +27,8 @@ class _LyricsProviderPriorityPageState late List _initialProviders; bool _hasChanges = false; - List get _disabledProviders => _allProviderIds - .where((id) => !_enabledProviders.contains(id)) - .toList(); + List get _disabledProviders => + _allProviderIds.where((id) => !_enabledProviders.contains(id)).toList(); @override void initState() { @@ -39,204 +39,86 @@ class _LyricsProviderPriorityPageState } void _markChanged() { - final changed = _enabledProviders.length != _initialProviders.length || - !_enabledProviders - .asMap() - .entries - .every((e) => - e.key < _initialProviders.length && - _initialProviders[e.key] == e.value); + final changed = + _enabledProviders.length != _initialProviders.length || + !_enabledProviders.asMap().entries.every( + (e) => + e.key < _initialProviders.length && + _initialProviders[e.key] == e.value, + ); setState(() => _hasChanges = changed); } @override Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final topPadding = normalizedHeaderTopPadding(context); final disabled = _disabledProviders; - return PopScope( - canPop: !_hasChanges, - onPopInvokedWithResult: (didPop, result) async { - if (didPop) return; - final shouldPop = await _confirmDiscard(context); - if (shouldPop && context.mounted) { - Navigator.pop(context); - } - }, - child: Scaffold( - body: CustomScrollView( - slivers: [ - // ── Collapsing App Bar ── - SliverAppBar( - expandedHeight: 120 + topPadding, - collapsedHeight: kToolbarHeight, - floating: false, - pinned: true, - backgroundColor: colorScheme.surface, - surfaceTintColor: Colors.transparent, - leading: IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () async { - if (_hasChanges) { - final shouldPop = await _confirmDiscard(context); - if (shouldPop && context.mounted) { - Navigator.pop(context); - } - } else { - Navigator.pop(context); - } - }, - ), - actions: [ - if (_hasChanges) - TextButton( - onPressed: _saveChanges, - child: const Text('Save'), - ), - ], - flexibleSpace: LayoutBuilder( - builder: (context, constraints) { - final maxHeight = 120 + topPadding; - final minHeight = kToolbarHeight + topPadding; - final expandRatio = ((constraints.maxHeight - minHeight) / - (maxHeight - minHeight)) - .clamp(0.0, 1.0); - final leftPadding = 56 - (32 * expandRatio); - return FlexibleSpaceBar( - expandedTitleScale: 1.0, - titlePadding: - EdgeInsets.only(left: leftPadding, bottom: 16), - title: Text( - 'Lyrics Providers', - style: TextStyle( - fontSize: 20 + (8 * expandRatio), - fontWeight: FontWeight.bold, - color: colorScheme.onSurface, - ), - ), - ); - }, - ), + return PrioritySettingsScaffold( + hasChanges: _hasChanges, + title: 'Lyrics Providers', + description: + 'Enable, disable and reorder lyrics sources. Providers are tried top-to-bottom until lyrics are found.', + infoText: + 'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.', + onSave: _saveChanges, + onConfirmDiscard: _confirmDiscard, + slivers: [ + if (_enabledProviders.isNotEmpty) + SliverToBoxAdapter( + child: SettingsSectionHeader( + title: 'Enabled (${_enabledProviders.length})', ), - - // ── Description ── - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 4, 16, 8), - child: Text( - 'Enable, disable and reorder lyrics sources. ' - 'Providers are tried top-to-bottom until lyrics are found.', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ), + ), + if (_enabledProviders.isNotEmpty) + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverReorderableList( + itemCount: _enabledProviders.length, + itemBuilder: (context, index) { + final id = _enabledProviders[index]; + final info = _getLyricsProviderInfo(id); + return _EnabledProviderItem( + key: ValueKey(id), + providerId: id, + info: info, + index: index, + isFirst: index == 0, + onToggle: () => _disableProvider(id), + ); + }, + onReorder: (oldIndex, newIndex) { + setState(() { + if (newIndex > oldIndex) newIndex -= 1; + final item = _enabledProviders.removeAt(oldIndex); + _enabledProviders.insert(newIndex, item); + }); + _markChanged(); + }, ), - - // ── Enabled section header ── - if (_enabledProviders.isNotEmpty) - SliverToBoxAdapter( - child: SettingsSectionHeader( - title: 'Enabled (${_enabledProviders.length})', - ), - ), - - // ── Reorderable enabled list ── - if (_enabledProviders.isNotEmpty) - SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 16), - sliver: SliverReorderableList( - itemCount: _enabledProviders.length, - itemBuilder: (context, index) { - final id = _enabledProviders[index]; - final info = _getLyricsProviderInfo(id); - return _EnabledProviderItem( - key: ValueKey(id), - providerId: id, - info: info, - index: index, - isFirst: index == 0, - onToggle: () => _disableProvider(id), - ); - }, - onReorder: (oldIndex, newIndex) { - setState(() { - if (newIndex > oldIndex) newIndex -= 1; - final item = _enabledProviders.removeAt(oldIndex); - _enabledProviders.insert(newIndex, item); - }); - _markChanged(); - }, - ), - ), - - // ── Disabled section header ── - if (disabled.isNotEmpty) - SliverToBoxAdapter( - child: SettingsSectionHeader( - title: 'Disabled (${disabled.length})', - ), - ), - - // ── Disabled list ── - if (disabled.isNotEmpty) - SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 16), - sliver: SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) { - final id = disabled[index]; - final info = _getLyricsProviderInfo(id); - return _DisabledProviderItem( - key: ValueKey(id), - providerId: id, - info: info, - onToggle: () => _enableProvider(id), - ); - }, - childCount: disabled.length, - ), - ), - ), - - // ── Info banner ── - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(16), - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: - colorScheme.tertiaryContainer.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(12), - ), - child: Row( - children: [ - Icon(Icons.info_outline, - size: 20, color: colorScheme.tertiary), - const SizedBox(width: 12), - Expanded( - child: Text( - 'Extension lyrics providers always run before ' - 'built-in providers. At least one provider must ' - 'remain enabled.', - style: - Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onTertiaryContainer, - ), - ), - ), - ], - ), - ), - ), + ), + if (disabled.isNotEmpty) + SliverToBoxAdapter( + child: SettingsSectionHeader( + title: 'Disabled (${disabled.length})', ), - - const SliverToBoxAdapter(child: SizedBox(height: 32)), - ], - ), - ), + ), + if (disabled.isNotEmpty) + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + final id = disabled[index]; + final info = _getLyricsProviderInfo(id); + return _DisabledProviderItem( + key: ValueKey(id), + providerId: id, + info: info, + onToggle: () => _enableProvider(id), + ); + }, childCount: disabled.length), + ), + ), + ], ); } @@ -282,8 +164,7 @@ class _LyricsProviderPriorityPageState context: context, builder: (context) => AlertDialog( title: const Text('Discard changes?'), - content: - const Text('You have unsaved changes that will be lost.'), + content: const Text('You have unsaved changes that will be lost.'), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), @@ -303,6 +184,12 @@ class _LyricsProviderPriorityPageState static _LyricsProviderInfo _getLyricsProviderInfo(String id) { switch (id) { + case 'spotify_api': + return _LyricsProviderInfo( + name: 'Spotify Lyrics API', + description: 'Spotify-sourced synced lyrics via community API', + icon: Icons.music_note_outlined, + ); case 'lrclib': return _LyricsProviderInfo( name: 'LRCLIB', @@ -419,17 +306,15 @@ class _EnabledProviderItem extends StatelessWidget { children: [ Text( info.name, - style: - Theme.of(context).textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.w500, - ), + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + ), ), Text( info.description, - style: - Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), ), ], ), @@ -438,18 +323,12 @@ class _EnabledProviderItem extends StatelessWidget { SizedBox( height: 32, child: FittedBox( - child: Switch( - value: true, - onChanged: (_) => onToggle(), - ), + child: Switch(value: true, onChanged: (_) => onToggle()), ), ), const SizedBox(width: 4), // Drag handle - Icon( - Icons.drag_handle, - color: colorScheme.onSurfaceVariant, - ), + Icon(Icons.drag_handle, color: colorScheme.onSurfaceVariant), ], ), ), @@ -498,8 +377,7 @@ class _DisabledProviderItem extends StatelessWidget { borderRadius: BorderRadius.circular(16), onTap: onToggle, child: Padding( - padding: - const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( children: [ // Empty space aligned with numbered badge @@ -515,9 +393,7 @@ class _DisabledProviderItem extends StatelessWidget { children: [ Text( info.name, - style: Theme.of(context) - .textTheme - .bodyLarge + style: Theme.of(context).textTheme.bodyLarge ?.copyWith( fontWeight: FontWeight.w500, color: colorScheme.onSurfaceVariant, @@ -525,12 +401,8 @@ class _DisabledProviderItem extends StatelessWidget { ), Text( info.description, - style: Theme.of(context) - .textTheme - .bodySmall - ?.copyWith( - color: colorScheme.outline, - ), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: colorScheme.outline), ), ], ), @@ -539,10 +411,7 @@ class _DisabledProviderItem extends StatelessWidget { SizedBox( height: 32, child: FittedBox( - child: Switch( - value: false, - onChanged: (_) => onToggle(), - ), + child: Switch(value: false, onChanged: (_) => onToggle()), ), ), ], diff --git a/lib/screens/settings/metadata_provider_priority_page.dart b/lib/screens/settings/metadata_provider_priority_page.dart index 51a78e4e..7631e27d 100644 --- a/lib/screens/settings/metadata_provider_priority_page.dart +++ b/lib/screens/settings/metadata_provider_priority_page.dart @@ -2,16 +2,18 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; -import 'package:spotiflac_android/utils/app_bar_layout.dart'; +import 'package:spotiflac_android/widgets/priority_settings_scaffold.dart'; class MetadataProviderPriorityPage extends ConsumerStatefulWidget { const MetadataProviderPriorityPage({super.key}); @override - ConsumerState createState() => _MetadataProviderPriorityPageState(); + ConsumerState createState() => + _MetadataProviderPriorityPageState(); } -class _MetadataProviderPriorityPageState extends ConsumerState { +class _MetadataProviderPriorityPageState + extends ConsumerState { late List _providers; bool _hasChanges = false; @@ -23,8 +25,10 @@ class _MetadataProviderPriorityPageState extends ConsumerState oldIndex) { - newIndex -= 1; - } - final item = _providers.removeAt(oldIndex); - _providers.insert(newIndex, item); - _hasChanges = true; - }); - }, - ), - ), - - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(16), - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: colorScheme.tertiaryContainer.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(12), - ), - child: Row( - children: [ - Icon(Icons.info_outline, size: 20, color: colorScheme.tertiary), - const SizedBox(width: 12), - Expanded( - child: Text( - context.l10n.metadataProviderPriorityInfo, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onTertiaryContainer, - ), - ), - ), - ], - ), - ), - ), - ), - - const SliverToBoxAdapter(child: SizedBox(height: 32)), - ], + return PrioritySettingsScaffold( + hasChanges: _hasChanges, + title: context.l10n.metadataProviderPriorityTitle, + description: context.l10n.metadataProviderPriorityDescription, + descriptionPadding: const EdgeInsets.all(16), + infoText: context.l10n.metadataProviderPriorityInfo, + saveLabel: context.l10n.dialogSave, + onSave: _saveChanges, + onConfirmDiscard: _confirmDiscard, + slivers: [ + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverReorderableList( + itemCount: _providers.length, + itemBuilder: (context, index) { + final provider = _providers[index]; + return _MetadataProviderItem( + key: ValueKey(provider), + provider: provider, + index: index, + isFirst: index == 0, + isLast: index == _providers.length - 1, + ); + }, + onReorder: (oldIndex, newIndex) { + setState(() { + if (newIndex > oldIndex) { + newIndex -= 1; + } + final item = _providers.removeAt(oldIndex); + _providers.insert(newIndex, item); + _hasChanges = true; + }); + }, + ), ), - ), + ], ); } @@ -201,7 +106,9 @@ class _MetadataProviderPriorityPageState extends ConsumerState _saveChanges() async { - await ref.read(extensionProvider.notifier).setMetadataProviderPriority(_providers); + await ref + .read(extensionProvider.notifier) + .setMetadataProviderPriority(_providers); setState(() { _hasChanges = false; }); @@ -300,10 +207,7 @@ class _MetadataProviderItem extends StatelessWidget { ], ), ), - Icon( - Icons.drag_handle, - color: colorScheme.onSurfaceVariant, - ), + Icon(Icons.drag_handle, color: colorScheme.onSurfaceVariant), ], ), ), @@ -312,7 +216,10 @@ class _MetadataProviderItem extends StatelessWidget { ); } - _MetadataProviderInfo _getProviderInfo(BuildContext context, String provider) { + _MetadataProviderInfo _getProviderInfo( + BuildContext context, + String provider, + ) { switch (provider) { case 'deezer': return _MetadataProviderInfo( diff --git a/lib/screens/settings/options_settings_page.dart b/lib/screens/settings/options_settings_page.dart index 013bc6e0..a5a6e07c 100644 --- a/lib/screens/settings/options_settings_page.dart +++ b/lib/screens/settings/options_settings_page.dart @@ -164,11 +164,24 @@ class OptionsSettingsPage extends ConsumerWidget { .read(settingsProvider.notifier) .setUseExtensionProviders(v), ), + SettingsSwitchItem( + icon: Icons.sell_outlined, + title: 'Embed Metadata', + subtitle: settings.embedMetadata + ? 'Write metadata, cover art, and embedded lyrics to files' + : 'Disabled (advanced): skip all metadata embedding', + value: settings.embedMetadata, + onChanged: (v) => + ref.read(settingsProvider.notifier).setEmbedMetadata(v), + ), SettingsSwitchItem( icon: Icons.image, title: context.l10n.optionsMaxQualityCover, - subtitle: context.l10n.optionsMaxQualityCoverSubtitle, + subtitle: settings.embedMetadata + ? context.l10n.optionsMaxQualityCoverSubtitle + : 'Disabled when metadata embedding is off', value: settings.maxQualityCover, + enabled: settings.embedMetadata, onChanged: (v) => ref .read(settingsProvider.notifier) .setMaxQualityCover(v), @@ -375,6 +388,7 @@ class OptionsSettingsPage extends ConsumerWidget { showModalBottomSheet( context: context, + useRootNavigator: true, isScrollControlled: true, backgroundColor: colorScheme.surface, shape: const RoundedRectangleBorder( @@ -972,9 +986,9 @@ class _MetadataSourceSelector extends ConsumerWidget { Expanded( child: Text( context.l10n.optionsSpotifyDeprecationWarning, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.error, - ), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: colorScheme.error), ), ), ], diff --git a/lib/screens/settings/provider_priority_page.dart b/lib/screens/settings/provider_priority_page.dart index b20ef45d..ed44ca46 100644 --- a/lib/screens/settings/provider_priority_page.dart +++ b/lib/screens/settings/provider_priority_page.dart @@ -8,7 +8,8 @@ class ProviderPriorityPage extends ConsumerStatefulWidget { const ProviderPriorityPage({super.key}); @override - ConsumerState createState() => _ProviderPriorityPageState(); + ConsumerState createState() => + _ProviderPriorityPageState(); } class _ProviderPriorityPageState extends ConsumerState { @@ -23,8 +24,10 @@ class _ProviderPriorityPageState extends ConsumerState { void _loadProviders() { final extState = ref.read(extensionProvider); - final allProviders = ref.read(extensionProvider.notifier).getAllDownloadProviders(); - + final allProviders = ref + .read(extensionProvider.notifier) + .getAllDownloadProviders(); + if (extState.providerPriority.isNotEmpty) { _providers = List.from(extState.providerPriority); for (final provider in allProviders) { @@ -86,13 +89,17 @@ class _ProviderPriorityPageState extends ConsumerState { builder: (context, constraints) { final maxHeight = 120 + topPadding; final minHeight = kToolbarHeight + topPadding; - final expandRatio = ((constraints.maxHeight - minHeight) / - (maxHeight - minHeight)) - .clamp(0.0, 1.0); + final expandRatio = + ((constraints.maxHeight - minHeight) / + (maxHeight - minHeight)) + .clamp(0.0, 1.0); final leftPadding = 56 - (32 * expandRatio); return FlexibleSpaceBar( expandedTitleScale: 1.0, - titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16), + titlePadding: EdgeInsets.only( + left: leftPadding, + bottom: 16, + ), title: Text( context.l10n.providerPriorityTitle, style: TextStyle( @@ -156,14 +163,19 @@ class _ProviderPriorityPageState extends ConsumerState { ), child: Row( children: [ - Icon(Icons.info_outline, size: 20, color: colorScheme.tertiary), + Icon( + Icons.info_outline, + size: 20, + color: colorScheme.tertiary, + ), const SizedBox(width: 12), Expanded( child: Text( context.l10n.providerPriorityInfo, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onTertiaryContainer, - ), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: colorScheme.onTertiaryContainer, + ), ), ), ], @@ -292,7 +304,9 @@ class _ProviderItem extends StatelessWidget { ), ), Text( - info.isBuiltIn ? context.l10n.providerBuiltIn : context.l10n.providerExtension, + info.isBuiltIn + ? context.l10n.providerBuiltIn + : context.l10n.providerExtension, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, ), @@ -300,10 +314,7 @@ class _ProviderItem extends StatelessWidget { ], ), ), - Icon( - Icons.drag_handle, - color: colorScheme.onSurfaceVariant, - ), + Icon(Icons.drag_handle, color: colorScheme.onSurfaceVariant), ], ), ), @@ -321,17 +332,19 @@ class _ProviderItem extends StatelessWidget { isBuiltIn: true, ); case 'qobuz': - return _ProviderInfo( - name: 'Qobuz', - icon: Icons.album, - isBuiltIn: true, - ); + return _ProviderInfo(name: 'Qobuz', icon: Icons.album, isBuiltIn: true); case 'amazon': return _ProviderInfo( name: 'Amazon Music', icon: Icons.shopping_bag, isBuiltIn: true, ); + case 'youtube': + return _ProviderInfo( + name: 'YouTube', + icon: Icons.play_circle_outline, + isBuiltIn: true, + ); default: return _ProviderInfo( name: provider, diff --git a/lib/screens/setup_screen.dart b/lib/screens/setup_screen.dart index e32204d0..ede9017d 100644 --- a/lib/screens/setup_screen.dart +++ b/lib/screens/setup_screen.dart @@ -30,14 +30,7 @@ class _SetupScreenState extends ConsumerState { bool _isLoading = false; int _androidSdkVersion = 0; - // Spotify form - final _clientIdController = TextEditingController(); - final _clientSecretController = TextEditingController(); - bool _useSpotifyApi = false; - bool _showClientSecret = false; - - // We add 1 for the Welcome step - int get _totalSteps => (_androidSdkVersion >= 33 ? 4 : 3) + 1; + int get _totalSteps => _androidSdkVersion >= 33 ? 4 : 3; @override void initState() { @@ -48,8 +41,6 @@ class _SetupScreenState extends ConsumerState { @override void dispose() { _pageController.dispose(); - _clientIdController.dispose(); - _clientSecretController.dispose(); super.dispose(); } @@ -291,6 +282,7 @@ class _SetupScreenState extends ConsumerState { final colorScheme = Theme.of(context).colorScheme; await showModalBottomSheet( context: context, + useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(28)), @@ -339,8 +331,13 @@ class _SetupScreenState extends ConsumerState { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(validation.errorReason ?? 'Invalid folder selected'), - backgroundColor: Theme.of(context).colorScheme.error, + content: Text( + validation.errorReason ?? + 'Invalid folder selected', + ), + backgroundColor: Theme.of( + context, + ).colorScheme.error, duration: const Duration(seconds: 4), ), ); @@ -402,20 +399,7 @@ class _SetupScreenState extends ConsumerState { ); } - if (_useSpotifyApi && - _clientIdController.text.trim().isNotEmpty && - _clientSecretController.text.trim().isNotEmpty) { - ref - .read(settingsProvider.notifier) - .setSpotifyCredentials( - _clientIdController.text.trim(), - _clientSecretController.text.trim(), - ); - ref.read(settingsProvider.notifier).setMetadataSource('spotify'); - } else { - ref.read(settingsProvider.notifier).setMetadataSource('deezer'); - } - + ref.read(settingsProvider.notifier).setMetadataSource('deezer'); ref.read(settingsProvider.notifier).setFirstLaunchComplete(); if (mounted) context.go('/tutorial'); @@ -474,8 +458,6 @@ class _SetupScreenState extends ConsumerState { return _notificationPermissionGranted; case 2: return _selectedDirectory != null; - case 3: - return false; // Spotify is last/submit } } else { switch (logicStep) { @@ -483,8 +465,6 @@ class _SetupScreenState extends ConsumerState { return _storagePermissionGranted; case 1: return _selectedDirectory != null; - case 2: - return false; // Spotify } } return false; @@ -561,7 +541,6 @@ class _SetupScreenState extends ConsumerState { if (_androidSdkVersion >= 33) _buildNotificationStep(colorScheme), _buildDirectoryStep(colorScheme), - _buildSpotifyStep(colorScheme), ], ), ), @@ -581,12 +560,7 @@ class _SetupScreenState extends ConsumerState { icon: const SizedBox.shrink(), // Custom layout ) : FloatingActionButton.extended( - onPressed: - (!_useSpotifyApi || - (_clientIdController.text.isNotEmpty && - _clientSecretController.text.isNotEmpty)) - ? _completeSetup - : null, + onPressed: _isLoading ? null : _completeSetup, label: _isLoading ? SizedBox( width: 20, @@ -760,112 +734,6 @@ class _SetupScreenState extends ConsumerState { ), ); } - - Widget _buildSpotifyStep(ColorScheme colorScheme) { - return SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Column( - children: [ - Icon(Icons.api, size: 48, color: colorScheme.primary), - const SizedBox(height: 24), - Text( - context.l10n.setupSpotifyApiOptional, - style: Theme.of( - context, - ).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold), - textAlign: TextAlign.center, - ), - const SizedBox(height: 8), - Text( - context.l10n.setupSpotifyApiDescription, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 32), - - Card( - elevation: 0, - color: colorScheme.surfaceContainerHighest, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - child: Column( - children: [ - SwitchListTile( - value: _useSpotifyApi, - onChanged: (v) => setState(() => _useSpotifyApi = v), - title: Text(context.l10n.setupUseSpotifyApi), - subtitle: Text( - _useSpotifyApi - ? context.l10n.setupEnterCredentialsBelow - : "Using bundled metadata", - ), - ), - if (_useSpotifyApi) ...[ - const Divider(), - Padding( - padding: const EdgeInsets.all(16), - child: Column( - children: [ - TextField( - controller: _clientIdController, - decoration: InputDecoration( - labelText: context.l10n.credentialsClientId, - prefixIcon: const Icon(Icons.key), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: colorScheme.outline, - width: 0.5, - ), - ), - ), - ), - const SizedBox(height: 16), - TextField( - controller: _clientSecretController, - obscureText: !_showClientSecret, - decoration: InputDecoration( - labelText: context.l10n.credentialsClientSecret, - prefixIcon: const Icon(Icons.lock), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: colorScheme.outline, - width: 0.5, - ), - ), - suffixIcon: IconButton( - icon: Icon( - _showClientSecret - ? Icons.visibility_off - : Icons.visibility, - ), - onPressed: () => setState( - () => _showClientSecret = !_showClientSecret, - ), - ), - ), - ), - ], - ), - ), - ], - ], - ), - ), - ], - ), - ); - } } class _StepLayout extends StatelessWidget { diff --git a/lib/screens/store_tab.dart b/lib/screens/store_tab.dart index d6689886..d8d83e7b 100644 --- a/lib/screens/store_tab.dart +++ b/lib/screens/store_tab.dart @@ -43,7 +43,30 @@ class _StoreTabState extends ConsumerState { @override Widget build(BuildContext context) { - final state = ref.watch(storeProvider); + final storeFilterState = ref.watch( + storeProvider.select( + (s) => (s.extensions, s.selectedCategory, s.searchQuery), + ), + ); + final extensions = storeFilterState.$1; + final selectedCategory = storeFilterState.$2; + final searchQuery = storeFilterState.$3; + final isLoading = ref.watch(storeProvider.select((s) => s.isLoading)); + final error = ref.watch(storeProvider.select((s) => s.error)); + final downloadingId = ref.watch( + storeProvider.select((s) => s.downloadingId), + ); + final filteredExtensions = StoreState( + extensions: extensions, + selectedCategory: selectedCategory, + searchQuery: searchQuery, + ).filteredExtensions; + if (_searchController.text != searchQuery) { + _searchController.value = TextEditingValue( + text: searchQuery, + selection: TextSelection.collapsed(offset: searchQuery.length), + ); + } final colorScheme = Theme.of(context).colorScheme; final topPadding = normalizedHeaderTopPadding(context); @@ -89,41 +112,46 @@ class _StoreTabState extends ConsumerState { SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), - child: TextField( - controller: _searchController, - decoration: InputDecoration( - hintText: context.l10n.storeSearch, - prefixIcon: const Icon(Icons.search), - suffixIcon: _searchController.text.isNotEmpty - ? IconButton( - icon: const Icon(Icons.clear), - onPressed: () { - _searchController.clear(); - ref - .read(storeProvider.notifier) - .setSearchQuery(''); - }, - ) - : null, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(28), - borderSide: BorderSide.none, - ), - filled: true, - fillColor: Theme.of(context).brightness == Brightness.dark - ? Color.alphaBlend( - Colors.white.withValues(alpha: 0.08), - colorScheme.surface, - ) - : colorScheme.surfaceContainerHighest, - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - ), - onChanged: (value) { - ref.read(storeProvider.notifier).setSearchQuery(value); - setState(() {}); + child: ValueListenableBuilder( + valueListenable: _searchController, + builder: (context, value, _) { + return TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: context.l10n.storeSearch, + prefixIcon: const Icon(Icons.search), + suffixIcon: value.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + ref + .read(storeProvider.notifier) + .setSearchQuery(''); + }, + ) + : null, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(28), + borderSide: BorderSide.none, + ), + filled: true, + fillColor: + Theme.of(context).brightness == Brightness.dark + ? Color.alphaBlend( + Colors.white.withValues(alpha: 0.08), + colorScheme.surface, + ) + : colorScheme.surfaceContainerHighest, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + ), + onChanged: (value) { + ref.read(storeProvider.notifier).setSearchQuery(value); + }, + ); }, ), ), @@ -141,7 +169,7 @@ class _StoreTabState extends ConsumerState { _CategoryChip( label: context.l10n.storeFilterAll, icon: Icons.apps, - isSelected: state.selectedCategory == null, + isSelected: selectedCategory == null, onTap: () => ref.read(storeProvider.notifier).setCategory(null), ), @@ -149,8 +177,7 @@ class _StoreTabState extends ConsumerState { _CategoryChip( label: context.l10n.storeFilterMetadata, icon: Icons.label_outline, - isSelected: - state.selectedCategory == StoreCategory.metadata, + isSelected: selectedCategory == StoreCategory.metadata, onTap: () => ref .read(storeProvider.notifier) .setCategory(StoreCategory.metadata), @@ -159,8 +186,7 @@ class _StoreTabState extends ConsumerState { _CategoryChip( label: context.l10n.storeFilterDownload, icon: Icons.download_outlined, - isSelected: - state.selectedCategory == StoreCategory.download, + isSelected: selectedCategory == StoreCategory.download, onTap: () => ref .read(storeProvider.notifier) .setCategory(StoreCategory.download), @@ -169,8 +195,7 @@ class _StoreTabState extends ConsumerState { _CategoryChip( label: context.l10n.storeFilterUtility, icon: Icons.build_outlined, - isSelected: - state.selectedCategory == StoreCategory.utility, + isSelected: selectedCategory == StoreCategory.utility, onTap: () => ref .read(storeProvider.notifier) .setCategory(StoreCategory.utility), @@ -179,8 +204,7 @@ class _StoreTabState extends ConsumerState { _CategoryChip( label: context.l10n.storeFilterLyrics, icon: Icons.lyrics_outlined, - isSelected: - state.selectedCategory == StoreCategory.lyrics, + isSelected: selectedCategory == StoreCategory.lyrics, onTap: () => ref .read(storeProvider.notifier) .setCategory(StoreCategory.lyrics), @@ -189,8 +213,7 @@ class _StoreTabState extends ConsumerState { _CategoryChip( label: context.l10n.storeFilterIntegration, icon: Icons.link, - isSelected: - state.selectedCategory == StoreCategory.integration, + isSelected: selectedCategory == StoreCategory.integration, onTap: () => ref .read(storeProvider.notifier) .setCategory(StoreCategory.integration), @@ -200,22 +223,26 @@ class _StoreTabState extends ConsumerState { ), ), - if (state.isLoading && state.extensions.isEmpty) + if (isLoading && extensions.isEmpty) const SliverFillRemaining( child: Center(child: CircularProgressIndicator()), ) - else if (state.error != null && state.extensions.isEmpty) + else if (error != null && extensions.isEmpty) + SliverFillRemaining(child: _buildErrorState(error, colorScheme)) + else if (filteredExtensions.isEmpty) SliverFillRemaining( - child: _buildErrorState(state.error!, colorScheme), + child: _buildEmptyState( + hasFilters: + searchQuery.isNotEmpty || selectedCategory != null, + colorScheme: colorScheme, + ), ) - else if (state.filteredExtensions.isEmpty) - SliverFillRemaining(child: _buildEmptyState(state, colorScheme)) else ...[ SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), child: Text( - '${state.filteredExtensions.length} ${state.filteredExtensions.length == 1 ? 'extension' : 'extensions'}', + '${filteredExtensions.length} ${filteredExtensions.length == 1 ? 'extension' : 'extensions'}', style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: colorScheme.onSurfaceVariant, ), @@ -227,16 +254,13 @@ class _StoreTabState extends ConsumerState { child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: SettingsGroup( - children: state.filteredExtensions.asMap().entries.map(( - entry, - ) { + children: filteredExtensions.asMap().entries.map((entry) { final index = entry.key; final ext = entry.value; return _ExtensionItem( extension: ext, - showDivider: - index < state.filteredExtensions.length - 1, - isDownloading: state.downloadingId == ext.id, + showDivider: index < filteredExtensions.length - 1, + isDownloading: downloadingId == ext.id, onInstall: () => _installExtension(ext), onUpdate: () => _updateExtension(ext), onTap: () => _showExtensionDetails(ext), @@ -288,10 +312,10 @@ class _StoreTabState extends ConsumerState { ); } - Widget _buildEmptyState(StoreState state, ColorScheme colorScheme) { - final hasFilters = - state.searchQuery.isNotEmpty || state.selectedCategory != null; - + Widget _buildEmptyState({ + required bool hasFilters, + required ColorScheme colorScheme, + }) { return Center( child: Column( mainAxisSize: MainAxisSize.min, @@ -541,7 +565,10 @@ class _ExtensionItem extends StatelessWidget { if (extension.requiresNewerApp) ...[ const SizedBox(height: 4), Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), decoration: BoxDecoration( color: colorScheme.errorContainer, borderRadius: BorderRadius.circular(4), @@ -549,14 +576,19 @@ class _ExtensionItem extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.warning_amber_rounded, size: 12, color: colorScheme.onErrorContainer), + Icon( + Icons.warning_amber_rounded, + size: 12, + color: colorScheme.onErrorContainer, + ), const SizedBox(width: 4), Text( 'Requires v${extension.minAppVersion}+', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: colorScheme.onErrorContainer, - fontWeight: FontWeight.w500, - ), + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: colorScheme.onErrorContainer, + fontWeight: FontWeight.w500, + ), ), ], ), @@ -565,9 +597,8 @@ class _ExtensionItem extends StatelessWidget { const SizedBox(height: 4), Text( extension.description, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: colorScheme.onSurfaceVariant), maxLines: 2, overflow: TextOverflow.ellipsis, ), diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index ca4f22b0..c34e7400 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -1,6 +1,5 @@ import 'dart:async'; import 'dart:io'; -import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -14,10 +13,12 @@ import 'package:path_provider/path_provider.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:share_plus/share_plus.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; +import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/ffmpeg_service.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/utils/logger.dart'; +import 'package:spotiflac_android/utils/string_utils.dart'; final _log = AppLogger('TrackMetadata'); @@ -52,6 +53,7 @@ class _TrackMetadataScreenState extends ConsumerState { _embeddedCoverPreviewCache = {}; bool _fileExists = false; + bool _hasCheckedFile = false; int? _fileSize; String? _lyrics; // Cleaned lyrics for display (no timestamps) String? _rawLyrics; // Raw LRC with timestamps for embedding @@ -76,6 +78,9 @@ class _TrackMetadataScreenState extends ConsumerState { ); static final RegExp _lrcSpeakerPrefixPattern = RegExp(r'^(v1|v2):\s*'); static final RegExp _lrcBackgroundLinePattern = RegExp(r'^\[bg:(.*)\]$'); + static final RegExp _invalidFileNameChars = RegExp(r'[<>:"/\\|?*\x00-\x1f]'); + static final RegExp _multiUnderscore = RegExp(r'_+'); + static final RegExp _leadingOrTrailingDots = RegExp(r'^\.+|\.+$'); static const List _months = [ 'Jan', 'Feb', @@ -178,14 +183,6 @@ class _TrackMetadataScreenState extends ConsumerState { return cached.previewPath; } - String? _normalizeOptionalString(String? value) { - if (value == null) return null; - final trimmed = value.trim(); - if (trimmed.isEmpty) return null; - if (trimmed.toLowerCase() == 'null') return null; - return trimmed; - } - @override void initState() { super.initState(); @@ -202,12 +199,19 @@ class _TrackMetadataScreenState extends ConsumerState { } void _onScroll() { - final shouldShow = _scrollController.offset > 280; + final expandedHeight = _calculateExpandedHeight(context); + final shouldShow = + _scrollController.offset > (expandedHeight - kToolbarHeight - 20); if (shouldShow != _showTitleInAppBar) { setState(() => _showTitleInAppBar = shouldShow); } } + double _calculateExpandedHeight(BuildContext context) { + final mediaSize = MediaQuery.of(context).size; + return (mediaSize.height * 0.55).clamp(360.0, 520.0); + } + Future _checkFile() async { var filePath = _filePath; if (filePath.startsWith('EXISTS:')) { @@ -224,10 +228,12 @@ class _TrackMetadataScreenState extends ConsumerState { } } catch (_) {} - if (mounted && (exists != _fileExists || size != _fileSize)) { + if (mounted && + (exists != _fileExists || size != _fileSize || !_hasCheckedFile)) { setState(() { _fileExists = exists; _fileSize = size; + _hasCheckedFile = true; }); } @@ -371,7 +377,7 @@ class _TrackMetadataScreenState extends ConsumerState { String? get albumArtist { final edited = _editedMetadata?['album_artist']?.toString(); if (edited != null && edited.isNotEmpty) return edited; - return _normalizeOptionalString( + return normalizeOptionalString( _isLocalItem ? _localLibraryItem!.albumArtist : _downloadItem!.albumArtist, @@ -506,19 +512,17 @@ class _TrackMetadataScreenState extends ConsumerState { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - final screenWidth = MediaQuery.of(context).size.width; - final coverSize = screenWidth * 0.5; + final expandedHeight = _calculateExpandedHeight(context); return Scaffold( body: CustomScrollView( controller: _scrollController, slivers: [ SliverAppBar( - expandedHeight: 320, + expandedHeight: expandedHeight, pinned: true, stretch: true, - backgroundColor: - colorScheme.surface, // Use theme color for collapsed state + backgroundColor: colorScheme.surface, surfaceTintColor: Colors.transparent, title: AnimatedOpacity( duration: const Duration(milliseconds: 200), @@ -538,21 +542,18 @@ class _TrackMetadataScreenState extends ConsumerState { builder: (context, constraints) { final collapseRatio = (constraints.maxHeight - kToolbarHeight) / - (320 - kToolbarHeight); + (expandedHeight - kToolbarHeight); final showContent = collapseRatio > 0.3; return FlexibleSpaceBar( - collapseMode: CollapseMode.none, + collapseMode: CollapseMode.pin, background: _buildHeaderBackground( context, colorScheme, - coverSize, + expandedHeight, showContent, ), - stretchModes: const [ - StretchMode.zoomBackground, - StretchMode.blurBackground, - ], + stretchModes: const [StretchMode.zoomBackground], ); }, ), @@ -560,10 +561,10 @@ class _TrackMetadataScreenState extends ConsumerState { icon: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: colorScheme.surface.withValues(alpha: 0.8), + color: Colors.black.withValues(alpha: 0.4), shape: BoxShape.circle, ), - child: Icon(Icons.arrow_back, color: colorScheme.onSurface), + child: const Icon(Icons.arrow_back, color: Colors.white), ), onPressed: _popWithMetadataResult, ), @@ -572,10 +573,10 @@ class _TrackMetadataScreenState extends ConsumerState { icon: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: colorScheme.surface.withValues(alpha: 0.8), + color: Colors.black.withValues(alpha: 0.4), shape: BoxShape.circle, ), - child: Icon(Icons.more_vert, color: colorScheme.onSurface), + child: const Icon(Icons.more_vert, color: Colors.white), ), onPressed: () => _showOptionsMenu(context, ref, colorScheme), ), @@ -588,10 +589,6 @@ class _TrackMetadataScreenState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildTrackInfoCard(context, colorScheme, _fileExists), - - const SizedBox(height: 16), - _buildMetadataCard(context, colorScheme, _fileSize), const SizedBox(height: 16), @@ -624,34 +621,23 @@ class _TrackMetadataScreenState extends ConsumerState { Widget _buildHeaderBackground( BuildContext context, ColorScheme colorScheme, - double coverSize, + double expandedHeight, bool showContent, ) { - final screenSize = MediaQuery.sizeOf(context); - final pixelRatio = MediaQuery.devicePixelRatioOf(context); - final backgroundCacheWidth = (screenSize.width * pixelRatio).round(); - final backgroundCacheHeight = (screenSize.height * 0.65 * pixelRatio) - .round(); - final coverCacheSize = (coverSize * pixelRatio).round(); - return Stack( fit: StackFit.expand, children: [ - // Blurred cover art background + // Full-screen cover background if (_hasPath(_embeddedCoverPreviewPath)) Image.file( File(_embeddedCoverPreviewPath!), fit: BoxFit.cover, - cacheWidth: backgroundCacheWidth, - cacheHeight: backgroundCacheHeight, errorBuilder: (_, _, _) => Container(color: colorScheme.surface), ) else if (_coverUrl != null) CachedNetworkImage( imageUrl: _coverUrl!, fit: BoxFit.cover, - memCacheWidth: backgroundCacheWidth, - memCacheHeight: backgroundCacheHeight, cacheManager: CoverCacheManager.instance, placeholder: (_, _) => Container(color: colorScheme.surface), errorWidget: (_, _, _) => Container(color: colorScheme.surface), @@ -660,113 +646,206 @@ class _TrackMetadataScreenState extends ConsumerState { Image.file( File(_localCoverPath!), fit: BoxFit.cover, - cacheWidth: backgroundCacheWidth, - cacheHeight: backgroundCacheHeight, errorBuilder: (_, _, _) => Container(color: colorScheme.surface), ) else - Container(color: colorScheme.surface), - - // Blur filter - ClipRect( - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30), - child: Container(color: colorScheme.surface.withValues(alpha: 0.4)), + Container( + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.music_note, + size: 80, + color: colorScheme.onSurfaceVariant, + ), ), - ), - - // Bottom fade to surface + // Bottom gradient for readability Positioned( left: 0, right: 0, bottom: 0, - height: 80, + height: expandedHeight * 0.65, child: Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ - colorScheme.surface.withValues(alpha: 0.0), - colorScheme.surface, + Colors.transparent, + Colors.black.withValues(alpha: 0.85), ], ), ), ), ), - - // Cover art - AnimatedOpacity( - duration: const Duration(milliseconds: 150), - opacity: showContent ? 1.0 : 0.0, - child: Center( - child: Padding( - padding: const EdgeInsets.only(top: 60), - child: Hero( - tag: 'cover_$_itemId', - child: Container( - width: coverSize, - height: coverSize, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.4), - blurRadius: 30, - offset: const Offset(0, 15), - ), - ], - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(20), - child: _hasPath(_embeddedCoverPreviewPath) - ? Image.file( - File(_embeddedCoverPreviewPath!), - fit: BoxFit.cover, - cacheWidth: coverCacheSize, - cacheHeight: coverCacheSize, - errorBuilder: (_, _, _) => Container( - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.music_note, - size: 64, - color: colorScheme.onSurfaceVariant, - ), - ), - ) - : _coverUrl != null - ? CachedNetworkImage( - imageUrl: _coverUrl!, - fit: BoxFit.cover, - memCacheWidth: (coverSize * 2).toInt(), - cacheManager: CoverCacheManager.instance, - placeholder: (_, _) => Container( - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.music_note, - size: 64, - color: colorScheme.onSurfaceVariant, - ), - ), - ) - : _localCoverPath != null && _localCoverPath!.isNotEmpty - ? Image.file( - File(_localCoverPath!), - fit: BoxFit.cover, - cacheWidth: coverCacheSize, - cacheHeight: coverCacheSize, - ) - : Container( - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.music_note, - size: 64, - color: colorScheme.onSurfaceVariant, - ), - ), + // Track info overlay at bottom + Positioned( + left: 20, + right: 20, + bottom: 40, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 150), + opacity: showContent ? 1.0 : 0.0, + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + trackName, + style: const TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.bold, + height: 1.2, ), + textAlign: TextAlign.center, + maxLines: 3, + overflow: TextOverflow.ellipsis, ), - ), + const SizedBox(height: 6), + Text( + artistName, + style: const TextStyle( + color: Colors.white70, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + albumName, + style: const TextStyle(color: Colors.white54, fontSize: 14), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 12), + Wrap( + alignment: WrapAlignment.center, + spacing: 8, + runSpacing: 8, + children: [ + if (_quality != null && _quality!.isNotEmpty) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + _quality!, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 12, + ), + ), + ), + if (duration != null) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + _formatDuration(duration!), + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 12, + ), + ), + ), + if (_service != 'local') + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + _service[0].toUpperCase() + _service.substring(1), + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 12, + ), + ), + ) + else + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.folder, + size: 14, + color: Colors.white, + ), + const SizedBox(width: 4), + const Text( + 'Local', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 12, + ), + ), + ], + ), + ), + if (_hasCheckedFile && !_fileExists) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.warning_rounded, + size: 14, + color: Colors.white, + ), + const SizedBox(width: 4), + Text( + context.l10n.trackFileNotFound, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 12, + ), + ), + ], + ), + ), + ], + ), + ], ), ), ), @@ -774,94 +853,6 @@ class _TrackMetadataScreenState extends ConsumerState { ); } - Widget _buildTrackInfoCard( - BuildContext context, - ColorScheme colorScheme, - bool fileExists, - ) { - return Card( - elevation: 0, - color: colorScheme.surfaceContainerLow, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - trackName, - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.bold, - color: colorScheme.onSurface, - ), - ), - const SizedBox(height: 4), - - Text( - artistName, - style: Theme.of( - context, - ).textTheme.titleMedium?.copyWith(color: colorScheme.primary), - ), - const SizedBox(height: 8), - - Row( - children: [ - Icon( - Icons.album, - size: 16, - color: colorScheme.onSurfaceVariant, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - albumName, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ), - ], - ), - - if (!fileExists) ...[ - const SizedBox(height: 12), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: colorScheme.errorContainer, - borderRadius: BorderRadius.circular(20), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.warning_rounded, - size: 16, - color: colorScheme.onErrorContainer, - ), - const SizedBox(width: 6), - Text( - context.l10n.trackFileNotFound, - style: TextStyle( - color: colorScheme.onErrorContainer, - fontSize: 12, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ), - ], - ], - ), - ), - ); - } - Widget _buildMetadataCard( BuildContext context, ColorScheme colorScheme, @@ -1722,9 +1713,19 @@ class _TrackMetadataScreenState extends ConsumerState { } } + String _sanitizeFileNameSegment(String value) { + var sanitized = value.replaceAll(_invalidFileNameChars, '_').trim(); + sanitized = sanitized.replaceAll(_leadingOrTrailingDots, ''); + sanitized = sanitized.replaceAll(_multiUnderscore, '_'); + if (sanitized.isEmpty) { + return 'untitled'; + } + return sanitized; + } + String _buildSaveBaseName() { - final artist = artistName.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_'); - final track = trackName.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_'); + final artist = _sanitizeFileNameSegment(artistName); + final track = _sanitizeFileNameSegment(trackName); return '$artist - $track'; } @@ -2336,6 +2337,7 @@ class _TrackMetadataScreenState extends ConsumerState { ) { showModalBottomSheet( context: context, + useRootNavigator: true, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), @@ -2566,6 +2568,7 @@ class _TrackMetadataScreenState extends ConsumerState { showModalBottomSheet( context: context, + useRootNavigator: true, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), @@ -3003,6 +3006,7 @@ class _TrackMetadataScreenState extends ConsumerState { final saved = await showModalBottomSheet( context: context, + useRootNavigator: true, isScrollControlled: true, backgroundColor: colorScheme.surface, shape: const RoundedRectangleBorder( @@ -3039,6 +3043,7 @@ class _TrackMetadataScreenState extends ConsumerState { ) { showDialog( context: context, + useRootNavigator: false, builder: (context) => AlertDialog( title: Text(context.l10n.trackDeleteConfirmTitle), content: Text(context.l10n.trackDeleteConfirmMessage), @@ -3088,7 +3093,15 @@ class _TrackMetadataScreenState extends ConsumerState { Future _openFile(BuildContext context, String filePath) async { try { - await openFile(filePath); + await ref + .read(playbackProvider.notifier) + .playLocalPath( + path: filePath, + title: trackName, + artist: artistName, + album: albumName, + coverUrl: _coverUrl ?? '', + ); } catch (e) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( diff --git a/lib/services/app_state_database.dart b/lib/services/app_state_database.dart new file mode 100644 index 00000000..92ca49f8 --- /dev/null +++ b/lib/services/app_state_database.dart @@ -0,0 +1,313 @@ +import 'dart:convert'; + +import 'package:path/path.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sqflite/sqflite.dart'; +import 'package:spotiflac_android/utils/logger.dart'; + +final _log = AppLogger('AppStateDb'); + +const _dbFileName = 'app_state.db'; +const _dbVersion = 1; + +const _queueTable = 'download_queue_items'; +const _recentTable = 'recent_access_items'; +const _hiddenRecentTable = 'hidden_recent_downloads'; + +const _legacyQueueKey = 'download_queue'; +const _legacyRecentAccessKey = 'recent_access_history'; +const _legacyHiddenDownloadsKey = 'hidden_downloads_in_recents'; + +const _queueMigrationKey = 'app_state_migrated_queue_to_sqlite_v1'; +const _recentMigrationKey = 'app_state_migrated_recent_to_sqlite_v1'; + +class AppStateDatabase { + static final AppStateDatabase instance = AppStateDatabase._init(); + static Database? _database; + + final Future _prefs = SharedPreferences.getInstance(); + + AppStateDatabase._init(); + + Future get database async { + if (_database != null) return _database!; + _database = await _initDb(); + return _database!; + } + + Future _initDb() async { + final dbPath = await getApplicationDocumentsDirectory(); + final path = join(dbPath.path, _dbFileName); + + _log.i('Initializing app state database at: $path'); + + return openDatabase( + path, + version: _dbVersion, + onConfigure: (db) async { + await db.rawQuery('PRAGMA journal_mode = WAL'); + await db.execute('PRAGMA synchronous = NORMAL'); + }, + onCreate: _createDb, + onUpgrade: _upgradeDb, + ); + } + + Future _createDb(Database db, int version) async { + _log.i('Creating app state database schema v$version'); + + await db.execute(''' + CREATE TABLE $_queueTable ( + id TEXT PRIMARY KEY, + item_json TEXT NOT NULL, + status TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + '''); + await db.execute( + 'CREATE INDEX idx_${_queueTable}_status ON $_queueTable(status)', + ); + await db.execute( + 'CREATE INDEX idx_${_queueTable}_created ON $_queueTable(created_at ASC)', + ); + + await db.execute(''' + CREATE TABLE $_recentTable ( + unique_key TEXT PRIMARY KEY, + item_json TEXT NOT NULL, + accessed_at TEXT NOT NULL + ) + '''); + await db.execute( + 'CREATE INDEX idx_${_recentTable}_accessed ON $_recentTable(accessed_at DESC)', + ); + + await db.execute(''' + CREATE TABLE $_hiddenRecentTable ( + download_id TEXT PRIMARY KEY, + updated_at TEXT NOT NULL + ) + '''); + } + + Future _upgradeDb(Database db, int oldVersion, int newVersion) async { + _log.i('Upgrading app state database from v$oldVersion to v$newVersion'); + } + + Future migrateQueueFromSharedPreferences() async { + final prefs = await _prefs; + if (prefs.getBool(_queueMigrationKey) == true) { + return false; + } + + final raw = prefs.getString(_legacyQueueKey); + if (raw == null || raw.isEmpty) { + await prefs.setBool(_queueMigrationKey, true); + return false; + } + + try { + final decoded = jsonDecode(raw); + if (decoded is! List) { + await prefs.setBool(_queueMigrationKey, true); + return false; + } + + final nowIso = DateTime.now().toIso8601String(); + final db = await database; + await db.transaction((txn) async { + final batch = txn.batch(); + for (final entry in decoded.whereType()) { + final map = Map.from(entry); + final id = map['id'] as String?; + if (id == null || id.isEmpty) continue; + + final status = map['status'] as String? ?? 'queued'; + if (status != 'queued' && status != 'downloading') { + continue; + } + + if (status == 'downloading') { + map['status'] = 'queued'; + map['progress'] = 0.0; + map['speedMBps'] = 0.0; + map['bytesReceived'] = 0; + } + + final createdAt = map['createdAt'] as String? ?? nowIso; + batch.insert(_queueTable, { + 'id': id, + 'item_json': jsonEncode(map), + 'status': 'queued', + 'created_at': createdAt, + 'updated_at': nowIso, + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + await batch.commit(noResult: true); + }); + + await prefs.setBool(_queueMigrationKey, true); + _log.i('Migrated legacy queue data to SQLite'); + return true; + } catch (e, stack) { + _log.e('Failed queue migration to SQLite: $e', e, stack); + return false; + } + } + + Future migrateRecentAccessFromSharedPreferences() async { + final prefs = await _prefs; + if (prefs.getBool(_recentMigrationKey) == true) { + return false; + } + + final rawRecent = prefs.getString(_legacyRecentAccessKey); + final hiddenIds = prefs.getStringList(_legacyHiddenDownloadsKey); + if ((rawRecent == null || rawRecent.isEmpty) && + (hiddenIds == null || hiddenIds.isEmpty)) { + await prefs.setBool(_recentMigrationKey, true); + return false; + } + + try { + final nowIso = DateTime.now().toIso8601String(); + final db = await database; + await db.transaction((txn) async { + if (rawRecent != null && rawRecent.isNotEmpty) { + final decoded = jsonDecode(rawRecent); + if (decoded is List) { + final batch = txn.batch(); + for (final entry in decoded.whereType()) { + final map = Map.from(entry); + final type = map['type'] as String?; + final id = map['id'] as String?; + final providerId = map['providerId'] as String?; + if (type == null || id == null || type.isEmpty || id.isEmpty) { + continue; + } + final uniqueKey = '$type:${providerId ?? 'default'}:$id'; + final accessedAt = map['accessedAt'] as String? ?? nowIso; + batch.insert(_recentTable, { + 'unique_key': uniqueKey, + 'item_json': jsonEncode(map), + 'accessed_at': accessedAt, + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + await batch.commit(noResult: true); + } + } + + if (hiddenIds != null && hiddenIds.isNotEmpty) { + final batch = txn.batch(); + for (final id in hiddenIds) { + if (id.isEmpty) continue; + batch.insert(_hiddenRecentTable, { + 'download_id': id, + 'updated_at': nowIso, + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + await batch.commit(noResult: true); + } + }); + + await prefs.setBool(_recentMigrationKey, true); + _log.i('Migrated legacy recent-access data to SQLite'); + return true; + } catch (e, stack) { + _log.e('Failed recent-access migration to SQLite: $e', e, stack); + return false; + } + } + + Future>> getPendingDownloadQueueRows() async { + final db = await database; + return db.query( + _queueTable, + where: 'status = ? OR status = ?', + whereArgs: ['queued', 'downloading'], + orderBy: 'created_at ASC, rowid ASC', + ); + } + + Future replacePendingDownloadQueueRows( + List> rows, + ) async { + final db = await database; + await db.transaction((txn) async { + await txn.delete(_queueTable); + if (rows.isEmpty) return; + + final batch = txn.batch(); + for (final row in rows) { + batch.insert( + _queueTable, + row, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + await batch.commit(noResult: true); + }); + } + + Future>> getRecentAccessRows({int? limit}) async { + final db = await database; + return db.query( + _recentTable, + orderBy: 'accessed_at DESC, rowid DESC', + limit: limit, + ); + } + + Future upsertRecentAccessRow({ + required String uniqueKey, + required String itemJson, + required String accessedAt, + }) async { + final db = await database; + await db.insert(_recentTable, { + 'unique_key': uniqueKey, + 'item_json': itemJson, + 'accessed_at': accessedAt, + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + + Future deleteRecentAccessRow(String uniqueKey) async { + final db = await database; + await db.delete( + _recentTable, + where: 'unique_key = ?', + whereArgs: [uniqueKey], + ); + } + + Future clearRecentAccessRows() async { + final db = await database; + await db.delete(_recentTable); + } + + Future> getHiddenRecentDownloadIds() async { + final db = await database; + final rows = await db.query(_hiddenRecentTable, columns: ['download_id']); + return rows + .map((row) => row['download_id'] as String?) + .whereType() + .toSet(); + } + + Future addHiddenRecentDownloadId(String downloadId) async { + final id = downloadId.trim(); + if (id.isEmpty) return; + final db = await database; + await db.insert(_hiddenRecentTable, { + 'download_id': id, + 'updated_at': DateTime.now().toIso8601String(), + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + + Future clearHiddenRecentDownloadIds() async { + final db = await database; + await db.delete(_hiddenRecentTable); + } +} diff --git a/lib/services/cover_cache_manager.dart b/lib/services/cover_cache_manager.dart index 007d8980..81cb6c06 100644 --- a/lib/services/cover_cache_manager.dart +++ b/lib/services/cover_cache_manager.dart @@ -1,5 +1,6 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; +import 'package:flutter/painting.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart' as p; @@ -41,17 +42,7 @@ class CoverCacheManager { debugPrint('CoverCacheManager: Initializing at $_cachePath'); - _instance = CacheManager( - Config( - _cacheKey, - stalePeriod: _maxCacheAge, - maxNrOfCacheObjects: _maxCacheObjects, - // Use path only (not databaseName) to store database in persistent directory - repo: JsonCacheInfoRepository(path: _cachePath), - fileSystem: IOFileSystem(_cachePath!), - fileService: HttpFileService(), - ), - ); + _instance = _createManager(_cachePath!); _initialized = true; debugPrint('CoverCacheManager: Initialized successfully'); @@ -62,12 +53,47 @@ class CoverCacheManager { } static Future clearCache() async { - if (!_initialized || _instance == null) return; - await _instance!.emptyCache(); + if (!_initialized || _instance == null || _cachePath == null) { + await initialize(); + } + + final instance = _instance; + final cachePath = _cachePath; + + if (instance == null || cachePath == null) return; + + // Ask cache manager to clear indexed entries first. + try { + await instance.emptyCache(); + } catch (e) { + debugPrint('CoverCacheManager: emptyCache failed, fallback to wipe: $e'); + } + + // Then wipe the directory to remove orphaned files/metadata leftovers. + await _wipeDirectory(cachePath); + + // Clear in-memory image cache so cleared covers are not retained in RAM. + final imageCache = PaintingBinding.instance.imageCache; + imageCache.clear(); + imageCache.clearLiveImages(); + + // Reset manager memory/index state after on-disk wipe. + instance.store.emptyMemoryCache(); + _instance = _createManager(cachePath); + _initialized = true; } static Future getStats() async { - if (!_initialized || _cachePath == null) { + if (_cachePath == null) { + try { + final appDir = await getApplicationSupportDirectory(); + _cachePath = p.join(appDir.path, 'cover_cache'); + } catch (_) { + return const CacheStats(fileCount: 0, totalSizeBytes: 0); + } + } + + if (_cachePath == null) { return const CacheStats(fileCount: 0, totalSizeBytes: 0); } @@ -93,6 +119,45 @@ class CoverCacheManager { return CacheStats(fileCount: fileCount, totalSizeBytes: totalSize); } + + static CacheManager _createManager(String cachePath) { + return CacheManager( + Config( + _cacheKey, + stalePeriod: _maxCacheAge, + maxNrOfCacheObjects: _maxCacheObjects, + // Use path only (not databaseName) to store database in persistent directory + repo: JsonCacheInfoRepository(path: cachePath), + fileSystem: IOFileSystem(cachePath), + fileService: HttpFileService(), + ), + ); + } + + static Future _wipeDirectory(String path) async { + final directory = Directory(path); + if (!await directory.exists()) { + await directory.create(recursive: true); + return; + } + + try { + final entities = []; + await for (final entity in directory.list(followLinks: false)) { + entities.add(entity); + } + + for (final entity in entities) { + try { + await entity.delete(recursive: true); + } catch (_) {} + } + } catch (_) {} + + try { + await directory.create(recursive: true); + } catch (_) {} + } } class CacheStats { diff --git a/lib/services/csv_import_service.dart b/lib/services/csv_import_service.dart index 5d77fa4e..6cb77e0d 100644 --- a/lib/services/csv_import_service.dart +++ b/lib/services/csv_import_service.dart @@ -106,6 +106,8 @@ class CsvImportService { artistName: trackData['artists'] as String? ?? track.artistName, albumName: trackData['album_name'] as String? ?? track.albumName, albumArtist: trackData['album_artist'] as String?, + artistId: trackData['artist_id']?.toString(), + albumId: trackData['album_id']?.toString(), coverUrl: coverUrl ?? track.coverUrl, isrc: trackData['isrc'] as String? ?? track.isrc, duration: durationMs > 0 ? durationMs ~/ 1000 : track.duration, diff --git a/lib/services/download_request_payload.dart b/lib/services/download_request_payload.dart index bff0a941..604e558e 100644 --- a/lib/services/download_request_payload.dart +++ b/lib/services/download_request_payload.dart @@ -10,6 +10,7 @@ class DownloadRequestPayload { final String outputDir; final String filenameFormat; final String quality; + final bool embedMetadata; final bool embedLyrics; final bool embedMaxQualityCover; final int trackNumber; @@ -33,6 +34,7 @@ class DownloadRequestPayload { final String safRelativeDir; final String safFileName; final String safOutputExt; + final String songLinkRegion; const DownloadRequestPayload({ this.isrc = '', @@ -46,6 +48,7 @@ class DownloadRequestPayload { required this.outputDir, required this.filenameFormat, this.quality = 'LOSSLESS', + this.embedMetadata = true, this.embedLyrics = true, this.embedMaxQualityCover = true, this.trackNumber = 1, @@ -69,6 +72,7 @@ class DownloadRequestPayload { this.safRelativeDir = '', this.safFileName = '', this.safOutputExt = '', + this.songLinkRegion = 'US', }); Map toJson() { @@ -84,6 +88,7 @@ class DownloadRequestPayload { 'output_dir': outputDir, 'filename_format': filenameFormat, 'quality': quality, + 'embed_metadata': embedMetadata, 'embed_lyrics': embedLyrics, 'embed_max_quality_cover': embedMaxQualityCover, 'track_number': trackNumber, @@ -107,6 +112,7 @@ class DownloadRequestPayload { 'saf_relative_dir': safRelativeDir, 'saf_file_name': safFileName, 'saf_output_ext': safOutputExt, + 'songlink_region': songLinkRegion, }; } @@ -126,6 +132,7 @@ class DownloadRequestPayload { outputDir: outputDir, filenameFormat: filenameFormat, quality: quality, + embedMetadata: embedMetadata, embedLyrics: embedLyrics, embedMaxQualityCover: embedMaxQualityCover, trackNumber: trackNumber, @@ -149,6 +156,7 @@ class DownloadRequestPayload { safRelativeDir: safRelativeDir, safFileName: safFileName, safOutputExt: safOutputExt, + songLinkRegion: songLinkRegion, ); } } diff --git a/lib/services/downloaded_embedded_cover_resolver.dart b/lib/services/downloaded_embedded_cover_resolver.dart index 9b613510..b7a20b44 100644 --- a/lib/services/downloaded_embedded_cover_resolver.dart +++ b/lib/services/downloaded_embedded_cover_resolver.dart @@ -25,6 +25,7 @@ class DownloadedEmbeddedCoverResolver { LinkedHashMap(); static final Set _pendingExtract = {}; static final Set _pendingRefresh = {}; + static final Set _pendingPreviewValidation = {}; static final Set _failedExtract = {}; static String cleanFilePath(String? filePath) { @@ -66,12 +67,9 @@ class DownloadedEmbeddedCoverResolver { final cached = _cache[cleanPath]; if (cached != null) { - if (File(cached.previewPath).existsSync()) { - _touch(cleanPath, cached); - return cached.previewPath; - } - _cache.remove(cleanPath); - _cleanupTempCoverPathSync(cached.previewPath); + _touch(cleanPath, cached); + _validateCachedPreviewAsync(cleanPath, cached, onChanged: onChanged); + return cached.previewPath; } return null; @@ -106,6 +104,7 @@ class DownloadedEmbeddedCoverResolver { final cached = _cache.remove(cleanPath); _pendingExtract.remove(cleanPath); _pendingRefresh.remove(cleanPath); + _pendingPreviewValidation.remove(cleanPath); _failedExtract.remove(cleanPath); if (cached != null) { _cleanupTempCoverPathSync(cached.previewPath); @@ -144,10 +143,36 @@ class DownloadedEmbeddedCoverResolver { } _pendingExtract.remove(oldestKey); _pendingRefresh.remove(oldestKey); + _pendingPreviewValidation.remove(oldestKey); _failedExtract.remove(oldestKey); } } + static void _validateCachedPreviewAsync( + String cleanPath, + _EmbeddedCoverCacheEntry entry, { + VoidCallback? onChanged, + }) { + if (_pendingPreviewValidation.contains(cleanPath)) return; + _pendingPreviewValidation.add(cleanPath); + Future.microtask(() async { + try { + final exists = await fileExists(entry.previewPath); + if (!exists) { + final latest = _cache[cleanPath]; + if (latest != null && latest.previewPath == entry.previewPath) { + _cache.remove(cleanPath); + _failedExtract.remove(cleanPath); + onChanged?.call(); + } + _cleanupTempCoverPathSync(entry.previewPath); + } + } finally { + _pendingPreviewValidation.remove(cleanPath); + } + }); + } + static void _ensureCover( String cleanPath, { bool forceRefresh = false, diff --git a/lib/services/ffmpeg_service.dart b/lib/services/ffmpeg_service.dart index 81b511d2..b352162f 100644 --- a/lib/services/ffmpeg_service.dart +++ b/lib/services/ffmpeg_service.dart @@ -1,9 +1,11 @@ import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; -import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit.dart'; -import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit_config.dart'; -import 'package:ffmpeg_kit_flutter_new_audio/return_code.dart'; +import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_kit.dart'; +import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_kit_config.dart'; +import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_session.dart'; +import 'package:ffmpeg_kit_flutter_new_full/return_code.dart'; +import 'package:ffmpeg_kit_flutter_new_full/session_state.dart'; import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/utils/logger.dart'; @@ -11,7 +13,20 @@ final _log = AppLogger('FFmpeg'); class FFmpegService { static const int _commandLogPreviewLength = 300; + static const Duration _liveTunnelStartupTimeout = Duration(seconds: 8); + static const Duration _liveTunnelStartupPollInterval = Duration( + milliseconds: 200, + ); + static const Duration _liveTunnelStabilizationDelay = Duration( + milliseconds: 900, + ); static int _tempEmbedCounter = 0; + static FFmpegSession? _activeLiveDecryptSession; + static String? _activeLiveDecryptUrl; + static String? _activeLiveTempInputPath; + static String? _activeNativeDashManifestPath; + static String? _activeNativeDashManifestUrl; + static final Set _preparedNativeDashManifestPaths = {}; static String _buildOutputPath(String inputPath, String extension) { final normalizedExt = extension.startsWith('.') ? extension : '.$extension'; @@ -305,6 +320,433 @@ class FFmpegService { return null; } + static bool isActiveLiveDecryptedUrl(String url) { + final active = _activeLiveDecryptUrl; + if (active == null || active.isEmpty) return false; + return active == url.trim(); + } + + static bool isActiveNativeDashManifestUrl(String url) { + final activeUrl = _activeNativeDashManifestUrl; + if (activeUrl == null || activeUrl.isEmpty) return false; + + final normalized = url.trim(); + if (activeUrl == normalized) return true; + + try { + final activePath = Uri.parse(activeUrl).toFilePath(); + final incomingPath = Uri.parse(normalized).toFilePath(); + return activePath == incomingPath; + } catch (_) { + return false; + } + } + + static Future prepareTidalDashManifestForNativePlayback({ + required String manifestPayload, + bool registerAsActive = true, + }) async { + final rawPayload = manifestPayload.trim(); + if (rawPayload.isEmpty) return null; + + final payload = rawPayload.startsWith('MANIFEST:') + ? rawPayload.substring('MANIFEST:'.length) + : rawPayload; + + final manifestPath = await _writeTempManifestFile(payload); + if (manifestPath == null) { + _log.e('Failed to prepare Tidal DASH manifest for native playback'); + return null; + } + + final manifestUrl = Uri.file(manifestPath).toString(); + _preparedNativeDashManifestPaths.add(manifestPath); + if (registerAsActive) { + await activatePreparedNativeDashManifest(manifestUrl); + } + return manifestUrl; + } + + static Future activatePreparedNativeDashManifest(String url) async { + final normalized = url.trim(); + if (normalized.isEmpty) return; + + final manifestPath = _nativeDashManifestPathFromUrl(normalized); + if (manifestPath == null || + !_preparedNativeDashManifestPaths.contains(manifestPath)) { + return; + } + + final previousPath = _activeNativeDashManifestPath; + _activeNativeDashManifestPath = manifestPath; + _activeNativeDashManifestUrl = Uri.file(manifestPath).toString(); + + if (previousPath != null && + previousPath.isNotEmpty && + previousPath != manifestPath) { + _preparedNativeDashManifestPaths.remove(previousPath); + await _deleteNativeDashManifestFile(previousPath); + } + } + + static Future stopNativeDashManifestPlayback() async { + final manifestPath = _activeNativeDashManifestPath; + _activeNativeDashManifestPath = null; + _activeNativeDashManifestUrl = null; + + if (manifestPath == null || manifestPath.isEmpty) return; + _preparedNativeDashManifestPaths.remove(manifestPath); + await _deleteNativeDashManifestFile(manifestPath); + } + + static Future cleanupInactivePreparedNativeDashManifests() async { + final activePath = _activeNativeDashManifestPath; + final stalePaths = _preparedNativeDashManifestPaths + .where((path) => path != activePath) + .toList(growable: false); + + for (final path in stalePaths) { + _preparedNativeDashManifestPaths.remove(path); + await _deleteNativeDashManifestFile(path); + } + } + + static String? _nativeDashManifestPathFromUrl(String url) { + try { + final uri = Uri.parse(url); + if (uri.scheme.toLowerCase() != 'file') { + return null; + } + final path = uri.toFilePath(); + return path.trim().isEmpty ? null : path; + } catch (_) { + return null; + } + } + + static Future _deleteNativeDashManifestFile(String path) async { + try { + final file = File(path); + if (await file.exists()) { + await file.delete(); + } + } catch (_) {} + } + + static Future stopLiveDecryptedStream() async { + final session = _activeLiveDecryptSession; + final tempInputPath = _activeLiveTempInputPath; + _activeLiveDecryptSession = null; + _activeLiveDecryptUrl = null; + _activeLiveTempInputPath = null; + + if (session != null) { + try { + await session.cancel(); + } catch (e) { + final sessionId = session.getSessionId(); + if (sessionId != null) { + try { + await FFmpegKit.cancel(sessionId); + } catch (_) {} + } + _log.w('Failed to stop live decrypt session cleanly: $e'); + } + } + + if (tempInputPath != null && tempInputPath.isNotEmpty) { + try { + final file = File(tempInputPath); + if (await file.exists()) { + await file.delete(); + } + } catch (_) {} + } + } + + static Future startTidalDashLiveStream({ + required String manifestPayload, + String preferredFormat = 'm4a', + }) async { + final rawPayload = manifestPayload.trim(); + if (rawPayload.isEmpty) return null; + + final payload = rawPayload.startsWith('MANIFEST:') + ? rawPayload.substring('MANIFEST:'.length) + : rawPayload; + + final manifestPath = await _writeTempManifestFile(payload); + if (manifestPath == null) { + _log.e('Failed to prepare Tidal DASH manifest for live stream'); + return null; + } + + await stopLiveDecryptedStream(); + await stopNativeDashManifestPlayback(); + + final attempts = _buildLiveDashFormatAttempts(preferredFormat); + for (final format in attempts) { + final stream = await _tryStartLiveDashAttempt( + manifestPath: manifestPath, + format: format, + ); + if (stream != null) { + _activeLiveDecryptSession = stream.session; + _activeLiveDecryptUrl = stream.localUrl; + _activeLiveTempInputPath = manifestPath; + return stream; + } + } + + try { + final file = File(manifestPath); + if (await file.exists()) { + await file.delete(); + } + } catch (_) {} + return null; + } + + static Future _writeTempManifestFile(String payload) async { + if (payload.trim().isEmpty) return null; + + Uint8List bytes; + try { + bytes = base64Decode(payload); + } catch (_) { + bytes = Uint8List.fromList(utf8.encode(payload)); + } + + final manifestText = utf8.decode(bytes, allowMalformed: true).trim(); + if (manifestText.isEmpty) return null; + + final tempDir = await getTemporaryDirectory(); + final manifestPath = + '${tempDir.path}${Platform.pathSeparator}tidal_dash_${DateTime.now().microsecondsSinceEpoch}.mpd'; + await File(manifestPath).writeAsString(manifestText, flush: true); + return manifestPath; + } + + static List<_LiveDecryptFormat> _buildLiveDashFormatAttempts( + String preferredFormat, + ) { + final normalized = preferredFormat.trim().toLowerCase(); + if (normalized == 'flac') { + return const [_LiveDecryptFormat.flac, _LiveDecryptFormat.m4a]; + } + return const [_LiveDecryptFormat.m4a, _LiveDecryptFormat.flac]; + } + + static Future _awaitLiveTunnelReady(FFmpegSession session) async { + final deadline = DateTime.now().add(_liveTunnelStartupTimeout); + var seenRunning = false; + + while (DateTime.now().isBefore(deadline)) { + final state = await session.getState(); + if (state == SessionState.running) { + seenRunning = true; + break; + } + if (state != SessionState.created) { + return false; + } + await Future.delayed(_liveTunnelStartupPollInterval); + } + + if (!seenRunning) { + return false; + } + + await Future.delayed(_liveTunnelStabilizationDelay); + return (await session.getState()) == SessionState.running; + } + + static Future _tryStartLiveDashAttempt({ + required String manifestPath, + required _LiveDecryptFormat format, + }) async { + final port = await _allocateLoopbackPort(); + final ext = format == _LiveDecryptFormat.flac ? 'flac' : 'm4a'; + final mimeType = format == _LiveDecryptFormat.flac + ? 'audio/flac' + : 'audio/mp4'; + final localUrl = 'http://localhost:$port/stream.$ext'; + + final commandArguments = [ + '-nostdin', + '-hide_banner', + '-loglevel', + 'error', + '-protocol_whitelist', + 'file,http,https,tcp,tls,crypto,data', + '-i', + manifestPath, + '-map', + '0:a:0', + '-c:a', + 'copy', + if (format == _LiveDecryptFormat.flac) ...['-f', 'flac'], + if (format == _LiveDecryptFormat.m4a) ...[ + '-movflags', + '+frag_keyframe+empty_moov+default_base_moof', + '-f', + 'mp4', + ], + '-content_type', + mimeType, + '-listen', + '1', + localUrl, + ]; + + _log.d( + 'Starting Tidal DASH tunnel: ${_previewCommandForLog(commandArguments.join(' '))}', + ); + + final session = await FFmpegKit.executeWithArgumentsAsync(commandArguments); + final isReady = await _awaitLiveTunnelReady(session); + if (isReady) { + return LiveDecryptedStreamResult( + localUrl: localUrl, + format: ext, + session: session, + ); + } + + final state = await session.getState(); + final output = (await session.getOutput() ?? '').trim(); + if (output.isNotEmpty) { + _log.w('Tidal DASH tunnel failed ($ext): $output'); + } else { + _log.w('Tidal DASH tunnel failed ($ext) with session state: $state'); + } + + try { + await session.cancel(); + } catch (_) {} + return null; + } + + static Future startAmazonLiveDecryptedStream({ + required String encryptedStreamUrl, + required String decryptionKey, + String preferredFormat = 'flac', + }) async { + final inputUrl = encryptedStreamUrl.trim(); + if (inputUrl.isEmpty) return null; + + final keyCandidates = _buildDecryptionKeyCandidates(decryptionKey); + if (keyCandidates.isEmpty) { + _log.e('No usable decryption key candidates for live stream'); + return null; + } + + await stopLiveDecryptedStream(); + + final attempts = _buildLiveDecryptFormatAttempts(preferredFormat); + for (final format in attempts) { + for (final keyCandidate in keyCandidates) { + final stream = await _tryStartLiveDecryptAttempt( + inputUrl: inputUrl, + decryptionKey: keyCandidate, + format: format, + ); + if (stream != null) { + _activeLiveDecryptSession = stream.session; + _activeLiveDecryptUrl = stream.localUrl; + _activeLiveTempInputPath = null; + return stream; + } + } + } + + return null; + } + + static List<_LiveDecryptFormat> _buildLiveDecryptFormatAttempts( + String preferredFormat, + ) { + final normalized = preferredFormat.trim().toLowerCase(); + if (normalized == 'm4a' || normalized == 'mp4' || normalized == 'aac') { + return const [_LiveDecryptFormat.m4a, _LiveDecryptFormat.flac]; + } + return const [_LiveDecryptFormat.flac, _LiveDecryptFormat.m4a]; + } + + static Future _tryStartLiveDecryptAttempt({ + required String inputUrl, + required String decryptionKey, + required _LiveDecryptFormat format, + }) async { + final port = await _allocateLoopbackPort(); + final ext = format == _LiveDecryptFormat.flac ? 'flac' : 'm4a'; + final mimeType = format == _LiveDecryptFormat.flac + ? 'audio/flac' + : 'audio/mp4'; + final localUrl = 'http://localhost:$port/stream.$ext'; + + final commandArguments = [ + '-nostdin', + '-hide_banner', + '-loglevel', + 'error', + '-decryption_key', + decryptionKey, + '-i', + inputUrl, + '-map', + '0:a:0', + '-c:a', + 'copy', + if (format == _LiveDecryptFormat.flac) ...['-f', 'flac'], + if (format == _LiveDecryptFormat.m4a) ...[ + '-movflags', + '+frag_keyframe+empty_moov+default_base_moof', + '-f', + 'mp4', + ], + '-content_type', + mimeType, + '-listen', + '1', + localUrl, + ]; + + _log.d( + 'Starting live decrypt tunnel: ${_previewCommandForLog(commandArguments.join(' '))}', + ); + + final session = await FFmpegKit.executeWithArgumentsAsync(commandArguments); + final isReady = await _awaitLiveTunnelReady(session); + if (isReady) { + return LiveDecryptedStreamResult( + localUrl: localUrl, + format: ext, + session: session, + ); + } + + final state = await session.getState(); + final output = (await session.getOutput() ?? '').trim(); + if (output.isNotEmpty) { + _log.w('Live decrypt attempt failed ($ext): $output'); + } else { + _log.w('Live decrypt attempt failed ($ext) with session state: $state'); + } + + try { + await session.cancel(); + } catch (_) {} + return null; + } + + static Future _allocateLoopbackPort() async { + final socket = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + final port = socket.port; + await socket.close(); + return port; + } + static Future convertFlacToOpus( String inputPath, { String bitrate = '128k', @@ -861,9 +1303,10 @@ class FFmpegService { for (final entry in vorbisMetadata.entries) { final key = entry.key.toUpperCase(); + final normalizedKey = key.replaceAll(RegExp(r'[^A-Z0-9]'), ''); final value = entry.value; - switch (key) { + switch (normalizedKey) { case 'TITLE': id3Map['title'] = value; break; @@ -878,10 +1321,12 @@ class FFmpegService { break; case 'TRACKNUMBER': case 'TRACK': + case 'TRCK': id3Map['track'] = value; break; case 'DISCNUMBER': case 'DISC': + case 'TPOS': id3Map['disc'] = value; break; case 'DATE': @@ -921,3 +1366,17 @@ class FFmpegResult { required this.output, }); } + +enum _LiveDecryptFormat { flac, m4a } + +class LiveDecryptedStreamResult { + final String localUrl; + final String format; + final FFmpegSession session; + + LiveDecryptedStreamResult({ + required this.localUrl, + required this.format, + required this.session, + }); +} diff --git a/lib/services/history_database.dart b/lib/services/history_database.dart index b20dd7bc..5a3c1739 100644 --- a/lib/services/history_database.dart +++ b/lib/services/history_database.dart @@ -35,6 +35,10 @@ class HistoryDatabase { return await openDatabase( path, version: 3, + onConfigure: (db) async { + await db.rawQuery('PRAGMA journal_mode = WAL'); + await db.execute('PRAGMA synchronous = NORMAL'); + }, onCreate: _createDB, onUpgrade: _upgradeDB, ); diff --git a/lib/services/library_collections_database.dart b/lib/services/library_collections_database.dart new file mode 100644 index 00000000..65577cce --- /dev/null +++ b/lib/services/library_collections_database.dart @@ -0,0 +1,413 @@ +import 'dart:convert'; + +import 'package:path/path.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sqflite/sqflite.dart'; +import 'package:spotiflac_android/utils/logger.dart'; + +final _log = AppLogger('LibraryCollectionsDb'); + +const _dbFileName = 'library_collections.db'; +const _dbVersion = 1; + +const _tableWishlist = 'wishlist_tracks'; +const _tableLoved = 'loved_tracks'; +const _tablePlaylists = 'playlists'; +const _tablePlaylistTracks = 'playlist_tracks'; + +const _legacyCollectionsStorageKey = 'library_collections_v1'; +const _migrationDoneKey = 'library_collections_migrated_to_sqlite_v1'; + +class LibraryCollectionsSnapshot { + final List> wishlistRows; + final List> lovedRows; + final List> playlistRows; + final List> playlistTrackRows; + + const LibraryCollectionsSnapshot({ + required this.wishlistRows, + required this.lovedRows, + required this.playlistRows, + required this.playlistTrackRows, + }); +} + +class LibraryCollectionsDatabase { + static final LibraryCollectionsDatabase instance = + LibraryCollectionsDatabase._init(); + static Database? _database; + + final Future _prefs = SharedPreferences.getInstance(); + + LibraryCollectionsDatabase._init(); + + Future get database async { + if (_database != null) return _database!; + _database = await _initDb(); + return _database!; + } + + Future _initDb() async { + final dbPath = await getApplicationDocumentsDirectory(); + final path = join(dbPath.path, _dbFileName); + + _log.i('Initializing collections database at: $path'); + + return openDatabase( + path, + version: _dbVersion, + onConfigure: (db) async { + await db.execute('PRAGMA foreign_keys = ON'); + await db.rawQuery('PRAGMA journal_mode = WAL'); + await db.execute('PRAGMA synchronous = NORMAL'); + }, + onCreate: _createDb, + onUpgrade: _upgradeDb, + ); + } + + Future _createDb(Database db, int version) async { + _log.i('Creating collections database schema v$version'); + + await db.execute(''' + CREATE TABLE $_tableWishlist ( + track_key TEXT PRIMARY KEY, + track_json TEXT NOT NULL, + added_at TEXT NOT NULL + ) + '''); + + await db.execute(''' + CREATE TABLE $_tableLoved ( + track_key TEXT PRIMARY KEY, + track_json TEXT NOT NULL, + added_at TEXT NOT NULL + ) + '''); + + await db.execute(''' + CREATE TABLE $_tablePlaylists ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + cover_image_path TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + '''); + + await db.execute(''' + CREATE TABLE $_tablePlaylistTracks ( + playlist_id TEXT NOT NULL, + track_key TEXT NOT NULL, + track_json TEXT NOT NULL, + added_at TEXT NOT NULL, + PRIMARY KEY (playlist_id, track_key), + FOREIGN KEY (playlist_id) REFERENCES $_tablePlaylists(id) ON DELETE CASCADE + ) + '''); + + await db.execute( + 'CREATE INDEX idx_${_tableWishlist}_added_at ON $_tableWishlist(added_at DESC)', + ); + await db.execute( + 'CREATE INDEX idx_${_tableLoved}_added_at ON $_tableLoved(added_at DESC)', + ); + await db.execute( + 'CREATE INDEX idx_${_tablePlaylists}_created_at ON $_tablePlaylists(created_at DESC)', + ); + await db.execute( + 'CREATE INDEX idx_${_tablePlaylistTracks}_playlist_id ON $_tablePlaylistTracks(playlist_id)', + ); + await db.execute( + 'CREATE INDEX idx_${_tablePlaylistTracks}_added_at ON $_tablePlaylistTracks(added_at DESC)', + ); + } + + Future _upgradeDb(Database db, int oldVersion, int newVersion) async { + _log.i('Upgrading collections database from v$oldVersion to v$newVersion'); + } + + Future migrateFromSharedPreferences() async { + final prefs = await _prefs; + if (prefs.getBool(_migrationDoneKey) == true) { + return false; + } + + final raw = prefs.getString(_legacyCollectionsStorageKey); + if (raw == null || raw.isEmpty) { + await prefs.setBool(_migrationDoneKey, true); + return false; + } + + try { + final decoded = jsonDecode(raw); + if (decoded is! Map) { + await prefs.setBool(_migrationDoneKey, true); + return false; + } + + final root = Map.from(decoded); + final wishlistRaw = (root['wishlist'] as List?) ?? const []; + final lovedRaw = (root['loved'] as List?) ?? const []; + final playlistsRaw = (root['playlists'] as List?) ?? const []; + final nowIso = DateTime.now().toIso8601String(); + + final db = await database; + await db.transaction((txn) async { + for (final entry in wishlistRaw.whereType()) { + final map = Map.from(entry); + final trackKey = map['key'] as String?; + final track = map['track']; + if (trackKey == null || track is! Map) continue; + final addedAt = (map['addedAt'] as String?) ?? nowIso; + await txn.insert(_tableWishlist, { + 'track_key': trackKey, + 'track_json': jsonEncode(track), + 'added_at': addedAt, + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + + for (final entry in lovedRaw.whereType()) { + final map = Map.from(entry); + final trackKey = map['key'] as String?; + final track = map['track']; + if (trackKey == null || track is! Map) continue; + final addedAt = (map['addedAt'] as String?) ?? nowIso; + await txn.insert(_tableLoved, { + 'track_key': trackKey, + 'track_json': jsonEncode(track), + 'added_at': addedAt, + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + + for (final playlistEntry in playlistsRaw.whereType()) { + final playlist = Map.from(playlistEntry); + final playlistId = playlist['id'] as String?; + if (playlistId == null || playlistId.isEmpty) continue; + + final createdAt = (playlist['createdAt'] as String?) ?? nowIso; + final updatedAt = (playlist['updatedAt'] as String?) ?? createdAt; + await txn.insert(_tablePlaylists, { + 'id': playlistId, + 'name': (playlist['name'] as String?) ?? '', + 'cover_image_path': playlist['coverImagePath'] as String?, + 'created_at': createdAt, + 'updated_at': updatedAt, + }, conflictAlgorithm: ConflictAlgorithm.replace); + + final tracksRaw = (playlist['tracks'] as List?) ?? const []; + for (final trackEntry in tracksRaw.whereType()) { + final trackMap = Map.from(trackEntry); + final trackKey = trackMap['key'] as String?; + final track = trackMap['track']; + if (trackKey == null || track is! Map) continue; + final addedAt = (trackMap['addedAt'] as String?) ?? nowIso; + await txn.insert(_tablePlaylistTracks, { + 'playlist_id': playlistId, + 'track_key': trackKey, + 'track_json': jsonEncode(track), + 'added_at': addedAt, + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + } + }); + + await prefs.setBool(_migrationDoneKey, true); + _log.i('Migrated legacy collections data to SQLite'); + return true; + } catch (e, stack) { + _log.e('Failed migrating collections to SQLite: $e', e, stack); + return false; + } + } + + Future loadSnapshot() async { + final db = await database; + final wishlistRows = await db.query( + _tableWishlist, + orderBy: 'added_at DESC, rowid DESC', + ); + final lovedRows = await db.query( + _tableLoved, + orderBy: 'added_at DESC, rowid DESC', + ); + final playlistRows = await db.query( + _tablePlaylists, + orderBy: 'created_at DESC, rowid DESC', + ); + final playlistTrackRows = await db.query( + _tablePlaylistTracks, + orderBy: 'playlist_id ASC, added_at DESC, rowid DESC', + ); + + return LibraryCollectionsSnapshot( + wishlistRows: wishlistRows, + lovedRows: lovedRows, + playlistRows: playlistRows, + playlistTrackRows: playlistTrackRows, + ); + } + + Future upsertWishlistEntry({ + required String trackKey, + required String trackJson, + required String addedAt, + }) async { + final db = await database; + await db.insert(_tableWishlist, { + 'track_key': trackKey, + 'track_json': trackJson, + 'added_at': addedAt, + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + + Future deleteWishlistEntry(String trackKey) async { + final db = await database; + await db.delete( + _tableWishlist, + where: 'track_key = ?', + whereArgs: [trackKey], + ); + } + + Future upsertLovedEntry({ + required String trackKey, + required String trackJson, + required String addedAt, + }) async { + final db = await database; + await db.insert(_tableLoved, { + 'track_key': trackKey, + 'track_json': trackJson, + 'added_at': addedAt, + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + + Future deleteLovedEntry(String trackKey) async { + final db = await database; + await db.delete(_tableLoved, where: 'track_key = ?', whereArgs: [trackKey]); + } + + Future upsertPlaylist({ + required String id, + required String name, + required String createdAt, + required String updatedAt, + String? coverImagePath, + }) async { + final db = await database; + await db.insert(_tablePlaylists, { + 'id': id, + 'name': name, + 'cover_image_path': coverImagePath, + 'created_at': createdAt, + 'updated_at': updatedAt, + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + + Future renamePlaylist({ + required String playlistId, + required String name, + required String updatedAt, + }) async { + final db = await database; + await db.update( + _tablePlaylists, + {'name': name, 'updated_at': updatedAt}, + where: 'id = ?', + whereArgs: [playlistId], + ); + } + + Future updatePlaylistCover({ + required String playlistId, + required String updatedAt, + String? coverImagePath, + }) async { + final db = await database; + await db.update( + _tablePlaylists, + {'cover_image_path': coverImagePath, 'updated_at': updatedAt}, + where: 'id = ?', + whereArgs: [playlistId], + ); + } + + Future deletePlaylist(String playlistId) async { + final db = await database; + await db.delete(_tablePlaylists, where: 'id = ?', whereArgs: [playlistId]); + } + + Future upsertPlaylistTrack({ + required String playlistId, + required String trackKey, + required String trackJson, + required String addedAt, + required String playlistUpdatedAt, + }) async { + final db = await database; + await db.transaction((txn) async { + await txn.insert(_tablePlaylistTracks, { + 'playlist_id': playlistId, + 'track_key': trackKey, + 'track_json': trackJson, + 'added_at': addedAt, + }, conflictAlgorithm: ConflictAlgorithm.replace); + await txn.update( + _tablePlaylists, + {'updated_at': playlistUpdatedAt}, + where: 'id = ?', + whereArgs: [playlistId], + ); + }); + } + + Future upsertPlaylistTracksBatch({ + required String playlistId, + required String playlistUpdatedAt, + required List> tracks, + }) async { + if (tracks.isEmpty) return; + final db = await database; + await db.transaction((txn) async { + final batch = txn.batch(); + for (final track in tracks) { + batch.insert(_tablePlaylistTracks, { + 'playlist_id': playlistId, + 'track_key': track['track_key'], + 'track_json': track['track_json'], + 'added_at': track['added_at'], + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + batch.update( + _tablePlaylists, + {'updated_at': playlistUpdatedAt}, + where: 'id = ?', + whereArgs: [playlistId], + ); + await batch.commit(noResult: true); + }); + } + + Future deletePlaylistTrack({ + required String playlistId, + required String trackKey, + required String playlistUpdatedAt, + }) async { + final db = await database; + await db.transaction((txn) async { + await txn.delete( + _tablePlaylistTracks, + where: 'playlist_id = ? AND track_key = ?', + whereArgs: [playlistId, trackKey], + ); + await txn.update( + _tablePlaylists, + {'updated_at': playlistUpdatedAt}, + where: 'id = ?', + whereArgs: [playlistId], + ); + }); + } +} diff --git a/lib/services/library_database.dart b/lib/services/library_database.dart index b2243e2b..c92fb7a3 100644 --- a/lib/services/library_database.dart +++ b/lib/services/library_database.dart @@ -95,39 +95,45 @@ class LocalLibraryItem { ); /// Create a unique key for matching tracks - String get matchKey => '${trackName.toLowerCase()}|${artistName.toLowerCase()}'; - String get albumKey => '${albumName.toLowerCase()}|${(albumArtist ?? artistName).toLowerCase()}'; + String get matchKey => + '${trackName.toLowerCase()}|${artistName.toLowerCase()}'; + String get albumKey => + '${albumName.toLowerCase()}|${(albumArtist ?? artistName).toLowerCase()}'; } class LibraryDatabase { static final LibraryDatabase instance = LibraryDatabase._init(); static Database? _database; - + LibraryDatabase._init(); - + Future get database async { if (_database != null) return _database!; _database = await _initDB('local_library.db'); return _database!; } - + Future _initDB(String fileName) async { final dbPath = await getApplicationDocumentsDirectory(); final path = join(dbPath.path, fileName); - + _log.i('Initializing library database at: $path'); - + return await openDatabase( path, version: 4, // Bumped version for bitrate column + onConfigure: (db) async { + await db.rawQuery('PRAGMA journal_mode = WAL'); + await db.execute('PRAGMA synchronous = NORMAL'); + }, onCreate: _createDB, onUpgrade: _upgradeDB, ); } - + Future _createDB(Database db, int version) async { _log.i('Creating library database schema v$version'); - + await db.execute(''' CREATE TABLE library ( id TEXT PRIMARY KEY, @@ -151,37 +157,43 @@ class LibraryDatabase { format TEXT ) '''); - + await db.execute('CREATE INDEX idx_library_isrc ON library(isrc)'); - await db.execute('CREATE INDEX idx_library_track_artist ON library(track_name, artist_name)'); - await db.execute('CREATE INDEX idx_library_album ON library(album_name, album_artist)'); - await db.execute('CREATE INDEX idx_library_file_path ON library(file_path)'); - + await db.execute( + 'CREATE INDEX idx_library_track_artist ON library(track_name, artist_name)', + ); + await db.execute( + 'CREATE INDEX idx_library_album ON library(album_name, album_artist)', + ); + await db.execute( + 'CREATE INDEX idx_library_file_path ON library(file_path)', + ); + _log.i('Library database schema created with indexes'); } - + Future _upgradeDB(Database db, int oldVersion, int newVersion) async { _log.i('Upgrading library database from v$oldVersion to v$newVersion'); - + if (oldVersion < 2) { // Add cover_path column await db.execute('ALTER TABLE library ADD COLUMN cover_path TEXT'); _log.i('Added cover_path column'); } - + if (oldVersion < 3) { // Add file_mod_time column for incremental scanning await db.execute('ALTER TABLE library ADD COLUMN file_mod_time INTEGER'); _log.i('Added file_mod_time column for incremental scanning'); } - + if (oldVersion < 4) { // Add bitrate column for lossy format quality info await db.execute('ALTER TABLE library ADD COLUMN bitrate INTEGER'); _log.i('Added bitrate column for lossy format quality'); } } - + Map _jsonToDbRow(Map json) { return { 'id': json['id'], @@ -205,7 +217,7 @@ class LibraryDatabase { 'format': json['format'], }; } - + Map _dbRowToJson(Map row) { return { 'id': row['id'], @@ -229,9 +241,9 @@ class LibraryDatabase { 'format': row['format'], }; } - + // CRUD Operations - + Future upsert(Map json) async { final db = await database; await db.insert( @@ -240,12 +252,12 @@ class LibraryDatabase { conflictAlgorithm: ConflictAlgorithm.replace, ); } - + Future upsertBatch(List> items) async { if (items.isEmpty) return; final db = await database; final batch = db.batch(); - + for (final json in items) { batch.insert( 'library', @@ -253,11 +265,11 @@ class LibraryDatabase { conflictAlgorithm: ConflictAlgorithm.replace, ); } - + await batch.commit(noResult: true); _log.i('Batch inserted ${items.length} items'); } - + Future>> getAll({int? limit, int? offset}) async { final db = await database; final rows = await db.query( @@ -268,7 +280,7 @@ class LibraryDatabase { ); return rows.map(_dbRowToJson).toList(); } - + Future?> getById(String id) async { final db = await database; final rows = await db.query( @@ -280,7 +292,7 @@ class LibraryDatabase { if (rows.isEmpty) return null; return _dbRowToJson(rows.first); } - + Future?> getByIsrc(String isrc) async { final db = await database; final rows = await db.query( @@ -292,7 +304,7 @@ class LibraryDatabase { if (rows.isEmpty) return null; return _dbRowToJson(rows.first); } - + Future existsByIsrc(String isrc) async { final db = await database; final result = await db.rawQuery( @@ -301,7 +313,7 @@ class LibraryDatabase { ); return result.isNotEmpty; } - + Future>> findByTrackAndArtist( String trackName, String artistName, @@ -314,7 +326,7 @@ class LibraryDatabase { ); return rows.map(_dbRowToJson).toList(); } - + Future?> findExisting({ String? isrc, String? trackName, @@ -325,42 +337,42 @@ class LibraryDatabase { final byIsrc = await getByIsrc(isrc); if (byIsrc != null) return byIsrc; } - + // Then try name matching if (trackName != null && artistName != null) { final matches = await findByTrackAndArtist(trackName, artistName); if (matches.isNotEmpty) return matches.first; } - + return null; } - + Future> getAllIsrcs() async { final db = await database; final rows = await db.rawQuery( - 'SELECT isrc FROM library WHERE isrc IS NOT NULL AND isrc != ""' + 'SELECT isrc FROM library WHERE isrc IS NOT NULL AND isrc != ""', ); return rows.map((r) => r['isrc'] as String).toSet(); } - + Future> getAllTrackKeys() async { final db = await database; final rows = await db.rawQuery( - 'SELECT LOWER(track_name) || "|" || LOWER(artist_name) as match_key FROM library' + 'SELECT LOWER(track_name) || "|" || LOWER(artist_name) as match_key FROM library', ); return rows.map((r) => r['match_key'] as String).toSet(); } - + Future deleteByPath(String filePath) async { final db = await database; await db.delete('library', where: 'file_path = ?', whereArgs: [filePath]); } - + Future delete(String id) async { final db = await database; await db.delete('library', where: 'id = ?', whereArgs: [id]); } - + Future cleanupMissingFiles() async { final db = await database; final rows = await db.query('library', columns: ['id', 'file_path']); @@ -409,44 +421,48 @@ class LibraryDatabase { } return removed; } - + Future clearAll() async { final db = await database; await db.delete('library'); _log.i('Cleared all library data'); } - + Future getCount() async { final db = await database; final result = await db.rawQuery('SELECT COUNT(*) as count FROM library'); return Sqflite.firstIntValue(result) ?? 0; } - - Future>> search(String query, {int limit = 50}) async { + + Future>> search( + String query, { + int limit = 50, + }) async { final db = await database; final searchQuery = '%${query.toLowerCase()}%'; final rows = await db.query( 'library', - where: 'LOWER(track_name) LIKE ? OR LOWER(artist_name) LIKE ? OR LOWER(album_name) LIKE ?', + where: + 'LOWER(track_name) LIKE ? OR LOWER(artist_name) LIKE ? OR LOWER(album_name) LIKE ?', whereArgs: [searchQuery, searchQuery, searchQuery], orderBy: 'track_name', limit: limit, ); return rows.map(_dbRowToJson).toList(); } - + Future close() async { final db = await database; await db.close(); _database = null; } - + /// Get all file paths with their modification times for incremental scanning /// Returns a map of filePath -> fileModTime (unix timestamp in milliseconds) Future> getFileModTimes() async { final db = await database; final rows = await db.rawQuery( - 'SELECT file_path, COALESCE(file_mod_time, 0) AS file_mod_time FROM library' + 'SELECT file_path, COALESCE(file_mod_time, 0) AS file_mod_time FROM library', ); final result = {}; for (final row in rows) { @@ -456,7 +472,7 @@ class LibraryDatabase { } return result; } - + /// Update file_mod_time for existing rows using file_path as key. Future updateFileModTimes(Map fileModTimes) async { if (fileModTimes.isEmpty) return; @@ -472,14 +488,14 @@ class LibraryDatabase { } await batch.commit(noResult: true); } - + /// Get all file paths in the library (for detecting deleted files) Future> getAllFilePaths() async { final db = await database; final rows = await db.rawQuery('SELECT file_path FROM library'); return rows.map((r) => r['file_path'] as String).toSet(); } - + /// Delete multiple items by their file paths Future deleteByPaths(List filePaths) async { if (filePaths.isEmpty) return 0; diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index f2351aeb..a0b35d54 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -7,6 +7,12 @@ final _log = AppLogger('PlatformBridge'); class PlatformBridge { static const _channel = MethodChannel('com.zarz.spotiflac/backend'); + static const _downloadProgressEvents = EventChannel( + 'com.zarz.spotiflac/download_progress_stream', + ); + static const _libraryScanProgressEvents = EventChannel( + 'com.zarz.spotiflac/library_scan_progress_stream', + ); static Future> parseSpotifyUrl(String url) async { _log.d('parseSpotifyUrl: $url'); @@ -48,6 +54,17 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } + static Future> getSpotifyRelatedArtists( + String artistId, { + int limit = 12, + }) async { + final result = await _channel.invokeMethod('getSpotifyRelatedArtists', { + 'artist_id': artistId, + 'limit': limit, + }); + return jsonDecode(result as String) as Map; + } + static Future> checkAvailability( String spotifyId, String isrc, @@ -113,6 +130,22 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } + static Stream> downloadProgressStream() { + return _downloadProgressEvents.receiveBroadcastStream().map((event) { + if (event is String) { + return jsonDecode(event) as Map; + } + if (event is Map) { + return Map.from(event); + } + return const {}; + }); + } + + static Future exitApp() async { + await _channel.invokeMethod('exitApp'); + } + static Future initItemProgress(String itemId) async { await _channel.invokeMethod('initItemProgress', {'item_id': itemId}); } @@ -133,6 +166,16 @@ class PlatformBridge { await _channel.invokeMethod('setDownloadDirectory', {'path': path}); } + static Future setNetworkCompatibilityOptions({ + required bool allowHttp, + required bool insecureTls, + }) async { + await _channel.invokeMethod('setNetworkCompatibilityOptions', { + 'allow_http': allowHttp, + 'insecure_tls': insecureTls, + }); + } + static Future> checkDuplicate( String outputDir, String isrc, @@ -244,6 +287,17 @@ class PlatformBridge { return result as bool? ?? false; } + static Future shareMultipleContentUris( + List uris, { + String title = '', + }) async { + final result = await _channel.invokeMethod('shareMultipleContentUris', { + 'uris': uris, + 'title': title, + }); + return result as bool? ?? false; + } + static Future> fetchLyrics( String spotifyId, String trackName, @@ -511,6 +565,17 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } + static Future> getDeezerRelatedArtists( + String artistId, { + int limit = 12, + }) async { + final result = await _channel.invokeMethod('getDeezerRelatedArtists', { + 'artist_id': artistId, + 'limit': limit, + }); + return jsonDecode(result as String) as Map; + } + static Future> getDeezerMetadata( String resourceType, String resourceId, @@ -1077,6 +1142,18 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } + static Stream> libraryScanProgressStream() { + return _libraryScanProgressEvents.receiveBroadcastStream().map((event) { + if (event is String) { + return jsonDecode(event) as Map; + } + if (event is Map) { + return Map.from(event); + } + return const {}; + }); + } + /// Cancel ongoing library scan static Future cancelLibraryScan() async { await _channel.invokeMethod('cancelLibraryScan'); diff --git a/lib/services/shell_navigation_service.dart b/lib/services/shell_navigation_service.dart new file mode 100644 index 00000000..614628c4 --- /dev/null +++ b/lib/services/shell_navigation_service.dart @@ -0,0 +1,30 @@ +import 'package:flutter/widgets.dart'; + +class ShellNavigationService { + static final GlobalKey homeTabNavigatorKey = + GlobalKey(); + static final GlobalKey libraryTabNavigatorKey = + GlobalKey(); + static final GlobalKey storeTabNavigatorKey = + GlobalKey(); + + static int _currentTabIndex = 0; + static bool _showStoreTab = false; + + static void syncState({ + required int currentTabIndex, + required bool showStoreTab, + }) { + _currentTabIndex = currentTabIndex; + _showStoreTab = showStoreTab; + } + + static NavigatorState? activeTabNavigator() { + if (_currentTabIndex == 0) return homeTabNavigatorKey.currentState; + if (_currentTabIndex == 1) return libraryTabNavigatorKey.currentState; + if (_showStoreTab && _currentTabIndex == 2) { + return storeTabNavigatorKey.currentState; + } + return null; + } +} diff --git a/lib/services/update_checker.dart b/lib/services/update_checker.dart index 1f791496..3a2fb50f 100644 --- a/lib/services/update_checker.dart +++ b/lib/services/update_checker.dart @@ -1,5 +1,4 @@ import 'dart:convert'; -import 'dart:io'; import 'package:http/http.dart' as http; import 'package:spotiflac_android/constants/app_info.dart'; import 'package:spotiflac_android/utils/logger.dart'; @@ -28,36 +27,6 @@ class UpdateChecker { static const String _latestApiUrl = 'https://api.github.com/repos/${AppInfo.githubRepo}/releases/latest'; static const String _allReleasesApiUrl = 'https://api.github.com/repos/${AppInfo.githubRepo}/releases'; - static Future _getDeviceArch() async { - if (!Platform.isAndroid) return 'unknown'; - - try { - final cpuInfo = await File('/proc/cpuinfo').readAsString(); - - if (cpuInfo.contains('AArch64') || cpuInfo.contains('aarch64')) { - return 'arm64'; - } - - final result = await Process.run('uname', ['-m']); - final arch = result.stdout.toString().trim().toLowerCase(); - - if (arch.contains('aarch64') || arch.contains('arm64')) { - return 'arm64'; - } else if (arch.contains('armv7') || arch.contains('arm')) { - return 'arm32'; - } else if (arch.contains('x86_64')) { - return 'x86_64'; - } else if (arch.contains('x86') || arch.contains('i686')) { - return 'x86'; - } - - return 'arm64'; - } catch (e) { - _log.e('Error detecting arch: $e'); - return 'arm64'; - } - } - /// Check for updates based on channel preference /// [channel] can be 'stable' or 'preview' static Future checkForUpdate({String channel = 'stable'}) async { @@ -109,11 +78,7 @@ class UpdateChecker { final htmlUrl = releaseData['html_url'] as String? ?? '${AppInfo.githubUrl}/releases'; final publishedAt = DateTime.tryParse(releaseData['published_at'] as String? ?? '') ?? DateTime.now(); - final deviceArch = await _getDeviceArch(); - _log.d('Device architecture: $deviceArch'); - String? arm64Url; - String? arm32Url; String? universalUrl; final assets = releaseData['assets'] as List? ?? []; @@ -128,22 +93,14 @@ class UpdateChecker { } if (name.contains('arm64') || name.contains('v8a')) { arm64Url = downloadUrl; - } else if (name.contains('arm32') || name.contains('v7a') || name.contains('armeabi')) { - arm32Url = downloadUrl; } else if (name.contains('universal')) { universalUrl = downloadUrl; } } } - String? apkUrl; - if (deviceArch == 'arm64') { - apkUrl = arm64Url ?? universalUrl ?? arm32Url; - } else if (deviceArch == 'arm32') { - apkUrl = arm32Url ?? universalUrl; - } else { - apkUrl = universalUrl ?? arm64Url ?? arm32Url; - } + // Only arm64 is supported; fall back to universal if available + final apkUrl = arm64Url ?? universalUrl; _log.i('Update available: $latestVersion (prerelease: $isPrerelease), APK URL: $apkUrl'); diff --git a/lib/utils/artist_utils.dart b/lib/utils/artist_utils.dart new file mode 100644 index 00000000..697a9a89 --- /dev/null +++ b/lib/utils/artist_utils.dart @@ -0,0 +1,15 @@ +final RegExp _artistNameSplitPattern = RegExp( + r'\s*(?:,|&|\bx\b)\s*|\s+\b(?:feat(?:uring)?|ft|with)\.?(?=\s|$)\s*', + caseSensitive: false, +); + +List splitArtistNames(String rawArtists) { + final raw = rawArtists.trim(); + if (raw.isEmpty) return const []; + + return raw + .split(_artistNameSplitPattern) + .map((part) => part.trim()) + .where((part) => part.isNotEmpty) + .toList(growable: false); +} diff --git a/lib/utils/clickable_metadata.dart b/lib/utils/clickable_metadata.dart new file mode 100644 index 00000000..e7f6d8d7 --- /dev/null +++ b/lib/utils/clickable_metadata.dart @@ -0,0 +1,577 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/screens/artist_screen.dart'; +import 'package:spotiflac_android/screens/album_screen.dart'; +import 'package:spotiflac_android/screens/home_tab.dart' + show ExtensionArtistScreen, ExtensionAlbumScreen; +import 'package:spotiflac_android/services/shell_navigation_service.dart'; +import 'package:spotiflac_android/utils/artist_utils.dart'; +import 'package:spotiflac_android/utils/logger.dart'; + +final _log = AppLogger('ClickableMetadata'); + +/// Navigate to an artist screen by searching Deezer for the artist ID. +/// +/// If [artistId] is provided and valid, navigates directly. +/// Otherwise, searches Deezer by [artistName] to resolve the ID first. +/// For extension-based content, pass [extensionId] to use ExtensionArtistScreen. +Future navigateToArtist( + BuildContext context, { + required String artistName, + String? artistId, + String? coverUrl, + String? extensionId, +}) async { + if (artistName.isEmpty) return; + + final normalizedArtistId = _normalizeArtistId(artistId); + + // If we have a valid artist ID already, navigate directly + if (normalizedArtistId != null && + _canNavigateArtistDirectly( + artistId: normalizedArtistId, + extensionId: extensionId, + )) { + _pushArtistScreen( + context, + artistId: normalizedArtistId, + artistName: artistName, + coverUrl: coverUrl, + extensionId: extensionId, + ); + return; + } + + // Search Deezer to resolve the artist ID + _showLoadingSnackBar(context, 'Looking up artist...'); + try { + final results = await PlatformBridge.searchDeezerAll( + artistName, + trackLimit: 0, + artistLimit: 3, + ); + if (!context.mounted) return; + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + + final artistList = results['artists'] as List? ?? []; + if (artistList.isEmpty) { + _showUnavailable(context, 'Artist'); + return; + } + + // Find best match - prefer exact name match (case-insensitive) + Map? bestMatch; + final lowerName = artistName.toLowerCase().trim(); + for (final a in artistList) { + if (a is Map) { + final name = (a['name'] as String? ?? '').toLowerCase().trim(); + if (name == lowerName) { + bestMatch = a; + break; + } + } + } + bestMatch ??= artistList.first as Map; + + final resolvedId = bestMatch['id'] as String? ?? ''; + final resolvedName = bestMatch['name'] as String? ?? artistName; + final resolvedImage = bestMatch['images'] as String?; + + if (resolvedId.isEmpty) { + _showUnavailable(context, 'Artist'); + return; + } + + if (!context.mounted) return; + _pushArtistScreen( + context, + artistId: resolvedId, + artistName: resolvedName, + coverUrl: resolvedImage ?? coverUrl, + ); + } catch (e) { + _log.e('Failed to look up artist "$artistName": $e', e); + if (!context.mounted) return; + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + _showUnavailable(context, 'Artist'); + } +} + +/// Navigate to an album screen by searching Deezer for the album ID. +/// +/// If [albumId] is provided and valid, navigates directly. +/// Otherwise, searches Deezer by [albumName] (optionally with [artistName]) to resolve the ID. +/// For extension-based content, pass [extensionId] to use ExtensionAlbumScreen. +Future navigateToAlbum( + BuildContext context, { + required String albumName, + String? albumId, + String? artistName, + String? coverUrl, + String? extensionId, +}) async { + if (albumName.isEmpty) return; + + // If we have a valid album ID already, navigate directly + if (albumId != null && + albumId.isNotEmpty && + albumId != 'unknown' && + albumId != 'deezer:unknown') { + _pushAlbumScreen( + context, + albumId: albumId, + albumName: albumName, + coverUrl: coverUrl, + extensionId: extensionId, + ); + return; + } + + // If it's extension-based content without an ID, can't search Deezer for it + if (extensionId != null) { + _showUnavailable(context, 'Album'); + return; + } + + // Search Deezer to resolve the album ID + _showLoadingSnackBar(context, 'Looking up album...'); + try { + // Build search query: "albumName artistName" for better accuracy + final query = artistName != null && artistName.isNotEmpty + ? '$albumName $artistName' + : albumName; + + final results = await PlatformBridge.searchDeezerAll( + query, + trackLimit: 0, + artistLimit: 0, + ); + if (!context.mounted) return; + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + + final albumList = results['albums'] as List? ?? []; + if (albumList.isEmpty) { + _showUnavailable(context, 'Album'); + return; + } + + // Find best match - prefer exact name match (case-insensitive) + Map? bestMatch; + final lowerName = albumName.toLowerCase().trim(); + for (final a in albumList) { + if (a is Map) { + final name = (a['name'] as String? ?? '').toLowerCase().trim(); + if (name == lowerName) { + bestMatch = a; + break; + } + } + } + bestMatch ??= albumList.first as Map; + + final resolvedId = bestMatch['id'] as String? ?? ''; + final resolvedName = bestMatch['name'] as String? ?? albumName; + final resolvedImage = bestMatch['images'] as String?; + + if (resolvedId.isEmpty) { + _showUnavailable(context, 'Album'); + return; + } + + if (!context.mounted) return; + _pushAlbumScreen( + context, + albumId: resolvedId, + albumName: resolvedName, + coverUrl: resolvedImage ?? coverUrl, + ); + } catch (e) { + _log.e('Failed to look up album "$albumName": $e', e); + if (!context.mounted) return; + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + _showUnavailable(context, 'Album'); + } +} + +void _pushArtistScreen( + BuildContext context, { + required String artistId, + required String artistName, + String? coverUrl, + String? extensionId, +}) { + _pushViaPreferredNavigator( + context, + (context) => extensionId != null + ? ExtensionArtistScreen( + extensionId: extensionId, + artistId: artistId, + artistName: artistName, + coverUrl: coverUrl, + ) + : ArtistScreen( + artistId: artistId, + artistName: artistName, + coverUrl: coverUrl, + ), + ); +} + +void _pushAlbumScreen( + BuildContext context, { + required String albumId, + required String albumName, + String? coverUrl, + String? extensionId, +}) { + _pushViaPreferredNavigator( + context, + (context) => extensionId != null + ? ExtensionAlbumScreen( + extensionId: extensionId, + albumId: albumId, + albumName: albumName, + coverUrl: coverUrl, + ) + : AlbumScreen( + albumId: albumId, + albumName: albumName, + coverUrl: coverUrl, + tracks: const [], + ), + ); +} + +void _pushViaPreferredNavigator(BuildContext context, WidgetBuilder builder) { + final currentNavigator = Navigator.of(context); + final rootNavigator = Navigator.of(context, rootNavigator: true); + final activeTabNavigator = ShellNavigationService.activeTabNavigator(); + + final shouldRouteToTabNavigator = + identical(currentNavigator, rootNavigator) && activeTabNavigator != null; + + if (!shouldRouteToTabNavigator) { + currentNavigator.push(MaterialPageRoute(builder: builder)); + return; + } + + final currentRoute = ModalRoute.of(context); + final shouldPopCurrentRoute = + currentRoute != null && currentRoute.isFirst == false; + + if (shouldPopCurrentRoute && currentNavigator.canPop()) { + currentNavigator.pop(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!activeTabNavigator.mounted) return; + activeTabNavigator.push(MaterialPageRoute(builder: builder)); + }); + return; + } + + activeTabNavigator.push(MaterialPageRoute(builder: builder)); +} + +void _showLoadingSnackBar(BuildContext context, String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: 12), + Text(message), + ], + ), + duration: const Duration(seconds: 10), + ), + ); +} + +void _showUnavailable(BuildContext context, String type) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('$type information not available'))); +} + +/// A reusable widget that makes text tappable to navigate to an artist screen. +/// +/// Wraps the text in a GestureDetector that, when tapped, looks up the artist +/// via Deezer search and navigates to the ArtistScreen. +class ClickableArtistName extends StatefulWidget { + final String artistName; + final String? artistId; + final String? coverUrl; + final String? extensionId; + final TextStyle? style; + final int? maxLines; + final TextOverflow? overflow; + final TextAlign? textAlign; + + const ClickableArtistName({ + super.key, + required this.artistName, + this.artistId, + this.coverUrl, + this.extensionId, + this.style, + this.maxLines, + this.overflow, + this.textAlign, + }); + + @override + State createState() => _ClickableArtistNameState(); +} + +class _ClickableArtistNameState extends State { + List<_ArtistTapTarget> _artistTargets = const []; + final List _recognizers = []; + + @override + void initState() { + super.initState(); + _rebuildArtistTargets(); + } + + @override + void didUpdateWidget(covariant ClickableArtistName oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.artistName != widget.artistName || + oldWidget.artistId != widget.artistId || + oldWidget.coverUrl != widget.coverUrl || + oldWidget.extensionId != widget.extensionId) { + _rebuildArtistTargets(); + } + } + + @override + void dispose() { + _disposeRecognizers(); + super.dispose(); + } + + void _disposeRecognizers() { + for (final recognizer in _recognizers) { + recognizer.dispose(); + } + _recognizers.clear(); + } + + void _rebuildArtistTargets() { + _disposeRecognizers(); + _artistTargets = _buildArtistTapTargets(widget.artistName, widget.artistId); + if (_artistTargets.length <= 1) return; + + for (final target in _artistTargets) { + final recognizer = TapGestureRecognizer() + ..onTap = () => navigateToArtist( + context, + artistName: target.name, + artistId: target.artistId, + coverUrl: widget.coverUrl, + extensionId: _extensionIdForTarget(target), + ); + _recognizers.add(recognizer); + } + } + + String? _extensionIdForTarget(_ArtistTapTarget target) { + if (widget.extensionId == null) return null; + if (_artistTargets.length == 1) return widget.extensionId; + return target.artistId != null ? widget.extensionId : null; + } + + List _buildMultiArtistSpans() { + final spans = []; + for (var i = 0; i < _artistTargets.length; i++) { + final target = _artistTargets[i]; + spans.add( + TextSpan( + text: target.name, + style: widget.style, + recognizer: _recognizers[i], + ), + ); + if (i < _artistTargets.length - 1) { + spans.add(TextSpan(text: ', ', style: widget.style)); + } + } + return spans; + } + + @override + Widget build(BuildContext context) { + if (_artistTargets.isEmpty) { + return Text( + widget.artistName, + style: widget.style, + maxLines: widget.maxLines, + overflow: widget.overflow, + textAlign: widget.textAlign, + ); + } + + if (_artistTargets.length == 1) { + final target = _artistTargets.first; + return GestureDetector( + onTap: () => navigateToArtist( + context, + artistName: target.name, + artistId: target.artistId, + coverUrl: widget.coverUrl, + extensionId: _extensionIdForTarget(target), + ), + child: Text( + target.name, + style: widget.style, + maxLines: widget.maxLines, + overflow: widget.overflow, + textAlign: widget.textAlign, + ), + ); + } + + return Text.rich( + TextSpan(style: widget.style, children: _buildMultiArtistSpans()), + maxLines: widget.maxLines, + overflow: widget.overflow ?? TextOverflow.clip, + textAlign: widget.textAlign ?? TextAlign.start, + ); + } +} + +class _ArtistTapTarget { + final String name; + final String? artistId; + + const _ArtistTapTarget({required this.name, this.artistId}); +} + +List<_ArtistTapTarget> _buildArtistTapTargets( + String rawArtistNames, + String? rawArtistIds, +) { + final parsedNames = splitArtistNames(rawArtistNames); + if (parsedNames.isEmpty) return const []; + + final uniqueNames = []; + final seen = {}; + for (final parsed in parsedNames) { + final key = parsed.toLowerCase().replaceAll(RegExp(r'\s+'), ' ').trim(); + if (key.isEmpty || !seen.add(key)) continue; + uniqueNames.add(parsed); + } + if (uniqueNames.isEmpty) return const []; + + if (uniqueNames.length == 1) { + return [ + _ArtistTapTarget( + name: uniqueNames.first, + artistId: _normalizeArtistId(rawArtistIds), + ), + ]; + } + + final parsedIds = _parseArtistIds(rawArtistIds); + if (parsedIds.length == uniqueNames.length) { + return List<_ArtistTapTarget>.generate( + uniqueNames.length, + (index) => _ArtistTapTarget( + name: uniqueNames[index], + artistId: parsedIds[index], + ), + growable: false, + ); + } + + return uniqueNames + .map((name) => _ArtistTapTarget(name: name)) + .toList(growable: false); +} + +List _parseArtistIds(String? rawArtistIds) { + final raw = rawArtistIds?.trim(); + if (raw == null || raw.isEmpty) return const []; + + final parsed = []; + for (final part in raw.split(RegExp(r'\s*,\s*'))) { + final normalized = _normalizeArtistId(part); + if (normalized != null) { + parsed.add(normalized); + } + } + return parsed; +} + +String? _normalizeArtistId(String? artistId) { + final id = artistId?.trim(); + if (id == null || id.isEmpty || id == 'unknown' || id == 'deezer:unknown') { + return null; + } + return id; +} + +bool _canNavigateArtistDirectly({ + required String artistId, + required String? extensionId, +}) { + if (extensionId != null) return true; + if (artistId.startsWith('deezer:')) return true; + return _spotifyArtistIdPattern.hasMatch(artistId); +} + +final RegExp _spotifyArtistIdPattern = RegExp(r'^[A-Za-z0-9]{22}$'); + +/// A reusable widget that makes text tappable to navigate to an album screen. +/// +/// Wraps the text in a GestureDetector that, when tapped, looks up the album +/// via Deezer search and navigates to the AlbumScreen. +class ClickableAlbumName extends StatelessWidget { + final String albumName; + final String? albumId; + final String? artistName; + final String? coverUrl; + final String? extensionId; + final TextStyle? style; + final int? maxLines; + final TextOverflow? overflow; + final TextAlign? textAlign; + + const ClickableAlbumName({ + super.key, + required this.albumName, + this.albumId, + this.artistName, + this.coverUrl, + this.extensionId, + this.style, + this.maxLines, + this.overflow, + this.textAlign, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () => navigateToAlbum( + context, + albumName: albumName, + albumId: albumId, + artistName: artistName, + coverUrl: coverUrl, + extensionId: extensionId, + ), + child: Text( + albumName, + style: style, + maxLines: maxLines, + overflow: overflow, + textAlign: textAlign, + ), + ); + } +} diff --git a/lib/utils/lyrics_metadata_helper.dart b/lib/utils/lyrics_metadata_helper.dart new file mode 100644 index 00000000..2e17c397 --- /dev/null +++ b/lib/utils/lyrics_metadata_helper.dart @@ -0,0 +1,76 @@ +import 'dart:io'; + +import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/utils/file_access.dart'; + +bool hasEmbeddedLyricsMetadata(Map metadata) { + final lyrics = (metadata['LYRICS'] ?? '').trim(); + if (lyrics.isNotEmpty) return true; + + final unsyncedLyrics = (metadata['UNSYNCEDLYRICS'] ?? '').trim(); + if (unsyncedLyrics.isNotEmpty) return true; + + return false; +} + +String _sidecarLrcPath(String path) { + final slash = path.lastIndexOf(Platform.pathSeparator); + final dot = path.lastIndexOf('.'); + if (dot > slash) { + return '${path.substring(0, dot)}.lrc'; + } + return '$path.lrc'; +} + +Future ensureLyricsMetadataForConversion({ + required Map metadata, + required String sourcePath, + required bool shouldEmbedLyrics, + required String trackName, + required String artistName, + String spotifyId = '', + int durationMs = 0, +}) async { + if (!shouldEmbedLyrics || hasEmbeddedLyricsMetadata(metadata)) { + return; + } + + String? lyrics; + + // Prefer sidecar .lrc when available to avoid network calls. + if (!isContentUri(sourcePath)) { + try { + final lrcPath = _sidecarLrcPath(sourcePath); + final lrcFile = File(lrcPath); + if (await lrcFile.exists()) { + final content = (await lrcFile.readAsString()).trim(); + if (content.isNotEmpty) { + lyrics = content; + } + } + } catch (_) {} + } + + if (lyrics == null || lyrics.isEmpty) { + try { + final fetched = await PlatformBridge.getLyricsLRC( + spotifyId, + trackName, + artistName, + durationMs: durationMs, + ); + final normalized = fetched.trim(); + if (normalized.isNotEmpty && + normalized.toLowerCase() != '[instrumental:true]') { + lyrics = normalized; + } + } catch (_) {} + } + + if (lyrics == null || lyrics.isEmpty) { + return; + } + + metadata['LYRICS'] = lyrics; + metadata['UNSYNCEDLYRICS'] = lyrics; +} diff --git a/lib/utils/string_utils.dart b/lib/utils/string_utils.dart new file mode 100644 index 00000000..8394d430 --- /dev/null +++ b/lib/utils/string_utils.dart @@ -0,0 +1,7 @@ +String? normalizeOptionalString(String? value) { + if (value == null) return null; + final trimmed = value.trim(); + if (trimmed.isEmpty) return null; + if (trimmed.toLowerCase() == 'null') return null; + return trimmed; +} diff --git a/lib/widgets/download_service_picker.dart b/lib/widgets/download_service_picker.dart index d87386b2..881b6101 100644 --- a/lib/widgets/download_service_picker.dart +++ b/lib/widgets/download_service_picker.dart @@ -22,25 +22,49 @@ class BuiltInService { }); } -/// Default quality options for built-in services (Tidal, Qobuz, Amazon, YouTube) +/// Default quality options for built-in services /// Note: Tidal lossy (HIGH) removed - use YouTube for lossy downloads const _builtInServices = [ BuiltInService( id: 'tidal', label: 'Tidal', qualityOptions: [ - QualityOption(id: 'LOSSLESS', label: 'FLAC Lossless', description: '16-bit / 44.1kHz'), - QualityOption(id: 'HI_RES', label: 'Hi-Res FLAC', description: '24-bit / up to 96kHz'), - QualityOption(id: 'HI_RES_LOSSLESS', label: 'Hi-Res FLAC Max', description: '24-bit / up to 192kHz'), + QualityOption( + id: 'LOSSLESS', + label: 'FLAC Lossless', + description: '16-bit / 44.1kHz', + ), + QualityOption( + id: 'HI_RES', + label: 'Hi-Res FLAC', + description: '24-bit / up to 96kHz', + ), + QualityOption( + id: 'HI_RES_LOSSLESS', + label: 'Hi-Res FLAC Max', + description: '24-bit / up to 192kHz', + ), ], ), BuiltInService( id: 'qobuz', label: 'Qobuz', qualityOptions: [ - QualityOption(id: 'LOSSLESS', label: 'FLAC Lossless', description: '16-bit / 44.1kHz'), - QualityOption(id: 'HI_RES', label: 'Hi-Res FLAC', description: '24-bit / up to 96kHz'), - QualityOption(id: 'HI_RES_LOSSLESS', label: 'Hi-Res FLAC Max', description: '24-bit / up to 192kHz'), + QualityOption( + id: 'LOSSLESS', + label: 'FLAC Lossless', + description: '16-bit / 44.1kHz', + ), + QualityOption( + id: 'HI_RES', + label: 'Hi-Res FLAC', + description: '24-bit / up to 96kHz', + ), + QualityOption( + id: 'HI_RES_LOSSLESS', + label: 'Hi-Res FLAC Max', + description: '24-bit / up to 192kHz', + ), ], ), BuiltInService( @@ -54,12 +78,31 @@ const _builtInServices = [ ), ], ), + BuiltInService( + id: 'deezer', + label: 'Deezer', + qualityOptions: [ + QualityOption( + id: 'FLAC', + label: 'FLAC Lossless', + description: '16-bit / 44.1kHz (CD Quality)', + ), + ], + ), BuiltInService( id: 'youtube', label: 'YouTube', qualityOptions: [ - QualityOption(id: 'opus_256', label: 'Opus 256kbps', description: 'Best quality lossy (~8MB per track)'), - QualityOption(id: 'mp3_320', label: 'MP3 320kbps', description: 'Best compatibility (~10MB per track)'), + QualityOption( + id: 'opus_256', + label: 'Opus 256kbps', + description: 'Best quality lossy (~8MB per track)', + ), + QualityOption( + id: 'mp3_320', + label: 'MP3 320kbps', + description: 'Best compatibility (~10MB per track)', + ), ], isDisabled: false, disabledReason: null, @@ -82,7 +125,8 @@ class DownloadServicePicker extends ConsumerStatefulWidget { }); @override - ConsumerState createState() => _DownloadServicePickerState(); + ConsumerState createState() => + _DownloadServicePickerState(); /// Show the download service picker as a modal bottom sheet static void show( @@ -93,9 +137,10 @@ class DownloadServicePicker extends ConsumerStatefulWidget { required void Function(String quality, String service) onSelect, }) { final colorScheme = Theme.of(context).colorScheme; - + showModalBottomSheet( context: context, + useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(28)), @@ -112,6 +157,9 @@ class DownloadServicePicker extends ConsumerStatefulWidget { } class _DownloadServicePickerState extends ConsumerState { + static const List _youtubeOpusSupportedBitrates = [128, 256]; + static const List _youtubeMp3SupportedBitrates = [128, 256, 320]; + late String _selectedService; @override @@ -122,28 +170,76 @@ class _DownloadServicePickerState extends ConsumerState { /// Get quality options for the selected service List _getQualityOptions() { - final builtIn = _builtInServices.where((s) => s.id == _selectedService).firstOrNull; + final settings = ref.read(settingsProvider); + if (_selectedService == 'youtube') { + final opusBitrate = _nearestSupportedBitrate( + settings.youtubeOpusBitrate, + _youtubeOpusSupportedBitrates, + ); + final mp3Bitrate = _nearestSupportedBitrate( + settings.youtubeMp3Bitrate, + _youtubeMp3SupportedBitrates, + ); + return [ + QualityOption( + id: 'opus_$opusBitrate', + label: 'Opus ${opusBitrate}kbps', + description: 'Configured from YouTube settings', + ), + QualityOption( + id: 'mp3_$mp3Bitrate', + label: 'MP3 ${mp3Bitrate}kbps', + description: 'Configured from YouTube settings', + ), + ]; + } + + final builtIn = _builtInServices + .where((s) => s.id == _selectedService) + .firstOrNull; if (builtIn != null) { return builtIn.qualityOptions; } final extensionState = ref.read(extensionProvider); - final ext = extensionState.extensions.where((e) => e.id == _selectedService).firstOrNull; + final ext = extensionState.extensions + .where((e) => e.id == _selectedService) + .firstOrNull; if (ext != null && ext.qualityOptions.isNotEmpty) { return ext.qualityOptions; } // Default fallback options return [ - const QualityOption(id: 'DEFAULT', label: 'Default Quality', description: 'Best available'), + const QualityOption( + id: 'DEFAULT', + label: 'Default Quality', + description: 'Best available', + ), ]; } + int _nearestSupportedBitrate(int value, List supported) { + var nearest = supported.first; + var nearestDistance = (value - nearest).abs(); + + for (final option in supported.skip(1)) { + final distance = (value - option).abs(); + if (distance < nearestDistance || + (distance == nearestDistance && option > nearest)) { + nearest = option; + nearestDistance = distance; + } + } + + return nearest; + } + @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final extensionState = ref.watch(extensionProvider); - + final downloadExtensions = extensionState.extensions .where((ext) => ext.enabled && ext.hasDownloadProvider) .toList(); @@ -162,7 +258,10 @@ class _DownloadServicePickerState extends ConsumerState { artistName: widget.artistName, coverUrl: widget.coverUrl, ), - Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)), + Divider( + height: 1, + color: colorScheme.outlineVariant.withValues(alpha: 0.5), + ), ] else ...[ const SizedBox(height: 8), Center( @@ -181,11 +280,13 @@ class _DownloadServicePickerState extends ConsumerState { padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), child: Text( context.l10n.downloadFrom, - style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), ), ), -Padding( + Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: Wrap( spacing: 8, @@ -193,13 +294,13 @@ Padding( children: [ for (final service in _builtInServices) _ServiceChip( - label: service.isDisabled + label: service.isDisabled ? '${service.label} (${service.disabledReason})' : service.label, isSelected: _selectedService == service.id, isDisabled: service.isDisabled, - onTap: service.isDisabled - ? null + onTap: service.isDisabled + ? null : () => setState(() => _selectedService = service.id), ), for (final ext in downloadExtensions) @@ -217,11 +318,15 @@ Padding( padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), child: Text( context.l10n.downloadSelectQuality, - style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), ), ), - if (_builtInServices.any((s) => s.id == _selectedService && s.id != 'youtube')) + if (_builtInServices.any( + (s) => s.id == _selectedService && s.id != 'youtube', + )) Padding( padding: const EdgeInsets.fromLTRB(24, 0, 24, 12), child: Text( @@ -264,27 +369,27 @@ Padding( } IconData _getQualityIcon(String qualityId) { - switch (qualityId.toUpperCase()) { + final normalized = qualityId.toUpperCase(); + if (normalized.startsWith('MP3_') || normalized == 'MP3') { + return Icons.audiotrack; + } + if (normalized.startsWith('OPUS_') || normalized == 'OPUS') { + return Icons.graphic_eq; + } + + switch (normalized) { case 'HI_RES_LOSSLESS': return Icons.four_k; case 'HI_RES': return Icons.high_quality; case 'LOSSLESS': return Icons.music_note; - case 'MP3_320': - case 'MP3': - return Icons.audiotrack; - case 'OPUS': - case 'OPUS_128': - case 'OPUS_256': - return Icons.graphic_eq; default: return Icons.music_note; } } } - class _QualityOption extends StatelessWidget { final String title; final String subtitle; @@ -313,7 +418,10 @@ class _QualityOption extends StatelessWidget { ), title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)), subtitle: subtitle.isNotEmpty - ? Text(subtitle, style: TextStyle(color: colorScheme.onSurfaceVariant)) + ? Text( + subtitle, + style: TextStyle(color: colorScheme.onSurfaceVariant), + ) : null, onTap: onTap, ); @@ -344,13 +452,17 @@ class _ServiceChip extends StatelessWidget { duration: const Duration(milliseconds: 200), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), decoration: BoxDecoration( - color: isDisabled + color: isDisabled ? colorScheme.surfaceContainerHighest.withValues(alpha: 0.5) - : isSelected - ? colorScheme.primaryContainer - : colorScheme.surfaceContainerHighest, + : isSelected + ? colorScheme.primaryContainer + : colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(12), - border: isSelected ? null : Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5)), + border: isSelected + ? null + : Border.all( + color: colorScheme.outlineVariant.withValues(alpha: 0.5), + ), ), child: Row( mainAxisSize: MainAxisSize.min, @@ -366,11 +478,11 @@ class _ServiceChip extends StatelessWidget { errorBuilder: (context, error, stackTrace) => Icon( Icons.extension, size: 18, - color: isDisabled + color: isDisabled ? colorScheme.onSurfaceVariant.withValues(alpha: 0.4) - : isSelected - ? colorScheme.onPrimaryContainer - : colorScheme.onSurfaceVariant, + : isSelected + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, ), ), ), @@ -380,11 +492,11 @@ class _ServiceChip extends StatelessWidget { label, style: TextStyle( fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, - color: isDisabled + color: isDisabled ? colorScheme.onSurfaceVariant.withValues(alpha: 0.4) - : isSelected - ? colorScheme.onPrimaryContainer - : colorScheme.onSurfaceVariant, + : isSelected + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, ), ), ], @@ -419,7 +531,9 @@ class _TrackInfoHeaderState extends State<_TrackInfoHeader> { return Material( color: Colors.transparent, child: InkWell( - onTap: _isOverflowing ? () => setState(() => _expanded = !_expanded) : null, + onTap: _isOverflowing + ? () => setState(() => _expanded = !_expanded) + : null, borderRadius: const BorderRadius.only( topLeft: Radius.circular(28), topRight: Radius.circular(28), @@ -447,26 +561,39 @@ class _TrackInfoHeaderState extends State<_TrackInfoHeader> { width: 56, height: 56, fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) => Container( - width: 56, - height: 56, - color: colorScheme.surfaceContainerHighest, - child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant), - ), + errorBuilder: (context, error, stackTrace) => + Container( + width: 56, + height: 56, + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.music_note, + color: colorScheme.onSurfaceVariant, + ), + ), ) : Container( width: 56, height: 56, color: colorScheme.surfaceContainerHighest, - child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant), + child: Icon( + Icons.music_note, + color: colorScheme.onSurfaceVariant, + ), ), ), const SizedBox(width: 12), Expanded( child: LayoutBuilder( builder: (context, constraints) { - final titleStyle = Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600); - final titleSpan = TextSpan(text: widget.trackName, style: titleStyle); + final titleStyle = Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(fontWeight: FontWeight.w600); + final titleSpan = TextSpan( + text: widget.trackName, + style: titleStyle, + ); final titlePainter = TextPainter( text: titleSpan, maxLines: 1, @@ -487,17 +614,22 @@ class _TrackInfoHeaderState extends State<_TrackInfoHeader> { widget.trackName, style: titleStyle, maxLines: _expanded ? 10 : 1, - overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis, + overflow: _expanded + ? TextOverflow.visible + : TextOverflow.ellipsis, ), if (widget.artistName != null) ...[ const SizedBox(height: 2), Text( widget.artistName!, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith( + color: colorScheme.onSurfaceVariant, + ), maxLines: _expanded ? 3 : 1, - overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis, + overflow: _expanded + ? TextOverflow.visible + : TextOverflow.ellipsis, ), ], ], diff --git a/lib/widgets/playlist_picker_sheet.dart b/lib/widgets/playlist_picker_sheet.dart new file mode 100644 index 00000000..2b6fd162 --- /dev/null +++ b/lib/widgets/playlist_picker_sheet.dart @@ -0,0 +1,392 @@ +import 'dart:io'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/providers/library_collections_provider.dart'; +import 'package:spotiflac_android/services/cover_cache_manager.dart'; + +Future showAddTrackToPlaylistSheet( + BuildContext context, + WidgetRef ref, + Track track, +) async { + return showAddTracksToPlaylistSheet(context, ref, [track]); +} + +Future showAddTracksToPlaylistSheet( + BuildContext context, + WidgetRef ref, + List tracks, +) async { + if (tracks.isEmpty) return; + + if (!context.mounted) return; + + await showModalBottomSheet( + context: context, + useRootNavigator: true, + showDragHandle: true, + isScrollControlled: true, + builder: (sheetContext) { + return _PlaylistPickerSheetContent(tracks: tracks); + }, + ); +} + +class _PlaylistPickerSheetContent extends ConsumerStatefulWidget { + final List tracks; + + const _PlaylistPickerSheetContent({required this.tracks}); + + @override + ConsumerState<_PlaylistPickerSheetContent> createState() => + _PlaylistPickerSheetContentState(); +} + +class _PlaylistPickerSheetContentState + extends ConsumerState<_PlaylistPickerSheetContent> { + final Set _selectedPlaylistIds = {}; + final Set _initialDisabledIds = {}; + bool _initialized = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (!_initialized) { + final playlists = ref.read(libraryCollectionsProvider).playlists; + for (final playlist in playlists) { + final alreadyInPlaylist = + widget.tracks.every((t) => playlist.containsTrack(t)); + if (alreadyInPlaylist) { + _initialDisabledIds.add(playlist.id); + _selectedPlaylistIds.add(playlist.id); + } + } + _initialized = true; + } + } + + void _handleDone() async { + final notifier = ref.read(libraryCollectionsProvider.notifier); + final idsToAdd = _selectedPlaylistIds.difference(_initialDisabledIds); + final addedNames = []; + + for (final playlistId in idsToAdd) { + final playlist = + ref.read(libraryCollectionsProvider).playlistById(playlistId); + if (playlist != null) { + addedNames.add(playlist.name); + } + await notifier.addTracksToPlaylist(playlistId, widget.tracks); + } + + if (!mounted) return; + Navigator.of(context).pop(); + + if (addedNames.isNotEmpty) { + final name = + addedNames.length == 1 ? addedNames.first : addedNames.join(', '); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.collectionAddedToPlaylist(name)), + ), + ); + } + } + + @override + Widget build(BuildContext context) { + final playlists = ref.watch( + libraryCollectionsProvider.select((state) => state.playlists), + ); + final notifier = ref.read(libraryCollectionsProvider.notifier); + + final String subtitle; + if (widget.tracks.length == 1) { + final track = widget.tracks.first; + subtitle = '${track.name} • ${track.artistName}'; + } else { + subtitle = + '${widget.tracks.length} ${widget.tracks.length == 1 ? 'track' : 'tracks'}'; + } + + final idsToAdd = _selectedPlaylistIds.difference(_initialDisabledIds); + final hasNewSelections = idsToAdd.isNotEmpty; + + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.playlist_add), + title: Text(context.l10n.collectionAddToPlaylist), + subtitle: Text(subtitle), + ), + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.add_circle_outline), + title: Text(context.l10n.collectionCreatePlaylist), + onTap: () async { + final name = await _promptPlaylistName(context); + if (name == null || name.trim().isEmpty || !context.mounted) { + return; + } + final playlistId = await notifier.createPlaylist(name.trim()); + await notifier.addTracksToPlaylist(playlistId, widget.tracks); + setState(() { + _initialDisabledIds.add(playlistId); + _selectedPlaylistIds.add(playlistId); + }); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.collectionAddedToPlaylist(name.trim())), + ), + ); + }, + ), + if (playlists.isEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 24), + child: Text( + context.l10n.collectionNoPlaylistsYet, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ) + else + Flexible( + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 320), + child: ListView.builder( + shrinkWrap: true, + itemCount: playlists.length, + itemBuilder: (context, index) { + final playlist = playlists[index]; + final isAlreadyIn = _initialDisabledIds.contains(playlist.id); + final isSelected = _selectedPlaylistIds.contains(playlist.id); + + return ListTile( + leading: _PlaylistPickerThumbnail( + playlist: playlist, + isSelected: isSelected, + ), + title: Text(playlist.name), + subtitle: Text( + context.l10n.collectionPlaylistTracks( + playlist.tracks.length, + ), + ), + enabled: !isAlreadyIn, + onTap: !isAlreadyIn + ? () { + setState(() { + if (isSelected) { + _selectedPlaylistIds.remove(playlist.id); + } else { + _selectedPlaylistIds.add(playlist.id); + } + }); + } + : null, + ); + }, + ), + ), + ), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: () { + if (hasNewSelections) { + _handleDone(); + } else { + Navigator.of(context).pop(); + } + }, + child: Text(context.l10n.dialogDone), + ), + ), + ), + ], + ), + ); + } +} + +Future _promptPlaylistName(BuildContext context) async { + final controller = TextEditingController(); + final formKey = GlobalKey(); + + final result = await showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + title: Text(dialogContext.l10n.collectionCreatePlaylist), + content: Form( + key: formKey, + child: TextFormField( + controller: controller, + autofocus: true, + textInputAction: TextInputAction.done, + decoration: InputDecoration( + hintText: dialogContext.l10n.collectionPlaylistNameHint, + ), + validator: (value) { + final trimmed = value?.trim() ?? ''; + if (trimmed.isEmpty) { + return dialogContext.l10n.collectionPlaylistNameRequired; + } + return null; + }, + onFieldSubmitted: (_) { + if (formKey.currentState?.validate() != true) return; + Navigator.of(dialogContext).pop(controller.text.trim()); + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: Text(dialogContext.l10n.dialogCancel), + ), + FilledButton( + onPressed: () { + if (formKey.currentState?.validate() != true) return; + Navigator.of(dialogContext).pop(controller.text.trim()); + }, + child: Text(dialogContext.l10n.actionCreate), + ), + ], + ); + }, + ); + + return result; +} + +class _PlaylistPickerThumbnail extends StatelessWidget { + final UserPlaylistCollection playlist; + final bool isSelected; + + const _PlaylistPickerThumbnail({ + required this.playlist, + required this.isSelected, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + const double size = 48; + final borderRadius = BorderRadius.circular(8); + + return SizedBox( + width: size, + height: size, + child: Stack( + children: [ + ClipRRect( + borderRadius: borderRadius, + child: _buildCoverImage(colorScheme, size), + ), + if (isSelected) ...[ + Positioned.fill( + child: Container( + decoration: BoxDecoration( + color: colorScheme.primary.withValues(alpha: 0.3), + borderRadius: borderRadius, + ), + ), + ), + Positioned( + right: 2, + top: 2, + child: Container( + decoration: BoxDecoration( + color: colorScheme.primary, + shape: BoxShape.circle, + border: Border.all(color: colorScheme.primary, width: 1.5), + ), + child: Icon( + Icons.check, + color: colorScheme.onPrimary, + size: 14, + ), + ), + ), + ], + ], + ), + ); + } + + Widget _buildCoverImage(ColorScheme colorScheme, double size) { + final customCoverPath = playlist.coverImagePath; + if (customCoverPath != null && customCoverPath.isNotEmpty) { + return Image.file( + File(customCoverPath), + width: size, + height: size, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => _iconFallback(colorScheme, size), + ); + } + + String? firstCoverUrl; + for (final entry in playlist.tracks) { + final coverUrl = entry.track.coverUrl; + if (coverUrl != null && coverUrl.isNotEmpty) { + firstCoverUrl = coverUrl; + break; + } + } + + if (firstCoverUrl != null) { + final isLocalPath = + !firstCoverUrl.startsWith('http://') && + !firstCoverUrl.startsWith('https://'); + + if (isLocalPath) { + return Image.file( + File(firstCoverUrl), + width: size, + height: size, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => _iconFallback(colorScheme, size), + ); + } + + return CachedNetworkImage( + imageUrl: firstCoverUrl, + width: size, + height: size, + fit: BoxFit.cover, + memCacheWidth: (size * 2).toInt(), + cacheManager: CoverCacheManager.instance, + placeholder: (_, _) => _iconFallback(colorScheme, size), + errorWidget: (_, _, _) => _iconFallback(colorScheme, size), + ); + } + + return _iconFallback(colorScheme, size); + } + + Widget _iconFallback(ColorScheme colorScheme, double size) { + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Icon(Icons.queue_music, color: colorScheme.onSurfaceVariant), + ); + } +} diff --git a/lib/widgets/priority_settings_scaffold.dart b/lib/widgets/priority_settings_scaffold.dart new file mode 100644 index 00000000..f866b46f --- /dev/null +++ b/lib/widgets/priority_settings_scaffold.dart @@ -0,0 +1,147 @@ +import 'package:flutter/material.dart'; +import 'package:spotiflac_android/utils/app_bar_layout.dart'; + +class PrioritySettingsScaffold extends StatelessWidget { + final bool hasChanges; + final String title; + final String description; + final String infoText; + final String saveLabel; + final EdgeInsetsGeometry descriptionPadding; + final List slivers; + final Future Function() onSave; + final Future Function(BuildContext context) onConfirmDiscard; + + const PrioritySettingsScaffold({ + super.key, + required this.hasChanges, + required this.title, + required this.description, + required this.infoText, + required this.slivers, + required this.onSave, + required this.onConfirmDiscard, + this.saveLabel = 'Save', + this.descriptionPadding = const EdgeInsets.fromLTRB(16, 4, 16, 8), + }); + + Future _handleBack(BuildContext context) async { + if (!hasChanges) { + Navigator.pop(context); + return; + } + final shouldPop = await onConfirmDiscard(context); + if (shouldPop && context.mounted) { + Navigator.pop(context); + } + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final topPadding = normalizedHeaderTopPadding(context); + + return PopScope( + canPop: !hasChanges, + onPopInvokedWithResult: (didPop, result) async { + if (didPop) return; + final shouldPop = await onConfirmDiscard(context); + if (shouldPop && context.mounted) { + Navigator.pop(context); + } + }, + child: Scaffold( + body: CustomScrollView( + slivers: [ + SliverAppBar( + expandedHeight: 120 + topPadding, + collapsedHeight: kToolbarHeight, + floating: false, + pinned: true, + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => _handleBack(context), + ), + actions: [ + if (hasChanges) + TextButton(onPressed: onSave, child: Text(saveLabel)), + ], + flexibleSpace: LayoutBuilder( + builder: (context, constraints) { + final maxHeight = 120 + topPadding; + final minHeight = kToolbarHeight + topPadding; + final expandRatio = + ((constraints.maxHeight - minHeight) / + (maxHeight - minHeight)) + .clamp(0.0, 1.0); + final leftPadding = 56 - (32 * expandRatio); + return FlexibleSpaceBar( + expandedTitleScale: 1.0, + titlePadding: EdgeInsets.only( + left: leftPadding, + bottom: 16, + ), + title: Text( + title, + style: TextStyle( + fontSize: 20 + (8 * expandRatio), + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + ); + }, + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: descriptionPadding, + child: Text( + description, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ), + ...slivers, + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(16), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.tertiaryContainer.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon( + Icons.info_outline, + size: 20, + color: colorScheme.tertiary, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + infoText, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: colorScheme.onTertiaryContainer, + ), + ), + ), + ], + ), + ), + ), + ), + const SliverToBoxAdapter(child: SizedBox(height: 32)), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/track_collection_quick_actions.dart b/lib/widgets/track_collection_quick_actions.dart new file mode 100644 index 00000000..d21a67fe --- /dev/null +++ b/lib/widgets/track_collection_quick_actions.dart @@ -0,0 +1,271 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/providers/library_collections_provider.dart'; +import 'package:spotiflac_android/services/cover_cache_manager.dart'; +import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart'; +import 'package:spotiflac_android/utils/clickable_metadata.dart'; + +class TrackCollectionQuickActions extends ConsumerWidget { + final Track track; + + const TrackCollectionQuickActions({super.key, required this.track}); + + static void showTrackOptionsSheet( + BuildContext context, + WidgetRef ref, + Track track, + ) { + final colorScheme = Theme.of(context).colorScheme; + showModalBottomSheet( + context: context, + useRootNavigator: true, + isScrollControlled: true, + backgroundColor: colorScheme.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + builder: (sheetContext) => _TrackOptionsSheet(track: track), + ); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colorScheme = Theme.of(context).colorScheme; + + return IconButton( + icon: Icon( + Icons.more_vert, + color: colorScheme.onSurfaceVariant, + size: 20, + ), + onPressed: () => showTrackOptionsSheet(context, ref, track), + padding: const EdgeInsets.only(left: 12), + constraints: const BoxConstraints(minWidth: 36, minHeight: 36), + ); + } +} + +class _TrackOptionsSheet extends ConsumerWidget { + final Track track; + + const _TrackOptionsSheet({required this.track}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colorScheme = Theme.of(context).colorScheme; + + final isLoved = ref.watch( + libraryCollectionsProvider.select((state) => state.isLoved(track)), + ); + final isInWishlist = ref.watch( + libraryCollectionsProvider.select((state) => state.isInWishlist(track)), + ); + + return SafeArea( + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.82, + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Header with drag handle + track info (matches _TrackInfoHeader) + Column( + children: [ + const SizedBox(height: 8), + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: colorScheme.onSurfaceVariant.withValues( + alpha: 0.4, + ), + borderRadius: BorderRadius.circular(2), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: + track.coverUrl != null && + track.coverUrl!.isNotEmpty + ? CachedNetworkImage( + imageUrl: track.coverUrl!, + width: 56, + height: 56, + fit: BoxFit.cover, + memCacheWidth: 112, + cacheManager: CoverCacheManager.instance, + errorWidget: (context, url, error) => + Container( + width: 56, + height: 56, + color: + colorScheme.surfaceContainerHighest, + child: Icon( + Icons.music_note, + color: colorScheme.onSurfaceVariant, + ), + ), + ) + : Container( + width: 56, + height: 56, + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.music_note, + color: colorScheme.onSurfaceVariant, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + track.name, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.w600), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + ClickableArtistName( + artistName: track.artistName, + artistId: track.artistId, + coverUrl: track.coverUrl, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + ), + ], + ), + Divider( + height: 1, + color: colorScheme.outlineVariant.withValues(alpha: 0.5), + ), + + // Action items (matches _QualityOption style) + _OptionTile( + icon: isLoved ? Icons.favorite : Icons.favorite_border, + iconColor: isLoved ? colorScheme.error : null, + title: isLoved + ? context.l10n.trackOptionRemoveFromLoved + : context.l10n.trackOptionAddToLoved, + onTap: () async { + Navigator.pop(context); + final added = await ref + .read(libraryCollectionsProvider.notifier) + .toggleLoved(track); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + added + ? context.l10n.collectionAddedToLoved(track.name) + : context.l10n.collectionRemovedFromLoved( + track.name, + ), + ), + ), + ); + }, + ), + _OptionTile( + icon: isInWishlist + ? Icons.playlist_add_check_circle + : Icons.add_circle_outline, + iconColor: isInWishlist ? colorScheme.primary : null, + title: isInWishlist + ? context.l10n.trackOptionRemoveFromWishlist + : context.l10n.trackOptionAddToWishlist, + onTap: () async { + Navigator.pop(context); + final added = await ref + .read(libraryCollectionsProvider.notifier) + .toggleWishlist(track); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + added + ? context.l10n.collectionAddedToWishlist(track.name) + : context.l10n.collectionRemovedFromWishlist( + track.name, + ), + ), + ), + ); + }, + ), + _OptionTile( + icon: Icons.playlist_add, + title: context.l10n.collectionAddToPlaylist, + onTap: () { + Navigator.pop(context); + showAddTrackToPlaylistSheet(context, ref, track); + }, + ), + + const SizedBox(height: 16), + ], + ), + ), + ), + ); + } +} + +/// Styled like _QualityOption in download_service_picker.dart +class _OptionTile extends StatelessWidget { + final IconData icon; + final Color? iconColor; + final String title; + final VoidCallback onTap; + + const _OptionTile({ + required this.icon, + this.iconColor, + required this.title, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4), + leading: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + icon, + color: iconColor ?? colorScheme.onPrimaryContainer, + size: 20, + ), + ), + title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)), + onTap: onTap, + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index a250b404..4fc7941f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -233,14 +233,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" - cupertino_icons: - dependency: "direct main" - description: - name: cupertino_icons - sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 - url: "https://pub.dev" - source: hosted - version: "1.0.8" dart_style: dependency: transitive description: @@ -273,22 +265,6 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.3" - dio: - dependency: "direct main" - description: - name: dio - sha256: d90ee57923d1828ac14e492ca49440f65477f4bb1263575900be731a3dac66a9 - url: "https://pub.dev" - source: hosted - version: "5.9.0" - dio_web_adapter: - dependency: transitive - description: - name: dio_web_adapter - sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" - url: "https://pub.dev" - source: hosted - version: "2.1.1" dynamic_color: dependency: "direct main" description: @@ -313,11 +289,11 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" - ffmpeg_kit_flutter_new_audio: + ffmpeg_kit_flutter_new_full: dependency: "direct main" description: - name: ffmpeg_kit_flutter_new_audio - sha256: "0a698b46cd163c8e9917af75325c84d27871a2a8b2c37de3b40486cd0ab662ae" + name: ffmpeg_kit_flutter_new_full + sha256: "48938db8d1bfb5ab4409d4291aedf99563e033dd7430ce41b5a677945a821679" url: "https://pub.dev" source: hosted version: "2.0.0" @@ -386,34 +362,34 @@ packages: dependency: "direct main" description: name: flutter_local_notifications - sha256: "76cd20bcfa72fabe50ea27eeaf165527f446f55d3033021462084b87805b4cac" + sha256: "0d9035862236fe38250fe1644d7ed3b8254e34a21b2c837c9f539fbb3bba5ef1" url: "https://pub.dev" source: hosted - version: "20.0.0" + version: "21.0.0" flutter_local_notifications_linux: dependency: transitive description: name: flutter_local_notifications_linux - sha256: dce0116868cedd2cdf768af0365fc37ff1cbef7c02c4f51d0587482e625868d0 + sha256: e0f25e243c6c44c825bbbc6b2b2e76f7d9222362adcfe9fd780bf01923c840bd url: "https://pub.dev" source: hosted - version: "7.0.0" + version: "8.0.0" flutter_local_notifications_platform_interface: dependency: transitive description: name: flutter_local_notifications_platform_interface - sha256: "23de31678a48c084169d7ae95866df9de5c9d2a44be3e5915a2ff067aeeba899" + sha256: e7db3d5b49c2b7ecc68deba4aaaa67a348f92ee0fef34c8e4b4459dbef0d7307 url: "https://pub.dev" source: hosted - version: "10.0.0" + version: "11.0.0" flutter_local_notifications_windows: dependency: transitive description: name: flutter_local_notifications_windows - sha256: "7ddd964fa85b6a23e96956c5b63ef55cdb9e5947b71b95712204db42ad46da61" + sha256: "3a2654ba104fbb52c618ebed9def24ef270228470718c43b3a6afcd5c81bef0c" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "3.0.0" flutter_localizations: dependency: "direct main" description: flutter @@ -483,14 +459,6 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.0" - flutter_svg: - dependency: "direct main" - description: - name: flutter_svg - sha256: "87fbd7c534435b6c5d9d98b01e1fd527812b82e68ddd8bd35fc45ed0fa8f0a95" - url: "https://pub.dev" - source: hosted - version: "2.2.3" flutter_test: dependency: "direct dev" description: flutter @@ -593,10 +561,10 @@ packages: dependency: transitive description: name: js - sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" url: "https://pub.dev" source: hosted - version: "0.6.7" + version: "0.7.2" json_annotation: dependency: "direct main" description: @@ -670,7 +638,7 @@ packages: source: hosted version: "0.12.17" material_color_utilities: - dependency: "direct main" + dependency: transitive description: name: material_color_utilities sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec @@ -749,14 +717,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" - path_parsing: - dependency: transitive - description: - name: path_parsing - sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" - url: "https://pub.dev" - source: hosted - version: "1.1.0" path_provider: dependency: "direct main" description: @@ -934,7 +894,7 @@ packages: source: hosted version: "1.0.0-dev.8" riverpod_annotation: - dependency: "direct main" + dependency: transitive description: name: riverpod_annotation sha256: cc1474bc2df55ec3c1da1989d139dcef22cd5e2bd78da382e867a69a8eca2e46 @@ -1230,10 +1190,10 @@ packages: dependency: transitive description: name: timezone - sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1 + sha256: "784a5e34d2eb62e1326f24d6f600aaaee452eb8ca8ef2f384a59244e292d158b" url: "https://pub.dev" source: hosted - version: "0.10.1" + version: "0.11.0" typed_data: dependency: transitive description: @@ -1314,30 +1274,6 @@ packages: url: "https://pub.dev" source: hosted version: "4.5.2" - vector_graphics: - dependency: transitive - description: - name: vector_graphics - sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6 - url: "https://pub.dev" - source: hosted - version: "1.1.19" - vector_graphics_codec: - dependency: transitive - description: - name: vector_graphics_codec - sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" - url: "https://pub.dev" - source: hosted - version: "1.1.13" - vector_graphics_compiler: - dependency: transitive - description: - name: vector_graphics_compiler - sha256: d354a7ec6931e6047785f4db12a1f61ec3d43b207fc0790f863818543f8ff0dc - url: "https://pub.dev" - source: hosted - version: "1.1.19" vector_math: dependency: transitive description: @@ -1436,4 +1372,4 @@ packages: version: "3.1.3" sdks: dart: ">=3.10.0 <4.0.0" - flutter: ">=3.35.0" + flutter: ">=3.38.1" diff --git a/pubspec.yaml b/pubspec.yaml index 53a9491b..ff4f6473 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music publish_to: "none" -version: 3.6.8+82 +version: 3.7.1+104 environment: sdk: ^3.10.0 @@ -17,7 +17,6 @@ dependencies: # State Management flutter_riverpod: ^3.1.0 - riverpod_annotation: ^4.0.0 # Navigation go_router: ^17.0.1 @@ -31,18 +30,14 @@ dependencies: # HTTP & Network http: ^1.6.0 - dio: ^5.8.0 connectivity_plus: 7.0.0 # UI Components - cupertino_icons: ^1.0.8 cached_network_image: ^3.4.1 flutter_cache_manager: ^3.4.1 - flutter_svg: ^2.1.0 # Material Expressive 3 / Dynamic Color dynamic_color: ^1.7.0 - material_color_utilities: ">=0.11.1 <0.14.0" # Permissions permission_handler: ^12.0.1 @@ -61,11 +56,11 @@ dependencies: logger: ^2.5.0 # FFmpeg for audio conversion - ffmpeg_kit_flutter_new_audio: ^2.0.0 + ffmpeg_kit_flutter_new_full: ^2.0.0 open_filex: ^4.7.0 # Notifications - flutter_local_notifications: 20.0.0 + flutter_local_notifications: 21.0.0 dev_dependencies: flutter_test: @@ -80,10 +75,13 @@ flutter_launcher_icons: android: true ios: true image_path: "icon.png" - adaptive_icon_background: "#1a1a2e" - adaptive_icon_foreground: "icon.png" + image_path_android: "icon_android.png" + adaptive_icon_background: "#000000" + adaptive_icon_foreground: "icon_foreground_android.png" + adaptive_icon_foreground_inset: 16 ios_content_mode: scaleAspectFill remove_alpha_ios: true + background_color_ios: "#000000" flutter: uses-material-design: true diff --git a/site/partners.html b/site/partners.html index 58af382e..0674391a 100644 --- a/site/partners.html +++ b/site/partners.html @@ -523,6 +523,23 @@
+ + + +
+
+ +
+
+
Ruubiiiii
+
Qobuz and Deezer download API provider. Hosts the MusicDL API that powers both Qobuz lossless (up to 24-bit/192kHz) and Deezer FLAC (CD Quality) downloads in SpotiFLAC.
+ + Ruubiiiii + + +
+
+