diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 04ade01e..1129349f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -309,32 +309,22 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v6 + with: + fetch-depth: 0 # Full history needed for git-cliff - - name: Extract changelog for version + - name: Generate changelog with git-cliff id: changelog + uses: orhun/git-cliff-action@v4 + with: + config: cliff.toml + args: --latest --strip header + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + OUTPUT: /tmp/changelog.txt + + - name: Show generated changelog run: | - VERSION=${{ needs.get-version.outputs.version }} - VERSION_NUM=${VERSION#v} # Remove 'v' prefix - - echo "Looking for version: $VERSION_NUM" - - # Extract changelog section for this version using sed - # Find the line with version, then print until next version header or end - CHANGELOG=$(sed -n "/^## \[$VERSION_NUM\]/,/^## \[/{ /^## \[$VERSION_NUM\]/d; /^## \[/d; p; }" CHANGELOG.md) - - # If no changelog found, use default message - if [ -z "$CHANGELOG" ]; then - echo "No changelog found for version $VERSION_NUM" - CHANGELOG="See CHANGELOG.md for details." - else - echo "Found changelog content" - # Remove trailing --- separator if present (CHANGELOG uses --- between versions) - CHANGELOG=$(echo "$CHANGELOG" | sed '/^---$/d') - fi - - # Save to file for multiline support - echo "$CHANGELOG" > /tmp/changelog.txt - echo "Extracted changelog:" + echo "Generated changelog:" cat /tmp/changelog.txt - name: Download Android APK @@ -352,15 +342,13 @@ jobs: - name: Prepare release body run: | VERSION=${{ needs.get-version.outputs.version }} - cat > /tmp/release_body.txt << 'HEADER' - ### What's New - HEADER - - cat /tmp/changelog.txt >> /tmp/release_body.txt - REPO_OWNER="${{ github.repository_owner }}" REPO_NAME="${{ github.event.repository.name }}" - + + # Start with git-cliff changelog + cp /tmp/changelog.txt /tmp/release_body.txt + + # Append download section cat >> /tmp/release_body.txt << FOOTER --- @@ -404,6 +392,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v6 + with: + fetch-depth: 0 - name: Download Android APK uses: actions/download-artifact@v7 @@ -417,52 +407,40 @@ jobs: name: ios-ipa path: ./release - - name: Extract changelog for version + - name: Generate changelog with git-cliff for Telegram + uses: orhun/git-cliff-action@v4 + with: + config: cliff.toml + args: --latest --strip all + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + OUTPUT: /tmp/cliff_tg.txt + + - name: Convert changelog for Telegram id: changelog run: | - VERSION=${{ needs.get-version.outputs.version }} - VERSION_NUM=${VERSION#v} - - # Extract changelog, limit to ~2500 chars for Telegram (4096 limit minus message overhead) - # Use tr -d '\r' to handle CRLF line endings from Windows - FULL_CHANGELOG=$(cat CHANGELOG.md | tr -d '\r' | sed -n "/^## \[$VERSION_NUM\]/,/^## \[/{ /^## \[$VERSION_NUM\]/d; /^## \[/d; p; }" | sed '/^---$/d') - - echo "DEBUG: Extracted changelog length: ${#FULL_CHANGELOG}" - echo "DEBUG: First 200 chars: ${FULL_CHANGELOG:0:200}" - - if [ -z "$FULL_CHANGELOG" ]; then - CHANGELOG="See release notes on GitHub for details." + if [ ! -s /tmp/cliff_tg.txt ]; then + echo "See release notes on GitHub for details." > /tmp/changelog.txt else - # Convert GitHub Markdown to Telegram HTML: - # - **text** → text - # - `code` → code - # - ### Header → Header - # - Escape HTML special chars first - # - Remove > blockquote prefix - CHANGELOG=$(echo "$FULL_CHANGELOG" | \ - sed 's/^> //' | \ + # Convert Markdown to Telegram HTML + CHANGELOG=$(cat /tmp/cliff_tg.txt | \ + sed '/^\*\*Full Changelog\*\*/d' | \ + sed 's/\[#\([0-9]*\)\]([^)]*)/#\1/g' | \ + sed 's/\[@\([^]]*\)\]([^)]*)/@\1/g' | \ sed 's/&/\&/g' | \ sed 's//\>/g' | \ - sed 's/`\([^`]*\)`/\1<\/code>/g' | \ sed 's/\*\*\([^*]*\)\*\*/\1<\/b>/g' | \ sed 's/^### \(.*\)$/\1<\/b>/g' | \ sed 's/^## \(.*\)$/\1<\/b>/g' | \ - sed 's/^- /• /g' | \ - sed 's/^ - / ◦ /g') - - # Take first 2500 characters, then cut at last complete line + sed 's/^- /• /g') + + # Truncate for Telegram 4096 char limit CHANGELOG=$(echo "$CHANGELOG" | head -c 2500 | sed '$d') - - # Check if truncated - FULL_LEN=${#FULL_CHANGELOG} - if [ $FULL_LEN -gt 2500 ]; then - CHANGELOG="${CHANGELOG}"$'\n\n... (see full changelog on GitHub)' - fi + echo "$CHANGELOG" > /tmp/changelog.txt fi - echo "$CHANGELOG" > /tmp/changelog.txt - echo "DEBUG: Final changelog:" + echo "Telegram changelog:" cat /tmp/changelog.txt - name: Send to Telegram Channel diff --git a/.gitignore b/.gitignore index e2e2649f..3c57112b 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,9 @@ Thumbs.db # Kiro specs (development only) .kiro/ +# Design assets (banners, mockups) +design/ + # Reference folder (development only) referensi/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 25d41bf0..ec8b7254 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,54 @@ # Changelog +## [3.7.2] - 2026-03-07 + +### Changed + +- **Amazon Music is now an Extension**: Amazon Music has been moved from a built-in service to a separate installable extension. Install the "Amazon Music" extension from the Store to continue using it. + +### Fixed + +- **Deezer Downloads Timing Out**: Deezer downloads were failing with "context deadline exceeded" on larger files. Now uses the proper download timeout, matching Tidal and Qobuz. +- **iOS Local Library Scan Fails**: Local library scanning was failing on iOS because the app lost access to user-picked folders after the FilePicker session ended. Implemented iOS security-scoped bookmark system: + - When a library folder is picked on iOS, a security-scoped bookmark is created and persisted in settings (`localLibraryBookmark`) + - Before each scan, the bookmark is resolved and security-scoped access is started; access is released in `finally` block after scan completes + - `cleanupMissingFiles` also activates the bookmark before checking file existence on iOS + - New `AppDelegate.swift` method channel handlers: `createIosBookmarkFromPath`, `startAccessingIosBookmark`, `stopAccessingIosBookmark`, `resolveIosBookmark` + - New `PlatformBridge` methods: `createIosBookmarkFromPath()`, `startAccessingIosBookmark()`, `stopAccessingIosBookmark()` + - All scan call-sites (Library Settings, Queue tab, Local Album screen) now pass the iOS bookmark to `startScan()` + +### Added + +- **Amazon Music Extension**: Available in `extension/Amazon-SpotiFLAC/` — same functionality as before, now as an installable extension. +- **Accessibility Tooltips**: Added localized tooltips to all `IconButton` and `PopupMenuButton` widgets across the entire UI for screen reader and long-press discoverability + - Back buttons use `MaterialLocalizations.backButtonTooltip` + - Close buttons use `MaterialLocalizations.closeButtonTooltip` + - Menu buttons use `MaterialLocalizations.showMenuTooltip` + - Search buttons use `MaterialLocalizations.searchFieldLabel` + - Contextual actions use descriptive labels: "Play track", "Dismiss", "Clear search", "Change folder", "Refresh" + - Screens affected: Album, Artist, Playlist, Downloaded Album, Local Album, Home, Search, Queue, Library Playlists, Library Tracks Folder, Setup, Tutorial, Track Metadata, Store, Extension Store Details, and all Settings sub-pages (About, Appearance, Cache Management, Donate, Download, Extensions, Extension Detail, Library, Log, Options, Provider Priority) +- **Semantics Wrappers**: Added `Semantics` widgets to interactive elements that previously had no accessibility information + - Album tiles in Artist screen: announces selection state and album name + - Recently downloaded track tiles in Home tab: announces track name and artist + - Explore items (albums/artists/playlists) in Home tab: announces item type and name + - Color palette picker in Appearance settings: announces selected state and color hex value + - Download button demo in Tutorial screen: added `ExcludeSemantics` on icon to prevent duplicate screen reader announcements + - Queue tab playlist cards: announces playlist name and item count + - Queue tab downloaded album cards: announces album name, artist, and track count + - Queue tab local album cards: announces album name, artist, and track count + - Queue tab play button on completed downloads: announces track name and artist with `ExcludeSemantics` on icon + - Queue tab download status indicators: "Finalizing download", "Download completed", "Downloaded file missing" labels with `ExcludeSemantics` on icons + +### Improved + +- **Code Formatting**: Reformatted and corrected indentation across multiple files to comply with Dart style guidelines + - `extension_detail_page.dart`: Fixed `SliverAppBar` and all subsequent slivers indentation (was 2 spaces short) + - `log_screen.dart`: Fixed `SliverAppBar` indentation alignment + - `donate_page.dart`: Reformatted ternary expressions and `_cr` function body + - `library_tracks_folder_screen.dart`: Minor line-break formatting + +--- + ## [3.7.1] - 2026-03-06 ### Added diff --git a/README.md b/README.md index 83669b33..c99cb71b 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,13 @@ -[![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) -
- - -Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music — no account required. - -![Android](https://img.shields.io/badge/Android-7.0%2B-3DDC84?style=for-the-badge&logo=android&logoColor=white) -![iOS](https://img.shields.io/badge/iOS-14.0%2B-000000?style=for-the-badge&logo=apple&logoColor=white) + + + + SpotiFLAC Mobile +
-### [Download](https://github.com/zarzet/SpotiFLAC-Mobile/releases) - ## Screenshots

@@ -24,6 +17,17 @@ Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music — no acc

+
+ +[![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) + +[![Telegram Channel](https://img.shields.io/badge/CHANNEL-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/spotiflac) +[![Telegram Community](https://img.shields.io/badge/COMMUNITY-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/spotiflac_chat) + +
+ ## Extensions Extensions allow the community to add new music sources and features without waiting for app updates. When a streaming service API changes or a new source becomes available, extensions can be updated independently. @@ -43,18 +47,13 @@ Want to create your own extension? Check out the [Extension Development Guide](h ### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC) Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux -## Telegram - -[![Telegram Channel](https://img.shields.io/badge/CHANNEL-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/spotiflac) -[![Telegram Community](https://img.shields.io/badge/COMMUNITY-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/spotiflac_chat) - ## FAQ **Q: Why is my download failing with "Song not found"?** -A: The track may not be available on Tidal, Qobuz, or Amazon Music. Try enabling more download services in Settings > Download > Provider Priority, or install additional extensions from the Store. +A: The track may not be available on the streaming services. Try enabling more download services in Settings > Download > Provider Priority, or install additional extensions like Amazon Music from the Store. **Q: Why are some tracks downloading in lower quality?** -A: Quality depends on what's available from the streaming service. Tidal offers up to 24-bit/192kHz, Qobuz up to 24-bit/192kHz, and Amazon up to 24-bit/48kHz. The app automatically selects the best available quality. +A: Quality depends on what's available from the streaming service and extensions. Built-in providers: Tidal offers up to 24-bit/192kHz, Qobuz up to 24-bit/192kHz, and Deezer up to 16-bit/44.1kHz. **Q: Can I download playlists?** A: Yes! Just paste the playlist URL in the search bar. The app will fetch all tracks and queue them for download. @@ -75,23 +74,6 @@ _If this software is useful and brings you value, consider supporting the projec [![Ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/zarzet) - -## Disclaimer - -This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement. - -**SpotiFLAC** is a third-party tool and is not affiliated with, endorsed by, or connected to Tidal, Qobuz, Amazon Music, Deezer, or any other streaming service. - -The application is purely a user interface that facilitates communication between your device and existing third-party services. - -You are solely responsible for: -1. Ensuring your use of this software complies with your local laws. -2. Reading and adhering to the Terms of Service of the respective platforms. -3. Any legal consequences resulting from the misuse of this tool. - -The software is provided "as is", without warranty of any kind. The author assumes no liability for any bans, damages, or legal issues arising from its use. - - ## API Credits [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) 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 c30a1775..5d5650da 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -766,6 +766,27 @@ class MainActivity: FlutterFragmentActivity() { val response = downloader(req.toString()) val respObj = JSONObject(response) if (respObj.optBoolean("success", false)) { + // Extension providers write to a local temp path instead of the SAF FD. + // Copy the local file into the SAF document so it is not empty. + val goFilePath = respObj.optString("file_path", "") + if (goFilePath.isNotEmpty() && + !goFilePath.startsWith("content://") && + !goFilePath.startsWith("/proc/self/fd/") + ) { + try { + val srcFile = java.io.File(goFilePath) + if (srcFile.exists() && srcFile.length() > 0) { + contentResolver.openOutputStream(document.uri, "wt")?.use { output -> + srcFile.inputStream().use { input -> + input.copyTo(output) + } + } + srcFile.delete() + } + } catch (e: Exception) { + android.util.Log.w("SpotiFLAC", "Failed to copy extension output to SAF: ${e.message}") + } + } respObj.put("file_path", document.uri.toString()) respObj.put("file_name", document.name ?: fileName) } else { @@ -786,6 +807,72 @@ class MainActivity: FlutterFragmentActivity() { } } + /** + * Get the parent DocumentFile directory for a SAF document URI. + * The child URI must be a tree-based document URI (e.g. from SAF tree scan). + * Returns a DocumentFile that supports findFile() for sibling lookup. + */ + private fun safParentDir(childUri: Uri): DocumentFile? { + try { + val docId = android.provider.DocumentsContract.getDocumentId(childUri) + if (docId.isNullOrEmpty()) return null + + // Document IDs typically look like "primary:Music/Album/file.cue" + // Parent would be "primary:Music/Album" + val lastSlash = docId.lastIndexOf('/') + if (lastSlash <= 0) return null + + val parentDocId = docId.substring(0, lastSlash) + + // Build a tree document URI for the parent so it supports listing/findFile + val treeDocId = android.provider.DocumentsContract.getTreeDocumentId(childUri) + if (treeDocId.isNullOrEmpty()) return null + + val parentUri = android.provider.DocumentsContract.buildDocumentUriUsingTree( + childUri, parentDocId + ) + return DocumentFile.fromTreeUri(this, parentUri) + ?: DocumentFile.fromSingleUri(this, parentUri) + } catch (e: Exception) { + android.util.Log.w("SpotiFLAC", "Failed to get SAF parent dir: ${e.message}") + return null + } + } + + /** + * Extract the audio filename referenced by a CUE sheet file. + * Reads the FILE "name" TYPE line from the .cue text. + * Returns just the filename (no path), or null if not found. + */ + private fun extractCueAudioFileName(cueTempPath: String): String? { + try { + val lines = File(cueTempPath).readLines() + for (line in lines) { + val trimmed = line.trim().let { l -> + // Strip BOM + if (l.startsWith("\uFEFF")) l.removePrefix("\uFEFF").trim() else l + } + if (trimmed.uppercase(Locale.ROOT).startsWith("FILE ")) { + val rest = trimmed.substring(5).trim() + // Parse: "filename" TYPE or filename TYPE + val filename = if (rest.startsWith("\"")) { + val endQuote = rest.indexOf('"', 1) + if (endQuote > 0) rest.substring(1, endQuote) else rest + } else { + // Last word is the type, everything else is the filename + val parts = rest.split("\\s+".toRegex()) + if (parts.size >= 2) parts.dropLast(1).joinToString(" ") else rest + } + // Return just the filename (strip any path separators) + return filename.substringAfterLast("/").substringAfterLast("\\") + } + } + } catch (e: Exception) { + android.util.Log.w("SpotiFLAC", "Failed to extract audio filename from CUE: ${e.message}") + } + return null + } + private fun scanSafTree(treeUriStr: String): String { if (treeUriStr.isBlank()) return "[]" @@ -799,8 +886,10 @@ class MainActivity: FlutterFragmentActivity() { it.currentFile = "Scanning folders..." } - val supportedExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg") + val supportedAudioExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg") val audioFiles = mutableListOf>() + // CUE files: (cueDoc, parentDir) — we need the parent to find sibling audio + val cueFiles = mutableListOf>() val visitedDirUris = mutableSetOf() var traversalErrors = 0 @@ -849,7 +938,9 @@ class MainActivity: FlutterFragmentActivity() { } else if (child.isFile) { val name = child.name ?: continue val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT) - if (ext.isNotBlank() && supportedExt.contains(".$ext")) { + if (ext == "cue") { + cueFiles.add(child to dir) + } else if (ext.isNotBlank() && supportedAudioExt.contains(".$ext")) { audioFiles.add(child to path) } } @@ -864,11 +955,12 @@ class MainActivity: FlutterFragmentActivity() { } } + val totalItems = audioFiles.size + cueFiles.size updateSafScanProgress { - it.totalFiles = audioFiles.size + it.totalFiles = totalItems } - if (audioFiles.isEmpty()) { + if (audioFiles.isEmpty() && cueFiles.isEmpty()) { updateSafScanProgress { it.isComplete = true it.progressPct = 100.0 @@ -880,12 +972,138 @@ class MainActivity: FlutterFragmentActivity() { var scanned = 0 var errors = traversalErrors + // --- CUE first pass: parse CUE sheets, expand to tracks, track referenced audio --- + val cueReferencedAudioUris = mutableSetOf() + + for ((cueDoc, parentDir) in cueFiles) { + if (safScanCancel) { + updateSafScanProgress { it.isComplete = true } + return "[]" + } + + val cueName = try { cueDoc.name ?: "" } catch (_: Exception) { "" } + updateSafScanProgress { it.currentFile = cueName } + + var tempCuePath: String? = null + var tempAudioPath: String? = null + try { + // Copy CUE to temp + tempCuePath = copyUriToTemp(cueDoc.uri, ".cue") + if (tempCuePath == null) { + errors++ + android.util.Log.w("SpotiFLAC", "SAF scan: failed to copy CUE ${cueDoc.uri}") + scanned++ + continue + } + + // Extract the audio filename from the CUE sheet text + val audioFileName = extractCueAudioFileName(tempCuePath) + + // Find the referenced audio file as a sibling in the same SAF directory + var audioDoc: DocumentFile? = null + if (!audioFileName.isNullOrBlank()) { + audioDoc = try { parentDir.findFile(audioFileName) } catch (_: Exception) { null } + } + + // Fallback: try common audio extensions with the CUE base name + if (audioDoc == null) { + val cueBaseName = cueName.substringBeforeLast('.') + val commonExts = listOf(".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a") + for (ext in commonExts) { + audioDoc = try { parentDir.findFile(cueBaseName + ext) } catch (_: Exception) { null } + if (audioDoc != null) break + // Try uppercase + audioDoc = try { parentDir.findFile(cueBaseName + ext.uppercase(Locale.ROOT)) } catch (_: Exception) { null } + if (audioDoc != null) break + } + } + + if (audioDoc == null) { + android.util.Log.w("SpotiFLAC", "SAF scan: no audio file found for CUE $cueName") + errors++ + scanned++ + continue + } + + // Mark this audio file so we skip it in the regular audio pass + cueReferencedAudioUris.add(audioDoc.uri.toString()) + + // Copy audio to same temp dir so Go can resolve it + val tempDir = File(tempCuePath).parent ?: cacheDir.absolutePath + val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" } + val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT) + val fallbackAudioExt = if (audioExt.isNotBlank()) ".$audioExt" else null + + tempAudioPath = copyUriToTemp(audioDoc.uri, fallbackAudioExt) + if (tempAudioPath == null) { + android.util.Log.w("SpotiFLAC", "SAF scan: failed to copy audio for CUE $cueName") + errors++ + scanned++ + continue + } + + // Rename temp audio to its original name so Go can find it by name + val renamedAudio = File(tempDir, audioName) + val tempAudioFile = File(tempAudioPath) + if (renamedAudio.absolutePath != tempAudioFile.absolutePath) { + tempAudioFile.renameTo(renamedAudio) + tempAudioPath = renamedAudio.absolutePath + } + + val cueLastModified = try { cueDoc.lastModified() } catch (_: Exception) { 0L } + + // Call Go to produce library scan entries for each CUE track + val cueResultsJson = Gobackend.scanCueSheetForLibrary( + tempCuePath, + tempDir, + cueDoc.uri.toString(), + cueLastModified + ) + + val cueArray = JSONArray(cueResultsJson) + for (j in 0 until cueArray.length()) { + results.put(cueArray.getJSONObject(j)) + } + + android.util.Log.d( + "SpotiFLAC", + "SAF scan: CUE $cueName -> ${cueArray.length()} tracks" + ) + } catch (e: Exception) { + errors++ + android.util.Log.w("SpotiFLAC", "SAF scan: error processing CUE $cueName: ${e.message}") + } finally { + try { tempCuePath?.let { File(it).delete() } } catch (_: Exception) {} + try { tempAudioPath?.let { File(it).delete() } } catch (_: Exception) {} + } + + scanned++ + val pct = scanned.toDouble() / totalItems.toDouble() * 100.0 + updateSafScanProgress { + it.scannedFiles = scanned + it.errorCount = errors + it.progressPct = pct + } + } + + // --- Regular audio file pass: skip files referenced by CUE sheets --- for ((doc, _) in audioFiles) { if (safScanCancel) { updateSafScanProgress { it.isComplete = true } return "[]" } + // Skip audio files that are represented by CUE track entries + if (cueReferencedAudioUris.contains(doc.uri.toString())) { + scanned++ + val pct = scanned.toDouble() / totalItems.toDouble() * 100.0 + updateSafScanProgress { + it.scannedFiles = scanned + it.progressPct = pct + } + continue + } + val name = try { doc.name ?: "" } catch (_: Exception) { "" } updateSafScanProgress { it.currentFile = name @@ -926,7 +1144,7 @@ class MainActivity: FlutterFragmentActivity() { } scanned++ - val pct = scanned.toDouble() / audioFiles.size.toDouble() * 100.0 + val pct = scanned.toDouble() / totalItems.toDouble() * 100.0 updateSafScanProgress { it.scannedFiles = scanned it.errorCount = errors @@ -944,6 +1162,8 @@ class MainActivity: FlutterFragmentActivity() { /** * Incremental SAF tree scan - only scans new or modified files. + * Supports .cue sheets: expands them into virtual track entries and + * deduplicates audio files referenced by CUE sheets. * @param treeUriStr The SAF tree URI to scan * @param existingFilesJson JSON object mapping file URI -> lastModified timestamp * @return JSON object with new/changed files and removed URIs @@ -986,13 +1206,29 @@ class MainActivity: FlutterFragmentActivity() { it.currentFile = "Scanning folders..." } - val supportedExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg") + val supportedAudioExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg") val audioFiles = mutableListOf>() // doc, path, lastModified + // CUE files to scan: (cueDoc, parentDir, lastModified) + val cueFilesToScan = mutableListOf>() + // Unchanged CUE files: (cueDoc, parentDir) — need to discover audio siblings for skip set + val unchangedCueFiles = mutableListOf>() val currentUris = mutableSetOf() val visitedDirUris = mutableSetOf() var traversalErrors = 0 - // Collect all audio files with lastModified + // Build a map of CUE base URIs -> existing virtual track URIs from the database. + // Virtual paths look like "content://...album.cue#track01". + // We need this to preserve virtual paths for unchanged CUE files. + val existingCueVirtualPaths = mutableMapOf>() // cueUri -> [virtualPaths] + for (key in existingFiles.keys) { + val hashIdx = key.indexOf("#track") + if (hashIdx > 0) { + val baseCueUri = key.substring(0, hashIdx) + existingCueVirtualPaths.getOrPut(baseCueUri) { mutableListOf() }.add(key) + } + } + + // Collect all files with lastModified val queue: ArrayDeque> = ArrayDeque() queue.add(root to "") @@ -1055,7 +1291,27 @@ class MainActivity: FlutterFragmentActivity() { val name = child.name ?: continue val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT) - if (ext.isNotBlank() && supportedExt.contains(".$ext")) { + + if (ext == "cue") { + val lastModified = try { + child.lastModified() + } catch (_: Exception) { 0L } + + // Check if any virtual track from this CUE exists with matching modTime + val virtualPaths = existingCueVirtualPaths[uriStr] + val existingModified = virtualPaths?.firstOrNull()?.let { existingFiles[it] } + + if (existingModified != null && existingModified == lastModified) { + // CUE is unchanged — mark virtual paths as current so they aren't removed + unchangedCueFiles.add(child to dir) + for (vp in virtualPaths) { + currentUris.add(vp) + } + } else { + // CUE is new or modified — needs scanning + cueFilesToScan.add(Triple(child, dir, lastModified)) + } + } else if (ext.isNotBlank() && supportedAudioExt.contains(".$ext")) { val existingModified = existingFiles[uriStr] val lastModified = try { child.lastModified() @@ -1083,13 +1339,14 @@ class MainActivity: FlutterFragmentActivity() { // Find removed files (in existing but not in current) val removedUris = existingFiles.keys.filter { !currentUris.contains(it) } val totalFiles = currentUris.size - val skippedCount = (totalFiles - audioFiles.size).coerceAtLeast(0) + val filesToProcess = audioFiles.size + cueFilesToScan.size + val skippedCount = (totalFiles - filesToProcess).coerceAtLeast(0) updateSafScanProgress { it.totalFiles = totalFiles } - if (audioFiles.isEmpty()) { + if (audioFiles.isEmpty() && cueFilesToScan.isEmpty()) { updateSafScanProgress { it.isComplete = true it.scannedFiles = totalFiles @@ -1107,6 +1364,173 @@ class MainActivity: FlutterFragmentActivity() { var scanned = 0 var errors = traversalErrors + // --- CUE first pass: parse new/modified CUE sheets --- + val cueReferencedAudioUris = mutableSetOf() + + for ((cueDoc, parentDir, cueLastModified) in cueFilesToScan) { + if (safScanCancel) { + updateSafScanProgress { it.isComplete = true } + val result = JSONObject() + result.put("files", JSONArray()) + result.put("removedUris", JSONArray()) + result.put("skippedCount", skippedCount) + result.put("totalFiles", totalFiles) + result.put("cancelled", true) + return result.toString() + } + + val cueName = try { cueDoc.name ?: "" } catch (_: Exception) { "" } + updateSafScanProgress { it.currentFile = cueName } + + var tempCuePath: String? = null + var tempAudioPath: String? = null + try { + // Copy CUE to temp + tempCuePath = copyUriToTemp(cueDoc.uri, ".cue") + if (tempCuePath == null) { + errors++ + android.util.Log.w("SpotiFLAC", "SAF incremental scan: failed to copy CUE ${cueDoc.uri}") + scanned++ + continue + } + + // Extract the audio filename from the CUE sheet text + val audioFileName = extractCueAudioFileName(tempCuePath) + + // Find the referenced audio file as a sibling in the same SAF directory + var audioDoc: DocumentFile? = null + if (!audioFileName.isNullOrBlank()) { + audioDoc = try { parentDir.findFile(audioFileName) } catch (_: Exception) { null } + } + + // Fallback: try common audio extensions with the CUE base name + if (audioDoc == null) { + val cueBaseName = cueName.substringBeforeLast('.') + val commonExts = listOf(".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a") + for (ext in commonExts) { + audioDoc = try { parentDir.findFile(cueBaseName + ext) } catch (_: Exception) { null } + if (audioDoc != null) break + audioDoc = try { parentDir.findFile(cueBaseName + ext.uppercase(Locale.ROOT)) } catch (_: Exception) { null } + if (audioDoc != null) break + } + } + + if (audioDoc == null) { + android.util.Log.w("SpotiFLAC", "SAF incremental scan: no audio file found for CUE $cueName") + errors++ + scanned++ + continue + } + + // Mark this audio file so we skip it in the regular audio pass + cueReferencedAudioUris.add(audioDoc.uri.toString()) + + // Copy audio to same temp dir so Go can resolve it + val tempDir = File(tempCuePath).parent ?: cacheDir.absolutePath + val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" } + val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT) + val fallbackAudioExt = if (audioExt.isNotBlank()) ".$audioExt" else null + + tempAudioPath = copyUriToTemp(audioDoc.uri, fallbackAudioExt) + if (tempAudioPath == null) { + android.util.Log.w("SpotiFLAC", "SAF incremental scan: failed to copy audio for CUE $cueName") + errors++ + scanned++ + continue + } + + // Rename temp audio to its original name so Go can find it by name + val renamedAudio = File(tempDir, audioName) + val tempAudioFile = File(tempAudioPath) + if (renamedAudio.absolutePath != tempAudioFile.absolutePath) { + tempAudioFile.renameTo(renamedAudio) + tempAudioPath = renamedAudio.absolutePath + } + + // Call Go to produce library scan entries for each CUE track + val cueResultsJson = Gobackend.scanCueSheetForLibrary( + tempCuePath, + tempDir, + cueDoc.uri.toString(), + cueLastModified + ) + + val cueArray = JSONArray(cueResultsJson) + for (j in 0 until cueArray.length()) { + val trackObj = cueArray.getJSONObject(j) + results.put(trackObj) + // Register each virtual path as current so deletion detection works + val virtualPath = trackObj.optString("filePath", "") + if (virtualPath.isNotBlank()) { + currentUris.add(virtualPath) + } + } + + android.util.Log.d( + "SpotiFLAC", + "SAF incremental scan: CUE $cueName -> ${cueArray.length()} tracks" + ) + } catch (e: Exception) { + errors++ + android.util.Log.w("SpotiFLAC", "SAF incremental scan: error processing CUE $cueName: ${e.message}") + } finally { + try { tempCuePath?.let { File(it).delete() } } catch (_: Exception) {} + try { tempAudioPath?.let { File(it).delete() } } catch (_: Exception) {} + } + + scanned++ + val processed = skippedCount + scanned + val pct = if (totalFiles > 0) { + processed.toDouble() / totalFiles.toDouble() * 100.0 + } else { + 100.0 + } + updateSafScanProgress { + it.scannedFiles = processed + it.errorCount = errors + it.progressPct = pct + } + } + + // Discover audio siblings for unchanged CUE files so we skip them + // in the regular audio pass. Copy the .cue to temp (tiny file) to extract + // the audio filename, then find the sibling by name. + for ((cueDoc, parentDir) in unchangedCueFiles) { + var tempCue: String? = null + try { + tempCue = copyUriToTemp(cueDoc.uri, ".cue") + if (tempCue != null) { + val audioFileName = extractCueAudioFileName(tempCue) + var audioDoc: DocumentFile? = null + if (!audioFileName.isNullOrBlank()) { + audioDoc = try { parentDir.findFile(audioFileName) } catch (_: Exception) { null } + } + // Fallback: try common extensions with CUE base name + if (audioDoc == null) { + val cueName = try { cueDoc.name ?: "" } catch (_: Exception) { "" } + val cueBaseName = cueName.substringBeforeLast('.') + if (cueBaseName.isNotBlank()) { + val commonExts = listOf(".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a") + for (ext in commonExts) { + audioDoc = try { parentDir.findFile(cueBaseName + ext) } catch (_: Exception) { null } + if (audioDoc != null) break + audioDoc = try { parentDir.findFile(cueBaseName + ext.uppercase(Locale.ROOT)) } catch (_: Exception) { null } + if (audioDoc != null) break + } + } + } + if (audioDoc != null) { + cueReferencedAudioUris.add(audioDoc.uri.toString()) + } + } + } catch (e: Exception) { + android.util.Log.w("SpotiFLAC", "SAF incremental scan: failed to resolve audio for unchanged CUE: ${e.message}") + } finally { + try { tempCue?.let { File(it).delete() } } catch (_: Exception) {} + } + } + + // --- Regular audio file pass: skip files referenced by CUE sheets --- for ((doc, _, lastModified) in audioFiles) { if (safScanCancel) { updateSafScanProgress { it.isComplete = true } @@ -1119,6 +1543,22 @@ class MainActivity: FlutterFragmentActivity() { return result.toString() } + // Skip audio files that are represented by CUE track entries + if (cueReferencedAudioUris.contains(doc.uri.toString())) { + scanned++ + val processed = skippedCount + scanned + val pct = if (totalFiles > 0) { + processed.toDouble() / totalFiles.toDouble() * 100.0 + } else { + 100.0 + } + updateSafScanProgress { + it.scannedFiles = processed + it.progressPct = pct + } + continue + } + val name = try { doc.name ?: "" } catch (_: Exception) { "" } updateSafScanProgress { it.currentFile = name @@ -1173,6 +1613,9 @@ class MainActivity: FlutterFragmentActivity() { } } + // Recalculate removedUris now that CUE virtual paths have been registered + val finalRemovedUris = existingFiles.keys.filter { !currentUris.contains(it) } + updateSafScanProgress { it.isComplete = true it.progressPct = 100.0 @@ -1180,7 +1623,7 @@ class MainActivity: FlutterFragmentActivity() { val result = JSONObject() result.put("files", results) - result.put("removedUris", JSONArray(removedUris)) + result.put("removedUris", JSONArray(finalRemovedUris)) result.put("skippedCount", skippedCount) result.put("totalFiles", totalFiles) return result.toString() @@ -1434,38 +1877,6 @@ class MainActivity: FlutterFragmentActivity() { } result.success(response) } - "getSpotifyMetadata" -> { - val url = call.argument("url") ?: "" - val response = withContext(Dispatchers.IO) { - Gobackend.getSpotifyMetadata(url) - } - result.success(response) - } - "searchSpotify" -> { - val query = call.argument("query") ?: "" - val limit = call.argument("limit") ?: 10 - val response = withContext(Dispatchers.IO) { - Gobackend.searchSpotify(query, limit.toLong()) - } - result.success(response) - } - "searchSpotifyAll" -> { - val query = call.argument("query") ?: "" - val trackLimit = call.argument("track_limit") ?: 15 - val artistLimit = call.argument("artist_limit") ?: 3 - val response = withContext(Dispatchers.IO) { - Gobackend.searchSpotifyAll(query, trackLimit.toLong(), artistLimit.toLong()) - } - 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") ?: "" @@ -2099,20 +2510,6 @@ class MainActivity: FlutterFragmentActivity() { "isDownloadServiceRunning" -> { result.success(DownloadService.isServiceRunning()) } - "setSpotifyCredentials" -> { - val clientId = call.argument("client_id") ?: "" - val clientSecret = call.argument("client_secret") ?: "" - withContext(Dispatchers.IO) { - Gobackend.setSpotifyAPICredentials(clientId, clientSecret) - } - result.success(null) - } - "hasSpotifyCredentials" -> { - val hasCredentials = withContext(Dispatchers.IO) { - Gobackend.checkSpotifyCredentials() - } - result.success(hasCredentials) - } "preWarmTrackCache" -> { val tracksJson = call.argument("tracks") ?: "[]" withContext(Dispatchers.IO) { @@ -2239,13 +2636,6 @@ class MainActivity: FlutterFragmentActivity() { } result.success(response) } - "getAmazonURLFromDeezerTrack" -> { - val deezerTrackId = call.argument("deezer_track_id") ?: "" - val response = withContext(Dispatchers.IO) { - Gobackend.getAmazonURLFromDeezerTrack(deezerTrackId) - } - result.success(response) - } // Log methods "getLogs" -> { val response = withContext(Dispatchers.IO) { @@ -2742,6 +3132,89 @@ class MainActivity: FlutterFragmentActivity() { } result.success(response) } + // CUE Sheet Parsing + "parseCueSheet" -> { + val cuePath = call.argument("cue_path") ?: "" + val audioDir = call.argument("audio_dir") ?: "" + val response = withContext(Dispatchers.IO) { + try { + if (cuePath.startsWith("content://")) { + val uri = Uri.parse(cuePath) + val tempCuePath = copyUriToTemp(uri, ".cue") + ?: return@withContext """{"error":"Failed to copy CUE file to temp"}""" + var tempAudioPath: String? = null + try { + // Extract audio filename from CUE text + val audioFileName = extractCueAudioFileName(tempCuePath) + + // Try to find the audio sibling in SAF + var audioDoc: DocumentFile? = null + val parentDir = safParentDir(uri) + if (parentDir != null && !audioFileName.isNullOrBlank()) { + audioDoc = try { parentDir.findFile(audioFileName) } catch (_: Exception) { null } + } + + // Fallback: try common extensions with the CUE base name + if (audioDoc == null && parentDir != null) { + val cueName = try { + DocumentFile.fromSingleUri(this@MainActivity, uri)?.name ?: "" + } catch (_: Exception) { "" } + val cueBaseName = cueName.substringBeforeLast('.') + if (cueBaseName.isNotBlank()) { + val commonExts = listOf(".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a") + for (ext in commonExts) { + audioDoc = try { parentDir.findFile(cueBaseName + ext) } catch (_: Exception) { null } + if (audioDoc != null) break + audioDoc = try { parentDir.findFile(cueBaseName + ext.uppercase(Locale.ROOT)) } catch (_: Exception) { null } + if (audioDoc != null) break + } + } + } + + val tempDir = File(tempCuePath).parent ?: cacheDir.absolutePath + if (audioDoc != null) { + // Copy audio to same temp dir with original name + val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" } + val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT) + val fallbackExt = if (audioExt.isNotBlank()) ".$audioExt" else null + val copiedAudio = copyUriToTemp(audioDoc.uri, fallbackExt) + if (copiedAudio != null) { + val renamedAudio = File(tempDir, audioName) + val copiedFile = File(copiedAudio) + if (renamedAudio.absolutePath != copiedFile.absolutePath) { + copiedFile.renameTo(renamedAudio) + } + tempAudioPath = renamedAudio.absolutePath + } + } + + // Parse with audio in temp dir; Go will resolve there + val resultJson = Gobackend.parseCueSheet(tempCuePath, tempDir) + + // Replace the temp audio_path with the SAF content:// URI + // so Dart knows it's a SAF file and handles it accordingly + if (audioDoc != null) { + val resultObj = JSONObject(resultJson) + resultObj.put("audio_path", audioDoc.uri.toString()) + // Also pass the original CUE URI for reference + resultObj.put("cue_path", cuePath) + resultObj.toString() + } else { + resultJson + } + } finally { + try { File(tempCuePath).delete() } catch (_: Exception) {} + try { tempAudioPath?.let { File(it).delete() } } catch (_: Exception) {} + } + } else { + Gobackend.parseCueSheet(cuePath, audioDir) + } + } catch (e: Exception) { + """{"error":"${e.message?.replace("\"", "'")}"}""" + } + } + result.success(response) + } else -> result.notImplemented() } } catch (e: Exception) { diff --git a/assets/images/banner-readme-dark.png b/assets/images/banner-readme-dark.png new file mode 100644 index 00000000..13c40d0b Binary files /dev/null and b/assets/images/banner-readme-dark.png differ diff --git a/assets/images/banner-readme-light.png b/assets/images/banner-readme-light.png new file mode 100644 index 00000000..2aaaba69 Binary files /dev/null and b/assets/images/banner-readme-light.png differ diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 00000000..0509818b --- /dev/null +++ b/cliff.toml @@ -0,0 +1,105 @@ +# git-cliff configuration for SpotiFLAC Mobile +# https://git-cliff.org/docs/configuration + +[changelog] +# Template for the changelog body +body = """ +{%- macro remote_url() -%} + https://github.com/zarzet/SpotiFLAC-Mobile +{%- endmacro -%} + +{% if version %}\ + ## {{ version | trim_start_matches(pat="v") }} +{% else %}\ + ## Unreleased +{% endif %}\ + +{% for group, commits in commits | group_by(attribute="group") %} + ### {{ group | striptags | trim | upper_first }} + {% for commit in commits %} + - {% if commit.scope %}**{{ commit.scope }}**: {% endif %}\ + {{ commit.message | upper_first }}\ + {% if commit.github.pr_number %} \ + ([#{{ commit.github.pr_number }}]({{ self::remote_url() }}/pull/{{ commit.github.pr_number }}))\ + {% endif %}\ + {%- if commit.github.username %} by [@{{ commit.github.username }}](https://github.com/{{ commit.github.username }}){%- endif %} + {%- endfor %} +{% endfor %} + +{%- if github.contributors | filter(attribute="is_first_time", value=true) | length != 0 %} + + ### New Contributors +{%- for contributor in github.contributors | filter(attribute="is_first_time", value=true) %} + * @{{ contributor.username }} made their first contribution + {%- if contributor.pr_number %} in \ + [#{{ contributor.pr_number }}]({{ self::remote_url() }}/pull/{{ contributor.pr_number }}) \ + {%- endif %} +{%- endfor %} +{%- endif -%} + +{% if version %} + {% if previous.version %} + **Full Changelog**: [{{ previous.version }}...{{ version }}]({{ self::remote_url() }}/compare/{{ previous.version }}...{{ version }}) + {% endif %} +{% else -%} + {% raw %}\n{% endraw %} +{% endif %} +""" +# Remove leading and trailing whitespace +trim = true + +[git] +# Parse conventional commits +conventional_commits = true +filter_unconventional = true + +# Process each line of a commit as an individual commit +split_commits = false + +# Regex for preprocessing the commit messages +commit_preprocessors = [ + # Remove PR number from message (we add it back via GitHub integration) + { pattern = '\(#(\d+)\)', replace = '' }, + # Strip conventional commit prefix for cleaner messages + # (group header already shows the type) +] + +# Regex for parsing and grouping commits +commit_parsers = [ + # Skip noise: translation commits from Crowdin + { message = "^New translations", skip = true }, + { message = "^Update source file", skip = true }, + # Skip merge commits + { message = "^Merge", skip = true }, + # Skip version bump commits + { message = "^v\\d+", skip = true }, + { message = "^chore: update VirusTotal", skip = true }, + + # Group by conventional commit type + { message = "^feat", group = "New Features" }, + { message = "^fix", group = "Bug Fixes" }, + { message = "^perf", group = "Performance" }, + { message = "^refactor", group = "Refactoring" }, + { message = "^doc", group = "Documentation" }, + { message = "^style", group = "Styling" }, + { message = "^test", group = "Testing" }, + { message = "^chore\\(deps\\)", group = "Dependencies" }, + { message = "^chore\\(l10n\\)", skip = true }, + { message = "^chore|^ci", group = "Chores" }, +] + +# Protect breaking changes from being skipped +protect_breaking_commits = true + +# Filter out commits by matching patterns +filter_commits = false + +# Tag pattern for version detection +tag_pattern = "v[0-9].*" + +# Sort commits by newest first +sort_commits = "newest" + +[remote.github] +owner = "zarzet" +repo = "SpotiFLAC-Mobile" diff --git a/go_backend/amazon.go b/go_backend/amazon.go deleted file mode 100644 index b30d669d..00000000 --- a/go_backend/amazon.go +++ /dev/null @@ -1,692 +0,0 @@ -package gobackend - -import ( - "bufio" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "os" - "path/filepath" - "regexp" - "strings" - "sync" - "time" -) - -// Amazon API timeout and retry configuration for mobile networks -const ( - amazonAPITimeoutMobile = 30 * time.Second // Longer timeout for unstable mobile networks - amazonMaxRetries = 2 // Number of retry attempts - amazonRetryDelay = 500 * time.Millisecond -) - -type AmazonDownloader struct { - client *http.Client -} - -var ( - globalAmazonDownloader *AmazonDownloader - amazonDownloaderOnce sync.Once - amazonASINRegex = regexp.MustCompile(`(?i)^B[0-9A-Z]{9}$`) - amazonASINFindRegex = regexp.MustCompile(`(?i)B[0-9A-Z]{9}`) -) - -// AfkarXYZResponse is the response from AfkarXYZ API -type AfkarXYZResponse struct { - Success bool `json:"success"` - Data struct { - DirectLink string `json:"direct_link"` - FileName string `json:"file_name"` - FileSize int64 `json:"file_size"` - } `json:"data"` -} - -// AmazonStreamResponse is the new response format from amzn.afkarxyz.fun/api/track/{asin} -type AmazonStreamResponse struct { - StreamURL string `json:"streamUrl"` - DecryptionKey string `json:"decryptionKey"` -} - -func NewAmazonDownloader() *AmazonDownloader { - amazonDownloaderOnce.Do(func() { - globalAmazonDownloader = &AmazonDownloader{ - client: NewHTTPClientWithTimeout(120 * time.Second), - } - }) - return globalAmazonDownloader -} - -// fetchAmazonURLWithRetry fetches from AfkarXYZ API with retry logic for mobile networks. -// Returns downloadURL, suggested fileName, optional decryptionKey. -func (a *AmazonDownloader) fetchAmazonURLWithRetry(amazonURL string) (string, string, string, error) { - var lastErr error - for attempt := 0; attempt <= amazonMaxRetries; attempt++ { - if attempt > 0 { - delay := amazonRetryDelay * time.Duration(1<<(attempt-1)) // Exponential backoff - GoLog("[Amazon] Retry %d/%d after %v...\n", attempt, amazonMaxRetries, delay) - time.Sleep(delay) - } - - downloadURL, fileName, decryptionKey, err := a.doAfkarXYZRequest(amazonURL) - if err == nil { - return downloadURL, fileName, decryptionKey, nil - } - - lastErr = err - errStr := strings.ToLower(err.Error()) - - // Check if error is retryable - isRetryable := strings.Contains(errStr, "timeout") || - strings.Contains(errStr, "connection reset") || - strings.Contains(errStr, "connection refused") || - strings.Contains(errStr, "eof") || - strings.Contains(errStr, "status 5") || - strings.Contains(errStr, "status 429") || - strings.Contains(errStr, "http 429") - - if !isRetryable { - return "", "", "", err - } - - GoLog("[Amazon] Attempt %d failed (retryable): %v\n", attempt+1, err) - } - - return "", "", "", fmt.Errorf("all %d attempts failed: %w", amazonMaxRetries+1, lastErr) -} - -func normalizeAmazonASIN(candidate string) string { - trimmed := strings.TrimSpace(candidate) - if trimmed == "" { - return "" - } - - if decoded, err := url.QueryUnescape(trimmed); err == nil { - trimmed = decoded - } - - trimmed = strings.ToUpper(trimmed) - if idx := strings.IndexAny(trimmed, "?#&/"); idx >= 0 { - trimmed = trimmed[:idx] - } - - if amazonASINRegex.MatchString(trimmed) { - return trimmed - } - - return "" -} - -func extractAmazonASIN(amazonURL string) string { - raw := strings.TrimSpace(amazonURL) - if raw == "" { - return "" - } - - parsed, err := url.Parse(raw) - if err == nil { - query := parsed.Query() - - // Prefer track-level ASIN when URL also contains albumAsin. - for _, key := range []string{"trackAsin", "trackasin", "trackASIN", "asin", "ASIN", "i"} { - if asin := normalizeAmazonASIN(query.Get(key)); asin != "" { - return asin - } - } - - path := strings.Trim(parsed.Path, "/") - if path != "" { - segments := strings.Split(path, "/") - - for i := 0; i < len(segments)-1; i++ { - segment := strings.ToLower(strings.TrimSpace(segments[i])) - if segment == "track" || segment == "tracks" { - if asin := normalizeAmazonASIN(segments[i+1]); asin != "" { - return asin - } - } - } - - if asin := normalizeAmazonASIN(segments[len(segments)-1]); asin != "" { - return asin - } - } - } - - match := amazonASINFindRegex.FindString(strings.ToUpper(raw)) - return normalizeAmazonASIN(match) -} - -// doAfkarXYZRequest performs a single request to Amazon API. -// It tries new endpoint first, then falls back to legacy /convert endpoint. -func (a *AmazonDownloader) doAfkarXYZRequest(amazonURL string) (string, string, string, error) { - asin := extractAmazonASIN(amazonURL) - if asin != "" { - GoLog("[Amazon] Using ASIN: %s\n", asin) - downloadURL, fileName, decryptKey, err := a.doAfkarXYZRequestNew(asin) - if err == nil { - return downloadURL, fileName, decryptKey, nil - } - GoLog("[Amazon] New API failed for ASIN %s, trying legacy endpoint: %v\n", asin, err) - } - return a.doAfkarXYZRequestLegacy(amazonURL) -} - -func (a *AmazonDownloader) doAfkarXYZRequestNew(asin string) (string, string, string, error) { - ctx, cancel := context.WithTimeout(context.Background(), amazonAPITimeoutMobile) - defer cancel() - - 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) - } - - req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36") - - resp, err := a.client.Do(req) - if err != nil { - return "", "", "", fmt.Errorf("failed to call Amazon API: %w", err) - } - defer resp.Body.Close() - - body, readErr := io.ReadAll(resp.Body) - if readErr != nil { - return "", "", "", fmt.Errorf("failed to read response: %w", readErr) - } - - if resp.StatusCode != 200 { - return "", "", "", fmt.Errorf("Amazon API returned status %d", resp.StatusCode) - } - - var apiResp AmazonStreamResponse - if err := json.Unmarshal(body, &apiResp); err != nil { - return "", "", "", fmt.Errorf("failed to decode response: %w", err) - } - - if strings.TrimSpace(apiResp.StreamURL) == "" { - return "", "", "", fmt.Errorf("Amazon API returned empty stream URL") - } - - fileName := asin + ".m4a" - return apiResp.StreamURL, fileName, strings.TrimSpace(apiResp.DecryptionKey), nil -} - -func (a *AmazonDownloader) doAfkarXYZRequestLegacy(amazonURL string) (string, string, string, error) { - ctx, cancel := context.WithTimeout(context.Background(), amazonAPITimeoutMobile) - defer cancel() - - 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) - } - - req.Header.Set("User-Agent", getRandomUserAgent()) - - resp, err := a.client.Do(req) - if err != nil { - return "", "", "", fmt.Errorf("failed to call legacy AfkarXYZ API: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return "", "", "", fmt.Errorf("legacy AfkarXYZ API returned status %d", resp.StatusCode) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", "", "", fmt.Errorf("failed to read legacy response: %w", err) - } - - var apiResp AfkarXYZResponse - if err := json.Unmarshal(body, &apiResp); err != nil { - return "", "", "", fmt.Errorf("failed to decode legacy response: %w", err) - } - - if !apiResp.Success || strings.TrimSpace(apiResp.Data.DirectLink) == "" { - return "", "", "", fmt.Errorf("legacy AfkarXYZ API failed or no download link found") - } - - fileName := apiResp.Data.FileName - if fileName == "" { - fileName = "track.flac" - } - - reg := regexp.MustCompile(`[<>:"/\\|?*]`) - fileName = reg.ReplaceAllString(fileName, "") - - return apiResp.Data.DirectLink, fileName, "", nil -} - -func (a *AmazonDownloader) downloadFromAfkarXYZ(amazonURL string) (string, string, string, error) { - GoLog("[Amazon] Fetching from AfkarXYZ API...\n") - - downloadURL, fileName, decryptionKey, err := a.fetchAmazonURLWithRetry(amazonURL) - if err != nil { - return "", "", "", err - } - - if decryptionKey != "" { - GoLog("[Amazon] AfkarXYZ returned encrypted stream (decryption key available)\n") - } - GoLog("[Amazon] AfkarXYZ returned: %s\n", fileName) - return downloadURL, fileName, decryptionKey, nil -} - -func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error { - 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 request: %w", err) - } - - req.Header.Set("User-Agent", getRandomUserAgent()) - - resp, err := a.client.Do(req) - if err != nil { - if isDownloadCancelled(itemID) { - return ErrDownloadCancelled - } - return err - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return fmt.Errorf("download failed: 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 buffer: %w", flushErr) - } - if closeErr != nil { - cleanupOutputOnError(outputPath, outputFD) - return fmt.Errorf("failed to close file: %w", closeErr) - } - - if expectedSize > 0 && written != expectedSize { - cleanupOutputOnError(outputPath, outputFD) - return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written) - } - - GoLog("[Amazon] Downloaded: %.2f MB (Complete)\n", float64(written)/(1024*1024)) - return nil -} - -// AmazonDownloadResult contains download result with quality info -type AmazonDownloadResult struct { - FilePath string - BitDepth int - SampleRate int - Title string - Artist string - Album string - ReleaseDate string - TrackNumber int - DiscNumber int - ISRC string - LyricsLRC string - 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() - - isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != "" - if !isSafOutput { - if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists { - return AmazonDownloadResult{FilePath: "EXISTS:" + existingFile}, nil - } - } - - amazonURL, err := resolveAmazonURLForRequest(req, "Amazon") - if err != nil { - return AmazonDownloadResult{}, err - } - - if !isSafOutput && req.OutputDir != "." { - if err := os.MkdirAll(req.OutputDir, 0755); err != nil { - return AmazonDownloadResult{}, fmt.Errorf("failed to create output directory: %w", err) - } - } - - // Download using AfkarXYZ API - downloadURL, afkarFileName, decryptionKey, err := downloader.downloadFromAfkarXYZ(amazonURL) - if err != nil { - return AmazonDownloadResult{}, fmt.Errorf("failed to get download URL from AfkarXYZ: %w", err) - } - - GoLog("[Amazon] Match found: '%s' by '%s'\n", req.TrackName, req.ArtistName) - - filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]any{ - "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 { - outputExt := strings.ToLower(filepath.Ext(afkarFileName)) - if outputExt == "" { - outputExt = ".flac" - } - filename = sanitizeFilename(filename) + outputExt - outputPath = filepath.Join(req.OutputDir, filename) - if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 { - return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil - } - } - - // START PARALLEL: Fetch cover and lyrics while downloading audio - 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), - ) - }() - - // Download audio file with item ID for progress tracking - if err := downloader.DownloadFile(downloadURL, outputPath, req.OutputFD, req.ItemID); err != nil { - if errors.Is(err, ErrDownloadCancelled) { - return AmazonDownloadResult{}, ErrDownloadCancelled - } - return AmazonDownloadResult{}, fmt.Errorf("download failed: %w", err) - } - - actualOutputPath := outputPath - needsDecryption := strings.TrimSpace(decryptionKey) != "" - if needsDecryption { - GoLog("[Amazon] Download requires decryption; deferring decrypt to Flutter FFmpeg path\n") - } - - // Wait for parallel operations to complete - <-parallelDone - - if req.ItemID != "" { - SetItemProgress(req.ItemID, 1.0, 0, 0) - SetItemFinalizing(req.ItemID) - } - - actualTrackNum := req.TrackNumber - actualDiscNum := req.DiscNumber - actualDate := req.ReleaseDate - actualAlbum := req.AlbumName - actualTitle := req.TrackName - actualArtist := req.ArtistName - - if !needsDecryption { - existingMeta, metaErr := ReadMetadata(actualOutputPath) - if metaErr == nil && existingMeta != nil { - if existingMeta.TrackNumber > 0 && (req.TrackNumber == 0 || req.TrackNumber == 1) { - actualTrackNum = existingMeta.TrackNumber - GoLog("[Amazon] Using track number from file: %d (request had: %d)\n", actualTrackNum, req.TrackNumber) - } - if existingMeta.DiscNumber > 0 && (req.DiscNumber == 0 || req.DiscNumber == 1) { - actualDiscNum = existingMeta.DiscNumber - GoLog("[Amazon] Using disc number from file: %d (request had: %d)\n", actualDiscNum, req.DiscNumber) - } - if existingMeta.Date != "" && req.ReleaseDate == "" { - actualDate = existingMeta.Date - GoLog("[Amazon] Using release date from file: %s\n", actualDate) - } - if existingMeta.Album != "" && req.AlbumName == "" { - actualAlbum = existingMeta.Album - GoLog("[Amazon] Using album from file: %s\n", actualAlbum) - } - GoLog("[Amazon] Existing metadata - Title: %s, Artist: %s, Album: %s, Date: %s\n", - existingMeta.Title, existingMeta.Artist, existingMeta.Album, existingMeta.Date) - } - } - - metadata := Metadata{ - Title: actualTitle, - Artist: actualArtist, - Album: actualAlbum, - AlbumArtist: req.AlbumArtist, - Date: actualDate, - TrackNumber: actualTrackNum, - TotalTracks: req.TotalTracks, - DiscNumber: actualDiscNum, - ISRC: req.ISRC, - Genre: req.Genre, - Label: req.Label, - Copyright: req.Copyright, - } - - var coverData []byte - if parallelResult != nil && parallelResult.CoverData != nil && len(parallelResult.CoverData) > 0 { - coverData = parallelResult.CoverData - GoLog("[Amazon] Using parallel-fetched cover (%d bytes)\n", len(coverData)) - } else { - existingCover, coverErr := ExtractCoverArt(actualOutputPath) - if coverErr == nil && len(existingCover) > 0 { - coverData = existingCover - GoLog("[Amazon] Using existing cover from Amazon file (%d bytes)\n", len(coverData)) - } else { - GoLog("[Amazon] No cover available (parallel fetch failed and no existing cover)\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 { - if err := EmbedMetadataWithCoverData(actualOutputPath, metadata, coverData); err != nil { - GoLog("[Amazon] Warning: failed to embed metadata: %v\n", err) - } - } else { - GoLog("[Amazon] Non-FLAC output detected (%s), skipping native metadata embedding\n", filepath.Ext(actualOutputPath)) - } - - if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { - lyricsMode := req.LyricsMode - if lyricsMode == "" { - lyricsMode = "embed" - } - - if lyricsMode == "external" || lyricsMode == "both" { - GoLog("[Amazon] Saving external LRC file...\n") - if lrcPath, lrcErr := SaveLRCFile(actualOutputPath, parallelResult.LyricsLRC); lrcErr != nil { - GoLog("[Amazon] Warning: failed to save LRC file: %v\n", lrcErr) - } else { - GoLog("[Amazon] LRC file saved: %s\n", lrcPath) - } - } - - if (lyricsMode == "embed" || lyricsMode == "both") && isFlacOutput { - GoLog("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines)) - if embedErr := EmbedLyrics(actualOutputPath, parallelResult.LyricsLRC); embedErr != nil { - GoLog("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr) - } else { - GoLog("[Amazon] Lyrics embedded successfully\n") - } - } else if (lyricsMode == "embed" || lyricsMode == "both") && !isFlacOutput { - GoLog("[Amazon] Skipping embedded lyrics for non-FLAC output\n") - } - } else if req.EmbedLyrics { - GoLog("[Amazon] No lyrics available from parallel fetch\n") - } - } - - GoLog("[Amazon] Downloaded successfully from Amazon Music\n") - - quality := AudioQuality{} - if isSafOutput || needsDecryption { - GoLog("[Amazon] SAF output detected - skipping post-write file inspection in backend\n") - } else { - quality, err = GetAudioQuality(actualOutputPath) - if err != nil { - GoLog("[Amazon] Warning: couldn't read quality from file: %v\n", err) - } else { - GoLog("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate) - } - - finalMeta, metaReadErr := ReadMetadata(actualOutputPath) - if metaReadErr == nil && finalMeta != nil { - GoLog("[Amazon] Final metadata from file - Track: %d, Disc: %d, Date: %s\n", - finalMeta.TrackNumber, finalMeta.DiscNumber, finalMeta.Date) - actualTrackNum = finalMeta.TrackNumber - actualDiscNum = finalMeta.DiscNumber - if finalMeta.Date != "" { - req.ReleaseDate = finalMeta.Date - } - } - } - - // Add to ISRC index for fast duplicate checking. - // When decryption is pending in Flutter, postpone indexing until final file is settled. - if !isSafOutput && !needsDecryption { - AddToISRCIndex(req.OutputDir, req.ISRC, actualOutputPath) - } - - bitDepth := 0 - sampleRate := 0 - if err == nil { - bitDepth = quality.BitDepth - sampleRate = quality.SampleRate - } - - lyricsLRC := "" - if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { - lyricsLRC = parallelResult.LyricsLRC - } - - return AmazonDownloadResult{ - FilePath: outputPath, - BitDepth: bitDepth, - SampleRate: sampleRate, - Title: req.TrackName, - Artist: req.ArtistName, - Album: req.AlbumName, - ReleaseDate: req.ReleaseDate, - TrackNumber: actualTrackNum, - DiscNumber: actualDiscNum, - ISRC: req.ISRC, - LyricsLRC: lyricsLRC, - DecryptionKey: decryptionKey, - }, nil -} diff --git a/go_backend/amazon_asin_test.go b/go_backend/amazon_asin_test.go deleted file mode 100644 index 705e9c01..00000000 --- a/go_backend/amazon_asin_test.go +++ /dev/null @@ -1,46 +0,0 @@ -package gobackend - -import "testing" - -func TestExtractAmazonASIN(t *testing.T) { - tests := []struct { - name string - url string - want string - }{ - { - name: "prefers trackAsin over albumAsin", - url: "https://music.amazon.com/albums/B0ALBUM123?trackAsin=B0TRACK456&musicTerritory=US", - want: "B0TRACK456", - }, - { - name: "extract from tracks path", - url: "https://music.amazon.com/tracks/B0CYQHGWZJ?musicTerritory=US", - want: "B0CYQHGWZJ", - }, - { - name: "extract from plain query asin", - url: "https://example.com/?asin=B0CYQHGWZJ", - want: "B0CYQHGWZJ", - }, - { - name: "fallback regex", - url: "https://example.com/path/B0CYQHGWZJ", - want: "B0CYQHGWZJ", - }, - { - name: "invalid url", - url: "https://music.amazon.com/tracks/not-valid", - want: "", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := extractAmazonASIN(tt.url) - if got != tt.want { - t.Fatalf("extractAmazonASIN() = %q, want %q", got, tt.want) - } - }) - } -} diff --git a/go_backend/audio_metadata.go b/go_backend/audio_metadata.go index 4ccfc627..faf4ccf2 100644 --- a/go_backend/audio_metadata.go +++ b/go_backend/audio_metadata.go @@ -12,7 +12,6 @@ import ( "strings" ) -// AudioMetadata represents common audio file metadata type AudioMetadata struct { Title string Artist string @@ -31,7 +30,6 @@ type AudioMetadata struct { Comment string } -// MP3Quality represents MP3 specific quality info type MP3Quality struct { SampleRate int BitDepth int @@ -39,7 +37,6 @@ type MP3Quality struct { Bitrate int } -// OggQuality represents Ogg/Opus specific quality info type OggQuality struct { SampleRate int BitDepth int @@ -47,10 +44,6 @@ type OggQuality struct { Bitrate int // estimated bitrate in bps } -// ============================================================================= -// ID3 Tag Reading (MP3) -// ============================================================================= - func ReadID3Tags(filePath string) (*AudioMetadata, error) { file, err := os.Open(filePath) if err != nil { @@ -1210,10 +1203,6 @@ func readLastOggGranulePosition(file *os.File, fileSize int64) int64 { return 0 } -// ============================================================================= -// ID3v1 Genre List -// ============================================================================= - var id3v1Genres = []string{ "Blues", "Classic Rock", "Country", "Dance", "Disco", "Funk", "Grunge", "Hip-Hop", "Jazz", "Metal", "New Age", "Oldies", "Other", "Pop", "R&B", @@ -1244,10 +1233,6 @@ var id3v1Genres = []string{ "Thrash Metal", "Anime", "J-Pop", "Synthpop", } -// ============================================================================= -// Cover Art Extraction -// ============================================================================= - func extractMP3CoverArt(filePath string) ([]byte, string, error) { file, err := os.Open(filePath) if err != nil { diff --git a/go_backend/cue_parser.go b/go_backend/cue_parser.go new file mode 100644 index 00000000..a938be2a --- /dev/null +++ b/go_backend/cue_parser.go @@ -0,0 +1,577 @@ +package gobackend + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" +) + +// CueSheet represents a parsed .cue file +type CueSheet struct { + // Album-level metadata + Performer string `json:"performer"` + Title string `json:"title"` + FileName string `json:"file_name"` + FileType string `json:"file_type"` // WAVE, FLAC, MP3, AIFF, etc. + Genre string `json:"genre,omitempty"` + Date string `json:"date,omitempty"` + Comment string `json:"comment,omitempty"` + Composer string `json:"composer,omitempty"` + Tracks []CueTrack `json:"tracks"` +} + +// CueTrack represents a single track in a cue sheet +type CueTrack struct { + Number int `json:"number"` + Title string `json:"title"` + Performer string `json:"performer"` + ISRC string `json:"isrc,omitempty"` + Composer string `json:"composer,omitempty"` + // Index positions in seconds (fractional) + StartTime float64 `json:"start_time"` // INDEX 01 in seconds + PreGap float64 `json:"pre_gap"` // INDEX 00 in seconds (or -1 if not present) +} + +// CueSplitInfo represents the information needed to split a CUE+audio file +type CueSplitInfo struct { + CuePath string `json:"cue_path"` + AudioPath string `json:"audio_path"` + Album string `json:"album"` + Artist string `json:"artist"` + Genre string `json:"genre,omitempty"` + Date string `json:"date,omitempty"` + Tracks []CueSplitTrack `json:"tracks"` +} + +// CueSplitTrack has the FFmpeg split parameters for a single track +type CueSplitTrack struct { + Number int `json:"number"` + Title string `json:"title"` + Artist string `json:"artist"` + ISRC string `json:"isrc,omitempty"` + Composer string `json:"composer,omitempty"` + StartSec float64 `json:"start_sec"` + EndSec float64 `json:"end_sec"` // -1 means until end of file +} + +var ( + reRemCommand = regexp.MustCompile(`^REM\s+(\S+)\s+(.+)$`) + reQuoted = regexp.MustCompile(`"([^"]*)"`) +) + +// ParseCueFile parses a .cue file and returns a CueSheet +func ParseCueFile(cuePath string) (*CueSheet, error) { + f, err := os.Open(cuePath) + if err != nil { + return nil, fmt.Errorf("failed to open cue file: %w", err) + } + defer f.Close() + + sheet := &CueSheet{} + var currentTrack *CueTrack + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + + // Handle BOM at start of file + if strings.HasPrefix(line, "\xef\xbb\xbf") { + line = strings.TrimPrefix(line, "\xef\xbb\xbf") + line = strings.TrimSpace(line) + } + + upper := strings.ToUpper(line) + + // REM commands (album-level metadata) + if strings.HasPrefix(upper, "REM ") { + matches := reRemCommand.FindStringSubmatch(line) + if len(matches) == 3 { + key := strings.ToUpper(matches[1]) + value := unquoteCue(matches[2]) + switch key { + case "GENRE": + sheet.Genre = value + case "DATE": + sheet.Date = value + case "COMMENT": + sheet.Comment = value + case "COMPOSER": + if currentTrack != nil { + currentTrack.Composer = value + } else { + sheet.Composer = value + } + } + } + continue + } + + // PERFORMER + if strings.HasPrefix(upper, "PERFORMER ") { + value := unquoteCue(line[len("PERFORMER "):]) + if currentTrack != nil { + currentTrack.Performer = value + } else { + sheet.Performer = value + } + continue + } + + // TITLE + if strings.HasPrefix(upper, "TITLE ") { + value := unquoteCue(line[len("TITLE "):]) + if currentTrack != nil { + currentTrack.Title = value + } else { + sheet.Title = value + } + continue + } + + // FILE + if strings.HasPrefix(upper, "FILE ") { + rest := line[len("FILE "):] + // Extract filename and type + // Format: FILE "filename.flac" WAVE + // or: FILE filename.flac WAVE + fname, ftype := parseCueFileLine(rest) + sheet.FileName = fname + sheet.FileType = ftype + continue + } + + // TRACK + if strings.HasPrefix(upper, "TRACK ") { + // Save previous track + if currentTrack != nil { + sheet.Tracks = append(sheet.Tracks, *currentTrack) + } + + parts := strings.Fields(line) + trackNum := 0 + if len(parts) >= 2 { + trackNum, _ = strconv.Atoi(parts[1]) + } + + currentTrack = &CueTrack{ + Number: trackNum, + PreGap: -1, + } + continue + } + + // INDEX + if strings.HasPrefix(upper, "INDEX ") && currentTrack != nil { + parts := strings.Fields(line) + if len(parts) >= 3 { + indexNum, _ := strconv.Atoi(parts[1]) + timeSec := parseCueTimestamp(parts[2]) + switch indexNum { + case 0: + currentTrack.PreGap = timeSec + case 1: + currentTrack.StartTime = timeSec + } + } + continue + } + + // ISRC + if strings.HasPrefix(upper, "ISRC ") && currentTrack != nil { + currentTrack.ISRC = strings.TrimSpace(line[len("ISRC "):]) + continue + } + + // SONGWRITER (used as composer sometimes) + if strings.HasPrefix(upper, "SONGWRITER ") { + value := unquoteCue(line[len("SONGWRITER "):]) + if currentTrack != nil { + currentTrack.Composer = value + } else { + sheet.Composer = value + } + continue + } + } + + // Don't forget the last track + if currentTrack != nil { + sheet.Tracks = append(sheet.Tracks, *currentTrack) + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("error reading cue file: %w", err) + } + + if len(sheet.Tracks) == 0 { + return nil, fmt.Errorf("no tracks found in cue file") + } + + return sheet, nil +} + +// parseCueTimestamp converts MM:SS:FF (frames at 75fps) to seconds +func parseCueTimestamp(ts string) float64 { + parts := strings.Split(ts, ":") + if len(parts) != 3 { + return 0 + } + + minutes, _ := strconv.Atoi(parts[0]) + seconds, _ := strconv.Atoi(parts[1]) + frames, _ := strconv.Atoi(parts[2]) + + return float64(minutes)*60 + float64(seconds) + float64(frames)/75.0 +} + +// formatCueTimestamp converts seconds to HH:MM:SS.mmm format for FFmpeg +func formatCueTimestamp(seconds float64) string { + if seconds < 0 { + return "0" + } + hours := int(seconds) / 3600 + mins := (int(seconds) % 3600) / 60 + secs := seconds - float64(hours*3600) - float64(mins*60) + return fmt.Sprintf("%02d:%02d:%06.3f", hours, mins, secs) +} + +// unquoteCue removes surrounding quotes from a CUE value +func unquoteCue(s string) string { + s = strings.TrimSpace(s) + if matches := reQuoted.FindStringSubmatch(s); len(matches) == 2 { + return matches[1] + } + return s +} + +// parseCueFileLine parses the FILE command's filename and type +func parseCueFileLine(rest string) (string, string) { + rest = strings.TrimSpace(rest) + + var filename, ftype string + + if strings.HasPrefix(rest, "\"") { + // Quoted filename + endQuote := strings.Index(rest[1:], "\"") + if endQuote >= 0 { + filename = rest[1 : endQuote+1] + remaining := strings.TrimSpace(rest[endQuote+2:]) + ftype = remaining + } else { + filename = rest + } + } else { + // Unquoted filename - last word is the type + parts := strings.Fields(rest) + if len(parts) >= 2 { + ftype = parts[len(parts)-1] + filename = strings.Join(parts[:len(parts)-1], " ") + } else if len(parts) == 1 { + filename = parts[0] + } + } + + return filename, strings.TrimSpace(ftype) +} + +// ResolveCueAudioPath finds the actual audio file referenced by a .cue sheet. +// It checks relative to the cue file's directory. +func ResolveCueAudioPath(cuePath string, cueFileName string) string { + cueDir := filepath.Dir(cuePath) + + // 1. Try the exact filename from the .cue + candidate := filepath.Join(cueDir, cueFileName) + if _, err := os.Stat(candidate); err == nil { + return candidate + } + + // 2. Try common case variations + baseName := strings.TrimSuffix(cueFileName, filepath.Ext(cueFileName)) + commonExts := []string{".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a"} + for _, ext := range commonExts { + candidate = filepath.Join(cueDir, baseName+ext) + if _, err := os.Stat(candidate); err == nil { + return candidate + } + // Try uppercase ext + candidate = filepath.Join(cueDir, baseName+strings.ToUpper(ext)) + if _, err := os.Stat(candidate); err == nil { + return candidate + } + } + + // 3. Try to find any audio file with the same base name as the .cue file + cueBase := strings.TrimSuffix(filepath.Base(cuePath), filepath.Ext(cuePath)) + for _, ext := range commonExts { + candidate = filepath.Join(cueDir, cueBase+ext) + if _, err := os.Stat(candidate); err == nil { + return candidate + } + } + + // 4. If there's only one audio file in the directory, use that + entries, err := os.ReadDir(cueDir) + if err == nil { + audioExts := map[string]bool{ + ".flac": true, ".wav": true, ".ape": true, ".mp3": true, + ".ogg": true, ".wv": true, ".m4a": true, ".aiff": true, + } + var audioFiles []string + for _, entry := range entries { + if entry.IsDir() { + continue + } + ext := strings.ToLower(filepath.Ext(entry.Name())) + if audioExts[ext] { + audioFiles = append(audioFiles, filepath.Join(cueDir, entry.Name())) + } + } + if len(audioFiles) == 1 { + return audioFiles[0] + } + } + + return "" +} + +// BuildCueSplitInfo creates the split information from a parsed CUE sheet. +// This is returned to the Dart side so FFmpeg can perform the splitting. +// audioDir, if non-empty, overrides the directory for audio file resolution. +func BuildCueSplitInfo(cuePath string, sheet *CueSheet, audioDir string) (*CueSplitInfo, error) { + resolveDir := cuePath + if audioDir != "" { + // Create a virtual path in audioDir so ResolveCueAudioPath looks there + resolveDir = filepath.Join(audioDir, filepath.Base(cuePath)) + } + audioPath := ResolveCueAudioPath(resolveDir, sheet.FileName) + if audioPath == "" { + return nil, fmt.Errorf("audio file not found for cue sheet: %s (referenced: %s)", cuePath, sheet.FileName) + } + + info := &CueSplitInfo{ + CuePath: cuePath, + AudioPath: audioPath, + Album: sheet.Title, + Artist: sheet.Performer, + Genre: sheet.Genre, + Date: sheet.Date, + } + + for i, track := range sheet.Tracks { + performer := track.Performer + if performer == "" { + performer = sheet.Performer + } + + composer := track.Composer + if composer == "" { + composer = sheet.Composer + } + + // End time is the start of the next track, or -1 for the last track + endSec := float64(-1) + if i+1 < len(sheet.Tracks) { + nextTrack := sheet.Tracks[i+1] + // Use pre-gap of next track if available, otherwise its start time + if nextTrack.PreGap >= 0 { + endSec = nextTrack.PreGap + } else { + endSec = nextTrack.StartTime + } + } + + info.Tracks = append(info.Tracks, CueSplitTrack{ + Number: track.Number, + Title: track.Title, + Artist: performer, + ISRC: track.ISRC, + Composer: composer, + StartSec: track.StartTime, + EndSec: endSec, + }) + } + + return info, nil +} + +// ParseCueFileJSON parses a .cue file and returns JSON with split info. +// This is the main entry point called from Dart via the platform bridge. +// audioDir, if non-empty, overrides the directory used for resolving the +// referenced audio file (useful when the .cue was copied to a temp dir +// but the audio still lives in the original location, e.g. SAF). +func ParseCueFileJSON(cuePath string, audioDir string) (string, error) { + sheet, err := ParseCueFile(cuePath) + if err != nil { + return "", fmt.Errorf("failed to parse cue file: %w", err) + } + + info, err := BuildCueSplitInfo(cuePath, sheet, audioDir) + if err != nil { + return "", err + } + + jsonBytes, err := json.Marshal(info) + if err != nil { + return "", fmt.Errorf("failed to marshal cue split info: %w", err) + } + + return string(jsonBytes), nil +} + +// ScanCueFileForLibrary parses a .cue file and returns multiple LibraryScanResult +// entries, one per track. This is used by the library scanner to populate the +// library with individual track entries from a single CUE+FLAC album. +func ScanCueFileForLibrary(cuePath string, scanTime string) ([]LibraryScanResult, error) { + return scanCueFileForLibraryInternal(cuePath, "", "", 0, scanTime) +} + +// ScanCueFileForLibraryExt is like ScanCueFileForLibrary but with extra parameters +// for SAF (Storage Access Framework) scenarios: +// - audioDir: if non-empty, overrides the directory used to find the audio file +// - virtualPathPrefix: if non-empty, used instead of cuePath as the base for +// virtual file paths (e.g. a content:// URI). IDs are also based on this. +// - fileModTime: if > 0, used as the FileModTime for all results instead of +// stat-ing the cuePath on disk (useful when the real file lives behind SAF) +func ScanCueFileForLibraryExt(cuePath, audioDir, virtualPathPrefix string, fileModTime int64, scanTime string) ([]LibraryScanResult, error) { + return scanCueFileForLibraryInternal(cuePath, audioDir, virtualPathPrefix, fileModTime, scanTime) +} + +func scanCueFileForLibraryInternal(cuePath, audioDir, virtualPathPrefix string, fileModTime int64, scanTime string) ([]LibraryScanResult, error) { + sheet, err := ParseCueFile(cuePath) + if err != nil { + return nil, err + } + + // Resolve audio file — optionally in an overridden directory + resolveBase := cuePath + if audioDir != "" { + resolveBase = filepath.Join(audioDir, filepath.Base(cuePath)) + } + audioPath := ResolveCueAudioPath(resolveBase, sheet.FileName) + if audioPath == "" { + return nil, fmt.Errorf("audio file not found for cue: %s (referenced: %s)", cuePath, sheet.FileName) + } + + // Try to get quality info from the audio file + var bitDepth, sampleRate int + var totalDurationSec float64 + audioExt := strings.ToLower(filepath.Ext(audioPath)) + switch audioExt { + case ".flac": + quality, qErr := GetAudioQuality(audioPath) + if qErr == nil { + bitDepth = quality.BitDepth + sampleRate = quality.SampleRate + if quality.SampleRate > 0 && quality.TotalSamples > 0 { + totalDurationSec = float64(quality.TotalSamples) / float64(quality.SampleRate) + } + } + case ".mp3": + quality, qErr := GetMP3Quality(audioPath) + if qErr == nil { + sampleRate = quality.SampleRate + totalDurationSec = float64(quality.Duration) + } + } + + // Extract cover from audio file for all tracks + var coverPath string + libraryCoverCacheMu.RLock() + coverCacheDir := libraryCoverCacheDir + libraryCoverCacheMu.RUnlock() + if coverCacheDir != "" { + cp, err := SaveCoverToCache(audioPath, coverCacheDir) + if err == nil && cp != "" { + coverPath = cp + } + } + + // Determine the base path for virtual paths and IDs + pathBase := cuePath + if virtualPathPrefix != "" { + pathBase = virtualPathPrefix + } + + // Determine fileModTime + modTime := fileModTime + if modTime <= 0 { + if info, err := os.Stat(cuePath); err == nil { + modTime = info.ModTime().UnixMilli() + } + } + + var results []LibraryScanResult + for i, track := range sheet.Tracks { + performer := track.Performer + if performer == "" { + performer = sheet.Performer + } + if performer == "" { + performer = "Unknown Artist" + } + + title := track.Title + if title == "" { + title = fmt.Sprintf("Track %02d", track.Number) + } + + album := sheet.Title + if album == "" { + album = "Unknown Album" + } + + // Calculate duration for this track + var duration int + if i+1 < len(sheet.Tracks) { + nextStart := sheet.Tracks[i+1].StartTime + if sheet.Tracks[i+1].PreGap >= 0 { + nextStart = sheet.Tracks[i+1].PreGap + } + duration = int(nextStart - track.StartTime) + } else if totalDurationSec > 0 { + duration = int(totalDurationSec - track.StartTime) + } + + // Use a unique ID based on pathBase + track number + id := generateLibraryID(fmt.Sprintf("%s#track%d", pathBase, track.Number)) + + // Use a virtual file path that includes the track number to ensure + // uniqueness in the database (file_path has a UNIQUE constraint). + // Format: /path/to/album.cue#track01 or content://...album.cue#track01 + virtualFilePath := fmt.Sprintf("%s#track%02d", pathBase, track.Number) + + result := LibraryScanResult{ + ID: id, + TrackName: title, + ArtistName: performer, + AlbumName: album, + AlbumArtist: sheet.Performer, + FilePath: virtualFilePath, + CoverPath: coverPath, + ScannedAt: scanTime, + ISRC: track.ISRC, + TrackNumber: track.Number, + DiscNumber: 1, + Duration: duration, + ReleaseDate: sheet.Date, + BitDepth: bitDepth, + SampleRate: sampleRate, + Genre: sheet.Genre, + Format: "cue+" + strings.TrimPrefix(audioExt, "."), + } + + result.FileModTime = modTime + + results = append(results, result) + } + + return results, nil +} diff --git a/go_backend/deezer_download.go b/go_backend/deezer_download.go index 0c64b658..01e5c654 100644 --- a/go_backend/deezer_download.go +++ b/go_backend/deezer_download.go @@ -120,7 +120,7 @@ func (c *DeezerClient) DownloadFromYoinkify(spotifyURL, outputPath string, outpu req.Header.Set("Accept", "*/*") req.Header.Set("User-Agent", getRandomUserAgent()) - resp, err := c.httpClient.Do(req) + resp, err := GetDownloadClient().Do(req) if err != nil { if isDownloadCancelled(itemID) { return ErrDownloadCancelled @@ -324,7 +324,7 @@ func (c *DeezerClient) DownloadFromMusicDL(deezerTrackURL, outputPath string, ou } req.Header.Set("User-Agent", getRandomUserAgent()) - resp, err := c.httpClient.Do(req) + resp, err := GetDownloadClient().Do(req) if err != nil { if isDownloadCancelled(itemID) { return ErrDownloadCancelled diff --git a/go_backend/exports.go b/go_backend/exports.go index 3c05a0fd..00791180 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -32,126 +32,6 @@ func ParseSpotifyURL(url string) (string, error) { return string(jsonBytes), nil } -func SetSpotifyAPICredentials(clientID, clientSecret string) { - SetSpotifyCredentials(clientID, clientSecret) -} - -func CheckSpotifyCredentials() bool { - return HasSpotifyCredentials() -} - -func GetSpotifyMetadata(spotifyURL string) (string, error) { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - client, err := NewSpotifyMetadataClient() - if err != nil { - if shouldTrySpotFetchFallback(err) { - data, apiErr := GetSpotifyDataWithAPI(ctx, spotifyURL, DefaultSpotFetchAPIBaseURL) - if apiErr == nil { - jsonBytes, marshalErr := json.Marshal(data) - if marshalErr != nil { - return "", marshalErr - } - return string(jsonBytes), nil - } - } - return "", err - } - data, err := client.GetFilteredData(ctx, spotifyURL, false, 0) - if err != nil { - if shouldTrySpotFetchFallback(err) { - fallbackData, apiErr := GetSpotifyDataWithAPI(ctx, spotifyURL, DefaultSpotFetchAPIBaseURL) - if apiErr == nil { - jsonBytes, marshalErr := json.Marshal(fallbackData) - if marshalErr != nil { - return "", marshalErr - } - return string(jsonBytes), nil - } - } - return "", err - } - - jsonBytes, err := json.Marshal(data) - if err != nil { - return "", err - } - - return string(jsonBytes), nil -} - -func SearchSpotify(query string, limit int) (string, error) { - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cancel() - - client, err := NewSpotifyMetadataClient() - if err != nil { - return "", err - } - results, err := client.SearchTracks(ctx, query, limit) - if err != nil { - return "", err - } - - jsonBytes, err := json.Marshal(results) - if err != nil { - return "", err - } - - return string(jsonBytes), nil -} - -func SearchSpotifyAll(query string, trackLimit, artistLimit int) (string, error) { - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cancel() - - client, err := NewSpotifyMetadataClient() - if err != nil { - return "", err - } - results, err := client.SearchAll(ctx, query, trackLimit, artistLimit) - if err != nil { - return "", err - } - - jsonBytes, err := json.Marshal(results) - if err != nil { - return "", err - } - - 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) @@ -478,25 +358,6 @@ func DownloadTrack(requestJSON string) (string, error) { } } err = qobuzErr - case "amazon": - amazonResult, amazonErr := downloadFromAmazon(req) - if amazonErr == nil { - result = DownloadResult{ - FilePath: amazonResult.FilePath, - BitDepth: amazonResult.BitDepth, - SampleRate: amazonResult.SampleRate, - Title: amazonResult.Title, - Artist: amazonResult.Artist, - Album: amazonResult.Album, - ReleaseDate: amazonResult.ReleaseDate, - TrackNumber: amazonResult.TrackNumber, - DiscNumber: amazonResult.DiscNumber, - ISRC: amazonResult.ISRC, - LyricsLRC: amazonResult.LyricsLRC, - DecryptionKey: amazonResult.DecryptionKey, - } - } - err = amazonErr case "deezer": deezerResult, deezerErr := downloadFromDeezer(req) if deezerErr == nil { @@ -640,7 +501,7 @@ func DownloadWithFallback(requestJSON string) (string, error) { enrichRequestExtendedMetadata(&req) - allServices := []string{"tidal", "qobuz", "amazon", "deezer"} + allServices := []string{"tidal", "qobuz", "deezer"} preferredService := req.Service if preferredService == "" { preferredService = "tidal" @@ -707,27 +568,6 @@ func DownloadWithFallback(requestJSON string) (string, error) { GoLog("[DownloadWithFallback] Qobuz error: %v\n", qobuzErr) } err = qobuzErr - case "amazon": - amazonResult, amazonErr := downloadFromAmazon(req) - if amazonErr == nil { - result = DownloadResult{ - FilePath: amazonResult.FilePath, - BitDepth: amazonResult.BitDepth, - SampleRate: amazonResult.SampleRate, - Title: amazonResult.Title, - Artist: amazonResult.Artist, - Album: amazonResult.Album, - ReleaseDate: amazonResult.ReleaseDate, - TrackNumber: amazonResult.TrackNumber, - DiscNumber: amazonResult.DiscNumber, - ISRC: amazonResult.ISRC, - LyricsLRC: amazonResult.LyricsLRC, - DecryptionKey: amazonResult.DecryptionKey, - } - } else if !errors.Is(amazonErr, ErrDownloadCancelled) { - GoLog("[DownloadWithFallback] Amazon error: %v\n", amazonErr) - } - err = amazonErr case "deezer": deezerResult, deezerErr := downloadFromDeezer(req) if deezerErr == nil { @@ -824,6 +664,7 @@ func CleanupConnections() { func ReadFileMetadata(filePath string) (string, error) { lower := strings.ToLower(filePath) isFlac := strings.HasSuffix(lower, ".flac") + isM4A := strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".aac") isMp3 := strings.HasSuffix(lower, ".mp3") isOgg := strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg") @@ -873,6 +714,12 @@ func ReadFileMetadata(filePath string) (string, error) { result["duration"] = int(quality.TotalSamples / int64(quality.SampleRate)) } } + } else if isM4A { + quality, qualityErr := GetM4AQuality(filePath) + if qualityErr == nil { + result["bit_depth"] = quality.BitDepth + result["sample_rate"] = quality.SampleRate + } } else if isMp3 { meta, err := ReadID3Tags(filePath) if err == nil && meta != nil { @@ -934,6 +781,32 @@ func ReadFileMetadata(filePath string) (string, error) { return string(jsonBytes), nil } +// ParseCueSheet parses a .cue file and returns JSON with split information. +// This is called from Dart to get track listing and timing data for CUE splitting. +// audioDir, if non-empty, overrides the directory used for resolving the +// referenced audio file (useful for SAF temp file scenarios). +func ParseCueSheet(cuePath string, audioDir string) (string, error) { + return ParseCueFileJSON(cuePath, audioDir) +} + +// ScanCueSheetForLibrary parses a .cue file and returns a JSON array of +// LibraryScanResult entries (one per track). This is the SAF-friendly variant: +// - audioDir overrides where the referenced audio file is resolved +// - virtualPathPrefix replaces cuePath in filePath / id fields (e.g. a content:// URI) +// - fileModTime is stamped on every result (pass 0 to stat cuePath instead) +func ScanCueSheetForLibrary(cuePath, audioDir, virtualPathPrefix string, fileModTime int64) (string, error) { + scanTime := time.Now().UTC().Format(time.RFC3339) + results, err := ScanCueFileForLibraryExt(cuePath, audioDir, virtualPathPrefix, fileModTime, scanTime) + if err != nil { + return "[]", err + } + jsonBytes, err := json.Marshal(results) + if err != nil { + return "[]", fmt.Errorf("failed to marshal cue scan results: %w", err) + } + return string(jsonBytes), nil +} + // EditFileMetadata writes metadata to an audio file. // For FLAC files, uses native Go FLAC library. // For MP3/Opus, returns the metadata map so Dart can use FFmpeg. @@ -1446,28 +1319,6 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - var spotifyErr error - - client, err := NewSpotifyMetadataClient() - if err != nil { - LogWarn("Spotify", "Credentials not configured, falling back to Deezer") - spotifyErr = err - } else { - data, err := client.GetFilteredData(ctx, spotifyURL, false, 0) - if err == nil { - jsonBytes, err := json.Marshal(data) - if err != nil { - return "", err - } - return string(jsonBytes), nil - } - - spotifyErr = err - if !shouldTrySpotFetchFallback(err) { - return "", err - } - } - spotFetchData, apiErr := GetSpotifyDataWithAPI(ctx, spotifyURL, DefaultSpotFetchAPIBaseURL) if apiErr == nil { GoLog("[Fallback] Spotify metadata fetched via SpotFetch API\n") @@ -1481,9 +1332,6 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) { parsed, parseErr := parseSpotifyURI(spotifyURL) if parseErr != nil { - if spotifyErr != nil { - return "", fmt.Errorf("spotify failed (%v), SpotFetch fallback failed (%v), and URL parsing failed: %w", spotifyErr, apiErr, parseErr) - } return "", fmt.Errorf("SpotFetch fallback failed (%v) and URL parsing failed: %w", apiErr, parseErr) } @@ -1494,15 +1342,9 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) { } if parsed.Type == "artist" { - if spotifyErr != nil { - return "", fmt.Errorf("spotify metadata unavailable (%v) and SpotFetch fallback failed (%v). Artist pages require Spotify/SpotFetch API", spotifyErr, apiErr) - } - return "", fmt.Errorf("SpotFetch fallback failed (%v). Artist pages require Spotify/SpotFetch API", apiErr) + return "", fmt.Errorf("SpotFetch fallback failed (%v). Artist pages now require SpotFetch or a metadata extension such as spotify-web", apiErr) } - if spotifyErr != nil { - return "", fmt.Errorf("spotify metadata unavailable (%v), SpotFetch fallback failed (%v), and Deezer conversion is unavailable for playlists", spotifyErr, apiErr) - } return "", fmt.Errorf("SpotFetch fallback failed (%v), and Deezer conversion is unavailable for playlists", apiErr) } @@ -1579,11 +1421,6 @@ func GetTidalURLFromDeezerTrack(deezerTrackID string) (string, error) { return client.GetTidalURLFromDeezer(deezerTrackID) } -func GetAmazonURLFromDeezerTrack(deezerTrackID string) (string, error) { - client := NewSongLinkClient() - return client.GetAmazonURLFromDeezer(deezerTrackID) -} - func errorResponse(msg string) (string, error) { errorType := "unknown" lowerMsg := strings.ToLower(msg) @@ -1838,8 +1675,8 @@ func ReEnrichFile(requestJSON string) (string, error) { GoLog("[ReEnrich] Starting re-enrichment for: %s\n", req.FilePath) - // When search_online is true, search for metadata from internet - // Priority: 1) Deezer (reliable, no credentials) 2) Extension providers (spotify-web etc) 3) Spotify built-in API (last resort, deprecated) + // When search_online is true, search for metadata from internet. + // Priority: 1) Deezer (reliable, no credentials) 2) Extension providers (spotify-web etc) if req.SearchOnline && req.TrackName != "" && req.ArtistName != "" { GoLog("[ReEnrich] Searching online metadata for: %s - %s\n", req.TrackName, req.ArtistName) searchQuery := req.TrackName + " " + req.ArtistName @@ -1913,37 +1750,6 @@ func ReEnrichFile(requestJSON string) (string, error) { } } - // 3) Try Spotify built-in API as last resort (will be deprecated) - if !found { - GoLog("[ReEnrich] Trying Spotify API (fallback)...\n") - spotifyClient, spotifyErr := NewSpotifyMetadataClient() - if spotifyErr == nil { - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - results, err := spotifyClient.SearchTracks(ctx, searchQuery, 5) - cancel() - if err == nil && len(results.Tracks) > 0 { - track := results.Tracks[0] - GoLog("[ReEnrich] Spotify match: %s - %s (album: %s)\n", track.Name, track.Artists, track.AlbumName) - req.SpotifyID = track.SpotifyID - req.AlbumName = track.AlbumName - req.AlbumArtist = track.AlbumArtist - req.TrackNumber = track.TrackNumber - req.DiscNumber = track.DiscNumber - req.ReleaseDate = track.ReleaseDate - req.ISRC = track.ISRC - if track.Images != "" { - req.CoverURL = track.Images - } - req.DurationMs = int64(track.DurationMS) - found = true - } else if err != nil { - GoLog("[ReEnrich] Spotify search failed: %v\n", err) - } - } else { - GoLog("[ReEnrich] Spotify client unavailable: %v\n", spotifyErr) - } - } - // Try to get extended metadata (genre, label) from Deezer if not already set if found && req.ISRC != "" && (req.Genre == "" || req.Label == "") { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) @@ -2146,8 +1952,6 @@ func ReEnrichFile(requestJSON string) (string, error) { return string(jsonBytes), nil } -// ==================== EXTENSION SYSTEM ==================== - func InitExtensionSystem(extensionsDir, dataDir string) error { manager := GetExtensionManager() if err := manager.SetDirectories(extensionsDir, dataDir); err != nil { @@ -2519,8 +2323,6 @@ func GetAllPendingFFmpegCommandsJSON() (string, error) { return string(jsonBytes), nil } -// ==================== EXTENSION CUSTOM SEARCH ==================== - func EnrichTrackWithExtensionJSON(extensionID, trackJSON string) (string, error) { manager := GetExtensionManager() ext, err := manager.GetExtension(extensionID) @@ -3273,9 +3075,6 @@ func GetExtensionBrowseCategoriesJSON(extensionID string) (string, error) { return callExtensionFunctionJSON(extensionID, "getBrowseCategories", 30*time.Second) } -// ==================== LOCAL LIBRARY SCANNING ==================== - -// SetLibraryCoverCacheDirJSON sets the directory for caching extracted cover art func SetLibraryCoverCacheDirJSON(cacheDir string) { SetLibraryCoverCacheDir(cacheDir) } @@ -3284,9 +3083,6 @@ func ScanLibraryFolderJSON(folderPath string) (string, error) { return ScanLibraryFolder(folderPath) } -// ScanLibraryFolderIncrementalJSON performs an incremental library scan -// existingFilesJSON: JSON object mapping filePath -> modTime (unix millis) -// Returns IncrementalScanResult as JSON func ScanLibraryFolderIncrementalJSON(folderPath, existingFilesJSON string) (string, error) { return ScanLibraryFolderIncremental(folderPath, existingFilesJSON) } diff --git a/go_backend/extension_manager.go b/go_backend/extension_manager.go index b9b460c2..5c73e251 100644 --- a/go_backend/extension_manager.go +++ b/go_backend/extension_manager.go @@ -401,7 +401,6 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx return nil, fmt.Errorf("failed to read manifest.json: %w", err) } - // Parse and validate manifest manifest, err := ParseManifest(manifestData) if err != nil { return nil, fmt.Errorf("Invalid extension manifest: %w", err) @@ -467,17 +466,11 @@ func (m *ExtensionManager) RemoveExtension(extensionID string) error { } } - // Optionally remove data directory (keep for now to preserve settings) - // if ext.DataDir != "" { - // os.RemoveAll(ext.DataDir) - // } - return nil } // Only allows upgrades (new version > current version), not downgrades func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension, error) { - // Validate file extension if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") { return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file") } @@ -529,7 +522,6 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension, return nil, fmt.Errorf("Extension '%s' is not installed. Use install instead of upgrade.", newManifest.DisplayName) } - // Compare versions - only allow upgrade, not downgrade versionCompare := compareVersions(newManifest.Version, existing.Manifest.Version) if versionCompare < 0 { return nil, fmt.Errorf("Cannot downgrade extension. Current version: %s, New version: %s", existing.Manifest.Version, newManifest.Version) @@ -540,7 +532,6 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension, GoLog("[Extension] Upgrading %s from v%s to v%s\n", newManifest.DisplayName, existing.Manifest.Version, newManifest.Version) - // Save data directory path and enabled state (we want to preserve them) extDataDir := existing.DataDir extDir := existing.SourceDir wasEnabled := existing.Enabled @@ -601,7 +592,6 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension, SourceDir: extDir, } - // Initialize Goja VM if err := m.initializeVM(ext); err != nil { ext.Error = err.Error() ext.Enabled = false @@ -626,7 +616,6 @@ type ExtensionUpgradeInfo struct { } func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*ExtensionUpgradeInfo, error) { - // Validate file extension if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") { return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file") } @@ -675,7 +664,6 @@ func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*Exte } if !exists { - // Not installed - this is a new install, not upgrade info.CurrentVersion = "" info.CanUpgrade = false } else { @@ -739,7 +727,6 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) { permissions = append(permissions, "storage:enabled") } - // Determine status status := "loaded" if ext.Error != "" { status = "error" @@ -940,7 +927,6 @@ func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) ( return nil, fmt.Errorf("extension is disabled") } - // Call the action function on the extension object script := fmt.Sprintf(` (function() { if (typeof extension !== 'undefined' && typeof extension.%s === 'function') { diff --git a/go_backend/extension_manifest.go b/go_backend/extension_manifest.go index f4164b4f..6166a667 100644 --- a/go_backend/extension_manifest.go +++ b/go_backend/extension_manifest.go @@ -1,4 +1,3 @@ -// Package gobackend provides extension manifest parsing and validation package gobackend import ( diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go index ceac1af4..89cf02a2 100644 --- a/go_backend/extension_providers.go +++ b/go_backend/extension_providers.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "os" "path/filepath" "sort" "strings" @@ -99,15 +100,16 @@ type ExtDownloadResult struct { ErrorMessage string `json:"error_message,omitempty"` ErrorType string `json:"error_type,omitempty"` - Title string `json:"title,omitempty"` - Artist string `json:"artist,omitempty"` - Album string `json:"album,omitempty"` - AlbumArtist string `json:"album_artist,omitempty"` - TrackNumber int `json:"track_number,omitempty"` - DiscNumber int `json:"disc_number,omitempty"` - ReleaseDate string `json:"release_date,omitempty"` - CoverURL string `json:"cover_url,omitempty"` - ISRC string `json:"isrc,omitempty"` + Title string `json:"title,omitempty"` + Artist string `json:"artist,omitempty"` + Album string `json:"album,omitempty"` + AlbumArtist string `json:"album_artist,omitempty"` + TrackNumber int `json:"track_number,omitempty"` + DiscNumber int `json:"disc_number,omitempty"` + ReleaseDate string `json:"release_date,omitempty"` + CoverURL string `json:"cover_url,omitempty"` + ISRC string `json:"isrc,omitempty"` + DecryptionKey string `json:"decryption_key,omitempty"` } type ExtensionProviderWrapper struct { @@ -388,7 +390,7 @@ func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTra return &enrichedTrack, nil } -func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName string) (*ExtAvailabilityResult, error) { +func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName, spotifyID, deezerID string) (*ExtAvailabilityResult, error) { if !p.extension.Manifest.IsDownloadProvider() { return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID) } @@ -403,11 +405,11 @@ func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName script := fmt.Sprintf(` (function() { if (typeof extension !== 'undefined' && typeof extension.checkAvailability === 'function') { - return extension.checkAvailability(%q, %q, %q); + return extension.checkAvailability(%q, %q, %q, {spotify_id: %q, deezer_id: %q}); } return null; })() - `, isrc, trackName, artistName) + `, isrc, trackName, artistName, spotifyID, deezerID) result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) if err != nil { @@ -631,7 +633,7 @@ func GetProviderPriority() []string { defer providerPriorityMu.RUnlock() if len(providerPriority) == 0 { - return []string{"tidal", "qobuz", "amazon", "deezer"} + return []string{"tidal", "qobuz", "deezer"} } result := make([]string, len(providerPriority)) @@ -642,8 +644,26 @@ func GetProviderPriority() []string { func SetMetadataProviderPriority(providerIDs []string) { metadataProviderPriorityMu.Lock() defer metadataProviderPriorityMu.Unlock() - metadataProviderPriority = providerIDs - GoLog("[Extension] Metadata provider priority set: %v\n", providerIDs) + + sanitized := make([]string, 0, len(providerIDs)+1) + seen := map[string]struct{}{} + for _, providerID := range providerIDs { + providerID = strings.TrimSpace(providerID) + if providerID == "" || providerID == "spotify" { + continue + } + if _, exists := seen[providerID]; exists { + continue + } + seen[providerID] = struct{}{} + sanitized = append(sanitized, providerID) + } + if _, exists := seen["deezer"]; !exists { + sanitized = append([]string{"deezer"}, sanitized...) + } + + metadataProviderPriority = sanitized + GoLog("[Extension] Metadata provider priority set: %v\n", sanitized) } func GetMetadataProviderPriority() []string { @@ -651,7 +671,7 @@ func GetMetadataProviderPriority() []string { defer metadataProviderPriorityMu.RUnlock() if len(metadataProviderPriority) == 0 { - return []string{"deezer", "spotify"} + return []string{"deezer"} } result := make([]string, len(metadataProviderPriority)) @@ -661,7 +681,7 @@ func GetMetadataProviderPriority() []string { func isBuiltInProvider(providerID string) bool { switch providerID { - case "tidal", "qobuz", "amazon", "deezer": + case "tidal", "qobuz", "deezer": return true default: return false @@ -694,6 +714,27 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro } priority = newPriority GoLog("[DownloadWithExtensionFallback] New priority order: %v\n", priority) + } else if !strictMode && req.Service != "" && !isBuiltInProvider(strings.ToLower(req.Service)) { + found := false + for _, p := range priority { + if strings.EqualFold(p, req.Service) { + found = true + break + } + } + newPriority := []string{req.Service} + for _, p := range priority { + if !strings.EqualFold(p, req.Service) { + newPriority = append(newPriority, p) + } + } + priority = newPriority + if !found { + GoLog("[DownloadWithExtensionFallback] Extension service '%s' added to priority front\n", req.Service) + } else { + GoLog("[DownloadWithExtensionFallback] Extension service '%s' moved to priority front\n", req.Service) + } + GoLog("[DownloadWithExtensionFallback] New priority order: %v\n", priority) } var lastErr error @@ -777,7 +818,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro GoLog("[DownloadWithExtensionFallback] Downloading from source extension with trackID: %s (skipBuiltInFallback: %v)\n", trackID, skipBuiltIn) - outputPath := buildOutputPath(req) + outputPath := buildOutputPathForExtension(req, ext) if req.ItemID != "" { StartItemProgress(req.ItemID) } @@ -813,6 +854,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro Genre: req.Genre, Label: req.Label, Copyright: req.Copyright, + DecryptionKey: result.DecryptionKey, } if req.EmbedMetadata && (req.Genre != "" || req.Label != "") { @@ -966,7 +1008,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro provider := NewExtensionProviderWrapper(ext) - availability, err := provider.CheckAvailability(req.ISRC, req.TrackName, req.ArtistName) + availability, err := provider.CheckAvailability(req.ISRC, req.TrackName, req.ArtistName, req.SpotifyID, req.DeezerID) if err != nil || !availability.Available { GoLog("[DownloadWithExtensionFallback] %s: not available\n", providerID) if err != nil { @@ -975,7 +1017,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro continue } - outputPath := buildOutputPath(req) + outputPath := buildOutputPathForExtension(req, ext) if req.ItemID != "" { StartItemProgress(req.ItemID) } @@ -1011,6 +1053,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro Genre: req.Genre, Label: req.Label, Copyright: req.Copyright, + DecryptionKey: result.DecryptionKey, } if req.EmbedMetadata && (req.Genre != "" || req.Label != "") { @@ -1128,25 +1171,6 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon } } err = qobuzErr - case "amazon": - amazonResult, amazonErr := downloadFromAmazon(req) - if amazonErr == nil { - result = DownloadResult{ - FilePath: amazonResult.FilePath, - BitDepth: amazonResult.BitDepth, - SampleRate: amazonResult.SampleRate, - Title: amazonResult.Title, - Artist: amazonResult.Artist, - Album: amazonResult.Album, - ReleaseDate: amazonResult.ReleaseDate, - TrackNumber: amazonResult.TrackNumber, - DiscNumber: amazonResult.DiscNumber, - ISRC: amazonResult.ISRC, - LyricsLRC: amazonResult.LyricsLRC, - DecryptionKey: amazonResult.DecryptionKey, - } - } - err = amazonErr case "deezer": deezerResult, deezerErr := downloadFromDeezer(req) if deezerErr == nil { @@ -1226,7 +1250,58 @@ func buildOutputPath(req DownloadRequest) string { ext = "." + ext } - return fmt.Sprintf("%s/%s%s", req.OutputDir, filename, ext) + outputDir := req.OutputDir + if strings.TrimSpace(outputDir) == "" { + outputDir = filepath.Join(os.TempDir(), "spotiflac-downloads") + os.MkdirAll(outputDir, 0755) + AddAllowedDownloadDir(outputDir) + } + + return filepath.Join(outputDir, filename+ext) +} + +func buildOutputPathForExtension(req DownloadRequest, ext *LoadedExtension) string { + if strings.TrimSpace(req.OutputPath) != "" { + return strings.TrimSpace(req.OutputPath) + } + + if strings.TrimSpace(req.OutputDir) != "" { + return buildOutputPath(req) + } + + // SAF mode: use extension's data dir as writable temp location + tempDir := filepath.Join(ext.DataDir, "downloads") + os.MkdirAll(tempDir, 0755) + AddAllowedDownloadDir(tempDir) + + metadata := map[string]interface{}{ + "title": req.TrackName, + "artist": req.ArtistName, + "album": req.AlbumName, + "album_artist": req.AlbumArtist, + "track": req.TrackNumber, + "track_number": req.TrackNumber, + "disc": req.DiscNumber, + "disc_number": req.DiscNumber, + "year": extractYear(req.ReleaseDate), + "date": req.ReleaseDate, + "release_date": req.ReleaseDate, + "isrc": req.ISRC, + } + + filename := buildFilenameFromTemplate(req.FilenameFormat, metadata) + if filename == "" { + filename = sanitizeFilename(fmt.Sprintf("%s - %s", req.ArtistName, req.TrackName)) + } + + outputExt := strings.TrimSpace(req.OutputExt) + if outputExt == "" { + outputExt = ".flac" + } else if !strings.HasPrefix(outputExt, ".") { + outputExt = "." + outputExt + } + + return filepath.Join(tempDir, filename+outputExt) } func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string]interface{}) ([]ExtTrackMetadata, error) { @@ -1653,7 +1728,6 @@ func (m *ExtensionManager) HandleURLWithExtension(url string) (*ExtURLHandleResu }, nil } -// GetPostProcessingProviders returns all extensions that provide post-processing func (m *ExtensionManager) GetPostProcessingProviders() []*ExtensionProviderWrapper { m.mu.RLock() defer m.mu.RUnlock() @@ -1667,7 +1741,6 @@ func (m *ExtensionManager) GetPostProcessingProviders() []*ExtensionProviderWrap return providers } -// RunPostProcessing runs all enabled post-processing hooks on a file func (m *ExtensionManager) RunPostProcessing(filePath string, metadata map[string]interface{}) (*PostProcessResult, error) { providers := m.GetPostProcessingProviders() if len(providers) == 0 { @@ -1713,7 +1786,6 @@ func (m *ExtensionManager) RunPostProcessing(filePath string, metadata map[strin return &PostProcessResult{Success: true, NewFilePath: currentPath}, nil } -// RunPostProcessingV2 runs all enabled post-processing hooks on a file input. func (m *ExtensionManager) RunPostProcessingV2(input PostProcessInput, metadata map[string]interface{}) (*PostProcessResult, error) { providers := m.GetPostProcessingProviders() if len(providers) == 0 { @@ -1768,9 +1840,6 @@ func (m *ExtensionManager) RunPostProcessingV2(input PostProcessInput, metadata return &PostProcessResult{Success: true, NewFilePath: currentInput.Path, NewFileURI: currentInput.URI}, nil } -// ==================== Lyrics Provider ==================== - -// ExtLyricsResult represents lyrics data returned from an extension type ExtLyricsResult struct { Lines []ExtLyricsLine `json:"lines"` SyncType string `json:"syncType"` @@ -1785,7 +1854,6 @@ type ExtLyricsLine struct { EndTimeMs int64 `json:"endTimeMs"` } -// FetchLyrics calls the extension's fetchLyrics function func (p *ExtensionProviderWrapper) FetchLyrics(trackName, artistName, albumName string, durationSec float64) (*LyricsResponse, error) { if !p.extension.Manifest.IsLyricsProvider() { return nil, fmt.Errorf("extension '%s' is not a lyrics provider", p.extension.ID) @@ -1885,7 +1953,6 @@ func (p *ExtensionProviderWrapper) FetchLyrics(trackName, artistName, albumName return response, nil } -// GetLyricsProviders returns all enabled extensions that provide lyrics func (m *ExtensionManager) GetLyricsProviders() []*ExtensionProviderWrapper { m.mu.RLock() defer m.mu.RUnlock() diff --git a/go_backend/extension_runtime_auth.go b/go_backend/extension_runtime_auth.go index de4ed06c..e17e0d4d 100644 --- a/go_backend/extension_runtime_auth.go +++ b/go_backend/extension_runtime_auth.go @@ -1,4 +1,3 @@ -// Package gobackend provides Auth API and PKCE support for extension runtime package gobackend import ( @@ -16,8 +15,6 @@ import ( "github.com/dop251/goja" ) -// ==================== Auth API (OAuth Support) ==================== - func validateExtensionAuthURL(urlStr string) error { parsed, err := url.Parse(urlStr) if err != nil { @@ -204,9 +201,6 @@ func (r *ExtensionRuntime) authGetTokens(call goja.FunctionCall) goja.Value { return r.vm.ToValue(result) } -// ==================== PKCE Support ==================== - -// generatePKCEVerifier generates a cryptographically random code verifier // Length should be between 43-128 characters (RFC 7636) func generatePKCEVerifier(length int) (string, error) { if length < 43 { @@ -394,9 +388,7 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V }) } -// authExchangeCodeWithPKCE exchanges auth code for tokens using PKCE // config: { tokenUrl, clientId, redirectUri, code, extraParams } -// Uses the stored PKCE verifier automatically func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja.Value { if len(call.Arguments) < 1 { return r.vm.ToValue(map[string]interface{}{ @@ -414,7 +406,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja }) } - // Required fields tokenURL, _ := config["tokenUrl"].(string) clientID, _ := config["clientId"].(string) redirectURI, _ := config["redirectUri"].(string) diff --git a/go_backend/extension_runtime_ffmpeg.go b/go_backend/extension_runtime_ffmpeg.go index 19e1b67c..7c1e8d69 100644 --- a/go_backend/extension_runtime_ffmpeg.go +++ b/go_backend/extension_runtime_ffmpeg.go @@ -1,4 +1,3 @@ -// Package gobackend provides FFmpeg API for extension runtime package gobackend import ( @@ -10,9 +9,7 @@ import ( "github.com/dop251/goja" ) -// ==================== FFmpeg API (Post-Processing) ==================== - -// FFmpegCommand holds a pending FFmpeg command for Flutter to execute +// FFmpegCommand holds a pending FFmpeg command for Flutter to execute. type FFmpegCommand struct { ExtensionID string Command string @@ -24,7 +21,6 @@ type FFmpegCommand struct { Output string } -// Global FFmpeg command queue var ( ffmpegCommands = make(map[string]*FFmpegCommand) ffmpegCommandsMu sync.RWMutex diff --git a/go_backend/extension_runtime_file.go b/go_backend/extension_runtime_file.go index 9bb1191e..414f174c 100644 --- a/go_backend/extension_runtime_file.go +++ b/go_backend/extension_runtime_file.go @@ -1,4 +1,3 @@ -// Package gobackend provides File API for extension runtime package gobackend import ( @@ -13,8 +12,6 @@ import ( "github.com/dop251/goja" ) -// ==================== File API (Sandboxed) ==================== - var ( allowedDownloadDirs []string allowedDownloadDirsMu sync.RWMutex diff --git a/go_backend/extension_runtime_http.go b/go_backend/extension_runtime_http.go index dcdd32f4..65775832 100644 --- a/go_backend/extension_runtime_http.go +++ b/go_backend/extension_runtime_http.go @@ -1,4 +1,3 @@ -// Package gobackend provides HTTP API for extension runtime package gobackend import ( @@ -12,8 +11,6 @@ import ( "github.com/dop251/goja" ) -// ==================== HTTP API (Sandboxed) ==================== - type HTTPResponse struct { StatusCode int `json:"statusCode"` Body string `json:"body"` diff --git a/go_backend/extension_runtime_matching.go b/go_backend/extension_runtime_matching.go index 4ad4b0e3..30b61e7b 100644 --- a/go_backend/extension_runtime_matching.go +++ b/go_backend/extension_runtime_matching.go @@ -1,4 +1,3 @@ -// Package gobackend provides Track Matching API for extension runtime package gobackend import ( @@ -7,8 +6,6 @@ import ( "github.com/dop251/goja" ) -// ==================== Track Matching API ==================== - func (r *ExtensionRuntime) matchingCompareStrings(call goja.FunctionCall) goja.Value { if len(call.Arguments) < 2 { return r.vm.ToValue(0.0) diff --git a/go_backend/extension_runtime_polyfills.go b/go_backend/extension_runtime_polyfills.go index 62892c70..a5334e06 100644 --- a/go_backend/extension_runtime_polyfills.go +++ b/go_backend/extension_runtime_polyfills.go @@ -1,4 +1,3 @@ -// Package gobackend provides Browser-like Polyfills for extension runtime package gobackend import ( @@ -13,12 +12,10 @@ import ( "github.com/dop251/goja" ) -// ==================== Browser-like Polyfills ==================== // These polyfills make porting browser/Node.js libraries easier -// without compromising sandbox security +// without compromising sandbox security. -// fetchPolyfill implements browser-compatible fetch() API -// Returns a Promise-like object with json(), text() methods +// Returns a Promise-like object with json(), text() methods. func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value { if len(call.Arguments) < 1 { return r.createFetchError("URL is required") @@ -141,7 +138,6 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value { return responseObj } -// createFetchError creates a fetch error response func (r *ExtensionRuntime) createFetchError(message string) goja.Value { errorObj := r.vm.NewObject() errorObj.Set("ok", false) @@ -157,7 +153,6 @@ func (r *ExtensionRuntime) createFetchError(message string) goja.Value { return errorObj } -// atobPolyfill implements browser atob() - decode base64 to string func (r *ExtensionRuntime) atobPolyfill(call goja.FunctionCall) goja.Value { if len(call.Arguments) < 1 { return r.vm.ToValue("") @@ -174,7 +169,6 @@ func (r *ExtensionRuntime) atobPolyfill(call goja.FunctionCall) goja.Value { return r.vm.ToValue(string(decoded)) } -// btoaPolyfill implements browser btoa() - encode string to base64 func (r *ExtensionRuntime) btoaPolyfill(call goja.FunctionCall) goja.Value { if len(call.Arguments) < 1 { return r.vm.ToValue("") @@ -183,7 +177,6 @@ func (r *ExtensionRuntime) btoaPolyfill(call goja.FunctionCall) goja.Value { return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input))) } -// registerTextEncoderDecoder registers TextEncoder and TextDecoder classes func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) { vm.Set("TextEncoder", func(call goja.ConstructorCall) *goja.Object { encoder := call.This @@ -429,9 +422,8 @@ func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) { }) } -// registerJSONGlobal ensures JSON global is properly set up +// JSON is already built-in to Goja; this ensures a fallback exists. func (r *ExtensionRuntime) registerJSONGlobal(vm *goja.Runtime) { - // JSON is already built-in to Goja, but we can enhance it jsonScript := ` if (typeof JSON === 'undefined') { var JSON = { diff --git a/go_backend/extension_runtime_storage.go b/go_backend/extension_runtime_storage.go index 06cbdd33..50815b40 100644 --- a/go_backend/extension_runtime_storage.go +++ b/go_backend/extension_runtime_storage.go @@ -1,4 +1,3 @@ -// Package gobackend provides Storage and Credentials API for extension runtime package gobackend import ( @@ -17,8 +16,6 @@ import ( "github.com/dop251/goja" ) -// ==================== Storage API ==================== - const ( defaultStorageFlushDelay = 400 * time.Millisecond storageFlushRetryDelay = 2 * time.Second diff --git a/go_backend/extension_runtime_utils.go b/go_backend/extension_runtime_utils.go index c3a7675e..f91918ff 100644 --- a/go_backend/extension_runtime_utils.go +++ b/go_backend/extension_runtime_utils.go @@ -1,4 +1,3 @@ -// Package gobackend provides Utility functions for extension runtime package gobackend import ( @@ -17,8 +16,6 @@ import ( "github.com/dop251/goja" ) -// ==================== Utility Functions ==================== - func (r *ExtensionRuntime) base64Encode(call goja.FunctionCall) goja.Value { if len(call.Arguments) < 1 { return r.vm.ToValue("") diff --git a/go_backend/extension_settings.go b/go_backend/extension_settings.go index 9ad0c0c1..e241f6e2 100644 --- a/go_backend/extension_settings.go +++ b/go_backend/extension_settings.go @@ -1,4 +1,3 @@ -// Package gobackend provides extension settings storage package gobackend import ( diff --git a/go_backend/extension_timeout.go b/go_backend/extension_timeout.go index e9a5605f..76e51cfa 100644 --- a/go_backend/extension_timeout.go +++ b/go_backend/extension_timeout.go @@ -1,4 +1,3 @@ -// Package gobackend provides timeout execution for extension JS code package gobackend import ( diff --git a/go_backend/httputil.go b/go_backend/httputil.go index 08137fa8..b54ec489 100644 --- a/go_backend/httputil.go +++ b/go_backend/httputil.go @@ -489,7 +489,6 @@ func IsISPBlocking(err error, requestURL string) *ISPBlockingError { } } - // Check error message patterns for common ISP blocking indicators blockingPatterns := []struct { pattern string reason string @@ -532,7 +531,6 @@ func CheckAndLogISPBlocking(err error, requestURL string, tag string) bool { return false } -// extractDomain extracts the domain from a URL string func extractDomain(rawURL string) string { if rawURL == "" { return "unknown" diff --git a/go_backend/httputil_utls.go b/go_backend/httputil_utls.go index bf5ec9ed..4b09deb7 100644 --- a/go_backend/httputil_utls.go +++ b/go_backend/httputil_utls.go @@ -91,7 +91,6 @@ func (t *utlsTransport) getPort(u *url.URL) string { return "80" } -// Cloudflare bypass client using uTLS Chrome fingerprint var cloudflareBypassTransport = newUTLSTransport() var cloudflareBypassClient = &http.Client{ @@ -111,7 +110,6 @@ func GetCloudflareBypassClient() *http.Client { func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) { req.Header.Set("User-Agent", getRandomUserAgent()) - // Try with standard client first resp, err := sharedClient.Do(req) if err == nil { // Check for Cloudflare challenge page (403 with specific markers) @@ -138,11 +136,9 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) { if isCloudflare { LogDebug("HTTP", "Cloudflare detected, retrying with Chrome TLS fingerprint...") - // Clone request for retry reqCopy := req.Clone(req.Context()) reqCopy.Header.Set("User-Agent", getRandomUserAgent()) - // Retry with uTLS Chrome fingerprint return cloudflareBypassClient.Do(reqCopy) } } @@ -168,11 +164,9 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) { if tlsRelated { LogDebug("HTTP", "TLS error detected, retrying with Chrome TLS fingerprint: %v", err) - // Clone request for retry reqCopy := req.Clone(req.Context()) reqCopy.Header.Set("User-Agent", getRandomUserAgent()) - // Retry with uTLS Chrome fingerprint return cloudflareBypassClient.Do(reqCopy) } diff --git a/go_backend/idhs.go b/go_backend/idhs.go index 72c8cff3..3b339ed0 100644 --- a/go_backend/idhs.go +++ b/go_backend/idhs.go @@ -22,13 +22,11 @@ var ( idhsRateLimiter = NewRateLimiter(8, time.Minute) // 8 req/min (below 10 limit) ) -// IDHSSearchRequest represents the request body for IDHS API type IDHSSearchRequest struct { Link string `json:"link"` Adapters []string `json:"adapters,omitempty"` } -// IDHSSearchResponse represents the response from IDHS API type IDHSSearchResponse struct { ID string `json:"id"` Type string `json:"type"` // song, album, artist, podcast, show @@ -41,7 +39,6 @@ type IDHSSearchResponse struct { Links []IDHSLink `json:"links"` } -// IDHSLink represents a link to a streaming platform type IDHSLink struct { Type string `json:"type"` // spotify, youTube, appleMusic, deezer, soundCloud, tidal URL string `json:"url"` @@ -49,7 +46,6 @@ type IDHSLink struct { NotAvailable bool `json:"notAvailable,omitempty"` } -// NewIDHSClient creates a new IDHS client func NewIDHSClient() *IDHSClient { idhsClientOnce.Do(func() { globalIDHSClient = &IDHSClient{ @@ -117,7 +113,6 @@ func (c *IDHSClient) Search(link string, adapters []string) (*IDHSSearchResponse func (c *IDHSClient) GetAvailabilityFromSpotify(spotifyTrackID string) (*TrackAvailability, error) { spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID) - // Request only the platforms we need adapters := []string{"tidal", "deezer"} result, err := c.Search(spotifyURL, adapters) @@ -151,11 +146,9 @@ func (c *IDHSClient) GetAvailabilityFromSpotify(spotifyTrackID string) (*TrackAv return availability, nil } -// GetAvailabilityFromDeezer checks track availability using IDHS func (c *IDHSClient) GetAvailabilityFromDeezer(deezerTrackID string) (*TrackAvailability, error) { deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID) - // Request only the platforms we need adapters := []string{"spotify", "tidal"} result, err := c.Search(deezerURL, adapters) diff --git a/go_backend/library_scan.go b/go_backend/library_scan.go index 52e91fb8..34d5c718 100644 --- a/go_backend/library_scan.go +++ b/go_backend/library_scan.go @@ -10,7 +10,6 @@ import ( "time" ) -// LibraryScanResult represents metadata from a scanned audio file type LibraryScanResult struct { ID string `json:"id"` TrackName string `json:"trackName"` @@ -42,7 +41,6 @@ type LibraryScanProgress struct { IsComplete bool `json:"is_complete"` } -// IncrementalScanResult contains results of an incremental library scan type IncrementalScanResult struct { Scanned []LibraryScanResult `json:"scanned"` // New or updated files DeletedPaths []string `json:"deletedPaths"` // Files that no longer exist @@ -65,6 +63,7 @@ var supportedAudioFormats = map[string]bool{ ".mp3": true, ".opus": true, ".ogg": true, + ".cue": true, } type libraryAudioFileInfo struct { @@ -168,6 +167,23 @@ func ScanLibraryFolder(folderPath string) (string, error) { scanTime := time.Now().UTC().Format(time.RFC3339) errorCount := 0 + // Track audio files referenced by .cue sheets to avoid duplicates + cueReferencedAudioFiles := make(map[string]bool) + + // First pass: scan .cue files to collect referenced audio paths + for _, filePath := range audioFiles { + ext := strings.ToLower(filepath.Ext(filePath)) + if ext == ".cue" { + sheet, err := ParseCueFile(filePath) + if err == nil && sheet.FileName != "" { + audioPath := ResolveCueAudioPath(filePath, sheet.FileName) + if audioPath != "" { + cueReferencedAudioFiles[audioPath] = true + } + } + } + } + for i, filePath := range audioFiles { select { case <-cancelCh: @@ -181,6 +197,28 @@ func ScanLibraryFolder(folderPath string) (string, error) { libraryScanProgress.ProgressPct = float64(i+1) / float64(totalFiles) * 100 libraryScanProgressMu.Unlock() + ext := strings.ToLower(filepath.Ext(filePath)) + + // Handle .cue files: produce multiple track results + if ext == ".cue" { + cueResults, err := ScanCueFileForLibrary(filePath, scanTime) + if err != nil { + errorCount++ + GoLog("[LibraryScan] Error scanning cue %s: %v\n", filePath, err) + continue + } + results = append(results, cueResults...) + GoLog("[LibraryScan] CUE sheet %s: %d tracks\n", filepath.Base(filePath), len(cueResults)) + continue + } + + // Skip audio files that are referenced by a .cue sheet + // (they will be represented by the cue sheet's track entries instead) + if cueReferencedAudioFiles[filePath] { + GoLog("[LibraryScan] Skipping %s (referenced by .cue sheet)\n", filepath.Base(filePath)) + continue + } + result, err := scanAudioFile(filePath, scanTime) if err != nil { errorCount++ @@ -216,7 +254,6 @@ func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) { Format: strings.TrimPrefix(ext, "."), } - // Get file modification time if info, err := os.Stat(filePath); err == nil { result.FileModTime = info.ModTime().UnixMilli() } @@ -466,7 +503,6 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, return "{}", fmt.Errorf("path is not a folder: %s", folderPath) } - // Parse existing files map existingFiles := make(map[string]int64) if existingFilesJSON != "" && existingFilesJSON != "{}" { if err := json.Unmarshal([]byte(existingFilesJSON), &existingFiles); err != nil { @@ -476,12 +512,10 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, GoLog("[LibraryScan] Incremental scan starting, %d existing files in database\n", len(existingFiles)) - // Reset progress libraryScanProgressMu.Lock() libraryScanProgress = LibraryScanProgress{} libraryScanProgressMu.Unlock() - // Setup cancellation libraryScanCancelMu.Lock() if libraryScanCancel != nil { close(libraryScanCancel) @@ -490,7 +524,6 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, cancelCh := libraryScanCancel libraryScanCancelMu.Unlock() - // Collect all audio files with their mod times currentFiles, err := collectLibraryAudioFiles(folderPath, cancelCh) if err != nil { return "{}", err @@ -509,24 +542,64 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, var filesToScan []libraryAudioFileInfo skippedCount := 0 + // Build a set of existing CUE virtual path base files for incremental matching. + // CUE tracks are stored with virtual paths like "/path/album.cue#track01". + // We need to match these against the actual .cue file's modTime. + cueBaseModTimes := make(map[string]int64) // base cue path -> modTime from disk + for _, f := range currentFiles { + if strings.ToLower(filepath.Ext(f.path)) == ".cue" { + cueBaseModTimes[f.path] = f.modTime + } + } + for _, f := range currentFiles { existingModTime, exists := existingFiles[f.path] if !exists { - // New file + // For .cue files, also check if any virtual path entries exist + if strings.ToLower(filepath.Ext(f.path)) == ".cue" { + hasCueTracks := false + for existingPath := range existingFiles { + if strings.HasPrefix(existingPath, f.path+"#track") { + hasCueTracks = true + break + } + } + if hasCueTracks { + // CUE file exists in DB via virtual paths; check if modTime changed + // Use modTime from any virtual path (they all share the same .cue modTime) + for existingPath, modTime := range existingFiles { + if strings.HasPrefix(existingPath, f.path+"#track") { + if f.modTime == modTime { + skippedCount++ + } else { + filesToScan = append(filesToScan, f) + } + break + } + } + continue + } + } filesToScan = append(filesToScan, f) } else if f.modTime != existingModTime { - // Modified file filesToScan = append(filesToScan, f) } else { - // Unchanged file - skip skippedCount++ } } - // Find deleted files var deletedPaths []string for existingPath := range existingFiles { - if !currentPathSet[existingPath] { + // For CUE virtual paths (e.g. "/path/album.cue#track01"), + // check if the base .cue file still exists on disk + if idx := strings.LastIndex(existingPath, "#track"); idx > 0 { + baseCuePath := existingPath[:idx] + if currentPathSet[baseCuePath] { + continue // Base .cue file still exists, not deleted + } + // Base CUE file is gone, mark virtual path as deleted + deletedPaths = append(deletedPaths, existingPath) + } else if !currentPathSet[existingPath] { deletedPaths = append(deletedPaths, existingPath) } } @@ -551,11 +624,25 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, return string(jsonBytes), nil } - // Scan the files that need scanning results := make([]LibraryScanResult, 0, len(filesToScan)) scanTime := time.Now().UTC().Format(time.RFC3339) errorCount := 0 + // Track audio files referenced by .cue sheets to avoid duplicates (incremental) + cueReferencedAudioFilesInc := make(map[string]bool) + for _, f := range filesToScan { + ext := strings.ToLower(filepath.Ext(f.path)) + if ext == ".cue" { + sheet, err := ParseCueFile(f.path) + if err == nil && sheet.FileName != "" { + audioPath := ResolveCueAudioPath(f.path, sheet.FileName) + if audioPath != "" { + cueReferencedAudioFilesInc[audioPath] = true + } + } + } + } + for i, f := range filesToScan { select { case <-cancelCh: @@ -569,6 +656,25 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, libraryScanProgress.ProgressPct = float64(skippedCount+i+1) / float64(totalFiles) * 100 libraryScanProgressMu.Unlock() + ext := strings.ToLower(filepath.Ext(f.path)) + + // Handle .cue files: produce multiple track results + if ext == ".cue" { + cueResults, err := ScanCueFileForLibrary(f.path, scanTime) + if err != nil { + errorCount++ + GoLog("[LibraryScan] Error scanning cue %s: %v\n", f.path, err) + continue + } + results = append(results, cueResults...) + continue + } + + // Skip audio files referenced by .cue sheets + if cueReferencedAudioFilesInc[f.path] { + continue + } + result, err := scanAudioFile(f.path, scanTime) if err != nil { errorCount++ diff --git a/go_backend/lyrics.go b/go_backend/lyrics.go index 3ec16555..1a6c4771 100644 --- a/go_backend/lyrics.go +++ b/go_backend/lyrics.go @@ -41,7 +41,6 @@ var DefaultLyricsProviders = []string{ LyricsProviderQQMusic, } -// Global lyrics provider configuration var ( lyricsProvidersMu sync.RWMutex lyricsProviders []string // ordered list of enabled providers @@ -598,7 +597,6 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st return lyricsHasUsableText(l) } - // Try extension lyrics providers first if len(extensionProviders) > 0 { for _, provider := range extensionProviders { GoLog("[Lyrics] Trying extension lyrics provider: %s\n", provider.extension.ID) @@ -621,7 +619,6 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st return &cachedCopy, nil } - // Get configured provider order providerOrder := GetLyricsProviderOrder() simplifiedTrack := simplifyTrackName(trackName) diff --git a/go_backend/lyrics_apple.go b/go_backend/lyrics_apple.go index 957db6fc..538b0b89 100644 --- a/go_backend/lyrics_apple.go +++ b/go_backend/lyrics_apple.go @@ -97,7 +97,6 @@ func (m *appleTokenManager) clearToken() { m.token = "" } -// Apple Music API response models type appleMusicSearchResponse struct { Results struct { Songs *struct { @@ -239,15 +238,12 @@ func (c *AppleMusicClient) FetchLyricsByID(songID string) (string, error) { return bodyStr, nil } -// formatPaxLyricsToLRC converts a pax proxy response to standard LRC format. func formatPaxLyricsToLRC(rawJSON string, multiPersonWordByWord bool) (string, error) { - // Try to parse as PaxResponse first var paxResp paxResponse if err := json.Unmarshal([]byte(rawJSON), &paxResp); err == nil && paxResp.Content != nil { return formatPaxContent(paxResp.Type, paxResp.Content, multiPersonWordByWord), nil } - // Try to parse as a direct list of PaxLyrics var directLyrics []paxLyrics if err := json.Unmarshal([]byte(rawJSON), &directLyrics); err == nil && len(directLyrics) > 0 { return formatPaxContent("Syllable", directLyrics, multiPersonWordByWord), nil diff --git a/go_backend/lyrics_musixmatch.go b/go_backend/lyrics_musixmatch.go index 71d4544e..f962a110 100644 --- a/go_backend/lyrics_musixmatch.go +++ b/go_backend/lyrics_musixmatch.go @@ -16,7 +16,6 @@ type MusixmatchClient struct { baseURL string } -// Musixmatch proxy response models type musixmatchSearchResponse struct { ID int64 `json:"id"` SongName string `json:"songName"` @@ -116,7 +115,6 @@ func (c *MusixmatchClient) FetchLyricsInLanguage(songID int64, language string) return nil, fmt.Errorf("failed to decode musixmatch language response: %w", err) } - // Prefer synced lyrics for selected language if result.SyncedLyrics != nil && strings.TrimSpace(result.SyncedLyrics.Lyrics) != "" { lines := parseSyncedLyrics(result.SyncedLyrics.Lyrics) if len(lines) > 0 { @@ -129,7 +127,6 @@ 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) != "" { lines := plainTextLyricsLines(result.UnsyncedLyrics.Lyrics) @@ -162,7 +159,6 @@ func (c *MusixmatchClient) FetchLyrics(trackName, artistName string, durationSec GoLog("[Musixmatch] Language override '%s' failed: %v\n", preferred, localizedErr) } - // Prefer synced lyrics if result.SyncedLyrics != nil && strings.TrimSpace(result.SyncedLyrics.Lyrics) != "" { lines := parseSyncedLyrics(result.SyncedLyrics.Lyrics) if len(lines) > 0 { @@ -175,7 +171,6 @@ func (c *MusixmatchClient) FetchLyrics(trackName, artistName string, durationSec } } - // Fall back to unsynced lyrics if result.UnsyncedLyrics != nil && strings.TrimSpace(result.UnsyncedLyrics.Lyrics) != "" { lines := plainTextLyricsLines(result.UnsyncedLyrics.Lyrics) diff --git a/go_backend/lyrics_netease.go b/go_backend/lyrics_netease.go index e9fbf1e6..f6ce6b6c 100644 --- a/go_backend/lyrics_netease.go +++ b/go_backend/lyrics_netease.go @@ -15,7 +15,6 @@ type NeteaseClient struct { httpClient *http.Client } -// Netease API response models type neteaseSearchResponse struct { Result struct { Songs []struct { @@ -172,7 +171,6 @@ func (c *NeteaseClient) FetchLyrics( return nil, err } - // Parse the LRC text into LyricsResponse lines := parseSyncedLyrics(lrcText) if len(lines) == 0 { // May be plain text lyrics without timestamps diff --git a/go_backend/lyrics_qqmusic.go b/go_backend/lyrics_qqmusic.go index 0b76a49f..dde3631d 100644 --- a/go_backend/lyrics_qqmusic.go +++ b/go_backend/lyrics_qqmusic.go @@ -17,7 +17,6 @@ type QQMusicClient struct { httpClient *http.Client } -// QQ Music search response models type qqMusicSearchResponse struct { Data struct { Song struct { @@ -184,7 +183,6 @@ func (c *QQMusicClient) FetchLyrics( }, nil } - // Fall back to plain text resultLines := plainTextLyricsLines(lrcText) if len(resultLines) > 0 { diff --git a/go_backend/mobile_deps.go b/go_backend/mobile_deps.go index bbdb1890..57aaaeec 100644 --- a/go_backend/mobile_deps.go +++ b/go_backend/mobile_deps.go @@ -1,10 +1,8 @@ -// mobile_deps.go // This file ensures gomobile dependencies are not removed by go mod tidy. // These packages are required by gomobile bind but not directly imported in code. package gobackend import ( - // Required for gomobile bind to work _ "golang.org/x/mobile/bind" ) diff --git a/go_backend/parallel.go b/go_backend/parallel.go index b275ade9..2526e139 100644 --- a/go_backend/parallel.go +++ b/go_backend/parallel.go @@ -10,7 +10,6 @@ import ( type TrackIDCacheEntry struct { TidalTrackID int64 QobuzTrackID int64 - AmazonURL string ExpiresAt time.Time } @@ -107,25 +106,6 @@ func (c *TrackIDCache) SetQobuz(isrc string, trackID int64) { } } -func (c *TrackIDCache) SetAmazonURL(isrc string, amazonURL string) { - c.mu.Lock() - defer c.mu.Unlock() - - entry, exists := c.cache[isrc] - if !exists { - entry = &TrackIDCacheEntry{} - c.cache[isrc] = entry - } - entry.AmazonURL = amazonURL - now := time.Now() - entry.ExpiresAt = now.Add(c.ttl) - - if c.cleanupInterval > 0 && (c.lastCleanup.IsZero() || now.Sub(c.lastCleanup) >= c.cleanupInterval) { - c.pruneExpiredLocked(now) - c.lastCleanup = now - } -} - func (c *TrackIDCache) Clear() { c.mu.Lock() defer c.mu.Unlock() @@ -235,8 +215,6 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) { preWarmTidalCache(r.ISRC, r.TrackName, r.ArtistName) case "qobuz": preWarmQobuzCache(r.ISRC, r.SpotifyID) - case "amazon": - preWarmAmazonCache(r.ISRC, r.SpotifyID) } }(req) } @@ -256,12 +234,10 @@ func preWarmTidalCache(isrc, _, _ string) { // 1. From SongLink (fast, no Qobuz API call needed) // 2. Direct ISRC search on Qobuz API (slower, may fail if ISRC not in Qobuz database) func preWarmQobuzCache(isrc, spotifyID string) { - // First, try to get QobuzID from SongLink - this is faster and more reliable if spotifyID != "" { client := NewSongLinkClient() availability, err := client.CheckTrackAvailability(spotifyID, isrc) if err == nil && availability != nil && availability.QobuzID != "" { - // Parse QobuzID to int64 var trackID int64 if _, parseErr := fmt.Sscanf(availability.QobuzID, "%d", &trackID); parseErr == nil && trackID > 0 { GoLog("[Qobuz] Pre-warm cache: Got Qobuz ID %d from SongLink for ISRC %s\n", trackID, isrc) @@ -271,7 +247,6 @@ func preWarmQobuzCache(isrc, spotifyID string) { } } - // Fallback: Direct ISRC search on Qobuz API downloader := NewQobuzDownloader() track, err := downloader.SearchTrackByISRC(isrc) if err == nil && track != nil { @@ -280,14 +255,6 @@ func preWarmQobuzCache(isrc, spotifyID string) { } } -func preWarmAmazonCache(isrc, spotifyID string) { - client := NewSongLinkClient() - availability, err := client.CheckTrackAvailability(spotifyID, isrc) - if err == nil && availability != nil && availability.AmazonURL != "" { - GetTrackIDCache().SetAmazonURL(isrc, availability.AmazonURL) - } -} - func PreWarmCache(tracksJSON string) error { var tracks []struct { ISRC string `json:"isrc"` diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index 015dca8d..092c0e96 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -923,18 +923,14 @@ type qobuzAPIResult struct { duration time.Duration } -// Qobuz API timeout configuration // Mobile networks are more unstable, so we use longer timeouts const ( qobuzAPITimeoutMobile = 25 * time.Second - qobuzMaxRetries = 2 // Number of retries per API + qobuzMaxRetries = 2 qobuzRetryDelay = 500 * time.Millisecond ) -// getQobuzAPITimeout returns appropriate timeout based on platform -// For mobile (gomobile builds), we use longer timeouts func getQobuzAPITimeout() time.Duration { - // Since this runs in gomobile context, we always use mobile timeout // The Go backend is only used on mobile (Android/iOS) return qobuzAPITimeoutMobile } @@ -944,7 +940,6 @@ func fetchQobuzURLWithRetry(provider qobuzAPIProvider, trackID int64, quality st return fetchQobuzURLSingleAttempt(provider, trackID, quality, timeout, "") } -// fetchQobuzURLSingleAttempt fetches download URL with retry logic for a single API+country combination func fetchQobuzURLSingleAttempt(provider qobuzAPIProvider, trackID int64, quality string, timeout time.Duration, country string) (qobuzDownloadInfo, error) { var lastErr error retryDelay := qobuzRetryDelay @@ -967,7 +962,7 @@ func fetchQobuzURLSingleAttempt(provider qobuzAPIProvider, trackID int64, qualit if attempt > 0 { GoLog("[Qobuz] Retry %d/%d for %s after %v\n", attempt, qobuzMaxRetries, provider.Name, retryDelay) time.Sleep(retryDelay) - retryDelay *= 2 // Exponential backoff + retryDelay *= 2 } client := NewHTTPClientWithTimeout(timeout) @@ -1014,11 +1009,10 @@ func fetchQobuzURLSingleAttempt(provider qobuzAPIProvider, trackID int64, qualit strings.Contains(errStr, "reset") || strings.Contains(errStr, "connection refused") || strings.Contains(errStr, "eof") { - continue // Retry + continue } - break // Non-retryable error + break } - // Server errors are retryable if resp.StatusCode >= 500 { io.Copy(io.Discard, resp.Body) resp.Body.Close() @@ -1031,7 +1025,7 @@ func fetchQobuzURLSingleAttempt(provider qobuzAPIProvider, trackID int64, qualit io.Copy(io.Discard, resp.Body) resp.Body.Close() lastErr = fmt.Errorf("rate limited") - retryDelay = 2 * time.Second // Wait longer for rate limit + retryDelay = 2 * time.Second continue } @@ -1308,7 +1302,6 @@ func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloade track = nil } else if track != nil { 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) } diff --git a/go_backend/ratelimit.go b/go_backend/ratelimit.go index 1f2ac1f6..662d86e4 100644 --- a/go_backend/ratelimit.go +++ b/go_backend/ratelimit.go @@ -48,7 +48,6 @@ func (r *RateLimiter) WaitForSlot() { r.timestamps = append(r.timestamps, time.Now()) } -// cleanOldTimestamps removes timestamps that are outside the current window func (r *RateLimiter) cleanOldTimestamps(now time.Time) { cutoff := now.Add(-r.window) validStart := 0 diff --git a/go_backend/romaji.go b/go_backend/romaji.go index d5a73963..3c45d2d9 100644 --- a/go_backend/romaji.go +++ b/go_backend/romaji.go @@ -170,11 +170,9 @@ func JapaneseToRomaji(text string) string { } func BuildSearchQuery(trackName, artistName string) string { - // Convert Japanese to romaji trackRomaji := JapaneseToRomaji(trackName) artistRomaji := JapaneseToRomaji(artistName) - // Clean up the query - remove special characters that might interfere with search trackClean := cleanSearchQuery(trackRomaji) artistClean := cleanSearchQuery(artistRomaji) @@ -196,16 +194,13 @@ func cleanSearchQuery(s string) string { func CleanToASCII(s string) string { var result strings.Builder for _, r := range s { - // Keep only ASCII letters, numbers, spaces, and basic punctuation if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == ' ' || r == '-' || r == '\'' { result.WriteRune(r) } else if r == ',' || r == '.' { - // Convert punctuation to space result.WriteRune(' ') } } - // Clean up multiple spaces cleaned := strings.Join(strings.Fields(result.String()), " ") return strings.TrimSpace(cleaned) } diff --git a/go_backend/songlink.go b/go_backend/songlink.go index b5dfd58d..f38a8edb 100644 --- a/go_backend/songlink.go +++ b/go_backend/songlink.go @@ -291,7 +291,7 @@ func extractDeezerIDFromURL(deezerURL string) string { return "" } -// extractQobuzIDFromURL extracts Qobuz track ID from URL +// extractQobuzIDFromURL extracts Qobuz track ID from URL. // URL formats: // - https://www.qobuz.com/us-en/album/.../12345678 (album page with track highlight) // - https://open.qobuz.com/track/12345678 @@ -302,29 +302,24 @@ func extractQobuzIDFromURL(qobuzURL string) string { return "" } - // Try to find /track/ID pattern first if strings.Contains(qobuzURL, "/track/") { parts := strings.Split(qobuzURL, "/track/") if len(parts) > 1 { idPart := parts[1] - // Remove query parameters if idx := strings.Index(idPart, "?"); idx > 0 { idPart = idPart[:idx] } - // Remove trailing slash or path if idx := strings.Index(idPart, "/"); idx > 0 { idPart = idPart[:idx] } idPart = strings.TrimSpace(idPart) - // Validate it's a number if idPart != "" && isNumeric(idPart) { return idPart } } } - // Try to extract from album URL with track highlight - // Format: /album/albumname/trackid or ?trackId=12345678 + // Try to extract from album URL with track highlight (e.g. ?trackId=12345678) if strings.Contains(qobuzURL, "trackId=") { parts := strings.Split(qobuzURL, "trackId=") if len(parts) > 1 { @@ -343,7 +338,6 @@ func extractQobuzIDFromURL(qobuzURL string) string { parts := strings.Split(qobuzURL, "/") for i := len(parts) - 1; i >= 0; i-- { part := parts[i] - // Remove query parameters if idx := strings.Index(part, "?"); idx > 0 { part = part[:idx] } @@ -386,7 +380,6 @@ func extractYouTubeIDFromURL(youtubeURL string) string { return "" } - // Handle youtu.be short URLs if strings.Contains(youtubeURL, "youtu.be/") { parts := strings.Split(youtubeURL, "youtu.be/") if len(parts) >= 2 { @@ -401,7 +394,6 @@ func extractYouTubeIDFromURL(youtubeURL string) string { } } - // Handle youtube.com URLs with ?v= parameter parsed, err := url.Parse(youtubeURL) if err != nil { return "" @@ -411,7 +403,6 @@ func extractYouTubeIDFromURL(youtubeURL string) string { return v } - // Handle /embed/ format if strings.Contains(parsed.Path, "/embed/") { parts := strings.Split(parsed.Path, "/embed/") if len(parts) >= 2 { @@ -540,7 +531,6 @@ func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*Tra return availability, nil } -// checkAvailabilityFromDeezerSongLink is the original SongLink implementation for Deezer func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID string) (*TrackAvailability, error) { songLinkRateLimiter.WaitForSlot() diff --git a/go_backend/spotify.go b/go_backend/spotify.go index 9728a72f..570c4e24 100644 --- a/go_backend/spotify.go +++ b/go_backend/spotify.go @@ -9,7 +9,6 @@ import ( "math/rand" "net/http" "net/url" - "os" "strings" "sync" "time" @@ -64,45 +63,20 @@ var ( credentialsMu sync.RWMutex ) -var ErrNoSpotifyCredentials = errors.New("Spotify credentials not configured. Please set your own Client ID and Secret in Settings, or use Deezer as metadata source (free, no credentials required)") +var ErrNoSpotifyCredentials = errors.New("built-in Spotify API metadata provider has been removed; use Deezer or the spotify-web extension instead") func SetSpotifyCredentials(clientID, clientSecret string) { credentialsMu.Lock() defer credentialsMu.Unlock() - customClientID = clientID - customClientSecret = clientSecret + customClientID = "" + customClientSecret = "" } func HasSpotifyCredentials() bool { - credentialsMu.RLock() - defer credentialsMu.RUnlock() - - if customClientID != "" && customClientSecret != "" { - return true - } - - if os.Getenv("SPOTIFY_CLIENT_ID") != "" && os.Getenv("SPOTIFY_CLIENT_SECRET") != "" { - return true - } - return false } func getCredentials() (string, string, error) { - credentialsMu.RLock() - defer credentialsMu.RUnlock() - - if customClientID != "" && customClientSecret != "" { - return customClientID, customClientSecret, nil - } - - clientID := os.Getenv("SPOTIFY_CLIENT_ID") - clientSecret := os.Getenv("SPOTIFY_CLIENT_SECRET") - - if clientID != "" && clientSecret != "" { - return clientID, clientSecret, nil - } - return "", "", ErrNoSpotifyCredentials } diff --git a/go_backend/tidal.go b/go_backend/tidal.go index 22fd2377..ea9a8886 100644 --- a/go_backend/tidal.go +++ b/go_backend/tidal.go @@ -103,7 +103,7 @@ type MPD struct { func NewTidalDownloader() *TidalDownloader { tidalDownloaderOnce.Do(func() { globalTidalDownloader = &TidalDownloader{ - client: NewHTTPClientWithTimeout(DefaultTimeout), // 60s timeout + client: NewHTTPClientWithTimeout(DefaultTimeout), } apis := globalTidalDownloader.GetAvailableAPIs() @@ -116,7 +116,7 @@ func NewTidalDownloader() *TidalDownloader { func (t *TidalDownloader) GetAvailableAPIs() []string { return []string{ - "https://tidal-api.binimum.org", // priority + "https://tidal-api.binimum.org", "https://tidal.kinoplus.online", "https://triton.squid.wtf", "https://vogel.qqdl.site", @@ -195,7 +195,6 @@ func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) { 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, albumName, spotifyISRC string, expectedDuration int) (*TidalTrack, error) { return nil, fmt.Errorf("tidal metadata search API disabled: no client credentials mode") } @@ -204,7 +203,6 @@ func (t *TidalDownloader) SearchTrackByMetadata(trackName, artistName string) (* return nil, fmt.Errorf("tidal metadata search API disabled: no client credentials mode") } -// TidalDownloadInfo contains download URL and quality info type TidalDownloadInfo struct { URL string BitDepth int @@ -218,15 +216,13 @@ type tidalAPIResult struct { duration time.Duration } -// Tidal API timeout configuration // Mobile networks are more unstable, so we use longer timeouts const ( tidalAPITimeoutMobile = 25 * time.Second - tidalMaxRetries = 2 // Number of retries per API + tidalMaxRetries = 2 tidalRetryDelay = 500 * time.Millisecond ) -// fetchTidalURLWithRetry fetches download URL from a single Tidal API with retry logic func fetchTidalURLWithRetry(api string, trackID int64, quality string, timeout time.Duration) (TidalDownloadInfo, error) { var lastErr error retryDelay := tidalRetryDelay @@ -235,7 +231,7 @@ func fetchTidalURLWithRetry(api string, trackID int64, quality string, timeout t if attempt > 0 { GoLog("[Tidal] Retry %d/%d for %s after %v\n", attempt, tidalMaxRetries, api, retryDelay) time.Sleep(retryDelay) - retryDelay *= 2 // Exponential backoff + retryDelay *= 2 } client := NewHTTPClientWithTimeout(timeout) @@ -250,17 +246,15 @@ func fetchTidalURLWithRetry(api string, trackID int64, quality string, timeout t resp, err := client.Do(req) if err != nil { lastErr = err - // Check for retryable errors (timeout, connection reset) errStr := strings.ToLower(err.Error()) if strings.Contains(errStr, "timeout") || strings.Contains(errStr, "reset") || strings.Contains(errStr, "connection refused") || strings.Contains(errStr, "eof") { - continue // Retry + continue } - break // Non-retryable error + break } - // Server errors are retryable if resp.StatusCode >= 500 { io.Copy(io.Discard, resp.Body) resp.Body.Close() @@ -273,7 +267,7 @@ func fetchTidalURLWithRetry(api string, trackID int64, quality string, timeout t io.Copy(io.Discard, resp.Body) resp.Body.Close() lastErr = fmt.Errorf("rate limited") - retryDelay = 2 * time.Second // Wait longer for rate limit + retryDelay = 2 * time.Second continue } diff --git a/go_backend/youtube.go b/go_backend/youtube.go index bfbedcbb..e43d0e39 100644 --- a/go_backend/youtube.go +++ b/go_backend/youtube.go @@ -1,4 +1,3 @@ -// Package gobackend - YouTube download via Cobalt API (lossy-only provider) package gobackend import ( @@ -161,7 +160,6 @@ func parseYouTubeQualityInput(raw string) (format string, bitrate int, normalize } } -// 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) searchQuery := url.QueryEscape(query) @@ -213,7 +211,6 @@ func (y *YouTubeDownloader) GetDownloadURL(youtubeURL string, quality YouTubeQua return resp, nil } -// requestCobaltDirect sends a download request to the primary Cobalt API. func (y *YouTubeDownloader) requestCobaltDirect(videoURL, audioFormat, audioBitrate string) (*CobaltResponse, error) { reqBody := CobaltRequest{ URL: videoURL, @@ -470,7 +467,6 @@ func BuildYouTubeWatchURL(videoID string) string { return fmt.Sprintf("https://music.youtube.com/watch?v=%s", videoID) } -// isYouTubeVideoID checks if s is an 11-char YouTube video ID func isYouTubeVideoID(s string) bool { if len(s) != 11 { return false @@ -707,7 +703,6 @@ func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) { GoLog("[YouTube] Downloading to: %s\n", outputPath) - // Parallel fetch cover art + lyrics var parallelResult *ParallelDownloadResult if req.EmbedLyrics || req.CoverURL != "" { GoLog("[YouTube] Starting parallel fetch for cover and lyrics...\n") diff --git a/image.png b/image.png deleted file mode 100644 index 565fab55..00000000 Binary files a/image.png and /dev/null differ diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 8dfaa378..b85020da 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -15,6 +15,9 @@ import Gobackend // Import Go framework private var libraryScanProgressEventSink: FlutterEventSink? private var lastLibraryScanProgressPayload: String? + /// Currently accessed security-scoped URL for library folder + private var activeSecurityScopedURL: URL? + override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? @@ -492,13 +495,6 @@ import Gobackend // Import Go framework if let error = error { throw error } return response - case "getAmazonURLFromDeezerTrack": - let args = call.arguments as! [String: Any] - let deezerTrackId = args["deezer_track_id"] as! String - let response = GobackendGetAmazonURLFromDeezerTrack(deezerTrackId, &error) - if let error = error { throw error } - return response - case "preWarmTrackCache": let args = call.arguments as! [String: Any] let tracksJson = args["tracks"] as! String @@ -922,6 +918,26 @@ import Gobackend // Import Go framework let response = GobackendReadAudioMetadataJSON(filePath, &error) if let error = error { throw error } return response + + // iOS Security-Scoped Bookmark for Local Library + case "resolveIosBookmark": + let args = call.arguments as! [String: Any] + let bookmarkBase64 = args["bookmark"] as! String + return try resolveIosBookmark(bookmarkBase64) + + case "startAccessingIosBookmark": + let args = call.arguments as! [String: Any] + let bookmarkBase64 = args["bookmark"] as! String + return try startAccessingIosBookmark(bookmarkBase64) + + case "stopAccessingIosBookmark": + stopAccessingIosBookmark() + return nil + + case "createIosBookmarkFromPath": + let args = call.arguments as! [String: Any] + let path = args["path"] as! String + return try createIosBookmarkFromPath(path) // Lyrics Provider Settings case "setLyricsProviders": @@ -953,6 +969,15 @@ import Gobackend // Import Go framework if let error = error { throw error } return response + // CUE Sheet Parsing + case "parseCueSheet": + let args = call.arguments as! [String: Any] + let cuePath = args["cue_path"] as! String + let audioDir = args["audio_dir"] as? String ?? "" + let response = GobackendParseCueSheet(cuePath, audioDir, &error) + if let error = error { throw error } + return response + default: throw NSError( domain: "SpotiFLAC", @@ -961,6 +986,112 @@ import Gobackend // Import Go framework ) } } + + // MARK: - iOS Security-Scoped Bookmark Helpers + + /// Create a security-scoped bookmark from a filesystem path (e.g. from FilePicker). + /// The path must currently be accessible (within the same picker session). + /// Returns base64-encoded bookmark data. + private func createIosBookmarkFromPath(_ path: String) throws -> String { + let url = URL(fileURLWithPath: path) + do { + let bookmarkData = try url.bookmarkData( + options: .minimalBookmark, + includingResourceValuesForKeys: nil, + relativeTo: nil + ) + return bookmarkData.base64EncodedString() + } catch { + throw NSError( + domain: "SpotiFLAC", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Failed to create bookmark for path \(path): \(error.localizedDescription)"] + ) + } + } + + /// Resolve a base64-encoded security-scoped bookmark and return the resolved path. + /// Does NOT start accessing the resource. + private func resolveIosBookmark(_ bookmarkBase64: String) throws -> String { + guard let bookmarkData = Data(base64Encoded: bookmarkBase64) else { + throw NSError( + domain: "SpotiFLAC", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Invalid base64 bookmark data"] + ) + } + + var isStale = false + let url: URL + do { + url = try URL( + resolvingBookmarkData: bookmarkData, + options: [], + relativeTo: nil, + bookmarkDataIsStale: &isStale + ) + } catch { + throw NSError( + domain: "SpotiFLAC", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Failed to resolve bookmark: \(error.localizedDescription)"] + ) + } + + return url.path + } + + /// Resolve a base64-encoded bookmark, start accessing the security-scoped resource, + /// and return the resolved filesystem path. The resource stays accessed until + /// `stopAccessingIosBookmark()` is called. + private func startAccessingIosBookmark(_ bookmarkBase64: String) throws -> String { + // Stop any previously accessed resource first + stopAccessingIosBookmark() + + guard let bookmarkData = Data(base64Encoded: bookmarkBase64) else { + throw NSError( + domain: "SpotiFLAC", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Invalid base64 bookmark data"] + ) + } + + var isStale = false + let url: URL + do { + url = try URL( + resolvingBookmarkData: bookmarkData, + options: [], + relativeTo: nil, + bookmarkDataIsStale: &isStale + ) + } catch { + throw NSError( + domain: "SpotiFLAC", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Failed to resolve bookmark: \(error.localizedDescription)"] + ) + } + + guard url.startAccessingSecurityScopedResource() else { + throw NSError( + domain: "SpotiFLAC", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Failed to start accessing security-scoped resource at \(url.path)"] + ) + } + + activeSecurityScopedURL = url + return url.path + } + + /// Stop accessing the currently active security-scoped resource, if any. + private func stopAccessingIosBookmark() { + if let url = activeSecurityScopedURL { + url.stopAccessingSecurityScopedResource() + activeSecurityScopedURL = nil + } + } } private final class ClosureStreamHandler: NSObject, FlutterStreamHandler { diff --git a/lib/app.dart b/lib/app.dart index 981c3bae..cf0ec25b 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -17,7 +17,6 @@ final _routerProvider = Provider((ref) { settingsProvider.select((s) => s.hasCompletedTutorial), ); - // Determine initial location based on app state String initialLocation; if (isFirstLaunch) { initialLocation = '/setup'; diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart index 48a1c1e2..d423fac6 100644 --- a/lib/constants/app_info.dart +++ b/lib/constants/app_info.dart @@ -1,8 +1,8 @@ /// App version and info constants /// Update version here only - all other files will reference this class AppInfo { - static const String version = '3.7.1'; - static const String buildNumber = '104'; + static const String version = '3.7.2'; + static const String buildNumber = '105'; static const String fullVersion = '$version+$buildNumber'; diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index b321656f..488563dc 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -763,7 +763,7 @@ abstract class AppLocalizations { /// App description in header card /// /// In en, this message translates to: - /// **'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'** + /// **'Download Spotify tracks in lossless quality from Tidal and Qobuz.'** String get aboutAppDescription; /// Section header for artist albums @@ -1306,6 +1306,24 @@ abstract class AppLocalizations { /// **'No tracks found'** String get errorNoTracksFound; + /// Error title - URL not handled by any extension or service + /// + /// In en, this message translates to: + /// **'Link not recognized'** + String get errorUrlNotRecognized; + + /// Error message - URL not recognized explanation + /// + /// In en, this message translates to: + /// **'This link is not supported. Make sure the URL is correct and a compatible extension is installed.'** + String get errorUrlNotRecognizedMessage; + + /// Error message - generic URL fetch failure + /// + /// In en, this message translates to: + /// **'Failed to load content from this link. Please try again.'** + String get errorUrlFetchFailed; + /// Error - extension source not available /// /// In en, this message translates to: @@ -1438,6 +1456,18 @@ abstract class AppLocalizations { /// **'No organization'** String get folderOrganizationNone; + /// Folder option - playlist folders + /// + /// In en, this message translates to: + /// **'By Playlist'** + String get folderOrganizationByPlaylist; + + /// Subtitle for playlist folder option + /// + /// In en, this message translates to: + /// **'Separate folder for each playlist'** + String get folderOrganizationByPlaylistSubtitle; + /// Folder option - artist folders /// /// In en, this message translates to: @@ -1576,7 +1606,7 @@ abstract class AppLocalizations { /// **'If a track is not available on the first provider, the app will automatically try the next one.'** String get providerPriorityInfo; - /// Label for built-in providers (Tidal/Qobuz/Amazon) + /// Label for built-in providers (Tidal/Qobuz) /// /// In en, this message translates to: /// **'Built-in'** @@ -3271,7 +3301,7 @@ abstract class AppLocalizations { /// Tutorial welcome tip 2 /// /// In en, this message translates to: - /// **'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'** + /// **'Get FLAC quality audio from Tidal, Qobuz, or Deezer'** String get tutorialWelcomeTip2; /// Tutorial welcome tip 3 @@ -3794,6 +3824,78 @@ abstract class AppLocalizations { /// **'Conversion failed'** String get trackConvertFailed; + /// Title for CUE split bottom sheet + /// + /// In en, this message translates to: + /// **'Split CUE Sheet'** + String get cueSplitTitle; + + /// Subtitle for CUE split menu item + /// + /// In en, this message translates to: + /// **'Split CUE+FLAC into individual tracks'** + String get cueSplitSubtitle; + + /// Album name in CUE split sheet + /// + /// In en, this message translates to: + /// **'Album: {album}'** + String cueSplitAlbum(String album); + + /// Artist name in CUE split sheet + /// + /// In en, this message translates to: + /// **'Artist: {artist}'** + String cueSplitArtist(String artist); + + /// Number of tracks in CUE sheet + /// + /// In en, this message translates to: + /// **'{count} tracks'** + String cueSplitTrackCount(int count); + + /// CUE split confirmation dialog title + /// + /// In en, this message translates to: + /// **'Split CUE Album'** + String get cueSplitConfirmTitle; + + /// CUE split confirmation dialog message + /// + /// In en, this message translates to: + /// **'Split \"{album}\" into {count} individual FLAC files?\n\nFiles will be saved to the same directory.'** + String cueSplitConfirmMessage(String album, int count); + + /// Snackbar while splitting CUE + /// + /// In en, this message translates to: + /// **'Splitting CUE sheet... ({current}/{total})'** + String cueSplitSplitting(int current, int total); + + /// Snackbar after successful CUE split + /// + /// In en, this message translates to: + /// **'Split into {count} tracks successfully'** + String cueSplitSuccess(int count); + + /// Snackbar when CUE split fails + /// + /// In en, this message translates to: + /// **'CUE split failed'** + String get cueSplitFailed; + + /// Error when CUE audio file is missing + /// + /// In en, this message translates to: + /// **'Audio file not found for this CUE sheet'** + String get cueSplitNoAudioFile; + + /// Button text to start CUE splitting + /// + /// In en, this message translates to: + /// **'Split into Tracks'** + String get cueSplitButton; + /// Generic action button - create /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 2b846be4..4cb5b75d 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -15,7 +15,7 @@ class AppLocalizationsDe extends AppLocalizations { String get navHome => 'Startseite'; @override - String get navLibrary => 'Archiv'; + String get navLibrary => 'Bibliothek'; @override String get navSettings => 'Einstellungen'; @@ -500,7 +500,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get setupNotificationBackgroundDescription => - 'Werde benachrichtigt über Download-Fortschritt und -Fertigstellung. Dies hilft Ihnen, Downloads zu verfolgen, wenn die App im Hintergrund ist.'; + 'Erhalte Benachrichtigungen über den Fortschritt und die Fertigstellung deiner Downloads, selbst wenn die App im Hintergrund läuft.'; @override String get setupSkipForNow => 'Vorerst überspringen'; @@ -592,11 +592,11 @@ class AppLocalizationsDe extends AppLocalizations { } @override - String get dialogImportPlaylistTitle => 'Wiedergabeliste importieren'; + String get dialogImportPlaylistTitle => 'Playlist importieren'; @override String dialogImportPlaylistMessage(int count) { - return '$count Titel in CSV gefunden. Zur Warteschlange hinzufügen?'; + return '$count Titel gefunden hinzufügen?'; } @override @@ -606,12 +606,12 @@ class AppLocalizationsDe extends AppLocalizations { @override String snackbarAddedToQueue(String trackName) { - return '\"$trackName\" zur Warteschlange hinzugefügt'; + return '\"$trackName\" hinzugefügt'; } @override String snackbarAddedTracksToQueue(int count) { - return '$count Titel zur Warteschlange hinzugefügt'; + return '$count Titel hinzugefügt'; } @override @@ -701,6 +701,17 @@ class AppLocalizationsDe extends AppLocalizations { @override String get errorNoTracksFound => 'Keine Titel gefunden'; + @override + String get errorUrlNotRecognized => 'Link not recognized'; + + @override + String get errorUrlNotRecognizedMessage => + 'This link is not supported. Make sure the URL is correct and a compatible extension is installed.'; + + @override + String get errorUrlFetchFailed => + 'Failed to load content from this link. Please try again.'; + @override String errorMissingExtensionSource(String item) { return 'Kann $item nicht lade wegen fehlender Erweiterungsquelle'; @@ -765,15 +776,22 @@ class AppLocalizationsDe extends AppLocalizations { String get filenameFormat => 'Dateinamenformat'; @override - String get filenameShowAdvancedTags => 'Show advanced tags'; + String get filenameShowAdvancedTags => 'Erweiterte Tags anzeigen'; @override String get filenameShowAdvancedTagsDescription => - 'Enable formatted tags for track padding and date patterns'; + 'Formatierte Tags für Track-Padding und Datumsmuster aktivieren'; @override String get folderOrganizationNone => 'Keine Organisation'; + @override + String get folderOrganizationByPlaylist => 'By Playlist'; + + @override + String get folderOrganizationByPlaylistSubtitle => + 'Separate folder for each playlist'; + @override String get folderOrganizationByArtist => 'Nach Künstler'; @@ -918,11 +936,11 @@ class AppLocalizationsDe extends AppLocalizations { @override String logEntries(int count) { - return 'Entries ($count)'; + return '$count Einträge'; } @override - String get credentialsTitle => 'Spotify Credentials'; + String get credentialsTitle => 'Spotify-Anmeldedaten'; @override String get credentialsDescription => @@ -984,7 +1002,7 @@ class AppLocalizationsDe extends AppLocalizations { 'Wähle wie Songtexte mit deinen Downloads gespeichert werden'; @override - String get lyricsModeEmbed => 'In Datei einbinden'; + String get lyricsModeEmbed => 'In Datei einbetten'; @override String get lyricsModeEmbedSubtitle => 'Lyrics in FLAC Metadaten gespeichert'; @@ -1001,7 +1019,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get lyricsModeBothSubtitle => - 'Lyrics einbinden und als .lrc speichern'; + 'Lyrics einbetten und als .lrc speichern'; @override String get sectionColor => 'Farbe'; @@ -1019,29 +1037,30 @@ class AppLocalizationsDe extends AppLocalizations { String get appearanceLanguage => 'App Sprache'; @override - String get settingsAppearanceSubtitle => 'Theme, colors, display'; + String get settingsAppearanceSubtitle => 'Design, Farben, Anzeige'; @override - String get settingsDownloadSubtitle => 'Service, quality, filename format'; + String get settingsDownloadSubtitle => 'Dienst, Qualität, Dateinamen-Format'; @override - String get settingsOptionsSubtitle => 'Fallback, lyrics, cover art, updates'; + String get settingsOptionsSubtitle => 'Fallback, Lyrics, Covers, Updates'; @override - String get settingsExtensionsSubtitle => 'Manage download providers'; + String get settingsExtensionsSubtitle => 'Download-Anbieter verwalten'; @override - String get settingsLogsSubtitle => 'View app logs for debugging'; + String get settingsLogsSubtitle => 'App-Logs zum Debuggen anzeigen'; @override - String get loadingSharedLink => 'Loading shared link...'; + String get loadingSharedLink => 'Link wird geladen...'; @override - String get pressBackAgainToExit => 'Press back again to exit'; + String get pressBackAgainToExit => + 'Drücke wieder \"zurück\" um die App zu beenden'; @override String downloadAllCount(int count) { - return 'Download All ($count)'; + return 'Alle $count Titel herunterladen'; } @override @@ -1049,65 +1068,65 @@ class AppLocalizationsDe extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count tracks', - one: '1 track', + other: '$count Titel', + one: '1 Titel', ); return '$_temp0'; } @override - String get trackCopyFilePath => 'Copy file path'; + String get trackCopyFilePath => 'Dateipfad kopieren'; @override - String get trackRemoveFromDevice => 'Remove from device'; + String get trackRemoveFromDevice => 'Vom Gerät entfernen'; @override - String get trackLoadLyrics => 'Load Lyrics'; + String get trackLoadLyrics => 'Lade Lyrics'; @override - String get trackMetadata => 'Metadata'; + String get trackMetadata => 'Metadaten'; @override - String get trackFileInfo => 'File Info'; + String get trackFileInfo => 'Datei-Info'; @override String get trackLyrics => 'Lyrics'; @override - String get trackFileNotFound => 'File not found'; + String get trackFileNotFound => 'Datei nicht gefunden'; @override - String get trackOpenInDeezer => 'Open in Deezer'; + String get trackOpenInDeezer => 'In Deezer öffnen'; @override - String get trackOpenInSpotify => 'Open in Spotify'; + String get trackOpenInSpotify => 'In Spotify öffnen'; @override - String get trackTrackName => 'Track name'; + String get trackTrackName => 'Name des Titels'; @override - String get trackArtist => 'Artist'; + String get trackArtist => 'Künstler'; @override - String get trackAlbumArtist => 'Album artist'; + String get trackAlbumArtist => 'Album Künstler'; @override String get trackAlbum => 'Album'; @override - String get trackTrackNumber => 'Track number'; + String get trackTrackNumber => 'Titelnummer'; @override - String get trackDiscNumber => 'Disc number'; + String get trackDiscNumber => 'CD-Nummer'; @override - String get trackDuration => 'Duration'; + String get trackDuration => 'Länge'; @override - String get trackAudioQuality => 'Audio quality'; + String get trackAudioQuality => 'Audioqualität'; @override - String get trackReleaseDate => 'Release date'; + String get trackReleaseDate => 'Erscheinungsdatum'; @override String get trackGenre => 'Genre'; @@ -1116,71 +1135,73 @@ class AppLocalizationsDe extends AppLocalizations { String get trackLabel => 'Label'; @override - String get trackCopyright => 'Copyright'; + String get trackCopyright => 'Urheberrecht'; @override - String get trackDownloaded => 'Downloaded'; + String get trackDownloaded => 'Heruntergeladen'; @override - String get trackCopyLyrics => 'Copy lyrics'; + String get trackCopyLyrics => 'Lyrics kopieren'; @override - String get trackLyricsNotAvailable => 'Lyrics not available for this track'; + String get trackLyricsNotAvailable => + 'Lyrics sind für diesen Titel nicht verfügbar'; @override - String get trackLyricsTimeout => 'Request timed out. Try again later.'; + String get trackLyricsTimeout => + 'Anfrage Timeout. Versuche es später erneut.'; @override - String get trackLyricsLoadFailed => 'Failed to load lyrics'; + String get trackLyricsLoadFailed => 'Fehler beim Laden der Lyrics'; @override - String get trackEmbedLyrics => 'Embed Lyrics'; + String get trackEmbedLyrics => 'Lyrics einbetten'; @override - String get trackLyricsEmbedded => 'Lyrics embedded successfully'; + String get trackLyricsEmbedded => 'Lyrics erfolgreich eingebettet'; @override - String get trackInstrumental => 'Instrumental track'; + String get trackInstrumental => 'Instrumentalspur'; @override - String get trackCopiedToClipboard => 'Copied to clipboard'; + String get trackCopiedToClipboard => 'In Zwischenablage kopiert'; @override - String get trackDeleteConfirmTitle => 'Remove from device?'; + String get trackDeleteConfirmTitle => 'Vom Gerät entfernen?'; @override String get trackDeleteConfirmMessage => - 'This will permanently delete the downloaded file and remove it from your history.'; + 'Dies wird die heruntergeladene Datei dauerhaft löschen und sie aus deinem Verlauf entfernen.'; @override - String get dateToday => 'Today'; + String get dateToday => 'Heute'; @override - String get dateYesterday => 'Yesterday'; + String get dateYesterday => 'Gestern'; @override String dateDaysAgo(int count) { - return '$count days ago'; + return 'Vor $count Tagen'; } @override String dateWeeksAgo(int count) { - return '$count weeks ago'; + return 'Vor $count Wochen'; } @override String dateMonthsAgo(int count) { - return '$count months ago'; + return 'Vor $count Monaten'; } @override - String get storeFilterAll => 'All'; + String get storeFilterAll => 'Alle'; @override - String get storeFilterMetadata => 'Metadata'; + String get storeFilterMetadata => 'Metadaten'; @override - String get storeFilterDownload => 'Download'; + String get storeFilterDownload => 'Herunterladen'; @override String get storeFilterUtility => 'Utility'; @@ -1192,142 +1213,146 @@ class AppLocalizationsDe extends AppLocalizations { String get storeFilterIntegration => 'Integration'; @override - String get storeClearFilters => 'Clear filters'; + String get storeClearFilters => 'Filter entfernen'; @override - String get extensionDefaultProvider => 'Default (Deezer/Spotify)'; + String get extensionDefaultProvider => 'Standard (Deezer/Spotify)'; @override - String get extensionDefaultProviderSubtitle => 'Use built-in search'; + String get extensionDefaultProviderSubtitle => 'Eingebaute Suche verwenden'; @override - String get extensionAuthor => 'Author'; + String get extensionAuthor => 'Entwickler'; @override String get extensionId => 'ID'; @override - String get extensionError => 'Error'; + String get extensionError => 'Fehler'; @override - String get extensionCapabilities => 'Capabilities'; + String get extensionCapabilities => 'Eigenschaften'; @override - String get extensionMetadataProvider => 'Metadata Provider'; + String get extensionMetadataProvider => 'Metadaten-Anbieter'; @override - String get extensionDownloadProvider => 'Download Provider'; + String get extensionDownloadProvider => 'Download-Anbieter'; @override - String get extensionLyricsProvider => 'Lyrics Provider'; + String get extensionLyricsProvider => 'Lyrics-Anbieter'; @override String get extensionUrlHandler => 'URL Handler'; @override - String get extensionQualityOptions => 'Quality Options'; + String get extensionQualityOptions => 'Qualitätsoptionen'; @override String get extensionPostProcessingHooks => 'Post-Processing Hooks'; @override - String get extensionPermissions => 'Permissions'; + String get extensionPermissions => 'Berechtigungen'; @override - String get extensionSettings => 'Settings'; + String get extensionSettings => 'Einstellungen'; @override - String get extensionRemoveButton => 'Remove Extension'; + String get extensionRemoveButton => 'Erweiterung entfernen'; @override - String get extensionUpdated => 'Updated'; + String get extensionUpdated => 'Aktualisiert'; @override - String get extensionMinAppVersion => 'Min App Version'; + String get extensionMinAppVersion => 'Min App-Version'; @override - String get extensionCustomTrackMatching => 'Custom Track Matching'; + String get extensionCustomTrackMatching => + 'Benutzerdefiniertes Track-Matching'; @override - String get extensionPostProcessing => 'Post-Processing'; + String get extensionPostProcessing => 'Post-processing'; @override String extensionHooksAvailable(int count) { - return '$count hook(s) available'; + return '$count Hook(s) verfügbar'; } @override String extensionPatternsCount(int count) { - return '$count pattern(s)'; + return '$count Muster'; } @override String extensionStrategy(String strategy) { - return 'Strategy: $strategy'; + return 'Strategie: $strategy'; } @override - String get extensionsProviderPrioritySection => 'Provider Priority'; + String get extensionsProviderPrioritySection => 'Provider-Priorität'; @override - String get extensionsInstalledSection => 'Installed Extensions'; + String get extensionsInstalledSection => 'Installierte Erweiterungen'; @override - String get extensionsNoExtensions => 'No extensions installed'; + String get extensionsNoExtensions => 'Keine Erweiterungen installiert'; @override String get extensionsNoExtensionsSubtitle => - 'Install .spotiflac-ext files to add new providers'; + 'Installiere .spotiflac-ext Dateien um neue Anbieter hinzuzufügen'; @override - String get extensionsInstallButton => 'Install Extension'; + String get extensionsInstallButton => 'Erweiterung installieren'; @override String get extensionsInfoTip => - 'Extensions can add new metadata and download providers. Only install extensions from trusted sources.'; + 'Erweiterungen können neue Metadaten und Download-Anbieter hinzufügen. Installiere nur Erweiterungen von vertrauenswürdigen Quellen.'; @override - String get extensionsInstalledSuccess => 'Extension installed successfully'; + String get extensionsInstalledSuccess => + 'Erweiterung erfolgreich installiert'; @override - String get extensionsDownloadPriority => 'Download Priority'; + String get extensionsDownloadPriority => 'Download-Priorität'; @override - String get extensionsDownloadPrioritySubtitle => 'Set download service order'; + String get extensionsDownloadPrioritySubtitle => + 'Download-Service-Reihenfolge festlegen'; @override String get extensionsNoDownloadProvider => - 'No extensions with download provider'; + 'Keine Erweiterungen mit Download-Provider'; @override - String get extensionsMetadataPriority => 'Metadata Priority'; + String get extensionsMetadataPriority => 'Metadaten Priorität'; @override String get extensionsMetadataPrioritySubtitle => - 'Set search & metadata source order'; + 'Reihenfolge der Such- und Metadaten quellen festlegen'; @override String get extensionsNoMetadataProvider => - 'No extensions with metadata provider'; + 'Keine Erweiterungen mit Metadaten-Anbieter'; @override - String get extensionsSearchProvider => 'Search Provider'; + String get extensionsSearchProvider => 'Such-Provider'; @override - String get extensionsNoCustomSearch => 'No extensions with custom search'; + String get extensionsNoCustomSearch => + 'Keine Erweiterungen mit benutzerdefinierter Suche'; @override String get extensionsSearchProviderDescription => - 'Choose which service to use for searching tracks'; + 'Wähle den Dienst für die Suche von Titel'; @override - String get extensionsCustomSearch => 'Custom search'; + String get extensionsCustomSearch => 'Benutzerdefinierte Suche'; @override - String get extensionsErrorLoading => 'Error loading extension'; + String get extensionsErrorLoading => 'Fehler beim Laden der Erweiterung'; @override - String get qualityFlacLossless => 'FLAC Lossless'; + String get qualityFlacLossless => 'FLAC Verlustfrei'; @override String get qualityFlacLosslessSubtitle => '16-bit / 44.1kHz'; @@ -1336,21 +1361,21 @@ class AppLocalizationsDe extends AppLocalizations { String get qualityHiResFlac => 'Hi-Res FLAC'; @override - String get qualityHiResFlacSubtitle => '24-bit / up to 96kHz'; + String get qualityHiResFlacSubtitle => '24-Bit / bis 96kHz'; @override String get qualityHiResFlacMax => 'Hi-Res FLAC Max'; @override - String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; + String get qualityHiResFlacMaxSubtitle => '24-Bit / bis 192kHz'; @override String get qualityNote => - 'Actual quality depends on track availability from the service'; + 'Die eigentliche Qualität hängt von der Verfügbarkeit des Dienstes ab'; @override String get youtubeQualityNote => - 'YouTube provides lossy audio only. Not part of lossless fallback.'; + 'YouTube bietet nur verlustbehaftete Audioqualität. Deswegen ist es kein Teil des verlustfreien Fallbacks.'; @override String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; @@ -1359,13 +1384,13 @@ class AppLocalizationsDe extends AppLocalizations { String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; @override - String get downloadAskBeforeDownload => 'Ask Before Download'; + String get downloadAskBeforeDownload => 'Qualität vor Download fragen'; @override - String get downloadDirectory => 'Download Directory'; + String get downloadDirectory => 'Downloadverzeichnis'; @override - String get downloadSeparateSinglesFolder => 'Separate Singles Folder'; + String get downloadSeparateSinglesFolder => 'Singles Ordner trennen'; @override String get downloadAlbumFolderStructure => 'Album Folder Structure'; @@ -1378,53 +1403,53 @@ class AppLocalizationsDe extends AppLocalizations { @override String get downloadUsePrimaryArtistOnlyEnabled => - 'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)'; + 'Vorgestellte Künstler aus dem Ordnernamen entfernt (z.B. Justin Bieber, Quavo → Justin Bieber)'; @override String get downloadUsePrimaryArtistOnlyDisabled => 'Full artist string used for folder name'; @override - String get downloadSelectQuality => 'Select Quality'; + String get downloadSelectQuality => 'Qualität wählen'; @override - String get downloadFrom => 'Download From'; + String get downloadFrom => 'Herunterladen von'; @override - String get appearanceAmoledDark => 'AMOLED Dark'; + String get appearanceAmoledDark => 'AMOLED Schwarz'; @override - String get appearanceAmoledDarkSubtitle => 'Pure black background'; + String get appearanceAmoledDarkSubtitle => 'AMOLED Hintergrund'; @override - String get queueClearAll => 'Clear All'; + String get queueClearAll => 'Alles löschen'; @override String get queueClearAllMessage => - 'Are you sure you want to clear all downloads?'; + 'Bist du dir sicher, dass du alle Downloads löschen möchten?'; @override String get settingsAutoExportFailed => 'Auto-export failed downloads'; @override String get settingsAutoExportFailedSubtitle => - 'Save failed downloads to TXT file automatically'; + 'Fehlgeschlagene Downloads automatisch in eine TXT-Datei speichern'; @override - String get settingsDownloadNetwork => 'Download Network'; + String get settingsDownloadNetwork => 'Download Netzwerk'; @override - String get settingsDownloadNetworkAny => 'WiFi + Mobile Data'; + String get settingsDownloadNetworkAny => 'WLAN + Mobile Daten'; @override - String get settingsDownloadNetworkWifiOnly => 'WiFi Only'; + String get settingsDownloadNetworkWifiOnly => 'Nur WLAN'; @override String get settingsDownloadNetworkSubtitle => - 'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.'; + 'Wähle aus, welches Netzwerk für Downloads verwendet werden soll. Wenn nur WLAN aktiviert wird, werden Downloads auf mobilen Daten angehalten.'; @override - String get albumFolderArtistAlbum => 'Artist / Album'; + String get albumFolderArtistAlbum => 'Künstler/Album'; @override String get albumFolderArtistAlbumSubtitle => 'Albums/Artist Name/Album Name/'; @@ -1434,13 +1459,13 @@ class AppLocalizationsDe extends AppLocalizations { @override String get albumFolderArtistYearAlbumSubtitle => - 'Albums/Artist Name/[2005] Album Name/'; + 'Albums/Künster Name/[2005] Album Name/'; @override - String get albumFolderAlbumOnly => 'Album Only'; + String get albumFolderAlbumOnly => 'Nur Alben'; @override - String get albumFolderAlbumOnlySubtitle => 'Albums/Album Name/'; + String get albumFolderAlbumOnlySubtitle => 'Alben/Album Name/'; @override String get albumFolderYearAlbum => '[Year] Album'; @@ -1456,39 +1481,39 @@ class AppLocalizationsDe extends AppLocalizations { 'Artist/Album/ and Artist/Singles/'; @override - String get downloadedAlbumDeleteSelected => 'Delete Selected'; + String get downloadedAlbumDeleteSelected => 'Ausgewählte löschen'; @override String downloadedAlbumDeleteMessage(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'tracks', - one: 'track', + other: 'Titel', + one: 'Titel', ); - return 'Delete $count $_temp0 from this album?\n\nThis will also delete the files from storage.'; + return '$count $_temp0 aus diesem Album löschen?\n\nDadurch werden auch die Dateien aus dem Speicher gelöscht.'; } @override String downloadedAlbumSelectedCount(int count) { - return '$count selected'; + return '$count ausgewählt'; } @override - String get downloadedAlbumAllSelected => 'All tracks selected'; + String get downloadedAlbumAllSelected => 'Alle Titel sind ausgewählt'; @override - String get downloadedAlbumTapToSelect => 'Tap tracks to select'; + String get downloadedAlbumTapToSelect => 'Tippe auf Titel zum Auswählen'; @override String downloadedAlbumDeleteCount(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'tracks', - one: 'track', + other: 'Titel', + one: 'Titel', ); - return 'Delete $count $_temp0'; + return 'Lösche $count $_temp0'; } @override @@ -1500,22 +1525,22 @@ class AppLocalizationsDe extends AppLocalizations { } @override - String get recentTypeArtist => 'Artist'; + String get recentTypeArtist => 'Künstler'; @override String get recentTypeAlbum => 'Album'; @override - String get recentTypeSong => 'Song'; + String get recentTypeSong => 'Titel'; @override String get recentTypePlaylist => 'Playlist'; @override - String get recentEmpty => 'No recent items yet'; + String get recentEmpty => 'Noch keine aktuellen Einträge'; @override - String get recentShowAllDownloads => 'Show All Downloads'; + String get recentShowAllDownloads => 'Alle Downloads anzeigen'; @override String recentPlaylistInfo(String name) { @@ -1523,41 +1548,41 @@ class AppLocalizationsDe extends AppLocalizations { } @override - String get discographyDownload => 'Download Discography'; + String get discographyDownload => 'Diskographie herunterladen'; @override - String get discographyDownloadAll => 'Download All'; + String get discographyDownloadAll => 'Alle Herunterladen'; @override String discographyDownloadAllSubtitle(int count, int albumCount) { - return '$count tracks from $albumCount releases'; + return '$count Titel von $albumCount Releases'; } @override - String get discographyAlbumsOnly => 'Albums Only'; + String get discographyAlbumsOnly => 'Nur Alben'; @override String discographyAlbumsOnlySubtitle(int count, int albumCount) { - return '$count tracks from $albumCount albums'; + return '$count Titel von $albumCount Albums'; } @override - String get discographySinglesOnly => 'Singles & EPs Only'; + String get discographySinglesOnly => 'Nur Singles & EPs'; @override String discographySinglesOnlySubtitle(int count, int albumCount) { - return '$count tracks from $albumCount singles'; + return '$count Titel von $albumCount Singles'; } @override - String get discographySelectAlbums => 'Select Albums...'; + String get discographySelectAlbums => 'Alben auswählen...'; @override String get discographySelectAlbumsSubtitle => 'Choose specific albums or singles'; @override - String get discographyFetchingTracks => 'Fetching tracks...'; + String get discographyFetchingTracks => 'Lade Titel...'; @override String discographyFetchingAlbum(int current, int total) { @@ -1566,11 +1591,11 @@ class AppLocalizationsDe extends AppLocalizations { @override String discographySelectedCount(int count) { - return '$count selected'; + return '$count ausgewählt'; } @override - String get discographyDownloadSelected => 'Download Selected'; + String get discographyDownloadSelected => 'Auswahl herunterladen'; @override String discographyAddedToQueue(int count) { @@ -1579,20 +1604,20 @@ class AppLocalizationsDe extends AppLocalizations { @override String discographySkippedDownloaded(int added, int skipped) { - return '$added added, $skipped already downloaded'; + return '$added hinzugefügt, $skipped bereits heruntergeladen'; } @override - String get discographyNoAlbums => 'No albums available'; + String get discographyNoAlbums => 'Es sind keine Alben verfügbar'; @override String get discographyFailedToFetch => 'Failed to fetch some albums'; @override - String get sectionStorageAccess => 'Storage Access'; + String get sectionStorageAccess => 'Speicherzugriff'; @override - String get allFilesAccess => 'All Files Access'; + String get allFilesAccess => 'Zugriff auf alle Dateien'; @override String get allFilesAccessEnabledSubtitle => 'Can write to any folder'; @@ -1602,170 +1627,170 @@ class AppLocalizationsDe extends AppLocalizations { @override String get allFilesAccessDescription => - 'Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.'; + 'Aktiviere die Option, wenn beim Speichern in benutzerdefinierten Ordnern Schreibfehler auftreten. Weil Android 13+ standardmäßig den Zugriff auf bestimmte Verzeichnisse einschränkt.'; @override String get allFilesAccessDeniedMessage => - 'Permission was denied. Please enable \'All files access\' manually in system settings.'; + 'Zugriff verweigert. Bitte aktiviere \"Zugriff auf alle Dateien\" manuell in den Systemeinstellungen.'; @override String get allFilesAccessDisabledMessage => - 'All Files Access disabled. The app will use limited storage access.'; + 'Zugriff auf alle Dateien ist deaktiviert. Die App verwendet nur begrenzten Zugriff auf den Speicher.'; @override - String get settingsLocalLibrary => 'Local Library'; + String get settingsLocalLibrary => 'Lokale Bibliothek'; @override String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates'; @override - String get settingsCache => 'Storage & Cache'; + String get settingsCache => 'Speicher & Cache'; @override String get settingsCacheSubtitle => 'View size and clear cached data'; @override - String get libraryTitle => 'Local Library'; + String get libraryTitle => 'Lokale Bibliothek'; @override - String get libraryScanSettings => 'Scan Settings'; + String get libraryScanSettings => 'Scan Einstellungen'; @override - String get libraryEnableLocalLibrary => 'Enable Local Library'; + String get libraryEnableLocalLibrary => 'Lokale Bibliothek aktivieren'; @override String get libraryEnableLocalLibrarySubtitle => 'Scan and track your existing music'; @override - String get libraryFolder => 'Library Folder'; + String get libraryFolder => 'Bibliotheksordner'; @override - String get libraryFolderHint => 'Tap to select folder'; + String get libraryFolderHint => 'Tippe um Ordner auszuwählen'; @override String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator'; @override String get libraryShowDuplicateIndicatorSubtitle => - 'Show when searching for existing tracks'; + 'Bei der Suche nach vorhandenen Titeln anzeigen'; @override - String get libraryActions => 'Actions'; + String get libraryActions => 'Aktionen'; @override - String get libraryScan => 'Scan Library'; + String get libraryScan => 'Bibliothek scannen'; @override - String get libraryScanSubtitle => 'Scan for audio files'; + String get libraryScanSubtitle => 'Suche nach Audiodateien'; @override - String get libraryScanSelectFolderFirst => 'Select a folder first'; + String get libraryScanSelectFolderFirst => 'Wähle zuerst einen Ordner'; @override - String get libraryCleanupMissingFiles => 'Cleanup Missing Files'; + String get libraryCleanupMissingFiles => 'Fehlende Dateien bereinigen'; @override String get libraryCleanupMissingFilesSubtitle => - 'Remove entries for files that no longer exist'; + 'Verlaufseinträge für Dateien löschen, die nicht mehr existieren'; @override - String get libraryClear => 'Clear Library'; + String get libraryClear => 'Bibliothek löschen'; @override - String get libraryClearSubtitle => 'Remove all scanned tracks'; + String get libraryClearSubtitle => 'Alle gescannten Titel entfernen'; @override - String get libraryClearConfirmTitle => 'Clear Library'; + String get libraryClearConfirmTitle => 'Bibliothek löschen'; @override String get libraryClearConfirmMessage => - 'This will remove all scanned tracks from your library. Your actual music files will not be deleted.'; + 'Dadurch werden alle gescannten Titel aus Ihrer Bibliothek entfernt. Ihre eigentlichen Musikdateien werden nicht gelöscht.'; @override - String get libraryAbout => 'About Local Library'; + String get libraryAbout => 'Über die lokale Bibliothek'; @override 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.'; + 'Durchsucht deine bestehende Musiksammlung, um Duplikate beim Herunterladen zu erkennen. Unterstützt die Formate FLAC, M4A, MP3, Opus und OGG. Metadaten werden, sofern verfügbar, aus den Dateitags gelesen.'; @override String libraryTracksUnit(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'tracks', - one: 'track', + other: '$count Titel', + one: '1 Titel', ); return '$_temp0'; } @override String libraryLastScanned(String time) { - return 'Last scanned: $time'; + return 'Zuletzt gescannt: $time'; } @override - String get libraryLastScannedNever => 'Never'; + String get libraryLastScannedNever => 'Nie'; @override - String get libraryScanning => 'Scanning...'; + String get libraryScanning => 'Scannen...'; @override String libraryScanProgress(String progress, int total) { - return '$progress% of $total files'; + return '$progress% von $total Dateien'; } @override - String get libraryInLibrary => 'In Library'; + String get libraryInLibrary => 'In Bibliothek'; @override String libraryRemovedMissingFiles(int count) { - return 'Removed $count missing files from library'; + return 'Entfernte $count fehlende Dateien aus der Bibliothek'; } @override - String get libraryCleared => 'Library cleared'; + String get libraryCleared => 'Bibliothek geleert'; @override - String get libraryStorageAccessRequired => 'Storage Access Required'; + String get libraryStorageAccessRequired => 'Speicherzugriff erforderlich'; @override String get libraryStorageAccessMessage => - 'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.'; + 'SpotiFLAC benötigt Speicherzugriff, um deine Musikbibliothek zu scannen. Bitte erteile die Berechtigung in den Einstellungen.'; @override - String get libraryFolderNotExist => 'Selected folder does not exist'; + String get libraryFolderNotExist => 'Der ausgewählte Ordner existiert nicht'; @override - String get librarySourceDownloaded => 'Downloaded'; + String get librarySourceDownloaded => 'Heruntergeladen'; @override - String get librarySourceLocal => 'Local'; + String get librarySourceLocal => 'Lokal'; @override - String get libraryFilterAll => 'All'; + String get libraryFilterAll => 'Alle'; @override - String get libraryFilterDownloaded => 'Downloaded'; + String get libraryFilterDownloaded => 'Heruntergeladen'; @override - String get libraryFilterLocal => 'Local'; + String get libraryFilterLocal => 'Lokal'; @override - String get libraryFilterTitle => 'Filters'; + String get libraryFilterTitle => 'Filter'; @override - String get libraryFilterReset => 'Reset'; + String get libraryFilterReset => 'Zurücksetzen'; @override - String get libraryFilterApply => 'Apply'; + String get libraryFilterApply => 'Anwenden'; @override - String get libraryFilterSource => 'Source'; + String get libraryFilterSource => 'Quelle'; @override - String get libraryFilterQuality => 'Quality'; + String get libraryFilterQuality => 'Qualität'; @override String get libraryFilterQualityHiRes => 'Hi-Res (24bit)'; @@ -1774,30 +1799,30 @@ class AppLocalizationsDe extends AppLocalizations { String get libraryFilterQualityCD => 'CD (16bit)'; @override - String get libraryFilterQualityLossy => 'Lossy'; + String get libraryFilterQualityLossy => 'Verlustbehaftet'; @override String get libraryFilterFormat => 'Format'; @override - String get libraryFilterSort => 'Sort'; + String get libraryFilterSort => 'Sortieren'; @override - String get libraryFilterSortLatest => 'Latest'; + String get libraryFilterSortLatest => 'Neuste'; @override - String get libraryFilterSortOldest => 'Oldest'; + String get libraryFilterSortOldest => 'Älteste'; @override - String get timeJustNow => 'Just now'; + String get timeJustNow => 'Gerade eben'; @override String timeMinutesAgo(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count minutes ago', - one: '1 minute ago', + other: 'vor $count Minuten', + one: 'vor $count Minute', ); return '$_temp0'; } @@ -1807,199 +1832,201 @@ class AppLocalizationsDe extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count hours ago', - one: '1 hour ago', + other: 'vor $count Stunden', + one: 'vor $count Stunde', ); return '$_temp0'; } @override - String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!'; + String get tutorialWelcomeTitle => 'Willkommen bei SpotiFLAC!'; @override String get tutorialWelcomeDesc => - 'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.'; + 'Lass uns lernen, wie du deine Lieblingsmusik in verlustfreier Qualität herunterlädst. Dieses schnelle Tutorial zeigt dir die Grundlagen.'; @override String get tutorialWelcomeTip1 => - 'Download music from Spotify, Deezer, or paste any supported URL'; + 'Lade Musik von Spotify, Deezer herunter oder jeden unterstützten Link einfügen'; @override String get tutorialWelcomeTip2 => - 'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'; + 'Hole dir FLAC Audio von Tidal, Qobuz oder Amazon Musik'; @override String get tutorialWelcomeTip3 => - 'Automatic metadata, cover art, and lyrics embedding'; + 'Automatische Metadaten, Cover und Lyrics einbetten'; @override - String get tutorialSearchTitle => 'Finding Music'; + String get tutorialSearchTitle => 'Suche Musik'; @override String get tutorialSearchDesc => - 'There are two easy ways to find music you want to download.'; + 'Es gibt zwei einfache Möglichkeiten, Musik zu finden, die du herunterladen möchtest.'; @override - String get tutorialDownloadTitle => 'Downloading Music'; + String get tutorialDownloadTitle => 'Musik wird heruntergeladen'; @override String get tutorialDownloadDesc => - 'Downloading music is simple and fast. Here\'s how it works.'; + 'Das Herunterladen von Musik ist einfach und schnell. So funktioniert es.'; @override - String get tutorialLibraryTitle => 'Your Library'; + String get tutorialLibraryTitle => 'Deine Bibliothek'; @override String get tutorialLibraryDesc => - 'All your downloaded music is organized in the Library tab.'; + 'Die gesamte heruntergeladene Musik ist in der Bibliothek organisiert.'; @override String get tutorialLibraryTip1 => - 'View download progress and queue in the Library tab'; + 'Fortschritt und Warteschlange im Bibliothek‑Tab anzeigen'; @override String get tutorialLibraryTip2 => - 'Tap any track to play it with your music player'; + 'Tippe auf einen Titel, um ihn mit deinem Musikplayer abzuspielen'; @override String get tutorialLibraryTip3 => - 'Switch between list and grid view for better browsing'; + 'Wechsle zwischen Listen- und Gitteransicht für ein besseres Surfen'; @override - String get tutorialExtensionsTitle => 'Extensions'; + String get tutorialExtensionsTitle => 'Erweiterungen'; @override String get tutorialExtensionsDesc => - 'Extend the app\'s capabilities with community extensions.'; + 'Erweitere die Fähigkeiten der App mit Community-Erweiterungen.'; @override String get tutorialExtensionsTip1 => - 'Browse the Store tab to discover useful extensions'; + 'Im Store Tab findest du nützliche Erweiterungen'; @override String get tutorialExtensionsTip2 => - 'Add new download providers or search sources'; + 'Neue Download- oder Suchanbieter hinzufügen'; @override String get tutorialExtensionsTip3 => - 'Get lyrics, enhanced metadata, and more features'; + 'Lyrics, erweiterte Metadaten und mehr Funktionen erhalten'; @override - String get tutorialSettingsTitle => 'Customize Your Experience'; + String get tutorialSettingsTitle => 'Passe deine Benutzererfahrung an'; @override String get tutorialSettingsDesc => - 'Personalize the app in Settings to match your preferences.'; + 'Personalisiere die App in den Einstellungen nach deiner Präferenz.'; @override String get tutorialSettingsTip1 => - 'Change download location and folder organization'; + 'Downloadverzeichnis und Ordnerorganisation ändern'; @override String get tutorialSettingsTip2 => - 'Set default audio quality and format preferences'; + 'Standard Audioqualität und Formateinstellungen festlegen'; @override - String get tutorialSettingsTip3 => 'Customize app theme and appearance'; + String get tutorialSettingsTip3 => 'App-Design und Aussehen anpassen'; @override String get tutorialReadyMessage => - 'You\'re all set! Start downloading your favorite music now.'; + 'Das ist alles! Lade jetzt deine Lieblingsmusik herunter.'; @override - String get libraryForceFullScan => 'Force Full Scan'; + String get libraryForceFullScan => 'Vollen Neu-Scan erzwingen'; @override - String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache'; + String get libraryForceFullScanSubtitle => + 'Alle Dateien erneut scannen und Cache ignorieren'; @override - String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads'; + String get cleanupOrphanedDownloads => 'Verwaiste Downloads bereinigen'; @override String get cleanupOrphanedDownloadsSubtitle => - 'Remove history entries for files that no longer exist'; + 'Verlaufseinträge für Dateien löschen, die nicht mehr existieren'; @override String cleanupOrphanedDownloadsResult(int count) { - return 'Removed $count orphaned entries from history'; + return 'Entfernte $count verwaiste Einträge aus dem Verlauf'; } @override - String get cleanupOrphanedDownloadsNone => 'No orphaned entries found'; + String get cleanupOrphanedDownloadsNone => + 'Keine verwaisten Einträge gefunden'; @override - String get cacheTitle => 'Storage & Cache'; + String get cacheTitle => 'Speicher & Cache'; @override - String get cacheSummaryTitle => 'Cache overview'; + String get cacheSummaryTitle => 'Cache-Übersicht'; @override String get cacheSummarySubtitle => - 'Clearing cache will not remove downloaded music files.'; + 'Das Leeren des Caches entfernt nicht heruntergeladene Musikdateien.'; @override String cacheEstimatedTotal(String size) { - return 'Estimated cache usage: $size'; + return 'Geschätzte Cache-Größe: $size'; } @override - String get cacheSectionStorage => 'Cached Data'; + String get cacheSectionStorage => 'Zwischengespeicherte Daten'; @override - String get cacheSectionMaintenance => 'Maintenance'; + String get cacheSectionMaintenance => 'Wartung'; @override - String get cacheAppDirectory => 'App cache directory'; + String get cacheAppDirectory => 'App-Cache Verzeichnis'; @override String get cacheAppDirectoryDesc => - 'HTTP responses, WebView data, and other temporary app data.'; + 'HTTP-Antworten, WebView Daten und andere temporäre App-Daten.'; @override - String get cacheTempDirectory => 'Temporary directory'; + String get cacheTempDirectory => 'Temporäres Verzeichnis'; @override String get cacheTempDirectoryDesc => - 'Temporary files from downloads and audio conversion.'; + 'Temporäre Dateien von Downloads und Audio-Konvertierung.'; @override - String get cacheCoverImage => 'Cover image cache'; + String get cacheCoverImage => 'Cover-Cache'; @override String get cacheCoverImageDesc => - 'Downloaded album and track cover art. Will re-download when viewed.'; + 'Album- und Titelcover heruntergeladen. Werden erneut heruntergeladen.'; @override - String get cacheLibraryCover => 'Library cover cache'; + String get cacheLibraryCover => 'Bibliotheks-Cover-Cache'; @override String get cacheLibraryCoverDesc => - 'Cover art extracted from local music files. Will re-extract on next scan.'; + 'Cover aus lokalen Musikdateien extrahiert. Wird beim nächsten Scannen neu extrahiert.'; @override - String get cacheExploreFeed => 'Explore feed cache'; + String get cacheExploreFeed => 'Feed-Cache entdecken'; @override String get cacheExploreFeedDesc => - 'Explore tab content (new releases, trending). Will refresh on next visit.'; + 'Startseiten-Inhalt (neue Releases, Trends). Wird bei einem Neustart aktualisiert.'; @override - String get cacheTrackLookup => 'Track lookup cache'; + String get cacheTrackLookup => 'Titel Such-Cache'; @override String get cacheTrackLookupDesc => - 'Spotify/Deezer track ID lookups. Clearing may slow next few searches.'; + 'Spotify/Deezer Track-ID-Lookups. Das Löschen kann die nächsten Suchergebnisse verlangsamen.'; @override String get cacheCleanupUnusedDesc => - 'Remove orphaned download history and library entries for missing files.'; + 'Verwaisten Downloadverlauf und Bibliothekseinträge für fehlende Dateien entfernen.'; @override - String get cacheNoData => 'No cached data'; + String get cacheNoData => 'Keine gecachten Daten'; @override String cacheSizeWithFiles(String size, int count) { - return '$size in $count files'; + return '$size in $count Dateien'; } @override @@ -2009,71 +2036,71 @@ class AppLocalizationsDe extends AppLocalizations { @override String cacheEntries(int count) { - return '$count entries'; + return '$count Einträge'; } @override String cacheClearSuccess(String target) { - return 'Cleared: $target'; + return 'Entfernt: $target'; } @override - String get cacheClearConfirmTitle => 'Clear cache?'; + String get cacheClearConfirmTitle => 'Cache leeren?'; @override String cacheClearConfirmMessage(String target) { - return 'This will clear cached data for $target. Downloaded music files will not be deleted.'; + return 'Dies löscht zwischengespeicherte Daten in $target. Die Musikdateien werden nicht gelöscht.'; } @override - String get cacheClearAllConfirmTitle => 'Clear all cache?'; + String get cacheClearAllConfirmTitle => 'Gesamten Cache leeren?'; @override String get cacheClearAllConfirmMessage => - 'This will clear all cache categories on this page. Downloaded music files will not be deleted.'; + 'Dadurch werden alle Cache-Kategorien auf dieser Seite gelöscht. Heruntergeladene Musikdateien werden nicht gelöscht.'; @override - String get cacheClearAll => 'Clear all cache'; + String get cacheClearAll => 'Gesamten Cache leeren'; @override - String get cacheCleanupUnused => 'Cleanup unused data'; + String get cacheCleanupUnused => 'Unbenutzte Daten bereinigen'; @override String get cacheCleanupUnusedSubtitle => - 'Remove orphaned download history and missing library entries'; + 'Verwaisten Downloadverlauf und fehlende Bibliothekseinträge löschen'; @override String cacheCleanupResult(int downloadCount, int libraryCount) { - return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries'; + return 'Bereinigung: $downloadCount verwaiste Downloads, $libraryCount fehlende Bibliothekseinträge'; } @override - String get cacheRefreshStats => 'Refresh stats'; + String get cacheRefreshStats => 'Statistik aktualisieren'; @override - String get trackSaveCoverArt => 'Save Cover Art'; + String get trackSaveCoverArt => 'Cover speichern'; @override - String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file'; + String get trackSaveCoverArtSubtitle => 'Albumcover als .jpg Datei speichern'; @override - String get trackSaveLyrics => 'Save Lyrics (.lrc)'; + String get trackSaveLyrics => 'Lyrics als .lrc speichern'; @override - String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file'; + String get trackSaveLyricsSubtitle => 'Lade Lyrics als .lrc Datei'; @override - String get trackSaveLyricsProgress => 'Saving lyrics...'; + String get trackSaveLyricsProgress => 'Speichere Lyrics...'; @override - String get trackReEnrich => 'Re-enrich'; + String get trackReEnrich => 'Neu-anreichern'; @override String get trackReEnrichOnlineSubtitle => - 'Search metadata online and embed into file'; + 'Metadaten online suchen und in Datei einbinden'; @override - String get trackEditMetadata => 'Edit Metadata'; + String get trackEditMetadata => 'Metadaten bearbeiten'; @override String trackCoverSaved(String fileName) { @@ -2085,43 +2112,44 @@ class AppLocalizationsDe extends AppLocalizations { @override String trackLyricsSaved(String fileName) { - return 'Lyrics saved to $fileName'; + return 'Lyrics in $fileName gespeichert'; } @override - String get trackReEnrichProgress => 'Re-enriching metadata...'; + String get trackReEnrichProgress => 'Metadaten neu anreichern...'; @override - String get trackReEnrichSearching => 'Searching metadata online...'; + String get trackReEnrichSearching => 'Suche Metadaten online...'; @override - String get trackReEnrichSuccess => 'Metadata re-enriched successfully'; + String get trackReEnrichSuccess => 'Metadaten erfolgreich neu angereichert'; @override - String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed'; + String get trackReEnrichFfmpegFailed => + 'FFmpeg Metadaten-Einbettung fehlgeschlagen'; @override String trackSaveFailed(String error) { - return 'Failed: $error'; + return 'Fehler: $error'; } @override - String get trackConvertFormat => 'Convert Format'; + String get trackConvertFormat => 'Format konvertieren'; @override - String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus'; + String get trackConvertFormatSubtitle => 'In MP3 oder Opus konvertieren'; @override - String get trackConvertTitle => 'Convert Audio'; + String get trackConvertTitle => 'Audio konvertieren'; @override - String get trackConvertTargetFormat => 'Target Format'; + String get trackConvertTargetFormat => 'Zielformat'; @override String get trackConvertBitrate => 'Bitrate'; @override - String get trackConvertConfirmTitle => 'Confirm Conversion'; + String get trackConvertConfirmTitle => 'Konvertierung bestätigen'; @override String trackConvertConfirmMessage( @@ -2129,198 +2157,247 @@ class AppLocalizationsDe extends AppLocalizations { String targetFormat, String bitrate, ) { - return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.'; + return 'Konvertieren von $sourceFormat in $targetFormat bei $bitrate?\n\nDie Originaldatei wird nach der Konvertierung gelöscht.'; } @override - String get trackConvertConverting => 'Converting audio...'; + String get trackConvertConverting => 'Konvertiere Audio...'; @override String trackConvertSuccess(String format) { - return 'Converted to $format successfully'; + return 'Konvertiert in $format erfolgreich'; } @override - String get trackConvertFailed => 'Conversion failed'; + String get trackConvertFailed => 'Konvertierung fehlgeschlagen'; @override - String get actionCreate => 'Create'; + String get cueSplitTitle => 'Split CUE Sheet'; @override - String get collectionFoldersTitle => 'My folders'; + String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks'; @override - String get collectionWishlist => 'Wishlist'; + String cueSplitAlbum(String album) { + return 'Album: $album'; + } @override - String get collectionLoved => 'Loved'; + String cueSplitArtist(String artist) { + return 'Artist: $artist'; + } @override - String get collectionPlaylists => 'Playlists'; + String cueSplitTrackCount(int count) { + return '$count tracks'; + } + + @override + String get cueSplitConfirmTitle => 'Split CUE Album'; + + @override + String cueSplitConfirmMessage(String album, int count) { + return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.'; + } + + @override + String cueSplitSplitting(int current, int total) { + return 'Splitting CUE sheet... ($current/$total)'; + } + + @override + String cueSplitSuccess(int count) { + return 'Split into $count tracks successfully'; + } + + @override + String get cueSplitFailed => 'CUE split failed'; + + @override + String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet'; + + @override + String get cueSplitButton => 'Split into Tracks'; + + @override + String get actionCreate => 'Erstellen'; + + @override + String get collectionFoldersTitle => 'Meine Ordner'; + + @override + String get collectionWishlist => 'Wunschliste'; + + @override + String get collectionLoved => 'Lieblingssongs'; + + @override + String get collectionPlaylists => 'Playlisten'; @override String get collectionPlaylist => 'Playlist'; @override - String get collectionAddToPlaylist => 'Add to playlist'; + String get collectionAddToPlaylist => 'Zur Playlist hinzufügen'; @override - String get collectionCreatePlaylist => 'Create playlist'; + String get collectionCreatePlaylist => 'Playlist erstellen'; @override - String get collectionNoPlaylistsYet => 'No playlists yet'; + String get collectionNoPlaylistsYet => 'Noch keine Playlists'; @override String get collectionNoPlaylistsSubtitle => - 'Create a playlist to start categorizing tracks'; + 'Playlist erstellen, um Titel zu kategorisieren'; @override String collectionPlaylistTracks(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count tracks', - one: '1 track', + other: '$count Titel', + one: '1 Titel', ); return '$_temp0'; } @override String collectionAddedToPlaylist(String playlistName) { - return 'Added to \"$playlistName\"'; + return 'Zu \"$playlistName \" hinzugefügt'; } @override String collectionAlreadyInPlaylist(String playlistName) { - return 'Already in \"$playlistName\"'; + return 'Bereits in \"$playlistName\"'; } @override - String get collectionPlaylistCreated => 'Playlist created'; + String get collectionPlaylistCreated => 'Playlist erstellt'; @override - String get collectionPlaylistNameHint => 'Playlist name'; + String get collectionPlaylistNameHint => 'Playlist-Name'; @override - String get collectionPlaylistNameRequired => 'Playlist name is required'; + String get collectionPlaylistNameRequired => 'Playlist-Name ist erforderlich'; @override - String get collectionRenamePlaylist => 'Rename playlist'; + String get collectionRenamePlaylist => 'Playlist umbenennen'; @override - String get collectionDeletePlaylist => 'Delete playlist'; + String get collectionDeletePlaylist => 'Playlist löschen'; @override String collectionDeletePlaylistMessage(String playlistName) { - return 'Delete \"$playlistName\" and all tracks inside it?'; + return 'Willst du \"$playlistName\" und alle darin enthaltenen Titel löschen?'; } @override - String get collectionPlaylistDeleted => 'Playlist deleted'; + String get collectionPlaylistDeleted => 'Playlist gelöscht'; @override - String get collectionPlaylistRenamed => 'Playlist renamed'; + String get collectionPlaylistRenamed => 'Playlist umbenannt'; @override - String get collectionWishlistEmptyTitle => 'Wishlist is empty'; + String get collectionWishlistEmptyTitle => 'Wunschliste ist leer'; @override String get collectionWishlistEmptySubtitle => - 'Tap + on tracks to save what you want to download later'; + 'Tippe auf das + bei den Titeln, um sie zum späteren Herunterladen zu speichern'; @override - String get collectionLovedEmptyTitle => 'Loved folder is empty'; + String get collectionLovedEmptyTitle => 'Lieblingssongs sind leer'; @override String get collectionLovedEmptySubtitle => - 'Tap love on tracks to keep your favorites'; + 'Tippe auf das Herz, um deine Favoriten zu behalten'; @override - String get collectionPlaylistEmptyTitle => 'Playlist is empty'; + String get collectionPlaylistEmptyTitle => 'Die Playlist ist leer'; @override String get collectionPlaylistEmptySubtitle => - 'Long-press + on any track to add it here'; + 'Drücke lange + auf einem beliebigen Titel, um ihn hier hinzuzufügen'; @override - String get collectionRemoveFromPlaylist => 'Remove from playlist'; + String get collectionRemoveFromPlaylist => 'Von Playlist entfernen'; @override - String get collectionRemoveFromFolder => 'Remove from folder'; + String get collectionRemoveFromFolder => 'Aus Ordner entfernen'; @override String collectionRemoved(String trackName) { - return '\"$trackName\" removed'; + return '\"$trackName\" entfernt'; } @override String collectionAddedToLoved(String trackName) { - return '\"$trackName\" added to Loved'; + return '\"$trackName\" zu Lieblingssongs hinzugefügt'; } @override String collectionRemovedFromLoved(String trackName) { - return '\"$trackName\" removed from Loved'; + return '\"$trackName\" aus Lieblingssongs entfernt'; } @override String collectionAddedToWishlist(String trackName) { - return '\"$trackName\" added to Wishlist'; + return '\"$trackName\" zur Wunschliste hinzugefügt'; } @override String collectionRemovedFromWishlist(String trackName) { - return '\"$trackName\" removed from Wishlist'; + return '\"$trackName\" aus der Wunschliste entfernt'; } @override - String get trackOptionAddToLoved => 'Add to Loved'; + String get trackOptionAddToLoved => 'Zu Lieblingssongs hinzufügen'; @override - String get trackOptionRemoveFromLoved => 'Remove from Loved'; + String get trackOptionRemoveFromLoved => 'Aus Lieblingssongs entfernt'; @override - String get trackOptionAddToWishlist => 'Add to Wishlist'; + String get trackOptionAddToWishlist => 'Zur Wunschliste hinzufügen'; @override - String get trackOptionRemoveFromWishlist => 'Remove from Wishlist'; + String get trackOptionRemoveFromWishlist => 'Von der Wunschliste entfernen'; @override - String get collectionPlaylistChangeCover => 'Change cover image'; + String get collectionPlaylistChangeCover => 'Coverbild ändern'; @override - String get collectionPlaylistRemoveCover => 'Remove cover image'; + String get collectionPlaylistRemoveCover => 'Cover entfernen'; @override String selectionShareCount(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'tracks', - one: 'track', + other: 'Titel', + one: 'Titel', ); - return 'Share $count $_temp0'; + return 'Teile $count $_temp0'; } @override - String get selectionShareNoFiles => 'No shareable files found'; + String get selectionShareNoFiles => 'Keine teilbare Dateien gefunden'; @override String selectionConvertCount(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'tracks', - one: 'track', + other: 'Titel', + one: 'Titel', ); - return 'Convert $count $_temp0'; + return 'Konvertiere $count $_temp0'; } @override - String get selectionConvertNoConvertible => 'No convertible tracks selected'; + String get selectionConvertNoConvertible => + 'Keine konvertierbare Titel ausgewählt'; @override - String get selectionBatchConvertConfirmTitle => 'Batch Convert'; + String get selectionBatchConvertConfirmTitle => 'Batch-Konvertierung'; @override String selectionBatchConvertConfirmMessage( @@ -2331,30 +2408,30 @@ class AppLocalizationsDe extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'tracks', - one: 'track', + other: 'Titel', + one: 'Titel', ); - return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.'; + return 'Konvertiere $count $format $_temp0 zu $bitrate?\n\nOriginaldateien werden nach der Konvertierung gelöscht.'; } @override String selectionBatchConvertProgress(int current, int total) { - return 'Converting $current of $total...'; + return 'Konvertiere $current von $total...'; } @override String selectionBatchConvertSuccess(int success, int total, String format) { - return 'Converted $success of $total tracks to $format'; + return '$success von $total Titeln in $format konvertiert'; } @override String downloadedAlbumDownloadedCount(int count) { - return '$count downloaded'; + return '$count heruntergeladen'; } @override String get downloadUseAlbumArtistForFoldersAlbumSubtitle => - 'Artist folders use Album Artist when available'; + 'Künstlerordner verwenden den Album-Interpreten, wenn verfügbar'; @override String get downloadUseAlbumArtistForFoldersTrackSubtitle => diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 8f244a7f..646f9a58 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -356,7 +356,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get aboutAppDescription => - 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; + 'Download Spotify tracks in lossless quality from Tidal and Qobuz.'; @override String get artistAlbums => 'Albums'; @@ -688,6 +688,17 @@ class AppLocalizationsEn extends AppLocalizations { @override String get errorNoTracksFound => 'No tracks found'; + @override + String get errorUrlNotRecognized => 'Link not recognized'; + + @override + String get errorUrlNotRecognizedMessage => + 'This link is not supported. Make sure the URL is correct and a compatible extension is installed.'; + + @override + String get errorUrlFetchFailed => + 'Failed to load content from this link. Please try again.'; + @override String errorMissingExtensionSource(String item) { return 'Cannot load $item: missing extension source'; @@ -761,6 +772,13 @@ class AppLocalizationsEn extends AppLocalizations { @override String get folderOrganizationNone => 'No organization'; + @override + String get folderOrganizationByPlaylist => 'By Playlist'; + + @override + String get folderOrganizationByPlaylistSubtitle => + 'Separate folder for each playlist'; + @override String get folderOrganizationByArtist => 'By Artist'; @@ -1809,7 +1827,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get tutorialWelcomeTip2 => - 'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'; + 'Get FLAC quality audio from Tidal, Qobuz, or Deezer'; @override String get tutorialWelcomeTip3 => @@ -2126,6 +2144,54 @@ class AppLocalizationsEn extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + @override + String get cueSplitTitle => 'Split CUE Sheet'; + + @override + String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks'; + + @override + String cueSplitAlbum(String album) { + return 'Album: $album'; + } + + @override + String cueSplitArtist(String artist) { + return 'Artist: $artist'; + } + + @override + String cueSplitTrackCount(int count) { + return '$count tracks'; + } + + @override + String get cueSplitConfirmTitle => 'Split CUE Album'; + + @override + String cueSplitConfirmMessage(String album, int count) { + return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.'; + } + + @override + String cueSplitSplitting(int current, int total) { + return 'Splitting CUE sheet... ($current/$total)'; + } + + @override + String cueSplitSuccess(int count) { + return 'Split into $count tracks successfully'; + } + + @override + String get cueSplitFailed => 'CUE split failed'; + + @override + String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet'; + + @override + String get cueSplitButton => 'Split into Tracks'; + @override String get actionCreate => 'Create'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 58ae25af..e44fa9d8 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -356,7 +356,7 @@ class AppLocalizationsEs extends AppLocalizations { @override String get aboutAppDescription => - 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; + 'Download Spotify tracks in lossless quality from Tidal and Qobuz.'; @override String get artistAlbums => 'Albums'; @@ -688,6 +688,17 @@ class AppLocalizationsEs extends AppLocalizations { @override String get errorNoTracksFound => 'No tracks found'; + @override + String get errorUrlNotRecognized => 'Link not recognized'; + + @override + String get errorUrlNotRecognizedMessage => + 'This link is not supported. Make sure the URL is correct and a compatible extension is installed.'; + + @override + String get errorUrlFetchFailed => + 'Failed to load content from this link. Please try again.'; + @override String errorMissingExtensionSource(String item) { return 'Cannot load $item: missing extension source'; @@ -761,6 +772,13 @@ class AppLocalizationsEs extends AppLocalizations { @override String get folderOrganizationNone => 'No organization'; + @override + String get folderOrganizationByPlaylist => 'By Playlist'; + + @override + String get folderOrganizationByPlaylistSubtitle => + 'Separate folder for each playlist'; + @override String get folderOrganizationByArtist => 'By Artist'; @@ -1809,7 +1827,7 @@ class AppLocalizationsEs extends AppLocalizations { @override String get tutorialWelcomeTip2 => - 'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'; + 'Get FLAC quality audio from Tidal, Qobuz, or Deezer'; @override String get tutorialWelcomeTip3 => @@ -2126,6 +2144,54 @@ class AppLocalizationsEs extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + @override + String get cueSplitTitle => 'Split CUE Sheet'; + + @override + String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks'; + + @override + String cueSplitAlbum(String album) { + return 'Album: $album'; + } + + @override + String cueSplitArtist(String artist) { + return 'Artist: $artist'; + } + + @override + String cueSplitTrackCount(int count) { + return '$count tracks'; + } + + @override + String get cueSplitConfirmTitle => 'Split CUE Album'; + + @override + String cueSplitConfirmMessage(String album, int count) { + return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.'; + } + + @override + String cueSplitSplitting(int current, int total) { + return 'Splitting CUE sheet... ($current/$total)'; + } + + @override + String cueSplitSuccess(int count) { + return 'Split into $count tracks successfully'; + } + + @override + String get cueSplitFailed => 'CUE split failed'; + + @override + String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet'; + + @override + String get cueSplitButton => 'Split into Tracks'; + @override String get actionCreate => 'Create'; @@ -2705,7 +2771,7 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get aboutAppDescription => - 'Descarga pistas de Spotify con calidad sin pérdida de Tidal, Qobuz y Amazon Music.'; + 'Descarga pistas de Spotify con calidad sin pérdida de Tidal y Qobuz.'; @override String get artistAlbums => 'Álbumes'; @@ -4150,7 +4216,7 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get tutorialWelcomeTip2 => - 'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'; + 'Obtén audio en calidad FLAC de Tidal, Qobuz o Deezer'; @override String get tutorialWelcomeTip3 => diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index adf0d48d..ae24b7e9 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -690,6 +690,17 @@ class AppLocalizationsFr extends AppLocalizations { @override String get errorNoTracksFound => 'No tracks found'; + @override + String get errorUrlNotRecognized => 'Link not recognized'; + + @override + String get errorUrlNotRecognizedMessage => + 'This link is not supported. Make sure the URL is correct and a compatible extension is installed.'; + + @override + String get errorUrlFetchFailed => + 'Failed to load content from this link. Please try again.'; + @override String errorMissingExtensionSource(String item) { return 'Cannot load $item: missing extension source'; @@ -763,6 +774,13 @@ class AppLocalizationsFr extends AppLocalizations { @override String get folderOrganizationNone => 'No organization'; + @override + String get folderOrganizationByPlaylist => 'By Playlist'; + + @override + String get folderOrganizationByPlaylistSubtitle => + 'Separate folder for each playlist'; + @override String get folderOrganizationByArtist => 'By Artist'; @@ -2128,6 +2146,54 @@ class AppLocalizationsFr extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + @override + String get cueSplitTitle => 'Split CUE Sheet'; + + @override + String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks'; + + @override + String cueSplitAlbum(String album) { + return 'Album: $album'; + } + + @override + String cueSplitArtist(String artist) { + return 'Artist: $artist'; + } + + @override + String cueSplitTrackCount(int count) { + return '$count tracks'; + } + + @override + String get cueSplitConfirmTitle => 'Split CUE Album'; + + @override + String cueSplitConfirmMessage(String album, int count) { + return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.'; + } + + @override + String cueSplitSplitting(int current, int total) { + return 'Splitting CUE sheet... ($current/$total)'; + } + + @override + String cueSplitSuccess(int count) { + return 'Split into $count tracks successfully'; + } + + @override + String get cueSplitFailed => 'CUE split failed'; + + @override + String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet'; + + @override + String get cueSplitButton => 'Split into Tracks'; + @override String get actionCreate => 'Create'; diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index 50ca83db..08101eac 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -688,6 +688,17 @@ class AppLocalizationsHi extends AppLocalizations { @override String get errorNoTracksFound => 'No tracks found'; + @override + String get errorUrlNotRecognized => 'Link not recognized'; + + @override + String get errorUrlNotRecognizedMessage => + 'This link is not supported. Make sure the URL is correct and a compatible extension is installed.'; + + @override + String get errorUrlFetchFailed => + 'Failed to load content from this link. Please try again.'; + @override String errorMissingExtensionSource(String item) { return 'Cannot load $item: missing extension source'; @@ -761,6 +772,13 @@ class AppLocalizationsHi extends AppLocalizations { @override String get folderOrganizationNone => 'No organization'; + @override + String get folderOrganizationByPlaylist => 'By Playlist'; + + @override + String get folderOrganizationByPlaylistSubtitle => + 'Separate folder for each playlist'; + @override String get folderOrganizationByArtist => 'By Artist'; @@ -2126,6 +2144,54 @@ class AppLocalizationsHi extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + @override + String get cueSplitTitle => 'Split CUE Sheet'; + + @override + String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks'; + + @override + String cueSplitAlbum(String album) { + return 'Album: $album'; + } + + @override + String cueSplitArtist(String artist) { + return 'Artist: $artist'; + } + + @override + String cueSplitTrackCount(int count) { + return '$count tracks'; + } + + @override + String get cueSplitConfirmTitle => 'Split CUE Album'; + + @override + String cueSplitConfirmMessage(String album, int count) { + return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.'; + } + + @override + String cueSplitSplitting(int current, int total) { + return 'Splitting CUE sheet... ($current/$total)'; + } + + @override + String cueSplitSuccess(int count) { + return 'Split into $count tracks successfully'; + } + + @override + String get cueSplitFailed => 'CUE split failed'; + + @override + String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet'; + + @override + String get cueSplitButton => 'Split into Tracks'; + @override String get actionCreate => 'Create'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index ff3af0fa..cab860dd 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -15,7 +15,7 @@ class AppLocalizationsId extends AppLocalizations { String get navHome => 'Beranda'; @override - String get navLibrary => 'Library'; + String get navLibrary => 'Pustaka'; @override String get navSettings => 'Pengaturan'; @@ -45,7 +45,7 @@ class AppLocalizationsId extends AppLocalizations { String get historyFilterSingles => 'Single'; @override - String get historySearchHint => 'Search history...'; + String get historySearchHint => 'Cari riwayat...'; @override String get settingsTitle => 'Pengaturan'; @@ -104,7 +104,7 @@ class AppLocalizationsId extends AppLocalizations { String get appearanceHistoryViewList => 'Daftar'; @override - String get appearanceHistoryViewGrid => 'Grid'; + String get appearanceHistoryViewGrid => 'Kisi'; @override String get optionsTitle => 'Opsi'; @@ -126,7 +126,7 @@ class AppLocalizationsId extends AppLocalizations { 'Ketuk Deezer atau Spotify untuk beralih dari ekstensi'; @override - String get optionsAutoFallback => 'Auto Fallback'; + String get optionsAutoFallback => 'Cadangan Otomatis'; @override String get optionsAutoFallbackSubtitle => @@ -217,7 +217,7 @@ class AppLocalizationsId extends AppLocalizations { @override String optionsSpotifyCredentialsConfigured(String clientId) { - return 'Client ID: $clientId...'; + return 'ID Klien: $clientId...'; } @override @@ -230,7 +230,7 @@ class AppLocalizationsId extends AppLocalizations { @override String get optionsSpotifyDeprecationWarning => - 'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.'; + 'Pencarian Spotify akan dihentikan pada 3 Maret 2026 karena perubahan API Spotify. Silakan beralih ke Deezer.'; @override String get extensionsTitle => 'Ekstensi'; @@ -283,7 +283,7 @@ class AppLocalizationsId extends AppLocalizations { 'Seniman berbakat yang membuat logo aplikasi kita yang indah!'; @override - String get aboutTranslators => 'Translators'; + String get aboutTranslators => 'Penerjemah'; @override String get aboutSpecialThanks => 'Terima Kasih Khusus'; @@ -311,19 +311,19 @@ class AppLocalizationsId extends AppLocalizations { 'Sarankan fitur baru untuk aplikasi'; @override - String get aboutTelegramChannel => 'Telegram Channel'; + String get aboutTelegramChannel => 'Saluran Telegram'; @override - String get aboutTelegramChannelSubtitle => 'Announcements and updates'; + String get aboutTelegramChannelSubtitle => 'Pengumuman dan pembaruan'; @override - String get aboutTelegramChat => 'Telegram Community'; + String get aboutTelegramChat => 'Komunitas Telegram'; @override - String get aboutTelegramChatSubtitle => 'Chat with other users'; + String get aboutTelegramChatSubtitle => 'Berbincang dengan pengguna lain'; @override - String get aboutSocial => 'Social'; + String get aboutSocial => 'Sosial'; @override String get aboutApp => 'Aplikasi'; @@ -341,7 +341,7 @@ class AppLocalizationsId extends AppLocalizations { @override String get aboutSjdonadoDesc => - 'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!'; + 'Pencipta I Don\'t Have Spotify (IDHS). Penyelesai tautan cadangan yang menyelamatkan keadaan!'; @override String get aboutDabMusic => 'DAB Music'; @@ -355,7 +355,7 @@ class AppLocalizationsId extends AppLocalizations { @override String get aboutSpotiSaverDesc => - 'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!'; + 'Tidal perangkat streaming FLAC resolusi tinggi. Bagian penting dari teka-teki tanpa kehilangan kualitas!'; @override String get aboutAppDescription => @@ -456,7 +456,7 @@ class AppLocalizationsId extends AppLocalizations { @override String get setupIcloudNotSupported => - 'iCloud Drive is not supported. Please use the app Documents folder.'; + 'iCloud Drive tidak didukung. Silakan gunakan folder Dokumen di aplikasi.'; @override String get setupDownloadInFlac => 'Unduh lagu Spotify dalam format FLAC'; @@ -593,7 +593,7 @@ class AppLocalizationsId extends AppLocalizations { @override String csvImportTracks(int count) { - return '$count tracks from CSV'; + return '$count trek dari CSV'; } @override @@ -613,7 +613,7 @@ class AppLocalizationsId extends AppLocalizations { @override String snackbarAlreadyInLibrary(String trackName) { - return '\"$trackName\" already exists in your library'; + return '\"$trackName\" sudah ada di perpustakaan Anda'; } @override @@ -691,6 +691,17 @@ class AppLocalizationsId extends AppLocalizations { @override String get errorNoTracksFound => 'Tidak ada lagu ditemukan'; + @override + String get errorUrlNotRecognized => 'Link tidak dikenali'; + + @override + String get errorUrlNotRecognizedMessage => + 'Link ini tidak didukung. Pastikan URL benar dan ekstensi yang kompatibel sudah terpasang.'; + + @override + String get errorUrlFetchFailed => + 'Gagal memuat konten dari link ini. Silakan coba lagi.'; + @override String errorMissingExtensionSource(String item) { return 'Tidak dapat memuat $item: sumber ekstensi tidak ada'; @@ -755,15 +766,22 @@ class AppLocalizationsId extends AppLocalizations { String get filenameFormat => 'Format Nama File'; @override - String get filenameShowAdvancedTags => 'Tampilkan tag lanjutan'; + String get filenameShowAdvancedTags => 'Show advanced tags'; @override String get filenameShowAdvancedTagsDescription => - 'Aktifkan tag format untuk padding nomor lagu dan pola tanggal'; + 'Enable formatted tags for track padding and date patterns'; @override String get folderOrganizationNone => 'Tidak ada'; + @override + String get folderOrganizationByPlaylist => 'By Playlist'; + + @override + String get folderOrganizationByPlaylistSubtitle => + 'Separate folder for each playlist'; + @override String get folderOrganizationByArtist => 'Berdasarkan Artis'; @@ -1343,10 +1361,10 @@ class AppLocalizationsId extends AppLocalizations { 'YouTube provides lossy audio only. Not part of lossless fallback.'; @override - String get youtubeOpusBitrateTitle => 'Bitrate Opus YouTube'; + String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; @override - String get youtubeMp3BitrateTitle => 'Bitrate MP3 YouTube'; + String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; @override String get downloadAskBeforeDownload => 'Tanya Sebelum Unduh'; @@ -1684,8 +1702,8 @@ class AppLocalizationsId extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'trek', - one: 'trek', + other: 'tracks', + one: 'track', ); return '$_temp0'; } @@ -2134,10 +2152,58 @@ class AppLocalizationsId extends AppLocalizations { String get trackConvertFailed => 'Conversion failed'; @override - String get actionCreate => 'Buat'; + String get cueSplitTitle => 'Split CUE Sheet'; @override - String get collectionFoldersTitle => 'Folder saya'; + String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks'; + + @override + String cueSplitAlbum(String album) { + return 'Album: $album'; + } + + @override + String cueSplitArtist(String artist) { + return 'Artist: $artist'; + } + + @override + String cueSplitTrackCount(int count) { + return '$count tracks'; + } + + @override + String get cueSplitConfirmTitle => 'Split CUE Album'; + + @override + String cueSplitConfirmMessage(String album, int count) { + return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.'; + } + + @override + String cueSplitSplitting(int current, int total) { + return 'Splitting CUE sheet... ($current/$total)'; + } + + @override + String cueSplitSuccess(int count) { + return 'Split into $count tracks successfully'; + } + + @override + String get cueSplitFailed => 'CUE split failed'; + + @override + String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet'; + + @override + String get cueSplitButton => 'Split into Tracks'; + + @override + String get actionCreate => 'Create'; + + @override + String get collectionFoldersTitle => 'My folders'; @override String get collectionWishlist => 'Wishlist'; @@ -2146,172 +2212,171 @@ class AppLocalizationsId extends AppLocalizations { String get collectionLoved => 'Loved'; @override - String get collectionPlaylists => 'Playlist'; + String get collectionPlaylists => 'Playlists'; @override String get collectionPlaylist => 'Playlist'; @override - String get collectionAddToPlaylist => 'Tambahkan ke playlist'; + String get collectionAddToPlaylist => 'Add to playlist'; @override - String get collectionCreatePlaylist => 'Buat playlist'; + String get collectionCreatePlaylist => 'Create playlist'; @override - String get collectionNoPlaylistsYet => 'Belum ada playlist'; + String get collectionNoPlaylistsYet => 'No playlists yet'; @override String get collectionNoPlaylistsSubtitle => - 'Buat playlist untuk mulai mengategorikan lagu'; + 'Create a playlist to start categorizing tracks'; @override String collectionPlaylistTracks(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count lagu', - one: '1 lagu', + other: '$count tracks', + one: '1 track', ); return '$_temp0'; } @override String collectionAddedToPlaylist(String playlistName) { - return 'Ditambahkan ke \"$playlistName\"'; + return 'Added to \"$playlistName\"'; } @override String collectionAlreadyInPlaylist(String playlistName) { - return 'Sudah ada di \"$playlistName\"'; + return 'Already in \"$playlistName\"'; } @override - String get collectionPlaylistCreated => 'Playlist berhasil dibuat'; + String get collectionPlaylistCreated => 'Playlist created'; @override - String get collectionPlaylistNameHint => 'Nama playlist'; + String get collectionPlaylistNameHint => 'Playlist name'; @override - String get collectionPlaylistNameRequired => 'Nama playlist wajib diisi'; + String get collectionPlaylistNameRequired => 'Playlist name is required'; @override - String get collectionRenamePlaylist => 'Ubah nama playlist'; + String get collectionRenamePlaylist => 'Rename playlist'; @override - String get collectionDeletePlaylist => 'Hapus playlist'; + String get collectionDeletePlaylist => 'Delete playlist'; @override String collectionDeletePlaylistMessage(String playlistName) { - return 'Hapus \"$playlistName\" beserta semua lagunya?'; + return 'Delete \"$playlistName\" and all tracks inside it?'; } @override - String get collectionPlaylistDeleted => 'Playlist dihapus'; + String get collectionPlaylistDeleted => 'Playlist deleted'; @override - String get collectionPlaylistRenamed => 'Nama playlist diperbarui'; + String get collectionPlaylistRenamed => 'Playlist renamed'; @override - String get collectionWishlistEmptyTitle => 'Wishlist masih kosong'; + String get collectionWishlistEmptyTitle => 'Wishlist is empty'; @override String get collectionWishlistEmptySubtitle => - 'Tap + di lagu untuk menyimpan yang ingin diunduh nanti'; + 'Tap + on tracks to save what you want to download later'; @override - String get collectionLovedEmptyTitle => 'Folder Loved masih kosong'; + String get collectionLovedEmptyTitle => 'Loved folder is empty'; @override String get collectionLovedEmptySubtitle => - 'Tap love di lagu untuk menyimpan favoritmu'; + 'Tap love on tracks to keep your favorites'; @override - String get collectionPlaylistEmptyTitle => 'Playlist masih kosong'; + String get collectionPlaylistEmptyTitle => 'Playlist is empty'; @override String get collectionPlaylistEmptySubtitle => - 'Tekan lama tombol + pada lagu untuk menambahkannya ke sini'; + 'Long-press + on any track to add it here'; @override - String get collectionRemoveFromPlaylist => 'Hapus dari playlist'; + String get collectionRemoveFromPlaylist => 'Remove from playlist'; @override - String get collectionRemoveFromFolder => 'Hapus dari folder'; + String get collectionRemoveFromFolder => 'Remove from folder'; @override String collectionRemoved(String trackName) { - return '\"$trackName\" dihapus'; + return '\"$trackName\" removed'; } @override String collectionAddedToLoved(String trackName) { - return '\"$trackName\" ditambahkan ke Loved'; + return '\"$trackName\" added to Loved'; } @override String collectionRemovedFromLoved(String trackName) { - return '\"$trackName\" dihapus dari Loved'; + return '\"$trackName\" removed from Loved'; } @override String collectionAddedToWishlist(String trackName) { - return '\"$trackName\" ditambahkan ke Wishlist'; + return '\"$trackName\" added to Wishlist'; } @override String collectionRemovedFromWishlist(String trackName) { - return '\"$trackName\" dihapus dari Wishlist'; + return '\"$trackName\" removed from Wishlist'; } @override - String get trackOptionAddToLoved => 'Tambahkan ke Loved'; + String get trackOptionAddToLoved => 'Add to Loved'; @override - String get trackOptionRemoveFromLoved => 'Hapus dari Loved'; + String get trackOptionRemoveFromLoved => 'Remove from Loved'; @override - String get trackOptionAddToWishlist => 'Tambahkan ke Wishlist'; + String get trackOptionAddToWishlist => 'Add to Wishlist'; @override - String get trackOptionRemoveFromWishlist => 'Hapus dari Wishlist'; + String get trackOptionRemoveFromWishlist => 'Remove from Wishlist'; @override - String get collectionPlaylistChangeCover => 'Ubah gambar sampul'; + String get collectionPlaylistChangeCover => 'Change cover image'; @override - String get collectionPlaylistRemoveCover => 'Hapus gambar sampul'; + String get collectionPlaylistRemoveCover => 'Remove cover image'; @override String selectionShareCount(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'trek', - one: 'trek', + other: 'tracks', + one: 'track', ); - return 'Bagikan $count $_temp0'; + return 'Share $count $_temp0'; } @override - String get selectionShareNoFiles => 'Tidak ada file yang dapat dibagikan'; + String get selectionShareNoFiles => 'No shareable files found'; @override String selectionConvertCount(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'trek', - one: 'trek', + other: 'tracks', + one: 'track', ); - return 'Konversi $count $_temp0'; + return 'Convert $count $_temp0'; } @override - String get selectionConvertNoConvertible => - 'Tidak ada trek yang dapat dikonversi dipilih'; + String get selectionConvertNoConvertible => 'No convertible tracks selected'; @override - String get selectionBatchConvertConfirmTitle => 'Konversi Massal'; + String get selectionBatchConvertConfirmTitle => 'Batch Convert'; @override String selectionBatchConvertConfirmMessage( @@ -2322,20 +2387,20 @@ class AppLocalizationsId extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'trek', - one: 'trek', + other: 'tracks', + one: 'track', ); - return 'Konversi $count $_temp0 ke $format pada $bitrate?\n\nFile asli akan dihapus setelah konversi.'; + return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.'; } @override String selectionBatchConvertProgress(int current, int total) { - return 'Mengonversi $current dari $total...'; + return 'Converting $current of $total...'; } @override String selectionBatchConvertSuccess(int success, int total, String format) { - return 'Berhasil mengonversi $success dari $total trek ke $format'; + return 'Converted $success of $total tracks to $format'; } @override diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index b473562c..6ce38247 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -15,7 +15,7 @@ class AppLocalizationsJa extends AppLocalizations { String get navHome => 'ホーム'; @override - String get navLibrary => 'Library'; + String get navLibrary => 'ライブラリ'; @override String get navSettings => '設定'; @@ -160,7 +160,7 @@ class AppLocalizationsJa extends AppLocalizations { @override String optionsConcurrentParallel(int count) { - return '$count parallel downloads'; + return '$count 件の分割ダウンロード'; } @override @@ -683,6 +683,17 @@ class AppLocalizationsJa extends AppLocalizations { @override String get errorNoTracksFound => 'トラックがありません'; + @override + String get errorUrlNotRecognized => 'Link not recognized'; + + @override + String get errorUrlNotRecognizedMessage => + 'This link is not supported. Make sure the URL is correct and a compatible extension is installed.'; + + @override + String get errorUrlFetchFailed => + 'Failed to load content from this link. Please try again.'; + @override String errorMissingExtensionSource(String item) { return '$item を読み込めません: 拡張ソースがありません'; @@ -756,6 +767,13 @@ class AppLocalizationsJa extends AppLocalizations { @override String get folderOrganizationNone => '構成がありません'; + @override + String get folderOrganizationByPlaylist => 'By Playlist'; + + @override + String get folderOrganizationByPlaylistSubtitle => + 'Separate folder for each playlist'; + @override String get folderOrganizationByArtist => 'アーティスト別'; @@ -1111,7 +1129,7 @@ class AppLocalizationsJa extends AppLocalizations { String get trackLyricsLoadFailed => '歌詞の読み込みに失敗しました'; @override - String get trackEmbedLyrics => 'Embed Lyrics'; + String get trackEmbedLyrics => '歌詞を埋め込む'; @override String get trackLyricsEmbedded => 'Lyrics embedded successfully'; @@ -1325,10 +1343,10 @@ class AppLocalizationsJa extends AppLocalizations { 'YouTube provides lossy audio only. Not part of lossless fallback.'; @override - String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; + String get youtubeOpusBitrateTitle => 'YouTube Opus のビットレート'; @override - String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; + String get youtubeMp3BitrateTitle => 'YouTube MP3 のビットレート'; @override String get downloadAskBeforeDownload => 'ダウンロード前に確認する'; @@ -1375,20 +1393,20 @@ class AppLocalizationsJa extends AppLocalizations { String get queueClearAllMessage => 'すべてのダウンロードを消去してもよろしいですか?'; @override - String get settingsAutoExportFailed => 'Auto-export failed downloads'; + String get settingsAutoExportFailed => 'ダウンロードの自動エクスポートに失敗しました'; @override String get settingsAutoExportFailedSubtitle => 'Save failed downloads to TXT file automatically'; @override - String get settingsDownloadNetwork => 'Download Network'; + String get settingsDownloadNetwork => 'ダウンロードネットワーク'; @override - String get settingsDownloadNetworkAny => 'WiFi + Mobile Data'; + String get settingsDownloadNetworkAny => 'Wi-Fi + モバイルデータ'; @override - String get settingsDownloadNetworkWifiOnly => 'WiFi Only'; + String get settingsDownloadNetworkWifiOnly => 'Wi-Fi のみ'; @override String get settingsDownloadNetworkSubtitle => @@ -1419,7 +1437,7 @@ class AppLocalizationsJa extends AppLocalizations { String get albumFolderYearAlbumSubtitle => 'アルバム/[2005] アルバム名/'; @override - String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles'; + String get albumFolderArtistAlbumSingles => 'アーティスト / アルバム + シングル'; @override String get albumFolderArtistAlbumSinglesSubtitle => @@ -1485,7 +1503,7 @@ class AppLocalizationsJa extends AppLocalizations { String get recentEmpty => 'No recent items yet'; @override - String get recentShowAllDownloads => 'Show All Downloads'; + String get recentShowAllDownloads => 'すべてのダウンロードを表示'; @override String recentPlaylistInfo(String name) { @@ -1559,10 +1577,10 @@ class AppLocalizationsJa extends AppLocalizations { String get discographyFailedToFetch => '一部のアルバムの取得に失敗しました'; @override - String get sectionStorageAccess => 'Storage Access'; + String get sectionStorageAccess => 'ストレージアクセス'; @override - String get allFilesAccess => 'All Files Access'; + String get allFilesAccess => 'すべてのファイルへのアクセス'; @override String get allFilesAccessEnabledSubtitle => 'Can write to any folder'; @@ -1583,35 +1601,35 @@ class AppLocalizationsJa extends AppLocalizations { 'All Files Access disabled. The app will use limited storage access.'; @override - String get settingsLocalLibrary => 'Local Library'; + String get settingsLocalLibrary => 'ローカルライブラリ'; @override String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates'; @override - String get settingsCache => 'Storage & Cache'; + String get settingsCache => 'ストレージとキャッシュ'; @override String get settingsCacheSubtitle => 'View size and clear cached data'; @override - String get libraryTitle => 'Local Library'; + String get libraryTitle => 'ローカルライブラリ'; @override - String get libraryScanSettings => 'Scan Settings'; + String get libraryScanSettings => 'スキャン設定'; @override - String get libraryEnableLocalLibrary => 'Enable Local Library'; + String get libraryEnableLocalLibrary => 'ローカルライブラリを有効'; @override String get libraryEnableLocalLibrarySubtitle => 'Scan and track your existing music'; @override - String get libraryFolder => 'Library Folder'; + String get libraryFolder => 'ライブラリのフォルダ'; @override - String get libraryFolderHint => 'Tap to select folder'; + String get libraryFolderHint => 'タップでフォルダを選択'; @override String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator'; @@ -1621,13 +1639,13 @@ class AppLocalizationsJa extends AppLocalizations { 'Show when searching for existing tracks'; @override - String get libraryActions => 'Actions'; + String get libraryActions => 'アクション'; @override - String get libraryScan => 'Scan Library'; + String get libraryScan => 'ライブラリをスキャン'; @override - String get libraryScanSubtitle => 'Scan for audio files'; + String get libraryScanSubtitle => 'オーディオファイルをスキャン'; @override String get libraryScanSelectFolderFirst => 'Select a folder first'; @@ -1640,20 +1658,20 @@ class AppLocalizationsJa extends AppLocalizations { 'Remove entries for files that no longer exist'; @override - String get libraryClear => 'Clear Library'; + String get libraryClear => 'ライブラリを消去'; @override String get libraryClearSubtitle => 'Remove all scanned tracks'; @override - String get libraryClearConfirmTitle => 'Clear Library'; + String get libraryClearConfirmTitle => 'ライブラリを消去'; @override String get libraryClearConfirmMessage => 'This will remove all scanned tracks from your library. Your actual music files will not be deleted.'; @override - String get libraryAbout => 'About Local Library'; + String get libraryAbout => 'ローカルライブラリについて'; @override String get libraryAboutDescription => @@ -1672,14 +1690,14 @@ class AppLocalizationsJa extends AppLocalizations { @override String libraryLastScanned(String time) { - return 'Last scanned: $time'; + return '最終スキャン: $time'; } @override String get libraryLastScannedNever => 'Never'; @override - String get libraryScanning => 'Scanning...'; + String get libraryScanning => 'スキャン中...'; @override String libraryScanProgress(String progress, int total) { @@ -1687,7 +1705,7 @@ class AppLocalizationsJa extends AppLocalizations { } @override - String get libraryInLibrary => 'In Library'; + String get libraryInLibrary => 'ライブラリ内'; @override String libraryRemovedMissingFiles(int count) { @@ -1698,7 +1716,7 @@ class AppLocalizationsJa extends AppLocalizations { String get libraryCleared => 'Library cleared'; @override - String get libraryStorageAccessRequired => 'Storage Access Required'; + String get libraryStorageAccessRequired => 'ストレージアクセスが必要です'; @override String get libraryStorageAccessMessage => @@ -1708,37 +1726,37 @@ class AppLocalizationsJa extends AppLocalizations { String get libraryFolderNotExist => 'Selected folder does not exist'; @override - String get librarySourceDownloaded => 'Downloaded'; + String get librarySourceDownloaded => 'ダウンロード済み'; @override - String get librarySourceLocal => 'Local'; + String get librarySourceLocal => 'ローカル'; @override - String get libraryFilterAll => 'All'; + String get libraryFilterAll => 'すべて'; @override - String get libraryFilterDownloaded => 'Downloaded'; + String get libraryFilterDownloaded => 'ダウンロード済み'; @override - String get libraryFilterLocal => 'Local'; + String get libraryFilterLocal => 'ローカル'; @override - String get libraryFilterTitle => 'Filters'; + String get libraryFilterTitle => 'フィルター'; @override - String get libraryFilterReset => 'Reset'; + String get libraryFilterReset => 'リセット'; @override - String get libraryFilterApply => 'Apply'; + String get libraryFilterApply => '適用'; @override - String get libraryFilterSource => 'Source'; + String get libraryFilterSource => 'ソース'; @override - String get libraryFilterQuality => 'Quality'; + String get libraryFilterQuality => '品質'; @override - String get libraryFilterQualityHiRes => 'Hi-Res (24bit)'; + String get libraryFilterQualityHiRes => 'ハイレゾ (24bit)'; @override String get libraryFilterQualityCD => 'CD (16bit)'; @@ -1747,7 +1765,7 @@ class AppLocalizationsJa extends AppLocalizations { String get libraryFilterQualityLossy => 'Lossy'; @override - String get libraryFilterFormat => 'Format'; + String get libraryFilterFormat => '形式'; @override String get libraryFilterSort => 'Sort'; @@ -1766,8 +1784,8 @@ class AppLocalizationsJa extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count minutes ago', - one: '1 minute ago', + other: '$count 分前', + one: '1 分前', ); return '$_temp0'; } @@ -1777,14 +1795,14 @@ class AppLocalizationsJa extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count hours ago', - one: '1 hour ago', + other: '$count 時間前', + one: '1 時間前', ); return '$_temp0'; } @override - String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!'; + String get tutorialWelcomeTitle => 'SpotiFLAC へようこそ!'; @override String get tutorialWelcomeDesc => @@ -1810,14 +1828,14 @@ class AppLocalizationsJa extends AppLocalizations { 'There are two easy ways to find music you want to download.'; @override - String get tutorialDownloadTitle => 'Downloading Music'; + String get tutorialDownloadTitle => '音楽をダウンロード中'; @override String get tutorialDownloadDesc => 'Downloading music is simple and fast. Here\'s how it works.'; @override - String get tutorialLibraryTitle => 'Your Library'; + String get tutorialLibraryTitle => 'あなたのライブラリ'; @override String get tutorialLibraryDesc => @@ -1836,7 +1854,7 @@ class AppLocalizationsJa extends AppLocalizations { 'Switch between list and grid view for better browsing'; @override - String get tutorialExtensionsTitle => 'Extensions'; + String get tutorialExtensionsTitle => '拡張'; @override String get tutorialExtensionsDesc => @@ -1877,7 +1895,7 @@ class AppLocalizationsJa extends AppLocalizations { 'You\'re all set! Start downloading your favorite music now.'; @override - String get libraryForceFullScan => 'Force Full Scan'; + String get libraryForceFullScan => '強制フルスキャン'; @override String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache'; @@ -1898,10 +1916,10 @@ class AppLocalizationsJa extends AppLocalizations { String get cleanupOrphanedDownloadsNone => 'No orphaned entries found'; @override - String get cacheTitle => 'Storage & Cache'; + String get cacheTitle => 'ストレージとキャッシュ'; @override - String get cacheSummaryTitle => 'Cache overview'; + String get cacheSummaryTitle => 'キャッシュの概要'; @override String get cacheSummarySubtitle => @@ -1913,34 +1931,34 @@ class AppLocalizationsJa extends AppLocalizations { } @override - String get cacheSectionStorage => 'Cached Data'; + String get cacheSectionStorage => 'キャッシュ済みデータ'; @override - String get cacheSectionMaintenance => 'Maintenance'; + String get cacheSectionMaintenance => 'メンテナンス'; @override - String get cacheAppDirectory => 'App cache directory'; + String get cacheAppDirectory => 'アプリキャッシュのディレクトリ'; @override String get cacheAppDirectoryDesc => 'HTTP responses, WebView data, and other temporary app data.'; @override - String get cacheTempDirectory => 'Temporary directory'; + String get cacheTempDirectory => '一時ディレクトリ'; @override String get cacheTempDirectoryDesc => 'Temporary files from downloads and audio conversion.'; @override - String get cacheCoverImage => 'Cover image cache'; + String get cacheCoverImage => 'カバー画像のキャッシュ'; @override String get cacheCoverImageDesc => 'Downloaded album and track cover art. Will re-download when viewed.'; @override - String get cacheLibraryCover => 'Library cover cache'; + String get cacheLibraryCover => 'ライブラリのカバーキャッシュ'; @override String get cacheLibraryCoverDesc => @@ -1965,7 +1983,7 @@ class AppLocalizationsJa extends AppLocalizations { 'Remove orphaned download history and library entries for missing files.'; @override - String get cacheNoData => 'No cached data'; + String get cacheNoData => 'キャッシュデータはありません'; @override String cacheSizeWithFiles(String size, int count) { @@ -1979,16 +1997,16 @@ class AppLocalizationsJa extends AppLocalizations { @override String cacheEntries(int count) { - return '$count entries'; + return '$count 個のエントリ'; } @override String cacheClearSuccess(String target) { - return 'Cleared: $target'; + return '消去済み: $target'; } @override - String get cacheClearConfirmTitle => 'Clear cache?'; + String get cacheClearConfirmTitle => 'キャッシュを消去しますか?'; @override String cacheClearConfirmMessage(String target) { @@ -1996,17 +2014,17 @@ class AppLocalizationsJa extends AppLocalizations { } @override - String get cacheClearAllConfirmTitle => 'Clear all cache?'; + String get cacheClearAllConfirmTitle => 'すべてのキャッシュを消去しますか?'; @override String get cacheClearAllConfirmMessage => 'This will clear all cache categories on this page. Downloaded music files will not be deleted.'; @override - String get cacheClearAll => 'Clear all cache'; + String get cacheClearAll => 'すべてのキャッシュを消去'; @override - String get cacheCleanupUnused => 'Cleanup unused data'; + String get cacheCleanupUnused => '未使用のデータを削除'; @override String get cacheCleanupUnusedSubtitle => @@ -2018,16 +2036,16 @@ class AppLocalizationsJa extends AppLocalizations { } @override - String get cacheRefreshStats => 'Refresh stats'; + String get cacheRefreshStats => '状態を更新'; @override - String get trackSaveCoverArt => 'Save Cover Art'; + String get trackSaveCoverArt => 'カバー画像を保存'; @override String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file'; @override - String get trackSaveLyrics => 'Save Lyrics (.lrc)'; + String get trackSaveLyrics => '歌詞を保存 (.lrc)'; @override String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file'; @@ -2043,7 +2061,7 @@ class AppLocalizationsJa extends AppLocalizations { 'Search metadata online and embed into file'; @override - String get trackEditMetadata => 'Edit Metadata'; + String get trackEditMetadata => 'メタデータを編集'; @override String trackCoverSaved(String fileName) { @@ -2072,26 +2090,26 @@ class AppLocalizationsJa extends AppLocalizations { @override String trackSaveFailed(String error) { - return 'Failed: $error'; + return '失敗: $error'; } @override - String get trackConvertFormat => 'Convert Format'; + String get trackConvertFormat => '変換の形式'; @override - String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus'; + String get trackConvertFormatSubtitle => 'MP3 または Opus に変換'; @override - String get trackConvertTitle => 'Convert Audio'; + String get trackConvertTitle => 'オーディオを変換'; @override - String get trackConvertTargetFormat => 'Target Format'; + String get trackConvertTargetFormat => 'ターゲットの形式'; @override - String get trackConvertBitrate => 'Bitrate'; + String get trackConvertBitrate => 'ビットレート'; @override - String get trackConvertConfirmTitle => 'Confirm Conversion'; + String get trackConvertConfirmTitle => '変換を確認'; @override String trackConvertConfirmMessage( @@ -2103,7 +2121,7 @@ class AppLocalizationsJa extends AppLocalizations { } @override - String get trackConvertConverting => 'Converting audio...'; + String get trackConvertConverting => 'オーディオを変換中...'; @override String trackConvertSuccess(String format) { @@ -2111,7 +2129,55 @@ class AppLocalizationsJa extends AppLocalizations { } @override - String get trackConvertFailed => 'Conversion failed'; + String get trackConvertFailed => '変換に失敗しました'; + + @override + String get cueSplitTitle => 'Split CUE Sheet'; + + @override + String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks'; + + @override + String cueSplitAlbum(String album) { + return 'Album: $album'; + } + + @override + String cueSplitArtist(String artist) { + return 'Artist: $artist'; + } + + @override + String cueSplitTrackCount(int count) { + return '$count tracks'; + } + + @override + String get cueSplitConfirmTitle => 'Split CUE Album'; + + @override + String cueSplitConfirmMessage(String album, int count) { + return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.'; + } + + @override + String cueSplitSplitting(int current, int total) { + return 'Splitting CUE sheet... ($current/$total)'; + } + + @override + String cueSplitSuccess(int count) { + return 'Split into $count tracks successfully'; + } + + @override + String get cueSplitFailed => 'CUE split failed'; + + @override + String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet'; + + @override + String get cueSplitButton => 'Split into Tracks'; @override String get actionCreate => 'Create'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index f94d1f3f..56287787 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -45,41 +45,40 @@ class AppLocalizationsKo extends AppLocalizations { String get historyFilterSingles => 'Singles'; @override - String get historySearchHint => 'Search history...'; + String get historySearchHint => '검색 기록...'; @override String get settingsTitle => 'Settings'; @override - String get settingsDownload => 'Download'; + String get settingsDownload => '다운로드'; @override - String get settingsAppearance => 'Appearance'; + String get settingsAppearance => '외관'; @override - String get settingsOptions => 'Options'; + String get settingsOptions => '옵션'; @override - String get settingsExtensions => 'Extensions'; + String get settingsExtensions => '확장 기능'; @override - String get settingsAbout => 'About'; + String get settingsAbout => '정보'; @override - String get downloadTitle => 'Download'; + String get downloadTitle => '다운로드'; @override - String get downloadAskQualitySubtitle => - 'Show quality picker for each download'; + String get downloadAskQualitySubtitle => '다운로드를 할 때마다 품질을 선택하도록 합니다'; @override - String get downloadFilenameFormat => 'Filename Format'; + String get downloadFilenameFormat => '파일 이름 형식'; @override - String get downloadFolderOrganization => 'Folder Organization'; + String get downloadFolderOrganization => '폴더 분류 형식'; @override - String get appearanceTitle => 'Appearance'; + String get appearanceTitle => '외관'; @override String get appearanceThemeSystem => 'System'; @@ -94,10 +93,10 @@ class AppLocalizationsKo extends AppLocalizations { String get appearanceDynamicColor => 'Dynamic Color'; @override - String get appearanceDynamicColorSubtitle => 'Use colors from your wallpaper'; + String get appearanceDynamicColorSubtitle => '배경 화면을 참고하여 강조 색상이 지정됩니다'; @override - String get appearanceHistoryView => 'History View'; + String get appearanceHistoryView => '기록 정렬 방식'; @override String get appearanceHistoryViewList => 'List'; @@ -106,112 +105,104 @@ class AppLocalizationsKo extends AppLocalizations { String get appearanceHistoryViewGrid => 'Grid'; @override - String get optionsTitle => 'Options'; + String get optionsTitle => '옵션'; @override - String get optionsPrimaryProvider => 'Primary Provider'; + String get optionsPrimaryProvider => '기본 제공자'; @override - String get optionsPrimaryProviderSubtitle => - 'Service used when searching by track name.'; + String get optionsPrimaryProviderSubtitle => '음반 이름으로 검색할 때 사용되는 서비스'; @override String optionsUsingExtension(String extensionName) { - return 'Using extension: $extensionName'; + return '확장 기능을 사용: $extensionName'; } @override - String get optionsSwitchBack => - 'Tap Deezer or Spotify to switch back from extension'; + String get optionsSwitchBack => 'Deezer 또는 Spotify를 탭하여 확장 기능에서 다시 전환하세요.'; @override - String get optionsAutoFallback => 'Auto Fallback'; + String get optionsAutoFallback => '자동 재시도'; @override String get optionsAutoFallbackSubtitle => '다운로드가 실패한 경우, 다른 서비스로 재시도'; @override - String get optionsUseExtensionProviders => 'Use Extension Providers'; + String get optionsUseExtensionProviders => '확장 기능 사용'; @override String get optionsUseExtensionProvidersOn => '확장 기능을 우선적으로 사용합니다'; @override - String get optionsUseExtensionProvidersOff => 'Using built-in providers only'; + String get optionsUseExtensionProvidersOff => '기본으로 제공되는 기능만 사용'; @override - String get optionsEmbedLyrics => 'Embed Lyrics'; + String get optionsEmbedLyrics => '가사 삽입'; @override - String get optionsEmbedLyricsSubtitle => - 'Embed synced lyrics into FLAC files'; + String get optionsEmbedLyricsSubtitle => 'FLAC 파일에 동기화된 가사를 삽입합니다'; @override - String get optionsMaxQualityCover => 'Max Quality Cover'; + String get optionsMaxQualityCover => '고품질 커버 이미지'; @override - String get optionsMaxQualityCoverSubtitle => - 'Download highest resolution cover art'; + String get optionsMaxQualityCoverSubtitle => '최고 품질의 커버 이미지를 다운로드'; @override - String get optionsConcurrentDownloads => 'Concurrent Downloads'; + String get optionsConcurrentDownloads => '동시 다운로드'; @override - String get optionsConcurrentSequential => 'Sequential (1 at a time)'; + String get optionsConcurrentSequential => '순차 다운로드 (한 번에 하나)'; @override String optionsConcurrentParallel(int count) { - return '$count parallel downloads'; + return '$count개 동시 다운로드'; } @override - String get optionsConcurrentWarning => - 'Parallel downloads may trigger rate limiting'; + String get optionsConcurrentWarning => '동시에 다수의 음반을 다운로드하면 속도 제한이 발생할 수 있습니다'; @override - String get optionsExtensionStore => 'Extension Store'; + String get optionsExtensionStore => '확장 기능 스토어'; @override - String get optionsExtensionStoreSubtitle => 'Show Store tab in navigation'; + String get optionsExtensionStoreSubtitle => '탐색 메뉴에 스토어 탭 표시'; @override - String get optionsCheckUpdates => 'Check for Updates'; + String get optionsCheckUpdates => '업데이트 확인'; @override - String get optionsCheckUpdatesSubtitle => - 'Notify when new version is available'; + String get optionsCheckUpdatesSubtitle => '새로운 버전이 출시되면 알림'; @override - String get optionsUpdateChannel => 'Update Channel'; + String get optionsUpdateChannel => '업데이트 채널'; @override - String get optionsUpdateChannelStable => 'Stable releases only'; + String get optionsUpdateChannelStable => '안정적인 버전만 수령'; @override - String get optionsUpdateChannelPreview => 'Get preview releases'; + String get optionsUpdateChannelPreview => '미리보기 버전을 수령'; @override - String get optionsUpdateChannelWarning => - 'Preview may contain bugs or incomplete features'; + String get optionsUpdateChannelWarning => '미리보기 버전은 불안정할 수 있습니다'; @override - String get optionsClearHistory => 'Clear Download History'; + String get optionsClearHistory => '다운로드 기록 삭제'; @override - String get optionsClearHistorySubtitle => - 'Remove all downloaded tracks from history'; + String get optionsClearHistorySubtitle => '기록에서 모든 다운로드 음반을 제거합니다'; @override - String get optionsDetailedLogging => 'Detailed Logging'; + String get optionsDetailedLogging => '상세 로깅'; @override - String get optionsDetailedLoggingOn => 'Detailed logs are being recorded'; + String get optionsDetailedLoggingOn => '상세한 로그가 기록되고 있습니다'; @override - String get optionsDetailedLoggingOff => 'Enable for bug reports'; + String get optionsDetailedLoggingOff => '버그 신고를 위한 기능입니다'; @override - String get optionsSpotifyCredentials => 'Spotify Credentials'; + String get optionsSpotifyCredentials => 'Spotify 자격 증명'; @override String optionsSpotifyCredentialsConfigured(String clientId) { @@ -219,21 +210,21 @@ class AppLocalizationsKo extends AppLocalizations { } @override - String get optionsSpotifyCredentialsRequired => 'Required - tap to configure'; + String get optionsSpotifyCredentialsRequired => '탭하여 설정'; @override String get optionsSpotifyWarning => - 'Spotify requires your own API credentials. Get them free from developer.spotify.com'; + 'Spotify는 사용자 고유의 API 자격 증명을 요구합니다. developer.spotify.com에서 무료로 발급받으세요'; @override String get optionsSpotifyDeprecationWarning => - 'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.'; + 'Spotify API 변경으로 인해 Spotify 검색 기능은 2026년 3월 3일부터 더 이상 지원되지 않습니다. Deezer로 전환해 주세요'; @override - String get extensionsTitle => 'Extensions'; + String get extensionsTitle => '확장 기능'; @override - String get extensionsDisabled => 'Disabled'; + String get extensionsDisabled => '비활성화'; @override String extensionsVersion(String version) { @@ -246,80 +237,79 @@ class AppLocalizationsKo extends AppLocalizations { } @override - String get extensionsUninstall => 'Uninstall'; + String get extensionsUninstall => '삭제'; @override - String get storeTitle => 'Extension Store'; + String get storeTitle => '확장 기능 스토어'; @override - String get storeSearch => 'Search extensions...'; + String get storeSearch => '확장 기능 검색'; @override - String get storeInstall => 'Install'; + String get storeInstall => '설치'; @override - String get storeInstalled => 'Installed'; + String get storeInstalled => '설치됨'; @override - String get storeUpdate => 'Update'; + String get storeUpdate => '업데이트'; @override - String get aboutTitle => 'About'; + String get aboutTitle => '정보'; @override - String get aboutContributors => 'Contributors'; + String get aboutContributors => '기여자'; @override - String get aboutMobileDeveloper => 'Mobile version developer'; + String get aboutMobileDeveloper => '모바일 버전 개발자'; @override - String get aboutOriginalCreator => 'Creator of the original SpotiFLAC'; + String get aboutOriginalCreator => '오리지널 SpotiFLAC 제작자'; @override - String get aboutLogoArtist => - 'The talented artist who created our beautiful app logo!'; + String get aboutLogoArtist => '아름다운 로고를 만들어주신 재능 있는 아티스트!'; @override - String get aboutTranslators => 'Translators'; + String get aboutTranslators => '번역가들'; @override - String get aboutSpecialThanks => 'Special Thanks'; + String get aboutSpecialThanks => '특별 감사'; @override - String get aboutLinks => 'Links'; + String get aboutLinks => '바로가기'; @override String get aboutMobileSource => 'Mobile source code'; @override - String get aboutPCSource => 'PC source code'; + String get aboutPCSource => 'PC 소스 코드'; @override - String get aboutReportIssue => 'Report an issue'; + String get aboutReportIssue => '문제 신고'; @override - String get aboutReportIssueSubtitle => 'Report any problems you encounter'; + String get aboutReportIssueSubtitle => '발생하는 모든 문제를 신고하여 주세요.'; @override - String get aboutFeatureRequest => 'Feature request'; + String get aboutFeatureRequest => '기능 요청'; @override - String get aboutFeatureRequestSubtitle => 'Suggest new features for the app'; + String get aboutFeatureRequestSubtitle => '앱의 새로운 기능을 제안하여 주세요.'; @override String get aboutTelegramChannel => 'Telegram Channel'; @override - String get aboutTelegramChannelSubtitle => 'Announcements and updates'; + String get aboutTelegramChannelSubtitle => '공지 및 업데이트 안내'; @override String get aboutTelegramChat => 'Telegram Community'; @override - String get aboutTelegramChatSubtitle => 'Chat with other users'; + String get aboutTelegramChatSubtitle => '다른 이용자와 소통'; @override - String get aboutSocial => 'Social'; + String get aboutSocial => '소셜'; @override String get aboutApp => 'App'; @@ -329,244 +319,239 @@ class AppLocalizationsKo extends AppLocalizations { @override String get aboutBinimumDesc => - 'The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn\'t exist!'; + 'QQDL 및 HiFi API 개발자입니다. 이 API가 없었다면 Tidal 다운로드는 불가능했을 것입니다!'; @override - String get aboutSachinsenalDesc => - 'The original HiFi project creator. The foundation of Tidal integration!'; + String get aboutSachinsenalDesc => '최초의 하이파이 프로젝트 창시자. 타이달 연동의 기반을 마련한 사람!'; @override String get aboutSjdonadoDesc => - 'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!'; + 'I Don\'t Have Spotify(IDHS) 개발자입니다. 위급 상황 발생 시 해결해 주는 대체 링크 해결 도구를 만들었습니다!'; @override String get aboutDabMusic => 'DAB Music'; @override String get aboutDabMusicDesc => - 'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!'; + '최고의 Qobuz 스트리밍 API입니다. 이 API가 없었다면 고해상도 다운로드는 불가능했을 겁니다!'; @override String get aboutSpotiSaver => 'SpotiSaver'; @override String get aboutSpotiSaverDesc => - 'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!'; + 'Tidal Hi-Res FLAC 스트리밍 엔드포인트. 무손실 음원 재생의 핵심 요소!'; @override String get aboutAppDescription => - 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; + 'Tidal, Qobuz, Amazon Music에서 Spotify 트랙을 무손실 음질로 다운로드하세요.'; @override - String get artistAlbums => 'Albums'; + String get artistAlbums => '앨범'; @override - String get artistSingles => 'Singles & EPs'; + String get artistSingles => '싱글 및 EP'; @override - String get artistCompilations => 'Compilations'; + String get artistCompilations => '편집'; @override - String get artistPopular => 'Popular'; + String get artistPopular => '인기순'; @override String artistMonthlyListeners(String count) { - return '$count monthly listeners'; + return '월간 청취자: $count'; } @override - String get trackMetadataService => 'Service'; + String get trackMetadataService => '제공업체'; @override - String get trackMetadataPlay => 'Play'; + String get trackMetadataPlay => '재생'; @override - String get trackMetadataShare => 'Share'; + String get trackMetadataShare => '공유'; @override - String get trackMetadataDelete => 'Delete'; + String get trackMetadataDelete => '삭제'; @override - String get setupGrantPermission => 'Grant Permission'; + String get setupGrantPermission => '권한을 제공해 주세요.'; @override - String get setupSkip => 'Skip for now'; + String get setupSkip => '다음에 할래요'; @override - String get setupStorageAccessRequired => 'Storage Access Required'; + String get setupStorageAccessRequired => '스토리지 접근 권한 필요'; @override String get setupStorageAccessMessageAndroid11 => - 'Android 11+ requires \"All files access\" permission to save files to your chosen download folder.'; + 'Android 11 이상 버전에서는 선택한 다운로드 폴더에 파일을 저장하려면 \"모든 파일 접근\" 권한이 필요합니다.'; @override - String get setupOpenSettings => 'Open Settings'; + String get setupOpenSettings => '설정으로 이동'; @override String get setupPermissionDeniedMessage => - 'Permission denied. Please grant all permissions to continue.'; + '권한이 거부되었습니다. 계속하려면 모든 권한을 허용해 주세요.'; @override String setupPermissionRequired(String permissionType) { - return '$permissionType Permission Required'; + return '$permissionType 권한 필요'; } @override String setupPermissionRequiredMessage(String permissionType) { - return '$permissionType permission is required for the best experience. You can change this later in Settings.'; + return '최상의 사용 경험을 위해 $permissionType 권한이 필요합니다. 설정에서 나중에 변경할 수 있습니다.'; } @override - String get setupUseDefaultFolder => 'Use Default Folder?'; + String get setupUseDefaultFolder => '기본 폴더를 사용하시겠습니까?'; @override - String get setupNoFolderSelected => - 'No folder selected. Would you like to use the default Music folder?'; + String get setupNoFolderSelected => '선택된 폴더가 없습니다. 기본 음악 폴더를 사용하시겠습니까?'; @override - String get setupUseDefault => 'Use Default'; + String get setupUseDefault => '기본값 사용'; @override - String get setupDownloadLocationTitle => 'Download Location'; + String get setupDownloadLocationTitle => '다운로드 경로'; @override String get setupDownloadLocationIosMessage => - 'On iOS, downloads are saved to the app\'s Documents folder. You can access them via the Files app.'; + 'iOS에서는 다운로드한 파일이 앱의 문서 폴더에 저장됩니다. 파일 앱을 통해 해당 파일에 접근할 수 있습니다.'; @override - String get setupAppDocumentsFolder => 'App Documents Folder'; + String get setupAppDocumentsFolder => '앱 문서 폴더'; @override - String get setupAppDocumentsFolderSubtitle => - 'Recommended - accessible via Files app'; + String get setupAppDocumentsFolderSubtitle => '권장 사항 - 파일 앱을 통해 접근 가능'; @override - String get setupChooseFromFiles => 'Choose from Files'; + String get setupChooseFromFiles => '파일 탐색기에서 선택'; @override - String get setupChooseFromFilesSubtitle => 'Select iCloud or other location'; + String get setupChooseFromFilesSubtitle => 'iCloud 또는 다른 위치를 선택하세요'; @override String get setupIosEmptyFolderWarning => - 'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.'; + 'iOS 제한 사항: 빈 폴더는 선택할 수 없습니다. 파일이 하나 이상 있는 폴더를 선택하세요.'; @override String get setupIcloudNotSupported => - 'iCloud Drive is not supported. Please use the app Documents folder.'; + 'iCloud Drive는 지원되지 않습니다. 앱의 문서 폴더를 사용해 주세요.'; @override - String get setupDownloadInFlac => 'Download Spotify tracks in FLAC'; + String get setupDownloadInFlac => 'Spotify 음악을 FLAC 형식으로 다운로드하세요.'; @override - String get setupStorageGranted => 'Storage Permission Granted!'; + String get setupStorageGranted => '저장소 접근 권한이 부여되었습니다!'; @override - String get setupStorageRequired => 'Storage Permission Required'; + String get setupStorageRequired => '저장소 접근 권한이 필요합니다.'; @override String get setupStorageDescription => - 'SpotiFLAC needs storage permission to save your downloaded music files.'; + 'SpotiFLAC은 다운로드한 음악 파일을 저장하기 위해 저장소 접근 권한이 필요합니다.'; @override - String get setupNotificationGranted => 'Notification Permission Granted!'; + String get setupNotificationGranted => '알림 권한이 부여되었습니다!'; @override - String get setupNotificationEnable => 'Enable Notifications'; + String get setupNotificationEnable => '알림 활성화'; @override - String get setupFolderChoose => 'Choose Download Folder'; + String get setupFolderChoose => '다운로드 폴더를 선택하세요'; @override - String get setupFolderDescription => - 'Select a folder where your downloaded music will be saved.'; + String get setupFolderDescription => '다운로드한 음악 파일이 저장될 폴더를 선택하세요.'; @override - String get setupSelectFolder => 'Select Folder'; + String get setupSelectFolder => '폴더 선택'; @override - String get setupEnableNotifications => 'Enable Notifications'; + String get setupEnableNotifications => '알림 활성화'; @override String get setupNotificationBackgroundDescription => - 'Get notified about download progress and completion. This helps you track downloads when the app is in background.'; + '알림으로 다운로드 진행 상황을 확인하세요. 앱이 백그라운드에서 실행 중일 때 다운로드 상태와 완료 여부를 확인할 수 있습니다.'; @override - String get setupSkipForNow => 'Skip for now'; + String get setupSkipForNow => '다음에 할래요.'; @override - String get setupNext => 'Next'; + String get setupNext => '다음'; @override - String get setupGetStarted => 'Get Started'; + String get setupGetStarted => '시작하기'; @override String get setupAllowAccessToManageFiles => - 'Please enable \"Allow access to manage all files\" in the next screen.'; + '다음 화면에서 \"모든 파일 관리 권한 허용\"을 활성화해 주세요.'; @override - String get dialogCancel => 'Cancel'; + String get dialogCancel => '취소'; @override - String get dialogSave => 'Save'; + String get dialogSave => '저장'; @override - String get dialogDelete => 'Delete'; + String get dialogDelete => '삭제'; @override - String get dialogRetry => 'Retry'; + String get dialogRetry => '재시도'; @override - String get dialogClear => 'Clear'; + String get dialogClear => '지우기'; @override - String get dialogDone => 'Done'; + String get dialogDone => '완료'; @override - String get dialogImport => 'Import'; + String get dialogImport => '불러오기'; @override - String get dialogDiscard => 'Discard'; + String get dialogDiscard => '취소'; @override - String get dialogRemove => 'Remove'; + String get dialogRemove => '제거'; @override - String get dialogUninstall => 'Uninstall'; + String get dialogUninstall => '삭제'; @override - String get dialogDiscardChanges => 'Discard Changes?'; + String get dialogDiscardChanges => '변경사항 취소'; @override - String get dialogUnsavedChanges => - 'You have unsaved changes. Do you want to discard them?'; + String get dialogUnsavedChanges => '저장되지 않은 변경 사항이 있습니다. 삭제하시겠습니까?'; @override - String get dialogClearAll => 'Clear All'; + String get dialogClearAll => '모두 제거:'; @override - String get dialogRemoveExtension => 'Remove Extension'; + String get dialogRemoveExtension => '확장 프로그램 제거'; @override String get dialogRemoveExtensionMessage => - 'Are you sure you want to remove this extension? This cannot be undone.'; + '이 확장 프로그램을 정말로 제거하시겠습니까? 이 작업은 되돌릴 수 없습니다.'; @override - String get dialogUninstallExtension => 'Uninstall Extension?'; + String get dialogUninstallExtension => '확장 프로그램을 제거하시겠습니까?'; @override String dialogUninstallExtensionMessage(String extensionName) { - return 'Are you sure you want to remove $extensionName?'; + return '$extensionName을 정말로 삭제하시겠습니까?'; } @override - String get dialogClearHistoryTitle => 'Clear History'; + String get dialogClearHistoryTitle => '기록 삭제'; @override String get dialogClearHistoryMessage => - 'Are you sure you want to clear all download history? This cannot be undone.'; + '다운로드 기록을 모두 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.'; @override - String get dialogDeleteSelectedTitle => 'Delete Selected'; + String get dialogDeleteSelectedTitle => '선택한 항목 삭제'; @override String dialogDeleteSelectedMessage(int count) { @@ -576,50 +561,50 @@ class AppLocalizationsKo extends AppLocalizations { other: 'tracks', one: 'track', ); - return 'Delete $count $_temp0 from history?\n\nThis will also delete the files from storage.'; + return '기록에서 $count $_temp0를 삭제하시겠습니까?'; } @override - String get dialogImportPlaylistTitle => 'Import Playlist'; + String get dialogImportPlaylistTitle => '재생 목록 가져오기'; @override String dialogImportPlaylistMessage(int count) { - return 'Found $count tracks in CSV. Add them to download queue?'; + return 'CSV 파일에서 $count개의 트랙을 찾았습니다. 다운로드 대기열에 추가하시겠습니까?'; } @override String csvImportTracks(int count) { - return '$count tracks from CSV'; + return 'CSV 파일의 트랙: $count'; } @override String snackbarAddedToQueue(String trackName) { - return 'Added \"$trackName\" to queue'; + return '\"$trackName\"(을)를 대기열에 추가했습니다.'; } @override String snackbarAddedTracksToQueue(int count) { - return 'Added $count tracks to queue'; + return '대기열에 $count개의 트랙을 추가했습니다.'; } @override String snackbarAlreadyDownloaded(String trackName) { - return '\"$trackName\" already downloaded'; + return '\"$trackName\"(은)는 이미 다운로드되었습니다.'; } @override String snackbarAlreadyInLibrary(String trackName) { - return '\"$trackName\" already exists in your library'; + return '라이브러리에 \"$trackName\"(은)는 이미 존재합니다.'; } @override - String get snackbarHistoryCleared => 'History cleared'; + String get snackbarHistoryCleared => '기록 삭제됨'; @override - String get snackbarCredentialsSaved => 'Credentials saved'; + String get snackbarCredentialsSaved => '자격 증명이 저장되었습니다.'; @override - String get snackbarCredentialsCleared => 'Credentials cleared'; + String get snackbarCredentialsCleared => '자격 증명이 제거되었습니다.'; @override String snackbarDeletedTracks(int count) { @@ -629,136 +614,152 @@ class AppLocalizationsKo extends AppLocalizations { other: 'tracks', one: 'track', ); - return 'Deleted $count $_temp0'; + return '$count$_temp0 제거됨'; } @override String snackbarCannotOpenFile(String error) { - return 'Cannot open file: $error'; + return '파일을 열 수 없습니다: $error'; } @override - String get snackbarFillAllFields => 'Please fill all fields'; + String get snackbarFillAllFields => '모든 항목을 입력해 주세요.'; @override String get snackbarViewQueue => 'View Queue'; @override String snackbarUrlCopied(String platform) { - return '$platform URL copied to clipboard'; + return '$platform 링크가 클립보드에 저장됨'; } @override - String get snackbarFileNotFound => 'File not found'; + String get snackbarFileNotFound => '파일을 찾을 수 없음'; @override - String get snackbarSelectExtFile => 'Please select a .spotiflac-ext file'; + String get snackbarSelectExtFile => '.spotiflac-ext 확장자 파일을 선택'; @override - String get snackbarProviderPrioritySaved => 'Provider priority saved'; + String get snackbarProviderPrioritySaved => '제공자 우선순위 저장됨'; @override - String get snackbarMetadataProviderSaved => - 'Metadata provider priority saved'; + String get snackbarMetadataProviderSaved => '메타데이터 제공자 우선순위 저장됨'; @override String snackbarExtensionInstalled(String extensionName) { - return '$extensionName installed.'; + return '$extensionName(이)가 설치됨'; } @override String snackbarExtensionUpdated(String extensionName) { - return '$extensionName updated.'; + return '$extensionName(이)가 설치됨.'; } @override - String get snackbarFailedToInstall => 'Failed to install extension'; + String get snackbarFailedToInstall => '확장 프로그램 설치 실패'; @override - String get snackbarFailedToUpdate => 'Failed to update extension'; + String get snackbarFailedToUpdate => '확장 프로그램 업데이트 실패'; @override String get errorRateLimited => 'Rate Limited'; @override - String get errorRateLimitedMessage => - 'Too many requests. Please wait a moment before searching again.'; + String get errorRateLimitedMessage => '요청이 너무 많습니다. 잠시 후 다시 검색해 주세요.'; @override - String get errorNoTracksFound => 'No tracks found'; + String get errorNoTracksFound => '트랙을 찾을 수 없습니다'; + + @override + String get errorUrlNotRecognized => 'Link not recognized'; + + @override + String get errorUrlNotRecognizedMessage => + 'This link is not supported. Make sure the URL is correct and a compatible extension is installed.'; + + @override + String get errorUrlFetchFailed => + 'Failed to load content from this link. Please try again.'; @override String errorMissingExtensionSource(String item) { - return 'Cannot load $item: missing extension source'; + return '확장 소스가 누락되어, $item(을)를 로드할 수 없습니다'; } @override - String get actionPause => 'Pause'; + String get actionPause => '멈추기'; @override - String get actionResume => 'Resume'; + String get actionResume => '재시작'; @override - String get actionCancel => 'Cancel'; + String get actionCancel => '취소'; @override - String get actionSelectAll => 'Select All'; + String get actionSelectAll => '모두 선택'; @override - String get actionDeselect => 'Deselect'; + String get actionDeselect => '선택 해제'; @override - String get actionRemoveCredentials => 'Remove Credentials'; + String get actionRemoveCredentials => '자격 증명 제거'; @override - String get actionSaveCredentials => 'Save Credentials'; + String get actionSaveCredentials => '자격 증명 저장'; @override String selectionSelected(int count) { - return '$count selected'; + return '$count개 선택됨'; } @override - String get selectionAllSelected => 'All tracks selected'; + String get selectionAllSelected => '모든 트랙 선택됨'; @override - String get selectionSelectToDelete => 'Select tracks to delete'; + String get selectionSelectToDelete => '삭제할 트랙을 선택'; @override String progressFetchingMetadata(int current, int total) { - return 'Fetching metadata... $current/$total'; + return '메타데이터 가져오는 중... $current/$total'; } @override - String get progressReadingCsv => 'Reading CSV...'; + String get progressReadingCsv => 'CSV 파일을 읽는 중...'; @override - String get searchSongs => 'Songs'; + String get searchSongs => '곡들'; @override - String get searchArtists => 'Artists'; + String get searchArtists => '아티스트들'; @override - String get searchAlbums => 'Albums'; + String get searchAlbums => '앨범들'; @override - String get searchPlaylists => 'Playlists'; + String get searchPlaylists => '재생목록들'; @override - String get tooltipPlay => 'Play'; + String get tooltipPlay => '재생'; @override - String get filenameFormat => 'Filename Format'; + String get filenameFormat => ''; @override - String get filenameShowAdvancedTags => 'Show advanced tags'; + String get filenameShowAdvancedTags => '고급 태그 표시'; @override String get filenameShowAdvancedTagsDescription => - 'Enable formatted tags for track padding and date patterns'; + '트랙 패딩 및 날짜 패턴에 대한 서식 있는 태그를 활성화합니다.'; @override - String get folderOrganizationNone => 'No organization'; + String get folderOrganizationNone => '정리하지 않음'; + + @override + String get folderOrganizationByPlaylist => 'By Playlist'; + + @override + String get folderOrganizationByPlaylistSubtitle => + 'Separate folder for each playlist'; @override String get folderOrganizationByArtist => 'By Artist'; @@ -877,116 +878,114 @@ class AppLocalizationsKo extends AppLocalizations { String get logShareLogs => 'Share logs'; @override - String get logClearLogs => 'Clear logs'; + String get logClearLogs => '로그 제거'; @override - String get logClearLogsTitle => 'Clear Logs'; + String get logClearLogsTitle => '로그 제거'; @override - String get logClearLogsMessage => 'Are you sure you want to clear all logs?'; + String get logClearLogsMessage => '모든 로그를 삭제하시겠습니까?'; @override - String get logFilterBySeverity => 'Filter logs by severity'; + String get logFilterBySeverity => '심각성에 따라 로그 분류'; @override - String get logNoLogsYet => 'No logs yet'; + String get logNoLogsYet => '어떠한 로그도 없음'; @override - String get logNoLogsYetSubtitle => 'Logs will appear here as you use the app'; + String get logNoLogsYetSubtitle => '앱을 사용하는 동안 로그가 여기에 표시됩니다.'; @override String logEntriesFiltered(int count) { - return 'Entries ($count filtered)'; + return '($count filtered)개 항목 필터링'; } @override String logEntries(int count) { - return 'Entries ($count)'; + return '항목 수: ($count)'; } @override - String get credentialsTitle => 'Spotify Credentials'; + String get credentialsTitle => 'Spotify 자격 증명'; @override String get credentialsDescription => - 'Enter your Client ID and Secret to use your own Spotify application quota.'; + 'Spotify 애플리케이션 할당량을 사용하려면 클라이언트 ID와 비밀키를 입력하세요.'; @override String get credentialsClientId => 'Client ID'; @override - String get credentialsClientIdHint => 'Paste Client ID'; + String get credentialsClientIdHint => 'Client ID를 붙여넣으세요'; @override - String get credentialsClientSecret => 'Client Secret'; + String get credentialsClientSecret => '비밀키'; @override - String get credentialsClientSecretHint => 'Paste Client Secret'; + String get credentialsClientSecretHint => '비밀키를 붙여넣으세요'; @override - String get channelStable => 'Stable'; + String get channelStable => '안정'; @override - String get channelPreview => 'Preview'; + String get channelPreview => '베타'; @override - String get sectionSearchSource => 'Search Source'; + String get sectionSearchSource => '검색 소스'; @override - String get sectionDownload => 'Download'; + String get sectionDownload => '다운로드'; @override - String get sectionPerformance => 'Performance'; + String get sectionPerformance => '성능'; @override - String get sectionApp => 'App'; + String get sectionApp => '앱'; @override - String get sectionData => 'Data'; + String get sectionData => '데이터'; @override String get sectionDebug => 'Debug'; @override - String get sectionService => 'Service'; + String get sectionService => '서비스'; @override - String get sectionAudioQuality => 'Audio Quality'; + String get sectionAudioQuality => '오디오 품질'; @override - String get sectionFileSettings => 'File Settings'; + String get sectionFileSettings => '파일 설정'; @override - String get sectionLyrics => 'Lyrics'; + String get sectionLyrics => '가사'; @override - String get lyricsMode => 'Lyrics Mode'; + String get lyricsMode => '가사 설정'; @override - String get lyricsModeDescription => - 'Choose how lyrics are saved with your downloads'; + String get lyricsModeDescription => '다운로드한 파일에 가사를 저장하는 방법을 선택하세요.'; @override - String get lyricsModeEmbed => 'Embed in file'; + String get lyricsModeEmbed => '파일에 포함'; @override - String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata'; + String get lyricsModeEmbedSubtitle => 'FLAC 메타데이터 내에 저장됩니다.'; @override - String get lyricsModeExternal => 'External .lrc file'; + String get lyricsModeExternal => '외부 .lrc 파일'; @override - String get lyricsModeExternalSubtitle => - 'Separate .lrc file for players like Samsung Music'; + String get lyricsModeExternalSubtitle => '삼성 뮤직과 같은 플레이어용 별도 .lrc 파일'; @override - String get lyricsModeBoth => 'Both'; + String get lyricsModeBoth => '둘 다'; @override - String get lyricsModeBothSubtitle => 'Embed and save .lrc file'; + String get lyricsModeBothSubtitle => '.lrc 파일을 삽입하고 저장합니다.'; @override - String get sectionColor => 'Color'; + String get sectionColor => '색상'; @override String get sectionTheme => 'Theme'; @@ -2125,6 +2124,54 @@ class AppLocalizationsKo extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + @override + String get cueSplitTitle => 'Split CUE Sheet'; + + @override + String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks'; + + @override + String cueSplitAlbum(String album) { + return 'Album: $album'; + } + + @override + String cueSplitArtist(String artist) { + return 'Artist: $artist'; + } + + @override + String cueSplitTrackCount(int count) { + return '$count tracks'; + } + + @override + String get cueSplitConfirmTitle => 'Split CUE Album'; + + @override + String cueSplitConfirmMessage(String album, int count) { + return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.'; + } + + @override + String cueSplitSplitting(int current, int total) { + return 'Splitting CUE sheet... ($current/$total)'; + } + + @override + String cueSplitSuccess(int count) { + return 'Split into $count tracks successfully'; + } + + @override + String get cueSplitFailed => 'CUE split failed'; + + @override + String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet'; + + @override + String get cueSplitButton => 'Split into Tracks'; + @override String get actionCreate => 'Create'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index be1be644..5f4975d9 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -688,6 +688,17 @@ class AppLocalizationsNl extends AppLocalizations { @override String get errorNoTracksFound => 'No tracks found'; + @override + String get errorUrlNotRecognized => 'Link not recognized'; + + @override + String get errorUrlNotRecognizedMessage => + 'This link is not supported. Make sure the URL is correct and a compatible extension is installed.'; + + @override + String get errorUrlFetchFailed => + 'Failed to load content from this link. Please try again.'; + @override String errorMissingExtensionSource(String item) { return 'Cannot load $item: missing extension source'; @@ -761,6 +772,13 @@ class AppLocalizationsNl extends AppLocalizations { @override String get folderOrganizationNone => 'No organization'; + @override + String get folderOrganizationByPlaylist => 'By Playlist'; + + @override + String get folderOrganizationByPlaylistSubtitle => + 'Separate folder for each playlist'; + @override String get folderOrganizationByArtist => 'By Artist'; @@ -2126,6 +2144,54 @@ class AppLocalizationsNl extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + @override + String get cueSplitTitle => 'Split CUE Sheet'; + + @override + String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks'; + + @override + String cueSplitAlbum(String album) { + return 'Album: $album'; + } + + @override + String cueSplitArtist(String artist) { + return 'Artist: $artist'; + } + + @override + String cueSplitTrackCount(int count) { + return '$count tracks'; + } + + @override + String get cueSplitConfirmTitle => 'Split CUE Album'; + + @override + String cueSplitConfirmMessage(String album, int count) { + return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.'; + } + + @override + String cueSplitSplitting(int current, int total) { + return 'Splitting CUE sheet... ($current/$total)'; + } + + @override + String cueSplitSuccess(int count) { + return 'Split into $count tracks successfully'; + } + + @override + String get cueSplitFailed => 'CUE split failed'; + + @override + String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet'; + + @override + String get cueSplitButton => 'Split into Tracks'; + @override String get actionCreate => 'Create'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 499adcbb..62b648ae 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -356,7 +356,7 @@ class AppLocalizationsPt extends AppLocalizations { @override String get aboutAppDescription => - 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; + 'Download Spotify tracks in lossless quality from Tidal and Qobuz.'; @override String get artistAlbums => 'Albums'; @@ -688,6 +688,17 @@ class AppLocalizationsPt extends AppLocalizations { @override String get errorNoTracksFound => 'No tracks found'; + @override + String get errorUrlNotRecognized => 'Link not recognized'; + + @override + String get errorUrlNotRecognizedMessage => + 'This link is not supported. Make sure the URL is correct and a compatible extension is installed.'; + + @override + String get errorUrlFetchFailed => + 'Failed to load content from this link. Please try again.'; + @override String errorMissingExtensionSource(String item) { return 'Cannot load $item: missing extension source'; @@ -761,6 +772,13 @@ class AppLocalizationsPt extends AppLocalizations { @override String get folderOrganizationNone => 'No organization'; + @override + String get folderOrganizationByPlaylist => 'By Playlist'; + + @override + String get folderOrganizationByPlaylistSubtitle => + 'Separate folder for each playlist'; + @override String get folderOrganizationByArtist => 'By Artist'; @@ -1809,7 +1827,7 @@ class AppLocalizationsPt extends AppLocalizations { @override String get tutorialWelcomeTip2 => - 'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'; + 'Get FLAC quality audio from Tidal, Qobuz, or Deezer'; @override String get tutorialWelcomeTip3 => @@ -2126,6 +2144,54 @@ class AppLocalizationsPt extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + @override + String get cueSplitTitle => 'Split CUE Sheet'; + + @override + String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks'; + + @override + String cueSplitAlbum(String album) { + return 'Album: $album'; + } + + @override + String cueSplitArtist(String artist) { + return 'Artist: $artist'; + } + + @override + String cueSplitTrackCount(int count) { + return '$count tracks'; + } + + @override + String get cueSplitConfirmTitle => 'Split CUE Album'; + + @override + String cueSplitConfirmMessage(String album, int count) { + return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.'; + } + + @override + String cueSplitSplitting(int current, int total) { + return 'Splitting CUE sheet... ($current/$total)'; + } + + @override + String cueSplitSuccess(int count) { + return 'Split into $count tracks successfully'; + } + + @override + String get cueSplitFailed => 'CUE split failed'; + + @override + String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet'; + + @override + String get cueSplitButton => 'Split into Tracks'; + @override String get actionCreate => 'Create'; @@ -2705,7 +2771,7 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get aboutAppDescription => - 'Baixe faixas do Spotify em qualidade sem perdas do Tidal, Qobuz e Amazon Music.'; + 'Baixe faixas do Spotify em qualidade sem perdas do Tidal e Qobuz.'; @override String get artistAlbums => 'Álbuns'; @@ -4147,7 +4213,7 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get tutorialWelcomeTip2 => - 'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'; + 'Obtenha áudio em qualidade FLAC do Tidal, Qobuz ou Deezer'; @override String get tutorialWelcomeTip3 => diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 85af897d..66c75d45 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -67,7 +67,7 @@ class AppLocalizationsRu extends AppLocalizations { String get settingsAbout => 'О программе'; @override - String get downloadTitle => 'Скачивание'; + String get downloadTitle => 'Скачать'; @override String get downloadAskQualitySubtitle => @@ -146,11 +146,11 @@ class AppLocalizationsRu extends AppLocalizations { 'Использование только встроенных провайдеров'; @override - String get optionsEmbedLyrics => 'Вставить текст песни'; + String get optionsEmbedLyrics => 'Вписать текст песни'; @override String get optionsEmbedLyricsSubtitle => - 'Вставить синхронизированные тексты в FLAC файлы'; + 'Вписать синхронизированные тексты во FLAC файлы'; @override String get optionsMaxQualityCover => 'Максимальное качество обложки'; @@ -337,7 +337,7 @@ class AppLocalizationsRu extends AppLocalizations { @override String get aboutBinimumDesc => - 'Создатель QQDL & HiFi API. Без этого API загрузки Tidal не существовали бы!'; + 'Создатель QQDL & HiFi API. Без него API загрузки Tidal не существовали бы!'; @override String get aboutSachinsenalDesc => @@ -601,7 +601,7 @@ class AppLocalizationsRu extends AppLocalizations { @override String csvImportTracks(int count) { - return '$count треков из CSV'; + return '$count трек(-ов) из CSV'; } @override @@ -702,6 +702,17 @@ class AppLocalizationsRu extends AppLocalizations { @override String get errorNoTracksFound => 'Треки не найдены'; + @override + String get errorUrlNotRecognized => 'Link not recognized'; + + @override + String get errorUrlNotRecognizedMessage => + 'This link is not supported. Make sure the URL is correct and a compatible extension is installed.'; + + @override + String get errorUrlFetchFailed => + 'Failed to load content from this link. Please try again.'; + @override String errorMissingExtensionSource(String item) { return 'Невозможно загрузить $item: отсутствует источник расширения'; @@ -766,15 +777,22 @@ class AppLocalizationsRu extends AppLocalizations { String get filenameFormat => 'Формат имени файла'; @override - String get filenameShowAdvancedTags => 'Show advanced tags'; + String get filenameShowAdvancedTags => 'Показать расширенные теги'; @override String get filenameShowAdvancedTagsDescription => - 'Enable formatted tags for track padding and date patterns'; + 'Включить форматированные теги для отслеживания заполнения и шаблонов дат'; @override String get folderOrganizationNone => 'Без организации'; + @override + String get folderOrganizationByPlaylist => 'By Playlist'; + + @override + String get folderOrganizationByPlaylistSubtitle => + 'Separate folder for each playlist'; + @override String get folderOrganizationByArtist => 'По исполнителю'; @@ -983,7 +1001,7 @@ class AppLocalizationsRu extends AppLocalizations { 'Выберите как сохранить тексты песен при скачивании'; @override - String get lyricsModeEmbed => 'Вставить в файл'; + String get lyricsModeEmbed => 'Вписать в файл'; @override String get lyricsModeEmbedSubtitle => 'Встроить текст в метаданные FLAC'; @@ -999,7 +1017,7 @@ class AppLocalizationsRu extends AppLocalizations { String get lyricsModeBoth => 'Оба варианта'; @override - String get lyricsModeBothSubtitle => 'Вставить и сохранить файл .lrc'; + String get lyricsModeBothSubtitle => 'Вписать и сохранить .lrc файл'; @override String get sectionColor => 'Цвет'; @@ -1138,7 +1156,7 @@ class AppLocalizationsRu extends AppLocalizations { String get trackLyricsLoadFailed => 'Не удалось загрузить текст песни'; @override - String get trackEmbedLyrics => 'Вставить текст песни'; + String get trackEmbedLyrics => 'Вписать текст песни'; @override String get trackLyricsEmbedded => 'Текст успешно добавлен'; @@ -1361,10 +1379,10 @@ class AppLocalizationsRu extends AppLocalizations { 'YouTube обеспечивает только звук с потерями(Lossy).'; @override - String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; + String get youtubeOpusBitrateTitle => 'Битрейт YouTube Opus'; @override - String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; + String get youtubeMp3BitrateTitle => 'Битрейт YouTube MP3'; @override String get downloadAskBeforeDownload => 'Спрашивать перед скачиванием'; @@ -1383,7 +1401,8 @@ class AppLocalizationsRu extends AppLocalizations { 'Использовать исполнителя альбома для папок'; @override - String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders'; + String get downloadUsePrimaryArtistOnly => + 'Основной исполнитель только для папок'; @override String get downloadUsePrimaryArtistOnlyEnabled => @@ -1391,7 +1410,7 @@ class AppLocalizationsRu extends AppLocalizations { @override String get downloadUsePrimaryArtistOnlyDisabled => - 'Full artist string used for folder name'; + 'Полная строка исполнителя, используемая для имени папки'; @override String get downloadSelectQuality => 'Выбор качества'; @@ -1423,7 +1442,7 @@ class AppLocalizationsRu extends AppLocalizations { String get settingsDownloadNetwork => 'Сеть для скачивания'; @override - String get settingsDownloadNetworkAny => 'WiFi и мобильная сеть'; + String get settingsDownloadNetworkAny => 'WiFi и Мобильная сеть'; @override String get settingsDownloadNetworkWifiOnly => 'Только WiFi'; @@ -1712,8 +1731,10 @@ class AppLocalizationsRu extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'tracks', - one: 'track', + other: 'треков', + many: 'треков', + few: 'трека', + one: 'трек', ); return '$_temp0'; } @@ -1800,7 +1821,7 @@ class AppLocalizationsRu extends AppLocalizations { String get libraryFilterQualityCD => 'CD (16 бит)'; @override - String get libraryFilterQualityLossy => 'С потерями'; + String get libraryFilterQualityLossy => 'Lossy'; @override String get libraryFilterFormat => 'Формат'; @@ -1904,7 +1925,7 @@ class AppLocalizationsRu extends AppLocalizations { @override String get tutorialExtensionsTip1 => - 'Browse the Store tab to discover useful extensions'; + 'Просмотрите вкладку Магазина, чтобы найти полезные расширения'; @override String get tutorialExtensionsTip2 => @@ -1912,14 +1933,14 @@ class AppLocalizationsRu extends AppLocalizations { @override String get tutorialExtensionsTip3 => - 'Get lyrics, enhanced metadata, and more features'; + 'Получайте тексты песен, улучшенные метаданные и другие возможности'; @override String get tutorialSettingsTitle => 'Настройте приложение под себя'; @override String get tutorialSettingsDesc => - 'Personalize the app in Settings to match your preferences.'; + 'Персонализируйте приложение в Настройках, чтобы оно соответствовало вашим предпочтениям.'; @override String get tutorialSettingsTip1 => @@ -1944,11 +1965,11 @@ class AppLocalizationsRu extends AppLocalizations { 'Пересканировать все файлы, игнорировать кэш'; @override - String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads'; + String get cleanupOrphanedDownloads => 'Очистка отложенных скачиваний'; @override String get cleanupOrphanedDownloadsSubtitle => - 'Remove history entries for files that no longer exist'; + 'Удалить историю записи для файлов, которых больше не существует'; @override String cleanupOrphanedDownloadsResult(int count) { @@ -1956,7 +1977,7 @@ class AppLocalizationsRu extends AppLocalizations { } @override - String get cleanupOrphanedDownloadsNone => 'No orphaned entries found'; + String get cleanupOrphanedDownloadsNone => 'Записей без описания не найдено'; @override String get cacheTitle => 'Хранилище и кэш'; @@ -1966,11 +1987,11 @@ class AppLocalizationsRu extends AppLocalizations { @override String get cacheSummarySubtitle => - 'Clearing cache will not remove downloaded music files.'; + 'Очистка кэша не приведет к удалению загруженных музыкальных файлов.'; @override String cacheEstimatedTotal(String size) { - return 'Estimated cache usage: $size'; + return 'Приблизительное использование кэша: $size'; } @override @@ -1984,42 +2005,42 @@ class AppLocalizationsRu extends AppLocalizations { @override String get cacheAppDirectoryDesc => - 'HTTP responses, WebView data, and other temporary app data.'; + 'HTTP-ответы, данные WebView и другие временные данные приложения.'; @override - String get cacheTempDirectory => 'Temporary directory'; + String get cacheTempDirectory => 'Временная директория'; @override String get cacheTempDirectoryDesc => - 'Temporary files from downloads and audio conversion.'; + 'Временные файлы из загрузок и аудио конвертации.'; @override - String get cacheCoverImage => 'Cover image cache'; + String get cacheCoverImage => 'Кэш обложек'; @override String get cacheCoverImageDesc => - 'Downloaded album and track cover art. Will re-download when viewed.'; + 'Скачанный альбом и трек обложки. Будет заново скачан после просмотра.'; @override - String get cacheLibraryCover => 'Library cover cache'; + String get cacheLibraryCover => 'Кэш обложек библиотеки'; @override String get cacheLibraryCoverDesc => - 'Cover art extracted from local music files. Will re-extract on next scan.'; + 'Обложка извлечена из локальных музыкальных файлов. Будет повторно извлечено при следующем сканировании.'; @override - String get cacheExploreFeed => 'Explore feed cache'; + String get cacheExploreFeed => 'Просмотреть кэш ленты'; @override String get cacheExploreFeedDesc => - 'Explore tab content (new releases, trending). Will refresh on next visit.'; + 'Изучите содержимое вкладки (новые релизы, тренды). Они обновятся при следующем посещении.'; @override - String get cacheTrackLookup => 'Track lookup cache'; + String get cacheTrackLookup => 'Отслеживать кэш поиска'; @override String get cacheTrackLookupDesc => - 'Spotify/Deezer track ID lookups. Clearing may slow next few searches.'; + 'Поиск ID трека в Spotify/Deezer. Очистка может замедлить следующие несколько поисков.'; @override String get cacheCleanupUnusedDesc => @@ -2040,7 +2061,7 @@ class AppLocalizationsRu extends AppLocalizations { @override String cacheEntries(int count) { - return '$count entries'; + return '$count записей'; } @override @@ -2053,7 +2074,7 @@ class AppLocalizationsRu extends AppLocalizations { @override String cacheClearConfirmMessage(String target) { - return 'This will clear cached data for $target. Downloaded music files will not be deleted.'; + return 'Это очистит кэш для $target. Загруженные музыкальные файлы не будут удалены.'; } @override @@ -2075,7 +2096,7 @@ class AppLocalizationsRu extends AppLocalizations { @override String cacheCleanupResult(int downloadCount, int libraryCount) { - return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries'; + return 'Очистка завершена: $downloadCount потерянных загрузок, $libraryCount отсутствующих записей в библиотеке'; } @override @@ -2095,14 +2116,14 @@ class AppLocalizationsRu extends AppLocalizations { 'Получить и сохранить текст песни в формате .lrc'; @override - String get trackSaveLyricsProgress => 'Saving lyrics...'; + String get trackSaveLyricsProgress => 'Сохранение текста...'; @override - String get trackReEnrich => 'Re-enrich'; + String get trackReEnrich => 'Обновить'; @override String get trackReEnrichOnlineSubtitle => - 'Search metadata online and embed into file'; + 'Поиск в сети метаданных и встраивание в файл'; @override String get trackEditMetadata => 'Редактировать метаданные'; @@ -2121,13 +2142,13 @@ class AppLocalizationsRu extends AppLocalizations { } @override - String get trackReEnrichProgress => 'Re-enriching metadata...'; + String get trackReEnrichProgress => 'Обновление метаданных...'; @override String get trackReEnrichSearching => 'Поиск метаданных в сети...'; @override - String get trackReEnrichSuccess => 'Metadata re-enriched successfully'; + String get trackReEnrichSuccess => 'Метаданные успешно обновлены'; @override String get trackReEnrichFfmpegFailed => @@ -2139,22 +2160,22 @@ class AppLocalizationsRu extends AppLocalizations { } @override - String get trackConvertFormat => 'Convert Format'; + String get trackConvertFormat => 'Переконвертировать формат'; @override - String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus'; + String get trackConvertFormatSubtitle => 'Конвертировать в MP3 или Opus'; @override - String get trackConvertTitle => 'Convert Audio'; + String get trackConvertTitle => 'Конвертировать аудио'; @override - String get trackConvertTargetFormat => 'Target Format'; + String get trackConvertTargetFormat => 'Целевой формат'; @override - String get trackConvertBitrate => 'Bitrate'; + String get trackConvertBitrate => 'Битрейт'; @override - String get trackConvertConfirmTitle => 'Confirm Conversion'; + String get trackConvertConfirmTitle => 'Подтвердить конвертацию'; @override String trackConvertConfirmMessage( @@ -2162,177 +2183,229 @@ class AppLocalizationsRu extends AppLocalizations { String targetFormat, String bitrate, ) { - return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.'; + return 'Конвертировать из $sourceFormat в $targetFormat $bitrate?\n\nОригинальный файл будет удален после конвертации.'; } @override - String get trackConvertConverting => 'Converting audio...'; + String get trackConvertConverting => 'Конвертация аудио...'; @override String trackConvertSuccess(String format) { - return 'Converted to $format successfully'; + return 'Успешно конвертировано в $format'; } @override - String get trackConvertFailed => 'Conversion failed'; + String get trackConvertFailed => 'Ошибка конвертации'; @override - String get actionCreate => 'Create'; + String get cueSplitTitle => 'Split CUE Sheet'; @override - String get collectionFoldersTitle => 'My folders'; + String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks'; @override - String get collectionWishlist => 'Wishlist'; + String cueSplitAlbum(String album) { + return 'Album: $album'; + } @override - String get collectionLoved => 'Loved'; + String cueSplitArtist(String artist) { + return 'Artist: $artist'; + } @override - String get collectionPlaylists => 'Playlists'; + String cueSplitTrackCount(int count) { + return '$count tracks'; + } @override - String get collectionPlaylist => 'Playlist'; + String get cueSplitConfirmTitle => 'Split CUE Album'; @override - String get collectionAddToPlaylist => 'Add to playlist'; + String cueSplitConfirmMessage(String album, int count) { + return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.'; + } @override - String get collectionCreatePlaylist => 'Create playlist'; + String cueSplitSplitting(int current, int total) { + return 'Splitting CUE sheet... ($current/$total)'; + } @override - String get collectionNoPlaylistsYet => 'No playlists yet'; + String cueSplitSuccess(int count) { + return 'Split into $count tracks successfully'; + } + + @override + String get cueSplitFailed => 'CUE split failed'; + + @override + String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet'; + + @override + String get cueSplitButton => 'Split into Tracks'; + + @override + String get actionCreate => 'Создать'; + + @override + String get collectionFoldersTitle => 'Мои папки'; + + @override + String get collectionWishlist => 'Список желаемого'; + + @override + String get collectionLoved => 'Любимые'; + + @override + String get collectionPlaylists => 'Плейлисты'; + + @override + String get collectionPlaylist => 'Плейлист'; + + @override + String get collectionAddToPlaylist => 'Добавить в плейлист'; + + @override + String get collectionCreatePlaylist => 'Создать плейлист'; + + @override + String get collectionNoPlaylistsYet => 'Плейлисты отсутствуют'; @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', + other: '$count треков', + many: '$count треков', + few: '$count трека', + one: '$count трек', ); return '$_temp0'; } @override String collectionAddedToPlaylist(String playlistName) { - return 'Added to \"$playlistName\"'; + return 'Добавлено в \"$playlistName\"'; } @override String collectionAlreadyInPlaylist(String playlistName) { - return 'Already in \"$playlistName\"'; + return 'Уже в \"$playlistName\"'; } @override - String get collectionPlaylistCreated => 'Playlist created'; + String get collectionPlaylistCreated => 'Плейлист создан'; @override - String get collectionPlaylistNameHint => 'Playlist name'; + String get collectionPlaylistNameHint => 'Название плейлиста'; @override - String get collectionPlaylistNameRequired => 'Playlist name is required'; + String get collectionPlaylistNameRequired => 'Имя плейлиста обязательно'; @override - String get collectionRenamePlaylist => 'Rename playlist'; + String get collectionRenamePlaylist => 'Переименовать плейлист'; @override - String get collectionDeletePlaylist => 'Delete playlist'; + String get collectionDeletePlaylist => 'Удалить плейлист'; @override String collectionDeletePlaylistMessage(String playlistName) { - return 'Delete \"$playlistName\" and all tracks inside it?'; + return 'Удалить \"$playlistName\" и все треки внутри него?'; } @override - String get collectionPlaylistDeleted => 'Playlist deleted'; + String get collectionPlaylistDeleted => 'Плейлист удалён'; @override - String get collectionPlaylistRenamed => 'Playlist renamed'; + String get collectionPlaylistRenamed => 'Плейлист переименован'; @override - String get collectionWishlistEmptyTitle => 'Wishlist is empty'; + String get collectionWishlistEmptyTitle => 'Список желаний пуст'; @override String get collectionWishlistEmptySubtitle => - 'Tap + on tracks to save what you want to download later'; + 'Нажмите + на треках, чтобы сохранить то, что вы хотите скачать позже'; @override - String get collectionLovedEmptyTitle => 'Loved folder is empty'; + String get collectionLovedEmptyTitle => 'Папка Любимые пуста'; @override String get collectionLovedEmptySubtitle => - 'Tap love on tracks to keep your favorites'; + 'Нажмите \"любовь\" на треках, чтобы сохранить ваши избранные'; @override - String get collectionPlaylistEmptyTitle => 'Playlist is empty'; + String get collectionPlaylistEmptyTitle => 'Плейлист пуст'; @override String get collectionPlaylistEmptySubtitle => - 'Long-press + on any track to add it here'; + 'Удерживайте + на любом треке, чтобы добавить его сюда'; @override - String get collectionRemoveFromPlaylist => 'Remove from playlist'; + String get collectionRemoveFromPlaylist => 'Удалить из плейлиста'; @override - String get collectionRemoveFromFolder => 'Remove from folder'; + String get collectionRemoveFromFolder => 'Убрать из папки'; @override String collectionRemoved(String trackName) { - return '\"$trackName\" removed'; + return '\"$trackName\" удалён'; } @override String collectionAddedToLoved(String trackName) { - return '\"$trackName\" added to Loved'; + return '\"$trackName\" добавлен в Любимые'; } @override String collectionRemovedFromLoved(String trackName) { - return '\"$trackName\" removed from Loved'; + return '\"$trackName\" удалено из Любимых'; } @override String collectionAddedToWishlist(String trackName) { - return '\"$trackName\" added to Wishlist'; + return '\"$trackName\" добавлен в список желаний'; } @override String collectionRemovedFromWishlist(String trackName) { - return '\"$trackName\" removed from Wishlist'; + return '\"$trackName\" удалён из списка желаний'; } @override - String get trackOptionAddToLoved => 'Add to Loved'; + String get trackOptionAddToLoved => 'Добавить в Любимое'; @override - String get trackOptionRemoveFromLoved => 'Remove from Loved'; + String get trackOptionRemoveFromLoved => 'Исключить из Любимых'; @override - String get trackOptionAddToWishlist => 'Add to Wishlist'; + String get trackOptionAddToWishlist => 'Добавить в список желаний'; @override - String get trackOptionRemoveFromWishlist => 'Remove from Wishlist'; + String get trackOptionRemoveFromWishlist => 'Удалить из списка желаний'; @override - String get collectionPlaylistChangeCover => 'Change cover image'; + String get collectionPlaylistChangeCover => 'Изменить обложку'; @override - String get collectionPlaylistRemoveCover => 'Remove cover image'; + String get collectionPlaylistRemoveCover => 'Удалить обложку'; @override String selectionShareCount(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'tracks', - one: 'track', + other: 'треков', + many: 'треков', + few: 'трека', + one: 'трек', ); - return 'Share $count $_temp0'; + return 'Отправить $count $_temp0'; } @override @@ -2343,17 +2416,19 @@ class AppLocalizationsRu extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'tracks', - one: 'track', + other: 'треков', + many: 'треков', + few: 'трека', + one: 'трек', ); - return 'Convert $count $_temp0'; + return 'Конвертировать $count $_temp0'; } @override - String get selectionConvertNoConvertible => 'No convertible tracks selected'; + String get selectionConvertNoConvertible => 'Не выбраны конвертируемые треки'; @override - String get selectionBatchConvertConfirmTitle => 'Batch Convert'; + String get selectionBatchConvertConfirmTitle => 'Пакетная конвертация'; @override String selectionBatchConvertConfirmMessage( @@ -2372,12 +2447,12 @@ class AppLocalizationsRu extends AppLocalizations { @override String selectionBatchConvertProgress(int current, int total) { - return 'Converting $current of $total...'; + return 'Конвертация $current из $total...'; } @override String selectionBatchConvertSuccess(int success, int total, String format) { - return 'Converted $success of $total tracks to $format'; + return 'Конвертировано $success треков $total в $format'; } @override @@ -2387,7 +2462,7 @@ class AppLocalizationsRu extends AppLocalizations { @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 bcc77961..3a88e179 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -361,7 +361,7 @@ class AppLocalizationsTr extends AppLocalizations { @override String get aboutAppDescription => - 'Spotify şarkılarını Tidal, Qobuz ve Amazon Music\'den yüksek kalitede indir.'; + 'Spotify şarkılarını Tidal ve Qobuz\'den yüksek kalitede indir.'; @override String get artistAlbums => 'Albümler'; @@ -693,6 +693,17 @@ class AppLocalizationsTr extends AppLocalizations { @override String get errorNoTracksFound => 'Parça bulunamadı'; + @override + String get errorUrlNotRecognized => 'Link not recognized'; + + @override + String get errorUrlNotRecognizedMessage => + 'This link is not supported. Make sure the URL is correct and a compatible extension is installed.'; + + @override + String get errorUrlFetchFailed => + 'Failed to load content from this link. Please try again.'; + @override String errorMissingExtensionSource(String item) { return '$item yüklenemedi: Eksik eklenti kaynağı'; @@ -766,6 +777,13 @@ class AppLocalizationsTr extends AppLocalizations { @override String get folderOrganizationNone => 'Organizasyon yok'; + @override + String get folderOrganizationByPlaylist => 'By Playlist'; + + @override + String get folderOrganizationByPlaylistSubtitle => + 'Separate folder for each playlist'; + @override String get folderOrganizationByArtist => 'Sanatçıya Göre'; @@ -1821,7 +1839,7 @@ class AppLocalizationsTr extends AppLocalizations { @override String get tutorialWelcomeTip2 => - 'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'; + 'Tidal, Qobuz veya Deezer\'den FLAC kalitesinde ses alın'; @override String get tutorialWelcomeTip3 => @@ -2138,6 +2156,54 @@ class AppLocalizationsTr extends AppLocalizations { @override String get trackConvertFailed => 'Conversion failed'; + @override + String get cueSplitTitle => 'Split CUE Sheet'; + + @override + String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks'; + + @override + String cueSplitAlbum(String album) { + return 'Album: $album'; + } + + @override + String cueSplitArtist(String artist) { + return 'Artist: $artist'; + } + + @override + String cueSplitTrackCount(int count) { + return '$count tracks'; + } + + @override + String get cueSplitConfirmTitle => 'Split CUE Album'; + + @override + String cueSplitConfirmMessage(String album, int count) { + return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.'; + } + + @override + String cueSplitSplitting(int current, int total) { + return 'Splitting CUE sheet... ($current/$total)'; + } + + @override + String cueSplitSuccess(int count) { + return 'Split into $count tracks successfully'; + } + + @override + String get cueSplitFailed => 'CUE split failed'; + + @override + String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet'; + + @override + String get cueSplitButton => 'Split into Tracks'; + @override String get actionCreate => 'Create'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 433e771b..3306bb1c 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -350,6 +350,2412 @@ class AppLocalizationsZh extends AppLocalizations { @override String get aboutSpotiSaver => 'SpotiSaver'; + @override + String get aboutSpotiSaverDesc => + 'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!'; + + @override + String get aboutAppDescription => + 'Download Spotify tracks in lossless quality from Tidal and Qobuz.'; + + @override + String get artistAlbums => 'Albums'; + + @override + String get artistSingles => 'Singles & EPs'; + + @override + String get artistCompilations => 'Compilations'; + + @override + String get artistPopular => 'Popular'; + + @override + String artistMonthlyListeners(String count) { + return '$count monthly listeners'; + } + + @override + String get trackMetadataService => 'Service'; + + @override + String get trackMetadataPlay => 'Play'; + + @override + String get trackMetadataShare => 'Share'; + + @override + String get trackMetadataDelete => 'Delete'; + + @override + String get setupGrantPermission => 'Grant Permission'; + + @override + String get setupSkip => 'Skip for now'; + + @override + String get setupStorageAccessRequired => 'Storage Access Required'; + + @override + String get setupStorageAccessMessageAndroid11 => + 'Android 11+ requires \"All files access\" permission to save files to your chosen download folder.'; + + @override + String get setupOpenSettings => 'Open Settings'; + + @override + String get setupPermissionDeniedMessage => + 'Permission denied. Please grant all permissions to continue.'; + + @override + String setupPermissionRequired(String permissionType) { + return '$permissionType Permission Required'; + } + + @override + String setupPermissionRequiredMessage(String permissionType) { + return '$permissionType permission is required for the best experience. You can change this later in Settings.'; + } + + @override + String get setupUseDefaultFolder => 'Use Default Folder?'; + + @override + String get setupNoFolderSelected => + 'No folder selected. Would you like to use the default Music folder?'; + + @override + String get setupUseDefault => 'Use Default'; + + @override + String get setupDownloadLocationTitle => 'Download Location'; + + @override + String get setupDownloadLocationIosMessage => + 'On iOS, downloads are saved to the app\'s Documents folder. You can access them via the Files app.'; + + @override + String get setupAppDocumentsFolder => 'App Documents Folder'; + + @override + String get setupAppDocumentsFolderSubtitle => + 'Recommended - accessible via Files app'; + + @override + String get setupChooseFromFiles => 'Choose from Files'; + + @override + String get setupChooseFromFilesSubtitle => 'Select iCloud or other location'; + + @override + String get setupIosEmptyFolderWarning => + 'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.'; + + @override + String get setupIcloudNotSupported => + 'iCloud Drive is not supported. Please use the app Documents folder.'; + + @override + String get setupDownloadInFlac => 'Download Spotify tracks in FLAC'; + + @override + String get setupStorageGranted => 'Storage Permission Granted!'; + + @override + String get setupStorageRequired => 'Storage Permission Required'; + + @override + String get setupStorageDescription => + 'SpotiFLAC needs storage permission to save your downloaded music files.'; + + @override + String get setupNotificationGranted => 'Notification Permission Granted!'; + + @override + String get setupNotificationEnable => 'Enable Notifications'; + + @override + String get setupFolderChoose => 'Choose Download Folder'; + + @override + String get setupFolderDescription => + 'Select a folder where your downloaded music will be saved.'; + + @override + String get setupSelectFolder => 'Select Folder'; + + @override + String get setupEnableNotifications => 'Enable Notifications'; + + @override + String get setupNotificationBackgroundDescription => + 'Get notified about download progress and completion. This helps you track downloads when the app is in background.'; + + @override + String get setupSkipForNow => 'Skip for now'; + + @override + String get setupNext => 'Next'; + + @override + String get setupGetStarted => 'Get Started'; + + @override + String get setupAllowAccessToManageFiles => + 'Please enable \"Allow access to manage all files\" in the next screen.'; + + @override + String get dialogCancel => 'Cancel'; + + @override + String get dialogSave => 'Save'; + + @override + String get dialogDelete => 'Delete'; + + @override + String get dialogRetry => 'Retry'; + + @override + String get dialogClear => 'Clear'; + + @override + String get dialogDone => 'Done'; + + @override + String get dialogImport => 'Import'; + + @override + String get dialogDiscard => 'Discard'; + + @override + String get dialogRemove => 'Remove'; + + @override + String get dialogUninstall => 'Uninstall'; + + @override + String get dialogDiscardChanges => 'Discard Changes?'; + + @override + String get dialogUnsavedChanges => + 'You have unsaved changes. Do you want to discard them?'; + + @override + String get dialogClearAll => 'Clear All'; + + @override + String get dialogRemoveExtension => 'Remove Extension'; + + @override + String get dialogRemoveExtensionMessage => + 'Are you sure you want to remove this extension? This cannot be undone.'; + + @override + String get dialogUninstallExtension => 'Uninstall Extension?'; + + @override + String dialogUninstallExtensionMessage(String extensionName) { + return 'Are you sure you want to remove $extensionName?'; + } + + @override + String get dialogClearHistoryTitle => 'Clear History'; + + @override + String get dialogClearHistoryMessage => + 'Are you sure you want to clear all download history? This cannot be undone.'; + + @override + String get dialogDeleteSelectedTitle => 'Delete Selected'; + + @override + String dialogDeleteSelectedMessage(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Delete $count $_temp0 from history?\n\nThis will also delete the files from storage.'; + } + + @override + String get dialogImportPlaylistTitle => 'Import Playlist'; + + @override + String dialogImportPlaylistMessage(int count) { + return 'Found $count tracks in CSV. Add them to download queue?'; + } + + @override + String csvImportTracks(int count) { + return '$count tracks from CSV'; + } + + @override + String snackbarAddedToQueue(String trackName) { + return 'Added \"$trackName\" to queue'; + } + + @override + String snackbarAddedTracksToQueue(int count) { + return 'Added $count tracks to queue'; + } + + @override + String snackbarAlreadyDownloaded(String trackName) { + return '\"$trackName\" already downloaded'; + } + + @override + String snackbarAlreadyInLibrary(String trackName) { + return '\"$trackName\" already exists in your library'; + } + + @override + String get snackbarHistoryCleared => 'History cleared'; + + @override + String get snackbarCredentialsSaved => 'Credentials saved'; + + @override + String get snackbarCredentialsCleared => 'Credentials cleared'; + + @override + String snackbarDeletedTracks(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Deleted $count $_temp0'; + } + + @override + String snackbarCannotOpenFile(String error) { + return 'Cannot open file: $error'; + } + + @override + String get snackbarFillAllFields => 'Please fill all fields'; + + @override + String get snackbarViewQueue => 'View Queue'; + + @override + String snackbarUrlCopied(String platform) { + return '$platform URL copied to clipboard'; + } + + @override + String get snackbarFileNotFound => 'File not found'; + + @override + String get snackbarSelectExtFile => 'Please select a .spotiflac-ext file'; + + @override + String get snackbarProviderPrioritySaved => 'Provider priority saved'; + + @override + String get snackbarMetadataProviderSaved => + 'Metadata provider priority saved'; + + @override + String snackbarExtensionInstalled(String extensionName) { + return '$extensionName installed.'; + } + + @override + String snackbarExtensionUpdated(String extensionName) { + return '$extensionName updated.'; + } + + @override + String get snackbarFailedToInstall => 'Failed to install extension'; + + @override + String get snackbarFailedToUpdate => 'Failed to update extension'; + + @override + String get errorRateLimited => 'Rate Limited'; + + @override + String get errorRateLimitedMessage => + 'Too many requests. Please wait a moment before searching again.'; + + @override + String get errorNoTracksFound => 'No tracks found'; + + @override + String get errorUrlNotRecognized => 'Link not recognized'; + + @override + String get errorUrlNotRecognizedMessage => + 'This link is not supported. Make sure the URL is correct and a compatible extension is installed.'; + + @override + String get errorUrlFetchFailed => + 'Failed to load content from this link. Please try again.'; + + @override + String errorMissingExtensionSource(String item) { + return 'Cannot load $item: missing extension source'; + } + + @override + String get actionPause => 'Pause'; + + @override + String get actionResume => 'Resume'; + + @override + String get actionCancel => 'Cancel'; + + @override + String get actionSelectAll => 'Select All'; + + @override + String get actionDeselect => 'Deselect'; + + @override + String get actionRemoveCredentials => 'Remove Credentials'; + + @override + String get actionSaveCredentials => 'Save Credentials'; + + @override + String selectionSelected(int count) { + return '$count selected'; + } + + @override + String get selectionAllSelected => 'All tracks selected'; + + @override + String get selectionSelectToDelete => 'Select tracks to delete'; + + @override + String progressFetchingMetadata(int current, int total) { + return 'Fetching metadata... $current/$total'; + } + + @override + String get progressReadingCsv => 'Reading CSV...'; + + @override + String get searchSongs => 'Songs'; + + @override + String get searchArtists => 'Artists'; + + @override + String get searchAlbums => 'Albums'; + + @override + String get searchPlaylists => 'Playlists'; + + @override + String get tooltipPlay => 'Play'; + + @override + String get filenameFormat => 'Filename Format'; + + @override + String get filenameShowAdvancedTags => 'Show advanced tags'; + + @override + String get filenameShowAdvancedTagsDescription => + 'Enable formatted tags for track padding and date patterns'; + + @override + String get folderOrganizationNone => 'No organization'; + + @override + String get folderOrganizationByPlaylist => 'By Playlist'; + + @override + String get folderOrganizationByPlaylistSubtitle => + 'Separate folder for each playlist'; + + @override + String get folderOrganizationByArtist => 'By Artist'; + + @override + String get folderOrganizationByAlbum => 'By Album'; + + @override + String get folderOrganizationByArtistAlbum => 'Artist/Album'; + + @override + String get folderOrganizationDescription => + 'Organize downloaded files into folders'; + + @override + String get folderOrganizationNoneSubtitle => 'All files in download folder'; + + @override + String get folderOrganizationByArtistSubtitle => + 'Separate folder for each artist'; + + @override + String get folderOrganizationByAlbumSubtitle => + 'Separate folder for each album'; + + @override + String get folderOrganizationByArtistAlbumSubtitle => + 'Nested folders for artist and album'; + + @override + String get updateAvailable => 'Update Available'; + + @override + String get updateLater => 'Later'; + + @override + String get updateStartingDownload => 'Starting download...'; + + @override + String get updateDownloadFailed => 'Download failed'; + + @override + String get updateFailedMessage => 'Failed to download update'; + + @override + String get updateNewVersionReady => 'A new version is ready'; + + @override + String get updateCurrent => 'Current'; + + @override + String get updateNew => 'New'; + + @override + String get updateDownloading => 'Downloading...'; + + @override + String get updateWhatsNew => 'What\'s New'; + + @override + String get updateDownloadInstall => 'Download & Install'; + + @override + String get updateDontRemind => 'Don\'t remind'; + + @override + String get providerPriorityTitle => 'Provider Priority'; + + @override + String get providerPriorityDescription => + 'Drag to reorder download providers. The app will try providers from top to bottom when downloading tracks.'; + + @override + String get providerPriorityInfo => + 'If a track is not available on the first provider, the app will automatically try the next one.'; + + @override + String get providerBuiltIn => 'Built-in'; + + @override + String get providerExtension => 'Extension'; + + @override + String get metadataProviderPriorityTitle => 'Metadata Priority'; + + @override + String get metadataProviderPriorityDescription => + 'Drag to reorder metadata providers. The app will try providers from top to bottom when searching for tracks and fetching metadata.'; + + @override + String get metadataProviderPriorityInfo => + 'Deezer has no rate limits and is recommended as primary. Spotify may rate limit after many requests.'; + + @override + String get metadataNoRateLimits => 'No rate limits'; + + @override + String get metadataMayRateLimit => 'May rate limit'; + + @override + String get logTitle => 'Logs'; + + @override + String get logCopied => 'Logs copied to clipboard'; + + @override + String get logSearchHint => 'Search logs...'; + + @override + String get logFilterLevel => 'Level'; + + @override + String get logFilterSection => 'Filter'; + + @override + String get logShareLogs => 'Share logs'; + + @override + String get logClearLogs => 'Clear logs'; + + @override + String get logClearLogsTitle => 'Clear Logs'; + + @override + String get logClearLogsMessage => 'Are you sure you want to clear all logs?'; + + @override + String get logFilterBySeverity => 'Filter logs by severity'; + + @override + String get logNoLogsYet => 'No logs yet'; + + @override + String get logNoLogsYetSubtitle => 'Logs will appear here as you use the app'; + + @override + String logEntriesFiltered(int count) { + return 'Entries ($count filtered)'; + } + + @override + String logEntries(int count) { + return 'Entries ($count)'; + } + + @override + String get credentialsTitle => 'Spotify Credentials'; + + @override + String get credentialsDescription => + 'Enter your Client ID and Secret to use your own Spotify application quota.'; + + @override + String get credentialsClientId => 'Client ID'; + + @override + String get credentialsClientIdHint => 'Paste Client ID'; + + @override + String get credentialsClientSecret => 'Client Secret'; + + @override + String get credentialsClientSecretHint => 'Paste Client Secret'; + + @override + String get channelStable => 'Stable'; + + @override + String get channelPreview => 'Preview'; + + @override + String get sectionSearchSource => 'Search Source'; + + @override + String get sectionDownload => 'Download'; + + @override + String get sectionPerformance => 'Performance'; + + @override + String get sectionApp => 'App'; + + @override + String get sectionData => 'Data'; + + @override + String get sectionDebug => 'Debug'; + + @override + String get sectionService => 'Service'; + + @override + String get sectionAudioQuality => 'Audio Quality'; + + @override + String get sectionFileSettings => 'File Settings'; + + @override + String get sectionLyrics => 'Lyrics'; + + @override + String get lyricsMode => 'Lyrics Mode'; + + @override + String get lyricsModeDescription => + 'Choose how lyrics are saved with your downloads'; + + @override + String get lyricsModeEmbed => 'Embed in file'; + + @override + String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata'; + + @override + String get lyricsModeExternal => 'External .lrc file'; + + @override + String get lyricsModeExternalSubtitle => + 'Separate .lrc file for players like Samsung Music'; + + @override + String get lyricsModeBoth => 'Both'; + + @override + String get lyricsModeBothSubtitle => 'Embed and save .lrc file'; + + @override + String get sectionColor => 'Color'; + + @override + String get sectionTheme => 'Theme'; + + @override + String get sectionLayout => 'Layout'; + + @override + String get sectionLanguage => 'Language'; + + @override + String get appearanceLanguage => 'App Language'; + + @override + String get settingsAppearanceSubtitle => 'Theme, colors, display'; + + @override + String get settingsDownloadSubtitle => 'Service, quality, filename format'; + + @override + String get settingsOptionsSubtitle => 'Fallback, lyrics, cover art, updates'; + + @override + String get settingsExtensionsSubtitle => 'Manage download providers'; + + @override + String get settingsLogsSubtitle => 'View app logs for debugging'; + + @override + String get loadingSharedLink => 'Loading shared link...'; + + @override + String get pressBackAgainToExit => 'Press back again to exit'; + + @override + String downloadAllCount(int count) { + return 'Download All ($count)'; + } + + @override + String tracksCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String get trackCopyFilePath => 'Copy file path'; + + @override + String get trackRemoveFromDevice => 'Remove from device'; + + @override + String get trackLoadLyrics => 'Load Lyrics'; + + @override + String get trackMetadata => 'Metadata'; + + @override + String get trackFileInfo => 'File Info'; + + @override + String get trackLyrics => 'Lyrics'; + + @override + String get trackFileNotFound => 'File not found'; + + @override + String get trackOpenInDeezer => 'Open in Deezer'; + + @override + String get trackOpenInSpotify => 'Open in Spotify'; + + @override + String get trackTrackName => 'Track name'; + + @override + String get trackArtist => 'Artist'; + + @override + String get trackAlbumArtist => 'Album artist'; + + @override + String get trackAlbum => 'Album'; + + @override + String get trackTrackNumber => 'Track number'; + + @override + String get trackDiscNumber => 'Disc number'; + + @override + String get trackDuration => 'Duration'; + + @override + String get trackAudioQuality => 'Audio quality'; + + @override + String get trackReleaseDate => 'Release date'; + + @override + String get trackGenre => 'Genre'; + + @override + String get trackLabel => 'Label'; + + @override + String get trackCopyright => 'Copyright'; + + @override + String get trackDownloaded => 'Downloaded'; + + @override + String get trackCopyLyrics => 'Copy lyrics'; + + @override + String get trackLyricsNotAvailable => 'Lyrics not available for this track'; + + @override + String get trackLyricsTimeout => 'Request timed out. Try again later.'; + + @override + String get trackLyricsLoadFailed => 'Failed to load lyrics'; + + @override + String get trackEmbedLyrics => 'Embed Lyrics'; + + @override + String get trackLyricsEmbedded => 'Lyrics embedded successfully'; + + @override + String get trackInstrumental => 'Instrumental track'; + + @override + String get trackCopiedToClipboard => 'Copied to clipboard'; + + @override + String get trackDeleteConfirmTitle => 'Remove from device?'; + + @override + String get trackDeleteConfirmMessage => + 'This will permanently delete the downloaded file and remove it from your history.'; + + @override + String get dateToday => 'Today'; + + @override + String get dateYesterday => 'Yesterday'; + + @override + String dateDaysAgo(int count) { + return '$count days ago'; + } + + @override + String dateWeeksAgo(int count) { + return '$count weeks ago'; + } + + @override + String dateMonthsAgo(int count) { + return '$count months ago'; + } + + @override + String get storeFilterAll => 'All'; + + @override + String get storeFilterMetadata => 'Metadata'; + + @override + String get storeFilterDownload => 'Download'; + + @override + String get storeFilterUtility => 'Utility'; + + @override + String get storeFilterLyrics => 'Lyrics'; + + @override + String get storeFilterIntegration => 'Integration'; + + @override + String get storeClearFilters => 'Clear filters'; + + @override + String get extensionDefaultProvider => 'Default (Deezer/Spotify)'; + + @override + String get extensionDefaultProviderSubtitle => 'Use built-in search'; + + @override + String get extensionAuthor => 'Author'; + + @override + String get extensionId => 'ID'; + + @override + String get extensionError => 'Error'; + + @override + String get extensionCapabilities => 'Capabilities'; + + @override + String get extensionMetadataProvider => 'Metadata Provider'; + + @override + String get extensionDownloadProvider => 'Download Provider'; + + @override + String get extensionLyricsProvider => 'Lyrics Provider'; + + @override + String get extensionUrlHandler => 'URL Handler'; + + @override + String get extensionQualityOptions => 'Quality Options'; + + @override + String get extensionPostProcessingHooks => 'Post-Processing Hooks'; + + @override + String get extensionPermissions => 'Permissions'; + + @override + String get extensionSettings => 'Settings'; + + @override + String get extensionRemoveButton => 'Remove Extension'; + + @override + String get extensionUpdated => 'Updated'; + + @override + String get extensionMinAppVersion => 'Min App Version'; + + @override + String get extensionCustomTrackMatching => 'Custom Track Matching'; + + @override + String get extensionPostProcessing => 'Post-Processing'; + + @override + String extensionHooksAvailable(int count) { + return '$count hook(s) available'; + } + + @override + String extensionPatternsCount(int count) { + return '$count pattern(s)'; + } + + @override + String extensionStrategy(String strategy) { + return 'Strategy: $strategy'; + } + + @override + String get extensionsProviderPrioritySection => 'Provider Priority'; + + @override + String get extensionsInstalledSection => 'Installed Extensions'; + + @override + String get extensionsNoExtensions => 'No extensions installed'; + + @override + String get extensionsNoExtensionsSubtitle => + 'Install .spotiflac-ext files to add new providers'; + + @override + String get extensionsInstallButton => 'Install Extension'; + + @override + String get extensionsInfoTip => + 'Extensions can add new metadata and download providers. Only install extensions from trusted sources.'; + + @override + String get extensionsInstalledSuccess => 'Extension installed successfully'; + + @override + String get extensionsDownloadPriority => 'Download Priority'; + + @override + String get extensionsDownloadPrioritySubtitle => 'Set download service order'; + + @override + String get extensionsNoDownloadProvider => + 'No extensions with download provider'; + + @override + String get extensionsMetadataPriority => 'Metadata Priority'; + + @override + String get extensionsMetadataPrioritySubtitle => + 'Set search & metadata source order'; + + @override + String get extensionsNoMetadataProvider => + 'No extensions with metadata provider'; + + @override + String get extensionsSearchProvider => 'Search Provider'; + + @override + String get extensionsNoCustomSearch => 'No extensions with custom search'; + + @override + String get extensionsSearchProviderDescription => + 'Choose which service to use for searching tracks'; + + @override + String get extensionsCustomSearch => 'Custom search'; + + @override + String get extensionsErrorLoading => 'Error loading extension'; + + @override + String get qualityFlacLossless => 'FLAC Lossless'; + + @override + String get qualityFlacLosslessSubtitle => '16-bit / 44.1kHz'; + + @override + String get qualityHiResFlac => 'Hi-Res FLAC'; + + @override + String get qualityHiResFlacSubtitle => '24-bit / up to 96kHz'; + + @override + String get qualityHiResFlacMax => 'Hi-Res FLAC Max'; + + @override + String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; + + @override + String get qualityNote => + 'Actual quality depends on track availability from the service'; + + @override + 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'; + + @override + String get downloadDirectory => 'Download Directory'; + + @override + String get downloadSeparateSinglesFolder => 'Separate Singles Folder'; + + @override + String get downloadAlbumFolderStructure => 'Album Folder Structure'; + + @override + String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders'; + + @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 downloadSelectQuality => 'Select Quality'; + + @override + String get downloadFrom => 'Download From'; + + @override + String get appearanceAmoledDark => 'AMOLED Dark'; + + @override + String get appearanceAmoledDarkSubtitle => 'Pure black background'; + + @override + String get queueClearAll => 'Clear All'; + + @override + String get queueClearAllMessage => + 'Are you sure you want to clear all downloads?'; + + @override + String get settingsAutoExportFailed => 'Auto-export failed downloads'; + + @override + String get settingsAutoExportFailedSubtitle => + 'Save failed downloads to TXT file automatically'; + + @override + String get settingsDownloadNetwork => 'Download Network'; + + @override + String get settingsDownloadNetworkAny => 'WiFi + Mobile Data'; + + @override + String get settingsDownloadNetworkWifiOnly => 'WiFi Only'; + + @override + String get settingsDownloadNetworkSubtitle => + 'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.'; + + @override + String get albumFolderArtistAlbum => 'Artist / Album'; + + @override + String get albumFolderArtistAlbumSubtitle => 'Albums/Artist Name/Album Name/'; + + @override + String get albumFolderArtistYearAlbum => 'Artist / [Year] Album'; + + @override + String get albumFolderArtistYearAlbumSubtitle => + 'Albums/Artist Name/[2005] Album Name/'; + + @override + String get albumFolderAlbumOnly => 'Album Only'; + + @override + String get albumFolderAlbumOnlySubtitle => 'Albums/Album Name/'; + + @override + String get albumFolderYearAlbum => '[Year] Album'; + + @override + String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/'; + + @override + String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles'; + + @override + String get albumFolderArtistAlbumSinglesSubtitle => + 'Artist/Album/ and Artist/Singles/'; + + @override + String get downloadedAlbumDeleteSelected => 'Delete Selected'; + + @override + String downloadedAlbumDeleteMessage(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Delete $count $_temp0 from this album?\n\nThis will also delete the files from storage.'; + } + + @override + String downloadedAlbumSelectedCount(int count) { + return '$count selected'; + } + + @override + String get downloadedAlbumAllSelected => 'All tracks selected'; + + @override + String get downloadedAlbumTapToSelect => 'Tap tracks to select'; + + @override + String downloadedAlbumDeleteCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Delete $count $_temp0'; + } + + @override + String get downloadedAlbumSelectToDelete => 'Select tracks to delete'; + + @override + String downloadedAlbumDiscHeader(int discNumber) { + return 'Disc $discNumber'; + } + + @override + String get recentTypeArtist => 'Artist'; + + @override + String get recentTypeAlbum => 'Album'; + + @override + String get recentTypeSong => 'Song'; + + @override + String get recentTypePlaylist => 'Playlist'; + + @override + String get recentEmpty => 'No recent items yet'; + + @override + String get recentShowAllDownloads => 'Show All Downloads'; + + @override + String recentPlaylistInfo(String name) { + return 'Playlist: $name'; + } + + @override + String get discographyDownload => 'Download Discography'; + + @override + String get discographyDownloadAll => 'Download All'; + + @override + String discographyDownloadAllSubtitle(int count, int albumCount) { + return '$count tracks from $albumCount releases'; + } + + @override + String get discographyAlbumsOnly => 'Albums Only'; + + @override + String discographyAlbumsOnlySubtitle(int count, int albumCount) { + return '$count tracks from $albumCount albums'; + } + + @override + String get discographySinglesOnly => 'Singles & EPs Only'; + + @override + String discographySinglesOnlySubtitle(int count, int albumCount) { + return '$count tracks from $albumCount singles'; + } + + @override + String get discographySelectAlbums => 'Select Albums...'; + + @override + String get discographySelectAlbumsSubtitle => + 'Choose specific albums or singles'; + + @override + String get discographyFetchingTracks => 'Fetching tracks...'; + + @override + String discographyFetchingAlbum(int current, int total) { + return 'Fetching $current of $total...'; + } + + @override + String discographySelectedCount(int count) { + return '$count selected'; + } + + @override + String get discographyDownloadSelected => 'Download Selected'; + + @override + String discographyAddedToQueue(int count) { + return 'Added $count tracks to queue'; + } + + @override + String discographySkippedDownloaded(int added, int skipped) { + return '$added added, $skipped already downloaded'; + } + + @override + String get discographyNoAlbums => 'No albums available'; + + @override + String get discographyFailedToFetch => 'Failed to fetch some albums'; + + @override + String get sectionStorageAccess => 'Storage Access'; + + @override + String get allFilesAccess => 'All Files Access'; + + @override + String get allFilesAccessEnabledSubtitle => 'Can write to any folder'; + + @override + String get allFilesAccessDisabledSubtitle => 'Limited to media folders only'; + + @override + String get allFilesAccessDescription => + 'Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.'; + + @override + String get allFilesAccessDeniedMessage => + 'Permission was denied. Please enable \'All files access\' manually in system settings.'; + + @override + String get allFilesAccessDisabledMessage => + 'All Files Access disabled. The app will use limited storage access.'; + + @override + String get settingsLocalLibrary => 'Local Library'; + + @override + String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates'; + + @override + String get settingsCache => 'Storage & Cache'; + + @override + String get settingsCacheSubtitle => 'View size and clear cached data'; + + @override + String get libraryTitle => 'Local Library'; + + @override + String get libraryScanSettings => 'Scan Settings'; + + @override + String get libraryEnableLocalLibrary => 'Enable Local Library'; + + @override + String get libraryEnableLocalLibrarySubtitle => + 'Scan and track your existing music'; + + @override + String get libraryFolder => 'Library Folder'; + + @override + String get libraryFolderHint => 'Tap to select folder'; + + @override + String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator'; + + @override + String get libraryShowDuplicateIndicatorSubtitle => + 'Show when searching for existing tracks'; + + @override + String get libraryActions => 'Actions'; + + @override + String get libraryScan => 'Scan Library'; + + @override + String get libraryScanSubtitle => 'Scan for audio files'; + + @override + String get libraryScanSelectFolderFirst => 'Select a folder first'; + + @override + String get libraryCleanupMissingFiles => 'Cleanup Missing Files'; + + @override + String get libraryCleanupMissingFilesSubtitle => + 'Remove entries for files that no longer exist'; + + @override + String get libraryClear => 'Clear Library'; + + @override + String get libraryClearSubtitle => 'Remove all scanned tracks'; + + @override + String get libraryClearConfirmTitle => 'Clear Library'; + + @override + String get libraryClearConfirmMessage => + 'This will remove all scanned tracks from your library. Your actual music files will not be deleted.'; + + @override + String get libraryAbout => 'About Local Library'; + + @override + 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 libraryTracksUnit(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return '$_temp0'; + } + + @override + String libraryLastScanned(String time) { + return 'Last scanned: $time'; + } + + @override + String get libraryLastScannedNever => 'Never'; + + @override + String get libraryScanning => 'Scanning...'; + + @override + String libraryScanProgress(String progress, int total) { + return '$progress% of $total files'; + } + + @override + String get libraryInLibrary => 'In Library'; + + @override + String libraryRemovedMissingFiles(int count) { + return 'Removed $count missing files from library'; + } + + @override + String get libraryCleared => 'Library cleared'; + + @override + String get libraryStorageAccessRequired => 'Storage Access Required'; + + @override + String get libraryStorageAccessMessage => + 'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.'; + + @override + String get libraryFolderNotExist => 'Selected folder does not exist'; + + @override + String get librarySourceDownloaded => 'Downloaded'; + + @override + String get librarySourceLocal => 'Local'; + + @override + String get libraryFilterAll => 'All'; + + @override + String get libraryFilterDownloaded => 'Downloaded'; + + @override + String get libraryFilterLocal => 'Local'; + + @override + String get libraryFilterTitle => 'Filters'; + + @override + String get libraryFilterReset => 'Reset'; + + @override + String get libraryFilterApply => 'Apply'; + + @override + String get libraryFilterSource => 'Source'; + + @override + String get libraryFilterQuality => 'Quality'; + + @override + String get libraryFilterQualityHiRes => 'Hi-Res (24bit)'; + + @override + String get libraryFilterQualityCD => 'CD (16bit)'; + + @override + String get libraryFilterQualityLossy => 'Lossy'; + + @override + String get libraryFilterFormat => 'Format'; + + @override + String get libraryFilterSort => 'Sort'; + + @override + String get libraryFilterSortLatest => 'Latest'; + + @override + String get libraryFilterSortOldest => 'Oldest'; + + @override + String get timeJustNow => 'Just now'; + + @override + String timeMinutesAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count minutes ago', + one: '1 minute ago', + ); + return '$_temp0'; + } + + @override + String timeHoursAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count hours ago', + one: '1 hour ago', + ); + return '$_temp0'; + } + + @override + String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!'; + + @override + String get tutorialWelcomeDesc => + 'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.'; + + @override + String get tutorialWelcomeTip1 => + 'Download music from Spotify, Deezer, or paste any supported URL'; + + @override + String get tutorialWelcomeTip2 => + 'Get FLAC quality audio from Tidal, Qobuz, or Deezer'; + + @override + String get tutorialWelcomeTip3 => + 'Automatic metadata, cover art, and lyrics embedding'; + + @override + String get tutorialSearchTitle => 'Finding Music'; + + @override + String get tutorialSearchDesc => + 'There are two easy ways to find music you want to download.'; + + @override + String get tutorialDownloadTitle => 'Downloading Music'; + + @override + String get tutorialDownloadDesc => + 'Downloading music is simple and fast. Here\'s how it works.'; + + @override + String get tutorialLibraryTitle => 'Your Library'; + + @override + String get tutorialLibraryDesc => + 'All your downloaded music is organized in the Library tab.'; + + @override + String get tutorialLibraryTip1 => + 'View download progress and queue in the Library tab'; + + @override + String get tutorialLibraryTip2 => + 'Tap any track to play it with your music player'; + + @override + String get tutorialLibraryTip3 => + 'Switch between list and grid view for better browsing'; + + @override + String get tutorialExtensionsTitle => 'Extensions'; + + @override + String get tutorialExtensionsDesc => + 'Extend the app\'s capabilities with community extensions.'; + + @override + String get tutorialExtensionsTip1 => + 'Browse the Store tab to discover useful extensions'; + + @override + String get tutorialExtensionsTip2 => + 'Add new download providers or search sources'; + + @override + String get tutorialExtensionsTip3 => + 'Get lyrics, enhanced metadata, and more features'; + + @override + String get tutorialSettingsTitle => 'Customize Your Experience'; + + @override + String get tutorialSettingsDesc => + 'Personalize the app in Settings to match your preferences.'; + + @override + String get tutorialSettingsTip1 => + 'Change download location and folder organization'; + + @override + String get tutorialSettingsTip2 => + 'Set default audio quality and format preferences'; + + @override + String get tutorialSettingsTip3 => 'Customize app theme and appearance'; + + @override + String get tutorialReadyMessage => + 'You\'re all set! Start downloading your favorite music now.'; + + @override + String get libraryForceFullScan => 'Force Full Scan'; + + @override + String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache'; + + @override + String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads'; + + @override + String get cleanupOrphanedDownloadsSubtitle => + 'Remove history entries for files that no longer exist'; + + @override + String cleanupOrphanedDownloadsResult(int count) { + return 'Removed $count orphaned entries from history'; + } + + @override + String get cleanupOrphanedDownloadsNone => 'No orphaned entries found'; + + @override + String get cacheTitle => 'Storage & Cache'; + + @override + String get cacheSummaryTitle => 'Cache overview'; + + @override + String get cacheSummarySubtitle => + 'Clearing cache will not remove downloaded music files.'; + + @override + String cacheEstimatedTotal(String size) { + return 'Estimated cache usage: $size'; + } + + @override + String get cacheSectionStorage => 'Cached Data'; + + @override + String get cacheSectionMaintenance => 'Maintenance'; + + @override + String get cacheAppDirectory => 'App cache directory'; + + @override + String get cacheAppDirectoryDesc => + 'HTTP responses, WebView data, and other temporary app data.'; + + @override + String get cacheTempDirectory => 'Temporary directory'; + + @override + String get cacheTempDirectoryDesc => + 'Temporary files from downloads and audio conversion.'; + + @override + String get cacheCoverImage => 'Cover image cache'; + + @override + String get cacheCoverImageDesc => + 'Downloaded album and track cover art. Will re-download when viewed.'; + + @override + String get cacheLibraryCover => 'Library cover cache'; + + @override + String get cacheLibraryCoverDesc => + 'Cover art extracted from local music files. Will re-extract on next scan.'; + + @override + String get cacheExploreFeed => 'Explore feed cache'; + + @override + String get cacheExploreFeedDesc => + 'Explore tab content (new releases, trending). Will refresh on next visit.'; + + @override + String get cacheTrackLookup => 'Track lookup cache'; + + @override + String get cacheTrackLookupDesc => + 'Spotify/Deezer track ID lookups. Clearing may slow next few searches.'; + + @override + String get cacheCleanupUnusedDesc => + 'Remove orphaned download history and library entries for missing files.'; + + @override + String get cacheNoData => 'No cached data'; + + @override + String cacheSizeWithFiles(String size, int count) { + return '$size in $count files'; + } + + @override + String cacheSizeOnly(String size) { + return '$size'; + } + + @override + String cacheEntries(int count) { + return '$count entries'; + } + + @override + String cacheClearSuccess(String target) { + return 'Cleared: $target'; + } + + @override + String get cacheClearConfirmTitle => 'Clear cache?'; + + @override + String cacheClearConfirmMessage(String target) { + return 'This will clear cached data for $target. Downloaded music files will not be deleted.'; + } + + @override + String get cacheClearAllConfirmTitle => 'Clear all cache?'; + + @override + String get cacheClearAllConfirmMessage => + 'This will clear all cache categories on this page. Downloaded music files will not be deleted.'; + + @override + String get cacheClearAll => 'Clear all cache'; + + @override + String get cacheCleanupUnused => 'Cleanup unused data'; + + @override + String get cacheCleanupUnusedSubtitle => + 'Remove orphaned download history and missing library entries'; + + @override + String cacheCleanupResult(int downloadCount, int libraryCount) { + return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries'; + } + + @override + String get cacheRefreshStats => 'Refresh stats'; + + @override + String get trackSaveCoverArt => 'Save Cover Art'; + + @override + String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file'; + + @override + String get trackSaveLyrics => 'Save Lyrics (.lrc)'; + + @override + String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file'; + + @override + String get trackSaveLyricsProgress => 'Saving lyrics...'; + + @override + String get trackReEnrich => 'Re-enrich'; + + @override + String get trackReEnrichOnlineSubtitle => + 'Search metadata online and embed into file'; + + @override + String get trackEditMetadata => 'Edit Metadata'; + + @override + String trackCoverSaved(String fileName) { + return 'Cover art saved to $fileName'; + } + + @override + String get trackCoverNoSource => 'No cover art source available'; + + @override + String trackLyricsSaved(String fileName) { + return 'Lyrics saved to $fileName'; + } + + @override + String get trackReEnrichProgress => 'Re-enriching metadata...'; + + @override + String get trackReEnrichSearching => 'Searching metadata online...'; + + @override + String get trackReEnrichSuccess => 'Metadata re-enriched successfully'; + + @override + String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed'; + + @override + String trackSaveFailed(String error) { + return 'Failed: $error'; + } + + @override + String get trackConvertFormat => 'Convert Format'; + + @override + String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus'; + + @override + String get trackConvertTitle => 'Convert Audio'; + + @override + String get trackConvertTargetFormat => 'Target Format'; + + @override + String get trackConvertBitrate => 'Bitrate'; + + @override + String get trackConvertConfirmTitle => 'Confirm Conversion'; + + @override + String trackConvertConfirmMessage( + String sourceFormat, + String targetFormat, + String bitrate, + ) { + return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.'; + } + + @override + String get trackConvertConverting => 'Converting audio...'; + + @override + String trackConvertSuccess(String format) { + return 'Converted to $format successfully'; + } + + @override + String get trackConvertFailed => 'Conversion failed'; + + @override + String get cueSplitTitle => 'Split CUE Sheet'; + + @override + String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks'; + + @override + String cueSplitAlbum(String album) { + return 'Album: $album'; + } + + @override + String cueSplitArtist(String artist) { + return 'Artist: $artist'; + } + + @override + String cueSplitTrackCount(int count) { + return '$count tracks'; + } + + @override + String get cueSplitConfirmTitle => 'Split CUE Album'; + + @override + String cueSplitConfirmMessage(String album, int count) { + return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.'; + } + + @override + String cueSplitSplitting(int current, int total) { + return 'Splitting CUE sheet... ($current/$total)'; + } + + @override + String cueSplitSuccess(int count) { + return 'Split into $count tracks successfully'; + } + + @override + String get cueSplitFailed => 'CUE split failed'; + + @override + String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet'; + + @override + String get cueSplitButton => 'Split into Tracks'; + + @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`). +class AppLocalizationsZhCn extends AppLocalizationsZh { + AppLocalizationsZhCn() : super('zh_CN'); + + @override + String get appName => 'SpotiFLAC'; + + @override + String get navHome => 'Home'; + + @override + String get navLibrary => 'Library'; + + @override + String get navSettings => 'Settings'; + + @override + String get navStore => 'Store'; + + @override + String get homeTitle => 'Home'; + + @override + String get homeSubtitle => 'Paste a Spotify link or search by name'; + + @override + String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs'; + + @override + String get homeRecent => 'Recent'; + + @override + String get historyFilterAll => 'All'; + + @override + String get historyFilterAlbums => 'Albums'; + + @override + String get historyFilterSingles => 'Singles'; + + @override + String get historySearchHint => 'Search history...'; + + @override + String get settingsTitle => 'Settings'; + + @override + String get settingsDownload => 'Download'; + + @override + String get settingsAppearance => 'Appearance'; + + @override + String get settingsOptions => 'Options'; + + @override + String get settingsExtensions => 'Extensions'; + + @override + String get settingsAbout => 'About'; + + @override + String get downloadTitle => 'Download'; + + @override + String get downloadAskQualitySubtitle => + 'Show quality picker for each download'; + + @override + String get downloadFilenameFormat => 'Filename Format'; + + @override + String get downloadFolderOrganization => 'Folder Organization'; + + @override + String get appearanceTitle => 'Appearance'; + + @override + String get appearanceThemeSystem => 'System'; + + @override + String get appearanceThemeLight => 'Light'; + + @override + String get appearanceThemeDark => 'Dark'; + + @override + String get appearanceDynamicColor => 'Dynamic Color'; + + @override + String get appearanceDynamicColorSubtitle => 'Use colors from your wallpaper'; + + @override + String get appearanceHistoryView => 'History View'; + + @override + String get appearanceHistoryViewList => 'List'; + + @override + String get appearanceHistoryViewGrid => 'Grid'; + + @override + String get optionsTitle => 'Options'; + + @override + String get optionsPrimaryProvider => 'Primary Provider'; + + @override + String get optionsPrimaryProviderSubtitle => + 'Service used when searching by track name.'; + + @override + String optionsUsingExtension(String extensionName) { + return 'Using extension: $extensionName'; + } + + @override + String get optionsSwitchBack => + 'Tap Deezer or Spotify to switch back from extension'; + + @override + String get optionsAutoFallback => 'Auto Fallback'; + + @override + String get optionsAutoFallbackSubtitle => + 'Try other services if download fails'; + + @override + String get optionsUseExtensionProviders => 'Use Extension Providers'; + + @override + String get optionsUseExtensionProvidersOn => 'Extensions will be tried first'; + + @override + String get optionsUseExtensionProvidersOff => 'Using built-in providers only'; + + @override + String get optionsEmbedLyrics => 'Embed Lyrics'; + + @override + String get optionsEmbedLyricsSubtitle => + 'Embed synced lyrics into FLAC files'; + + @override + String get optionsMaxQualityCover => 'Max Quality Cover'; + + @override + String get optionsMaxQualityCoverSubtitle => + 'Download highest resolution cover art'; + + @override + String get optionsConcurrentDownloads => 'Concurrent Downloads'; + + @override + String get optionsConcurrentSequential => 'Sequential (1 at a time)'; + + @override + String optionsConcurrentParallel(int count) { + return '$count parallel downloads'; + } + + @override + String get optionsConcurrentWarning => + 'Parallel downloads may trigger rate limiting'; + + @override + String get optionsExtensionStore => 'Extension Store'; + + @override + String get optionsExtensionStoreSubtitle => 'Show Store tab in navigation'; + + @override + String get optionsCheckUpdates => 'Check for Updates'; + + @override + String get optionsCheckUpdatesSubtitle => + 'Notify when new version is available'; + + @override + String get optionsUpdateChannel => 'Update Channel'; + + @override + String get optionsUpdateChannelStable => 'Stable releases only'; + + @override + String get optionsUpdateChannelPreview => 'Get preview releases'; + + @override + String get optionsUpdateChannelWarning => + 'Preview may contain bugs or incomplete features'; + + @override + String get optionsClearHistory => 'Clear Download History'; + + @override + String get optionsClearHistorySubtitle => + 'Remove all downloaded tracks from history'; + + @override + String get optionsDetailedLogging => 'Detailed Logging'; + + @override + String get optionsDetailedLoggingOn => 'Detailed logs are being recorded'; + + @override + String get optionsDetailedLoggingOff => 'Enable for bug reports'; + + @override + String get optionsSpotifyCredentials => 'Spotify Credentials'; + + @override + String optionsSpotifyCredentialsConfigured(String clientId) { + return 'Client ID: $clientId...'; + } + + @override + String get optionsSpotifyCredentialsRequired => 'Required - tap to configure'; + + @override + String get optionsSpotifyWarning => + 'Spotify requires your own API credentials. Get them free from developer.spotify.com'; + + @override + String get optionsSpotifyDeprecationWarning => + 'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.'; + + @override + String get extensionsTitle => 'Extensions'; + + @override + String get extensionsDisabled => 'Disabled'; + + @override + String extensionsVersion(String version) { + return 'Version $version'; + } + + @override + String extensionsAuthor(String author) { + return 'by $author'; + } + + @override + String get extensionsUninstall => 'Uninstall'; + + @override + String get storeTitle => 'Extension Store'; + + @override + String get storeSearch => 'Search extensions...'; + + @override + String get storeInstall => 'Install'; + + @override + String get storeInstalled => 'Installed'; + + @override + String get storeUpdate => 'Update'; + + @override + String get aboutTitle => 'About'; + + @override + String get aboutContributors => 'Contributors'; + + @override + String get aboutMobileDeveloper => 'Mobile version developer'; + + @override + String get aboutOriginalCreator => 'Creator of the original SpotiFLAC'; + + @override + String get aboutLogoArtist => + 'The talented artist who created our beautiful app logo!'; + + @override + String get aboutTranslators => 'Translators'; + + @override + String get aboutSpecialThanks => 'Special Thanks'; + + @override + String get aboutLinks => 'Links'; + + @override + String get aboutMobileSource => 'Mobile source code'; + + @override + String get aboutPCSource => 'PC source code'; + + @override + String get aboutReportIssue => 'Report an issue'; + + @override + String get aboutReportIssueSubtitle => 'Report any problems you encounter'; + + @override + String get aboutFeatureRequest => 'Feature request'; + + @override + String get aboutFeatureRequestSubtitle => 'Suggest new features for the app'; + + @override + String get aboutTelegramChannel => 'Telegram Channel'; + + @override + String get aboutTelegramChannelSubtitle => 'Announcements and updates'; + + @override + String get aboutTelegramChat => 'Telegram Community'; + + @override + String get aboutTelegramChatSubtitle => 'Chat with other users'; + + @override + String get aboutSocial => 'Social'; + + @override + String get aboutApp => 'App'; + + @override + String get aboutVersion => 'Version'; + + @override + String get aboutBinimumDesc => + 'The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn\'t exist!'; + + @override + String get aboutSachinsenalDesc => + 'The original HiFi project creator. The foundation of Tidal integration!'; + + @override + String get aboutSjdonadoDesc => + 'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!'; + + @override + String get aboutDabMusic => 'DAB Music'; + + @override + String get aboutDabMusicDesc => + 'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!'; + + @override + String get aboutSpotiSaver => 'SpotiSaver'; + @override String get aboutSpotiSaverDesc => 'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!'; @@ -2344,2118 +4750,6 @@ class AppLocalizationsZh extends AppLocalizations { 'Artist folders use Track Artist only'; } -/// The translations for Chinese, as used in China (`zh_CN`). -class AppLocalizationsZhCn extends AppLocalizationsZh { - AppLocalizationsZhCn() : super('zh_CN'); - - @override - String get appName => 'SpotiFLAC'; - - @override - String get navHome => 'Home'; - - @override - String get navLibrary => 'Library'; - - @override - String get navSettings => 'Settings'; - - @override - String get navStore => 'Store'; - - @override - String get homeTitle => 'Home'; - - @override - String get homeSubtitle => 'Paste a Spotify link or search by name'; - - @override - String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs'; - - @override - String get homeRecent => 'Recent'; - - @override - String get historyFilterAll => 'All'; - - @override - String get historyFilterAlbums => 'Albums'; - - @override - String get historyFilterSingles => 'Singles'; - - @override - String get historySearchHint => 'Search history...'; - - @override - String get settingsTitle => 'Settings'; - - @override - String get settingsDownload => 'Download'; - - @override - String get settingsAppearance => 'Appearance'; - - @override - String get settingsOptions => 'Options'; - - @override - String get settingsExtensions => 'Extensions'; - - @override - String get settingsAbout => 'About'; - - @override - String get downloadTitle => 'Download'; - - @override - String get downloadAskQualitySubtitle => - 'Show quality picker for each download'; - - @override - String get downloadFilenameFormat => 'Filename Format'; - - @override - String get downloadFolderOrganization => 'Folder Organization'; - - @override - String get appearanceTitle => 'Appearance'; - - @override - String get appearanceThemeSystem => 'System'; - - @override - String get appearanceThemeLight => 'Light'; - - @override - String get appearanceThemeDark => 'Dark'; - - @override - String get appearanceDynamicColor => 'Dynamic Color'; - - @override - String get appearanceDynamicColorSubtitle => 'Use colors from your wallpaper'; - - @override - String get appearanceHistoryView => 'History View'; - - @override - String get appearanceHistoryViewList => 'List'; - - @override - String get appearanceHistoryViewGrid => 'Grid'; - - @override - String get optionsTitle => 'Options'; - - @override - String get optionsPrimaryProvider => 'Primary Provider'; - - @override - String get optionsPrimaryProviderSubtitle => - 'Service used when searching by track name.'; - - @override - String optionsUsingExtension(String extensionName) { - return 'Using extension: $extensionName'; - } - - @override - String get optionsSwitchBack => - 'Tap Deezer or Spotify to switch back from extension'; - - @override - String get optionsAutoFallback => 'Auto Fallback'; - - @override - String get optionsAutoFallbackSubtitle => - 'Try other services if download fails'; - - @override - String get optionsUseExtensionProviders => 'Use Extension Providers'; - - @override - String get optionsUseExtensionProvidersOn => 'Extensions will be tried first'; - - @override - String get optionsUseExtensionProvidersOff => 'Using built-in providers only'; - - @override - String get optionsEmbedLyrics => 'Embed Lyrics'; - - @override - String get optionsEmbedLyricsSubtitle => - 'Embed synced lyrics into FLAC files'; - - @override - String get optionsMaxQualityCover => 'Max Quality Cover'; - - @override - String get optionsMaxQualityCoverSubtitle => - 'Download highest resolution cover art'; - - @override - String get optionsConcurrentDownloads => 'Concurrent Downloads'; - - @override - String get optionsConcurrentSequential => 'Sequential (1 at a time)'; - - @override - String optionsConcurrentParallel(int count) { - return '$count parallel downloads'; - } - - @override - String get optionsConcurrentWarning => - 'Parallel downloads may trigger rate limiting'; - - @override - String get optionsExtensionStore => 'Extension Store'; - - @override - String get optionsExtensionStoreSubtitle => 'Show Store tab in navigation'; - - @override - String get optionsCheckUpdates => 'Check for Updates'; - - @override - String get optionsCheckUpdatesSubtitle => - 'Notify when new version is available'; - - @override - String get optionsUpdateChannel => 'Update Channel'; - - @override - String get optionsUpdateChannelStable => 'Stable releases only'; - - @override - String get optionsUpdateChannelPreview => 'Get preview releases'; - - @override - String get optionsUpdateChannelWarning => - 'Preview may contain bugs or incomplete features'; - - @override - String get optionsClearHistory => 'Clear Download History'; - - @override - String get optionsClearHistorySubtitle => - 'Remove all downloaded tracks from history'; - - @override - String get optionsDetailedLogging => 'Detailed Logging'; - - @override - String get optionsDetailedLoggingOn => 'Detailed logs are being recorded'; - - @override - String get optionsDetailedLoggingOff => 'Enable for bug reports'; - - @override - String get optionsSpotifyCredentials => 'Spotify Credentials'; - - @override - String optionsSpotifyCredentialsConfigured(String clientId) { - return 'Client ID: $clientId...'; - } - - @override - String get optionsSpotifyCredentialsRequired => 'Required - tap to configure'; - - @override - String get optionsSpotifyWarning => - 'Spotify requires your own API credentials. Get them free from developer.spotify.com'; - - @override - String get optionsSpotifyDeprecationWarning => - 'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.'; - - @override - String get extensionsTitle => 'Extensions'; - - @override - String get extensionsDisabled => 'Disabled'; - - @override - String extensionsVersion(String version) { - return 'Version $version'; - } - - @override - String extensionsAuthor(String author) { - return 'by $author'; - } - - @override - String get extensionsUninstall => 'Uninstall'; - - @override - String get storeTitle => 'Extension Store'; - - @override - String get storeSearch => 'Search extensions...'; - - @override - String get storeInstall => 'Install'; - - @override - String get storeInstalled => 'Installed'; - - @override - String get storeUpdate => 'Update'; - - @override - String get aboutTitle => 'About'; - - @override - String get aboutContributors => 'Contributors'; - - @override - String get aboutMobileDeveloper => 'Mobile version developer'; - - @override - String get aboutOriginalCreator => 'Creator of the original SpotiFLAC'; - - @override - String get aboutLogoArtist => - 'The talented artist who created our beautiful app logo!'; - - @override - String get aboutTranslators => 'Translators'; - - @override - String get aboutSpecialThanks => 'Special Thanks'; - - @override - String get aboutLinks => 'Links'; - - @override - String get aboutMobileSource => 'Mobile source code'; - - @override - String get aboutPCSource => 'PC source code'; - - @override - String get aboutReportIssue => 'Report an issue'; - - @override - String get aboutReportIssueSubtitle => 'Report any problems you encounter'; - - @override - String get aboutFeatureRequest => 'Feature request'; - - @override - String get aboutFeatureRequestSubtitle => 'Suggest new features for the app'; - - @override - String get aboutTelegramChannel => 'Telegram Channel'; - - @override - String get aboutTelegramChannelSubtitle => 'Announcements and updates'; - - @override - String get aboutTelegramChat => 'Telegram Community'; - - @override - String get aboutTelegramChatSubtitle => 'Chat with other users'; - - @override - String get aboutSocial => 'Social'; - - @override - String get aboutApp => 'App'; - - @override - String get aboutVersion => 'Version'; - - @override - String get aboutBinimumDesc => - 'The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn\'t exist!'; - - @override - String get aboutSachinsenalDesc => - 'The original HiFi project creator. The foundation of Tidal integration!'; - - @override - String get aboutSjdonadoDesc => - 'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!'; - - @override - String get aboutDabMusic => 'DAB Music'; - - @override - String get aboutDabMusicDesc => - 'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!'; - - @override - String get aboutSpotiSaver => 'SpotiSaver'; - - @override - String get aboutSpotiSaverDesc => - 'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!'; - - @override - String get aboutAppDescription => - 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; - - @override - String get artistAlbums => 'Albums'; - - @override - String get artistSingles => 'Singles & EPs'; - - @override - String get artistCompilations => 'Compilations'; - - @override - String get artistPopular => 'Popular'; - - @override - String artistMonthlyListeners(String count) { - return '$count monthly listeners'; - } - - @override - String get trackMetadataService => 'Service'; - - @override - String get trackMetadataPlay => 'Play'; - - @override - String get trackMetadataShare => 'Share'; - - @override - String get trackMetadataDelete => 'Delete'; - - @override - String get setupGrantPermission => 'Grant Permission'; - - @override - String get setupSkip => 'Skip for now'; - - @override - String get setupStorageAccessRequired => 'Storage Access Required'; - - @override - String get setupStorageAccessMessageAndroid11 => - 'Android 11+ requires \"All files access\" permission to save files to your chosen download folder.'; - - @override - String get setupOpenSettings => 'Open Settings'; - - @override - String get setupPermissionDeniedMessage => - 'Permission denied. Please grant all permissions to continue.'; - - @override - String setupPermissionRequired(String permissionType) { - return '$permissionType Permission Required'; - } - - @override - String setupPermissionRequiredMessage(String permissionType) { - return '$permissionType permission is required for the best experience. You can change this later in Settings.'; - } - - @override - String get setupUseDefaultFolder => 'Use Default Folder?'; - - @override - String get setupNoFolderSelected => - 'No folder selected. Would you like to use the default Music folder?'; - - @override - String get setupUseDefault => 'Use Default'; - - @override - String get setupDownloadLocationTitle => 'Download Location'; - - @override - String get setupDownloadLocationIosMessage => - 'On iOS, downloads are saved to the app\'s Documents folder. You can access them via the Files app.'; - - @override - String get setupAppDocumentsFolder => 'App Documents Folder'; - - @override - String get setupAppDocumentsFolderSubtitle => - 'Recommended - accessible via Files app'; - - @override - String get setupChooseFromFiles => 'Choose from Files'; - - @override - String get setupChooseFromFilesSubtitle => 'Select iCloud or other location'; - - @override - String get setupIosEmptyFolderWarning => - 'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.'; - - @override - String get setupIcloudNotSupported => - 'iCloud Drive is not supported. Please use the app Documents folder.'; - - @override - String get setupDownloadInFlac => 'Download Spotify tracks in FLAC'; - - @override - String get setupStorageGranted => 'Storage Permission Granted!'; - - @override - String get setupStorageRequired => 'Storage Permission Required'; - - @override - String get setupStorageDescription => - 'SpotiFLAC needs storage permission to save your downloaded music files.'; - - @override - String get setupNotificationGranted => 'Notification Permission Granted!'; - - @override - String get setupNotificationEnable => 'Enable Notifications'; - - @override - String get setupFolderChoose => 'Choose Download Folder'; - - @override - String get setupFolderDescription => - 'Select a folder where your downloaded music will be saved.'; - - @override - String get setupSelectFolder => 'Select Folder'; - - @override - String get setupEnableNotifications => 'Enable Notifications'; - - @override - String get setupNotificationBackgroundDescription => - 'Get notified about download progress and completion. This helps you track downloads when the app is in background.'; - - @override - String get setupSkipForNow => 'Skip for now'; - - @override - String get setupNext => 'Next'; - - @override - String get setupGetStarted => 'Get Started'; - - @override - String get setupAllowAccessToManageFiles => - 'Please enable \"Allow access to manage all files\" in the next screen.'; - - @override - String get dialogCancel => 'Cancel'; - - @override - String get dialogSave => 'Save'; - - @override - String get dialogDelete => 'Delete'; - - @override - String get dialogRetry => 'Retry'; - - @override - String get dialogClear => 'Clear'; - - @override - String get dialogDone => 'Done'; - - @override - String get dialogImport => 'Import'; - - @override - String get dialogDiscard => 'Discard'; - - @override - String get dialogRemove => 'Remove'; - - @override - String get dialogUninstall => 'Uninstall'; - - @override - String get dialogDiscardChanges => 'Discard Changes?'; - - @override - String get dialogUnsavedChanges => - 'You have unsaved changes. Do you want to discard them?'; - - @override - String get dialogClearAll => 'Clear All'; - - @override - String get dialogRemoveExtension => 'Remove Extension'; - - @override - String get dialogRemoveExtensionMessage => - 'Are you sure you want to remove this extension? This cannot be undone.'; - - @override - String get dialogUninstallExtension => 'Uninstall Extension?'; - - @override - String dialogUninstallExtensionMessage(String extensionName) { - return 'Are you sure you want to remove $extensionName?'; - } - - @override - String get dialogClearHistoryTitle => 'Clear History'; - - @override - String get dialogClearHistoryMessage => - 'Are you sure you want to clear all download history? This cannot be undone.'; - - @override - String get dialogDeleteSelectedTitle => 'Delete Selected'; - - @override - String dialogDeleteSelectedMessage(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: 'tracks', - one: 'track', - ); - return 'Delete $count $_temp0 from history?\n\nThis will also delete the files from storage.'; - } - - @override - String get dialogImportPlaylistTitle => 'Import Playlist'; - - @override - String dialogImportPlaylistMessage(int count) { - return 'Found $count tracks in CSV. Add them to download queue?'; - } - - @override - String csvImportTracks(int count) { - return '$count tracks from CSV'; - } - - @override - String snackbarAddedToQueue(String trackName) { - return 'Added \"$trackName\" to queue'; - } - - @override - String snackbarAddedTracksToQueue(int count) { - return 'Added $count tracks to queue'; - } - - @override - String snackbarAlreadyDownloaded(String trackName) { - return '\"$trackName\" already downloaded'; - } - - @override - String snackbarAlreadyInLibrary(String trackName) { - return '\"$trackName\" already exists in your library'; - } - - @override - String get snackbarHistoryCleared => 'History cleared'; - - @override - String get snackbarCredentialsSaved => 'Credentials saved'; - - @override - String get snackbarCredentialsCleared => 'Credentials cleared'; - - @override - String snackbarDeletedTracks(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: 'tracks', - one: 'track', - ); - return 'Deleted $count $_temp0'; - } - - @override - String snackbarCannotOpenFile(String error) { - return 'Cannot open file: $error'; - } - - @override - String get snackbarFillAllFields => 'Please fill all fields'; - - @override - String get snackbarViewQueue => 'View Queue'; - - @override - String snackbarUrlCopied(String platform) { - return '$platform URL copied to clipboard'; - } - - @override - String get snackbarFileNotFound => 'File not found'; - - @override - String get snackbarSelectExtFile => 'Please select a .spotiflac-ext file'; - - @override - String get snackbarProviderPrioritySaved => 'Provider priority saved'; - - @override - String get snackbarMetadataProviderSaved => - 'Metadata provider priority saved'; - - @override - String snackbarExtensionInstalled(String extensionName) { - return '$extensionName installed.'; - } - - @override - String snackbarExtensionUpdated(String extensionName) { - return '$extensionName updated.'; - } - - @override - String get snackbarFailedToInstall => 'Failed to install extension'; - - @override - String get snackbarFailedToUpdate => 'Failed to update extension'; - - @override - String get errorRateLimited => 'Rate Limited'; - - @override - String get errorRateLimitedMessage => - 'Too many requests. Please wait a moment before searching again.'; - - @override - String get errorNoTracksFound => 'No tracks found'; - - @override - String errorMissingExtensionSource(String item) { - return 'Cannot load $item: missing extension source'; - } - - @override - String get actionPause => 'Pause'; - - @override - String get actionResume => 'Resume'; - - @override - String get actionCancel => 'Cancel'; - - @override - String get actionSelectAll => 'Select All'; - - @override - String get actionDeselect => 'Deselect'; - - @override - String get actionRemoveCredentials => 'Remove Credentials'; - - @override - String get actionSaveCredentials => 'Save Credentials'; - - @override - String selectionSelected(int count) { - return '$count selected'; - } - - @override - String get selectionAllSelected => 'All tracks selected'; - - @override - String get selectionSelectToDelete => 'Select tracks to delete'; - - @override - String progressFetchingMetadata(int current, int total) { - return 'Fetching metadata... $current/$total'; - } - - @override - String get progressReadingCsv => 'Reading CSV...'; - - @override - String get searchSongs => 'Songs'; - - @override - String get searchArtists => 'Artists'; - - @override - String get searchAlbums => 'Albums'; - - @override - String get searchPlaylists => 'Playlists'; - - @override - String get tooltipPlay => 'Play'; - - @override - String get filenameFormat => 'Filename Format'; - - @override - String get folderOrganizationNone => 'No organization'; - - @override - String get folderOrganizationByArtist => 'By Artist'; - - @override - String get folderOrganizationByAlbum => 'By Album'; - - @override - String get folderOrganizationByArtistAlbum => 'Artist/Album'; - - @override - String get folderOrganizationDescription => - 'Organize downloaded files into folders'; - - @override - String get folderOrganizationNoneSubtitle => 'All files in download folder'; - - @override - String get folderOrganizationByArtistSubtitle => - 'Separate folder for each artist'; - - @override - String get folderOrganizationByAlbumSubtitle => - 'Separate folder for each album'; - - @override - String get folderOrganizationByArtistAlbumSubtitle => - 'Nested folders for artist and album'; - - @override - String get updateAvailable => 'Update Available'; - - @override - String get updateLater => 'Later'; - - @override - String get updateStartingDownload => 'Starting download...'; - - @override - String get updateDownloadFailed => 'Download failed'; - - @override - String get updateFailedMessage => 'Failed to download update'; - - @override - String get updateNewVersionReady => 'A new version is ready'; - - @override - String get updateCurrent => 'Current'; - - @override - String get updateNew => 'New'; - - @override - String get updateDownloading => 'Downloading...'; - - @override - String get updateWhatsNew => 'What\'s New'; - - @override - String get updateDownloadInstall => 'Download & Install'; - - @override - String get updateDontRemind => 'Don\'t remind'; - - @override - String get providerPriorityTitle => 'Provider Priority'; - - @override - String get providerPriorityDescription => - 'Drag to reorder download providers. The app will try providers from top to bottom when downloading tracks.'; - - @override - String get providerPriorityInfo => - 'If a track is not available on the first provider, the app will automatically try the next one.'; - - @override - String get providerBuiltIn => 'Built-in'; - - @override - String get providerExtension => 'Extension'; - - @override - String get metadataProviderPriorityTitle => 'Metadata Priority'; - - @override - String get metadataProviderPriorityDescription => - 'Drag to reorder metadata providers. The app will try providers from top to bottom when searching for tracks and fetching metadata.'; - - @override - String get metadataProviderPriorityInfo => - 'Deezer has no rate limits and is recommended as primary. Spotify may rate limit after many requests.'; - - @override - String get metadataNoRateLimits => 'No rate limits'; - - @override - String get metadataMayRateLimit => 'May rate limit'; - - @override - String get logTitle => 'Logs'; - - @override - String get logCopied => 'Logs copied to clipboard'; - - @override - String get logSearchHint => 'Search logs...'; - - @override - String get logFilterLevel => 'Level'; - - @override - String get logFilterSection => 'Filter'; - - @override - String get logShareLogs => 'Share logs'; - - @override - String get logClearLogs => 'Clear logs'; - - @override - String get logClearLogsTitle => 'Clear Logs'; - - @override - String get logClearLogsMessage => 'Are you sure you want to clear all logs?'; - - @override - String get logFilterBySeverity => 'Filter logs by severity'; - - @override - String get logNoLogsYet => 'No logs yet'; - - @override - String get logNoLogsYetSubtitle => 'Logs will appear here as you use the app'; - - @override - String logEntriesFiltered(int count) { - return 'Entries ($count filtered)'; - } - - @override - String logEntries(int count) { - return 'Entries ($count)'; - } - - @override - String get credentialsTitle => 'Spotify Credentials'; - - @override - String get credentialsDescription => - 'Enter your Client ID and Secret to use your own Spotify application quota.'; - - @override - String get credentialsClientId => 'Client ID'; - - @override - String get credentialsClientIdHint => 'Paste Client ID'; - - @override - String get credentialsClientSecret => 'Client Secret'; - - @override - String get credentialsClientSecretHint => 'Paste Client Secret'; - - @override - String get channelStable => 'Stable'; - - @override - String get channelPreview => 'Preview'; - - @override - String get sectionSearchSource => 'Search Source'; - - @override - String get sectionDownload => 'Download'; - - @override - String get sectionPerformance => 'Performance'; - - @override - String get sectionApp => 'App'; - - @override - String get sectionData => 'Data'; - - @override - String get sectionDebug => 'Debug'; - - @override - String get sectionService => 'Service'; - - @override - String get sectionAudioQuality => 'Audio Quality'; - - @override - String get sectionFileSettings => 'File Settings'; - - @override - String get sectionLyrics => 'Lyrics'; - - @override - String get lyricsMode => 'Lyrics Mode'; - - @override - String get lyricsModeDescription => - 'Choose how lyrics are saved with your downloads'; - - @override - String get lyricsModeEmbed => 'Embed in file'; - - @override - String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata'; - - @override - String get lyricsModeExternal => 'External .lrc file'; - - @override - String get lyricsModeExternalSubtitle => - 'Separate .lrc file for players like Samsung Music'; - - @override - String get lyricsModeBoth => 'Both'; - - @override - String get lyricsModeBothSubtitle => 'Embed and save .lrc file'; - - @override - String get sectionColor => 'Color'; - - @override - String get sectionTheme => 'Theme'; - - @override - String get sectionLayout => 'Layout'; - - @override - String get sectionLanguage => 'Language'; - - @override - String get appearanceLanguage => 'App Language'; - - @override - String get settingsAppearanceSubtitle => 'Theme, colors, display'; - - @override - String get settingsDownloadSubtitle => 'Service, quality, filename format'; - - @override - String get settingsOptionsSubtitle => 'Fallback, lyrics, cover art, updates'; - - @override - String get settingsExtensionsSubtitle => 'Manage download providers'; - - @override - String get settingsLogsSubtitle => 'View app logs for debugging'; - - @override - String get loadingSharedLink => 'Loading shared link...'; - - @override - String get pressBackAgainToExit => 'Press back again to exit'; - - @override - String downloadAllCount(int count) { - return 'Download All ($count)'; - } - - @override - String tracksCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count tracks', - one: '1 track', - ); - return '$_temp0'; - } - - @override - String get trackCopyFilePath => 'Copy file path'; - - @override - String get trackRemoveFromDevice => 'Remove from device'; - - @override - String get trackLoadLyrics => 'Load Lyrics'; - - @override - String get trackMetadata => 'Metadata'; - - @override - String get trackFileInfo => 'File Info'; - - @override - String get trackLyrics => 'Lyrics'; - - @override - String get trackFileNotFound => 'File not found'; - - @override - String get trackOpenInDeezer => 'Open in Deezer'; - - @override - String get trackOpenInSpotify => 'Open in Spotify'; - - @override - String get trackTrackName => 'Track name'; - - @override - String get trackArtist => 'Artist'; - - @override - String get trackAlbumArtist => 'Album artist'; - - @override - String get trackAlbum => 'Album'; - - @override - String get trackTrackNumber => 'Track number'; - - @override - String get trackDiscNumber => 'Disc number'; - - @override - String get trackDuration => 'Duration'; - - @override - String get trackAudioQuality => 'Audio quality'; - - @override - String get trackReleaseDate => 'Release date'; - - @override - String get trackGenre => 'Genre'; - - @override - String get trackLabel => 'Label'; - - @override - String get trackCopyright => 'Copyright'; - - @override - String get trackDownloaded => 'Downloaded'; - - @override - String get trackCopyLyrics => 'Copy lyrics'; - - @override - String get trackLyricsNotAvailable => 'Lyrics not available for this track'; - - @override - String get trackLyricsTimeout => 'Request timed out. Try again later.'; - - @override - String get trackLyricsLoadFailed => 'Failed to load lyrics'; - - @override - String get trackEmbedLyrics => 'Embed Lyrics'; - - @override - String get trackLyricsEmbedded => 'Lyrics embedded successfully'; - - @override - String get trackInstrumental => 'Instrumental track'; - - @override - String get trackCopiedToClipboard => 'Copied to clipboard'; - - @override - String get trackDeleteConfirmTitle => 'Remove from device?'; - - @override - String get trackDeleteConfirmMessage => - 'This will permanently delete the downloaded file and remove it from your history.'; - - @override - String get dateToday => 'Today'; - - @override - String get dateYesterday => 'Yesterday'; - - @override - String dateDaysAgo(int count) { - return '$count days ago'; - } - - @override - String dateWeeksAgo(int count) { - return '$count weeks ago'; - } - - @override - String dateMonthsAgo(int count) { - return '$count months ago'; - } - - @override - String get storeFilterAll => 'All'; - - @override - String get storeFilterMetadata => 'Metadata'; - - @override - String get storeFilterDownload => 'Download'; - - @override - String get storeFilterUtility => 'Utility'; - - @override - String get storeFilterLyrics => 'Lyrics'; - - @override - String get storeFilterIntegration => 'Integration'; - - @override - String get storeClearFilters => 'Clear filters'; - - @override - String get extensionDefaultProvider => 'Default (Deezer/Spotify)'; - - @override - String get extensionDefaultProviderSubtitle => 'Use built-in search'; - - @override - String get extensionAuthor => 'Author'; - - @override - String get extensionId => 'ID'; - - @override - String get extensionError => 'Error'; - - @override - String get extensionCapabilities => 'Capabilities'; - - @override - String get extensionMetadataProvider => 'Metadata Provider'; - - @override - String get extensionDownloadProvider => 'Download Provider'; - - @override - String get extensionLyricsProvider => 'Lyrics Provider'; - - @override - String get extensionUrlHandler => 'URL Handler'; - - @override - String get extensionQualityOptions => 'Quality Options'; - - @override - String get extensionPostProcessingHooks => 'Post-Processing Hooks'; - - @override - String get extensionPermissions => 'Permissions'; - - @override - String get extensionSettings => 'Settings'; - - @override - String get extensionRemoveButton => 'Remove Extension'; - - @override - String get extensionUpdated => 'Updated'; - - @override - String get extensionMinAppVersion => 'Min App Version'; - - @override - String get extensionCustomTrackMatching => 'Custom Track Matching'; - - @override - String get extensionPostProcessing => 'Post-Processing'; - - @override - String extensionHooksAvailable(int count) { - return '$count hook(s) available'; - } - - @override - String extensionPatternsCount(int count) { - return '$count pattern(s)'; - } - - @override - String extensionStrategy(String strategy) { - return 'Strategy: $strategy'; - } - - @override - String get extensionsProviderPrioritySection => 'Provider Priority'; - - @override - String get extensionsInstalledSection => 'Installed Extensions'; - - @override - String get extensionsNoExtensions => 'No extensions installed'; - - @override - String get extensionsNoExtensionsSubtitle => - 'Install .spotiflac-ext files to add new providers'; - - @override - String get extensionsInstallButton => 'Install Extension'; - - @override - String get extensionsInfoTip => - 'Extensions can add new metadata and download providers. Only install extensions from trusted sources.'; - - @override - String get extensionsInstalledSuccess => 'Extension installed successfully'; - - @override - String get extensionsDownloadPriority => 'Download Priority'; - - @override - String get extensionsDownloadPrioritySubtitle => 'Set download service order'; - - @override - String get extensionsNoDownloadProvider => - 'No extensions with download provider'; - - @override - String get extensionsMetadataPriority => 'Metadata Priority'; - - @override - String get extensionsMetadataPrioritySubtitle => - 'Set search & metadata source order'; - - @override - String get extensionsNoMetadataProvider => - 'No extensions with metadata provider'; - - @override - String get extensionsSearchProvider => 'Search Provider'; - - @override - String get extensionsNoCustomSearch => 'No extensions with custom search'; - - @override - String get extensionsSearchProviderDescription => - 'Choose which service to use for searching tracks'; - - @override - String get extensionsCustomSearch => 'Custom search'; - - @override - String get extensionsErrorLoading => 'Error loading extension'; - - @override - String get qualityFlacLossless => 'FLAC Lossless'; - - @override - String get qualityFlacLosslessSubtitle => '16-bit / 44.1kHz'; - - @override - String get qualityHiResFlac => 'Hi-Res FLAC'; - - @override - String get qualityHiResFlacSubtitle => '24-bit / up to 96kHz'; - - @override - String get qualityHiResFlacMax => 'Hi-Res FLAC Max'; - - @override - String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; - - @override - String get qualityNote => - 'Actual quality depends on track availability from the service'; - - @override - String get youtubeQualityNote => - 'YouTube provides lossy audio only. Not part of lossless fallback.'; - - @override - String get downloadAskBeforeDownload => 'Ask Before Download'; - - @override - String get downloadDirectory => 'Download Directory'; - - @override - String get downloadSeparateSinglesFolder => 'Separate Singles Folder'; - - @override - String get downloadAlbumFolderStructure => 'Album Folder Structure'; - - @override - String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders'; - - @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 downloadSelectQuality => 'Select Quality'; - - @override - String get downloadFrom => 'Download From'; - - @override - String get appearanceAmoledDark => 'AMOLED Dark'; - - @override - String get appearanceAmoledDarkSubtitle => 'Pure black background'; - - @override - String get queueClearAll => 'Clear All'; - - @override - String get queueClearAllMessage => - 'Are you sure you want to clear all downloads?'; - - @override - String get settingsAutoExportFailed => 'Auto-export failed downloads'; - - @override - String get settingsAutoExportFailedSubtitle => - 'Save failed downloads to TXT file automatically'; - - @override - String get settingsDownloadNetwork => 'Download Network'; - - @override - String get settingsDownloadNetworkAny => 'WiFi + Mobile Data'; - - @override - String get settingsDownloadNetworkWifiOnly => 'WiFi Only'; - - @override - String get settingsDownloadNetworkSubtitle => - 'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.'; - - @override - String get albumFolderArtistAlbum => 'Artist / Album'; - - @override - String get albumFolderArtistAlbumSubtitle => 'Albums/Artist Name/Album Name/'; - - @override - String get albumFolderArtistYearAlbum => 'Artist / [Year] Album'; - - @override - String get albumFolderArtistYearAlbumSubtitle => - 'Albums/Artist Name/[2005] Album Name/'; - - @override - String get albumFolderAlbumOnly => 'Album Only'; - - @override - String get albumFolderAlbumOnlySubtitle => 'Albums/Album Name/'; - - @override - String get albumFolderYearAlbum => '[Year] Album'; - - @override - String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/'; - - @override - String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles'; - - @override - String get albumFolderArtistAlbumSinglesSubtitle => - 'Artist/Album/ and Artist/Singles/'; - - @override - String get downloadedAlbumDeleteSelected => 'Delete Selected'; - - @override - String downloadedAlbumDeleteMessage(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: 'tracks', - one: 'track', - ); - return 'Delete $count $_temp0 from this album?\n\nThis will also delete the files from storage.'; - } - - @override - String downloadedAlbumSelectedCount(int count) { - return '$count selected'; - } - - @override - String get downloadedAlbumAllSelected => 'All tracks selected'; - - @override - String get downloadedAlbumTapToSelect => 'Tap tracks to select'; - - @override - String downloadedAlbumDeleteCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: 'tracks', - one: 'track', - ); - return 'Delete $count $_temp0'; - } - - @override - String get downloadedAlbumSelectToDelete => 'Select tracks to delete'; - - @override - String downloadedAlbumDiscHeader(int discNumber) { - return 'Disc $discNumber'; - } - - @override - String get recentTypeArtist => 'Artist'; - - @override - String get recentTypeAlbum => 'Album'; - - @override - String get recentTypeSong => 'Song'; - - @override - String get recentTypePlaylist => 'Playlist'; - - @override - String get recentEmpty => 'No recent items yet'; - - @override - String get recentShowAllDownloads => 'Show All Downloads'; - - @override - String recentPlaylistInfo(String name) { - return 'Playlist: $name'; - } - - @override - String get discographyDownload => 'Download Discography'; - - @override - String get discographyDownloadAll => 'Download All'; - - @override - String discographyDownloadAllSubtitle(int count, int albumCount) { - return '$count tracks from $albumCount releases'; - } - - @override - String get discographyAlbumsOnly => 'Albums Only'; - - @override - String discographyAlbumsOnlySubtitle(int count, int albumCount) { - return '$count tracks from $albumCount albums'; - } - - @override - String get discographySinglesOnly => 'Singles & EPs Only'; - - @override - String discographySinglesOnlySubtitle(int count, int albumCount) { - return '$count tracks from $albumCount singles'; - } - - @override - String get discographySelectAlbums => 'Select Albums...'; - - @override - String get discographySelectAlbumsSubtitle => - 'Choose specific albums or singles'; - - @override - String get discographyFetchingTracks => 'Fetching tracks...'; - - @override - String discographyFetchingAlbum(int current, int total) { - return 'Fetching $current of $total...'; - } - - @override - String discographySelectedCount(int count) { - return '$count selected'; - } - - @override - String get discographyDownloadSelected => 'Download Selected'; - - @override - String discographyAddedToQueue(int count) { - return 'Added $count tracks to queue'; - } - - @override - String discographySkippedDownloaded(int added, int skipped) { - return '$added added, $skipped already downloaded'; - } - - @override - String get discographyNoAlbums => 'No albums available'; - - @override - String get discographyFailedToFetch => 'Failed to fetch some albums'; - - @override - String get sectionStorageAccess => 'Storage Access'; - - @override - String get allFilesAccess => 'All Files Access'; - - @override - String get allFilesAccessEnabledSubtitle => 'Can write to any folder'; - - @override - String get allFilesAccessDisabledSubtitle => 'Limited to media folders only'; - - @override - String get allFilesAccessDescription => - 'Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.'; - - @override - String get allFilesAccessDeniedMessage => - 'Permission was denied. Please enable \'All files access\' manually in system settings.'; - - @override - String get allFilesAccessDisabledMessage => - 'All Files Access disabled. The app will use limited storage access.'; - - @override - String get settingsLocalLibrary => 'Local Library'; - - @override - String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates'; - - @override - String get settingsCache => 'Storage & Cache'; - - @override - String get settingsCacheSubtitle => 'View size and clear cached data'; - - @override - String get libraryTitle => 'Local Library'; - - @override - String get libraryScanSettings => 'Scan Settings'; - - @override - String get libraryEnableLocalLibrary => 'Enable Local Library'; - - @override - String get libraryEnableLocalLibrarySubtitle => - 'Scan and track your existing music'; - - @override - String get libraryFolder => 'Library Folder'; - - @override - String get libraryFolderHint => 'Tap to select folder'; - - @override - String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator'; - - @override - String get libraryShowDuplicateIndicatorSubtitle => - 'Show when searching for existing tracks'; - - @override - String get libraryActions => 'Actions'; - - @override - String get libraryScan => 'Scan Library'; - - @override - String get libraryScanSubtitle => 'Scan for audio files'; - - @override - String get libraryScanSelectFolderFirst => 'Select a folder first'; - - @override - String get libraryCleanupMissingFiles => 'Cleanup Missing Files'; - - @override - String get libraryCleanupMissingFilesSubtitle => - 'Remove entries for files that no longer exist'; - - @override - String get libraryClear => 'Clear Library'; - - @override - String get libraryClearSubtitle => 'Remove all scanned tracks'; - - @override - String get libraryClearConfirmTitle => 'Clear Library'; - - @override - String get libraryClearConfirmMessage => - 'This will remove all scanned tracks from your library. Your actual music files will not be deleted.'; - - @override - String get libraryAbout => 'About Local Library'; - - @override - 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 libraryLastScanned(String time) { - return 'Last scanned: $time'; - } - - @override - String get libraryLastScannedNever => 'Never'; - - @override - String get libraryScanning => 'Scanning...'; - - @override - String libraryScanProgress(String progress, int total) { - return '$progress% of $total files'; - } - - @override - String get libraryInLibrary => 'In Library'; - - @override - String libraryRemovedMissingFiles(int count) { - return 'Removed $count missing files from library'; - } - - @override - String get libraryCleared => 'Library cleared'; - - @override - String get libraryStorageAccessRequired => 'Storage Access Required'; - - @override - String get libraryStorageAccessMessage => - 'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.'; - - @override - String get libraryFolderNotExist => 'Selected folder does not exist'; - - @override - String get librarySourceDownloaded => 'Downloaded'; - - @override - String get librarySourceLocal => 'Local'; - - @override - String get libraryFilterAll => 'All'; - - @override - String get libraryFilterDownloaded => 'Downloaded'; - - @override - String get libraryFilterLocal => 'Local'; - - @override - String get libraryFilterTitle => 'Filters'; - - @override - String get libraryFilterReset => 'Reset'; - - @override - String get libraryFilterApply => 'Apply'; - - @override - String get libraryFilterSource => 'Source'; - - @override - String get libraryFilterQuality => 'Quality'; - - @override - String get libraryFilterQualityHiRes => 'Hi-Res (24bit)'; - - @override - String get libraryFilterQualityCD => 'CD (16bit)'; - - @override - String get libraryFilterQualityLossy => 'Lossy'; - - @override - String get libraryFilterFormat => 'Format'; - - @override - String get libraryFilterSort => 'Sort'; - - @override - String get libraryFilterSortLatest => 'Latest'; - - @override - String get libraryFilterSortOldest => 'Oldest'; - - @override - String get timeJustNow => 'Just now'; - - @override - String timeMinutesAgo(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count minutes ago', - one: '1 minute ago', - ); - return '$_temp0'; - } - - @override - String timeHoursAgo(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count hours ago', - one: '1 hour ago', - ); - return '$_temp0'; - } - - @override - String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!'; - - @override - String get tutorialWelcomeDesc => - 'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.'; - - @override - String get tutorialWelcomeTip1 => - 'Download music from Spotify, Deezer, or paste any supported URL'; - - @override - String get tutorialWelcomeTip2 => - 'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'; - - @override - String get tutorialWelcomeTip3 => - 'Automatic metadata, cover art, and lyrics embedding'; - - @override - String get tutorialSearchTitle => 'Finding Music'; - - @override - String get tutorialSearchDesc => - 'There are two easy ways to find music you want to download.'; - - @override - String get tutorialDownloadTitle => 'Downloading Music'; - - @override - String get tutorialDownloadDesc => - 'Downloading music is simple and fast. Here\'s how it works.'; - - @override - String get tutorialLibraryTitle => 'Your Library'; - - @override - String get tutorialLibraryDesc => - 'All your downloaded music is organized in the Library tab.'; - - @override - String get tutorialLibraryTip1 => - 'View download progress and queue in the Library tab'; - - @override - String get tutorialLibraryTip2 => - 'Tap any track to play it with your music player'; - - @override - String get tutorialLibraryTip3 => - 'Switch between list and grid view for better browsing'; - - @override - String get tutorialExtensionsTitle => 'Extensions'; - - @override - String get tutorialExtensionsDesc => - 'Extend the app\'s capabilities with community extensions.'; - - @override - String get tutorialExtensionsTip1 => - 'Browse the Store tab to discover useful extensions'; - - @override - String get tutorialExtensionsTip2 => - 'Add new download providers or search sources'; - - @override - String get tutorialExtensionsTip3 => - 'Get lyrics, enhanced metadata, and more features'; - - @override - String get tutorialSettingsTitle => 'Customize Your Experience'; - - @override - String get tutorialSettingsDesc => - 'Personalize the app in Settings to match your preferences.'; - - @override - String get tutorialSettingsTip1 => - 'Change download location and folder organization'; - - @override - String get tutorialSettingsTip2 => - 'Set default audio quality and format preferences'; - - @override - String get tutorialSettingsTip3 => 'Customize app theme and appearance'; - - @override - String get tutorialReadyMessage => - 'You\'re all set! Start downloading your favorite music now.'; - - @override - String get libraryForceFullScan => 'Force Full Scan'; - - @override - String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache'; - - @override - String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads'; - - @override - String get cleanupOrphanedDownloadsSubtitle => - 'Remove history entries for files that no longer exist'; - - @override - String cleanupOrphanedDownloadsResult(int count) { - return 'Removed $count orphaned entries from history'; - } - - @override - String get cleanupOrphanedDownloadsNone => 'No orphaned entries found'; - - @override - String get cacheTitle => 'Storage & Cache'; - - @override - String get cacheSummaryTitle => 'Cache overview'; - - @override - String get cacheSummarySubtitle => - 'Clearing cache will not remove downloaded music files.'; - - @override - String cacheEstimatedTotal(String size) { - return 'Estimated cache usage: $size'; - } - - @override - String get cacheSectionStorage => 'Cached Data'; - - @override - String get cacheSectionMaintenance => 'Maintenance'; - - @override - String get cacheAppDirectory => 'App cache directory'; - - @override - String get cacheAppDirectoryDesc => - 'HTTP responses, WebView data, and other temporary app data.'; - - @override - String get cacheTempDirectory => 'Temporary directory'; - - @override - String get cacheTempDirectoryDesc => - 'Temporary files from downloads and audio conversion.'; - - @override - String get cacheCoverImage => 'Cover image cache'; - - @override - String get cacheCoverImageDesc => - 'Downloaded album and track cover art. Will re-download when viewed.'; - - @override - String get cacheLibraryCover => 'Library cover cache'; - - @override - String get cacheLibraryCoverDesc => - 'Cover art extracted from local music files. Will re-extract on next scan.'; - - @override - String get cacheExploreFeed => 'Explore feed cache'; - - @override - String get cacheExploreFeedDesc => - 'Explore tab content (new releases, trending). Will refresh on next visit.'; - - @override - String get cacheTrackLookup => 'Track lookup cache'; - - @override - String get cacheTrackLookupDesc => - 'Spotify/Deezer track ID lookups. Clearing may slow next few searches.'; - - @override - String get cacheCleanupUnusedDesc => - 'Remove orphaned download history and library entries for missing files.'; - - @override - String get cacheNoData => 'No cached data'; - - @override - String cacheSizeWithFiles(String size, int count) { - return '$size in $count files'; - } - - @override - String cacheSizeOnly(String size) { - return '$size'; - } - - @override - String cacheEntries(int count) { - return '$count entries'; - } - - @override - String cacheClearSuccess(String target) { - return 'Cleared: $target'; - } - - @override - String get cacheClearConfirmTitle => 'Clear cache?'; - - @override - String cacheClearConfirmMessage(String target) { - return 'This will clear cached data for $target. Downloaded music files will not be deleted.'; - } - - @override - String get cacheClearAllConfirmTitle => 'Clear all cache?'; - - @override - String get cacheClearAllConfirmMessage => - 'This will clear all cache categories on this page. Downloaded music files will not be deleted.'; - - @override - String get cacheClearAll => 'Clear all cache'; - - @override - String get cacheCleanupUnused => 'Cleanup unused data'; - - @override - String get cacheCleanupUnusedSubtitle => - 'Remove orphaned download history and missing library entries'; - - @override - String cacheCleanupResult(int downloadCount, int libraryCount) { - return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries'; - } - - @override - String get cacheRefreshStats => 'Refresh stats'; - - @override - String get trackSaveCoverArt => 'Save Cover Art'; - - @override - String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file'; - - @override - String get trackSaveLyrics => 'Save Lyrics (.lrc)'; - - @override - String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file'; - - @override - String get trackSaveLyricsProgress => 'Saving lyrics...'; - - @override - String get trackReEnrich => 'Re-enrich'; - - @override - String get trackReEnrichOnlineSubtitle => - 'Search metadata online and embed into file'; - - @override - String get trackEditMetadata => 'Edit Metadata'; - - @override - String trackCoverSaved(String fileName) { - return 'Cover art saved to $fileName'; - } - - @override - String get trackCoverNoSource => 'No cover art source available'; - - @override - String trackLyricsSaved(String fileName) { - return 'Lyrics saved to $fileName'; - } - - @override - String get trackReEnrichProgress => 'Re-enriching metadata...'; - - @override - String get trackReEnrichSearching => 'Searching metadata online...'; - - @override - String get trackReEnrichSuccess => 'Metadata re-enriched successfully'; - - @override - String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed'; - - @override - String trackSaveFailed(String error) { - return 'Failed: $error'; - } - - @override - String get trackConvertFormat => 'Convert Format'; - - @override - String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus'; - - @override - String get trackConvertTitle => 'Convert Audio'; - - @override - String get trackConvertTargetFormat => 'Target Format'; - - @override - String get trackConvertBitrate => 'Bitrate'; - - @override - String get trackConvertConfirmTitle => 'Confirm Conversion'; - - @override - String trackConvertConfirmMessage( - String sourceFormat, - String targetFormat, - String bitrate, - ) { - return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.'; - } - - @override - String get trackConvertConverting => 'Converting audio...'; - - @override - String trackConvertSuccess(String format) { - return 'Converted to $format successfully'; - } - - @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`). class AppLocalizationsZhTw extends AppLocalizationsZh { AppLocalizationsZhTw() : super('zh_TW'); @@ -5203,6 +5497,13 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get filenameFormat => 'Filename Format'; + @override + String get filenameShowAdvancedTags => 'Show advanced tags'; + + @override + String get filenameShowAdvancedTagsDescription => + 'Enable formatted tags for track padding and date patterns'; + @override String get folderOrganizationNone => 'No organization'; @@ -5780,6 +6081,12 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { 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'; @@ -6111,6 +6418,17 @@ 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 libraryTracksUnit(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return '$_temp0'; + } + @override String libraryLastScanned(String time) { return 'Last scanned: $time'; @@ -6554,6 +6872,210 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @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'; diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index b40c1f71..e32aa56e 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -9,7 +9,7 @@ "@navHome": { "description": "Bottom navigation - Home tab" }, - "navLibrary": "Archiv", + "navLibrary": "Bibliothek", "@navLibrary": { "description": "Bottom navigation - Library tab" }, @@ -623,7 +623,7 @@ "@setupEnableNotifications": { "description": "Button to enable notifications" }, - "setupNotificationBackgroundDescription": "Werde benachrichtigt über Download-Fortschritt und -Fertigstellung. Dies hilft Ihnen, Downloads zu verfolgen, wenn die App im Hintergrund ist.", + "setupNotificationBackgroundDescription": "Erhalte Benachrichtigungen über den Fortschritt und die Fertigstellung deiner Downloads, selbst wenn die App im Hintergrund läuft.", "@setupNotificationBackgroundDescription": { "description": "Detailed notification explanation" }, @@ -737,11 +737,11 @@ } } }, - "dialogImportPlaylistTitle": "Wiedergabeliste importieren", + "dialogImportPlaylistTitle": "Playlist importieren", "@dialogImportPlaylistTitle": { "description": "Dialog title - import CSV playlist" }, - "dialogImportPlaylistMessage": "{count} Titel in CSV gefunden. Zur Warteschlange hinzufügen?", + "dialogImportPlaylistMessage": "{count} Titel gefunden hinzufügen?", "csvImportTracks": "{count} Titel aus CSV", "@csvImportTracks": { "description": "Label shown in quality picker for CSV import", @@ -759,7 +759,7 @@ } } }, - "snackbarAddedToQueue": "\"{trackName}\" zur Warteschlange hinzugefügt", + "snackbarAddedToQueue": "\"{trackName}\" hinzugefügt", "@snackbarAddedToQueue": { "description": "Snackbar - track added to download queue", "placeholders": { @@ -768,7 +768,7 @@ } } }, - "snackbarAddedTracksToQueue": "{count} Titel zur Warteschlange hinzugefügt", + "snackbarAddedTracksToQueue": "{count} Titel hinzugefügt", "@snackbarAddedTracksToQueue": { "description": "Snackbar - multiple tracks added to queue", "placeholders": { @@ -991,6 +991,14 @@ "@filenameFormat": { "description": "Setting title - filename pattern" }, + "filenameShowAdvancedTags": "Erweiterte Tags anzeigen", + "@filenameShowAdvancedTags": { + "description": "Toggle label for showing advanced filename tags" + }, + "filenameShowAdvancedTagsDescription": "Formatierte Tags für Track-Padding und Datumsmuster aktivieren", + "@filenameShowAdvancedTagsDescription": { + "description": "Description for advanced filename tag toggle" + }, "folderOrganizationNone": "Keine Organisation", "@folderOrganizationNone": { "description": "Folder option - flat structure" @@ -1172,7 +1180,7 @@ } } }, - "logEntries": "Entries ({count})", + "logEntries": "{count} Einträge", "@logEntries": { "description": "Total log count", "placeholders": { @@ -1181,7 +1189,7 @@ } } }, - "credentialsTitle": "Spotify Credentials", + "credentialsTitle": "Spotify-Anmeldedaten", "@credentialsTitle": { "description": "Credentials dialog title" }, @@ -1261,7 +1269,7 @@ "@lyricsModeDescription": { "description": "Lyrics mode picker description" }, - "lyricsModeEmbed": "In Datei einbinden", + "lyricsModeEmbed": "In Datei einbetten", "@lyricsModeEmbed": { "description": "Lyrics mode option - embed in audio file" }, @@ -1281,7 +1289,7 @@ "@lyricsModeBoth": { "description": "Lyrics mode option - embed and external" }, - "lyricsModeBothSubtitle": "Lyrics einbinden und als .lrc speichern", + "lyricsModeBothSubtitle": "Lyrics einbetten und als .lrc speichern", "@lyricsModeBothSubtitle": { "description": "Subtitle for both option" }, @@ -1305,35 +1313,35 @@ "@appearanceLanguage": { "description": "Language setting title" }, - "settingsAppearanceSubtitle": "Theme, colors, display", + "settingsAppearanceSubtitle": "Design, Farben, Anzeige", "@settingsAppearanceSubtitle": { "description": "Appearance settings description" }, - "settingsDownloadSubtitle": "Service, quality, filename format", + "settingsDownloadSubtitle": "Dienst, Qualität, Dateinamen-Format", "@settingsDownloadSubtitle": { "description": "Download settings description" }, - "settingsOptionsSubtitle": "Fallback, lyrics, cover art, updates", + "settingsOptionsSubtitle": "Fallback, Lyrics, Covers, Updates", "@settingsOptionsSubtitle": { "description": "Options settings description" }, - "settingsExtensionsSubtitle": "Manage download providers", + "settingsExtensionsSubtitle": "Download-Anbieter verwalten", "@settingsExtensionsSubtitle": { "description": "Extensions settings description" }, - "settingsLogsSubtitle": "View app logs for debugging", + "settingsLogsSubtitle": "App-Logs zum Debuggen anzeigen", "@settingsLogsSubtitle": { "description": "Logs settings description" }, - "loadingSharedLink": "Loading shared link...", + "loadingSharedLink": "Link wird geladen...", "@loadingSharedLink": { "description": "Status when opening shared URL" }, - "pressBackAgainToExit": "Press back again to exit", + "pressBackAgainToExit": "Drücke wieder \"zurück\" um die App zu beenden", "@pressBackAgainToExit": { "description": "Exit confirmation message" }, - "downloadAllCount": "Download All ({count})", + "downloadAllCount": "Alle {count} Titel herunterladen", "@downloadAllCount": { "description": "Download all button with count", "placeholders": { @@ -1342,7 +1350,7 @@ } } }, - "tracksCount": "{count, plural, =1{1 track} other{{count} tracks}}", + "tracksCount": "{count, plural, =1{1 Titel} other{{count} Titel}}", "@tracksCount": { "description": "Track count display", "placeholders": { @@ -1351,23 +1359,23 @@ } } }, - "trackCopyFilePath": "Copy file path", + "trackCopyFilePath": "Dateipfad kopieren", "@trackCopyFilePath": { "description": "Action - copy file path" }, - "trackRemoveFromDevice": "Remove from device", + "trackRemoveFromDevice": "Vom Gerät entfernen", "@trackRemoveFromDevice": { "description": "Action - delete downloaded file" }, - "trackLoadLyrics": "Load Lyrics", + "trackLoadLyrics": "Lade Lyrics", "@trackLoadLyrics": { "description": "Action - fetch lyrics" }, - "trackMetadata": "Metadata", + "trackMetadata": "Metadaten", "@trackMetadata": { "description": "Tab title - track metadata" }, - "trackFileInfo": "File Info", + "trackFileInfo": "Datei-Info", "@trackFileInfo": { "description": "Tab title - file information" }, @@ -1375,27 +1383,27 @@ "@trackLyrics": { "description": "Tab title - lyrics" }, - "trackFileNotFound": "File not found", + "trackFileNotFound": "Datei nicht gefunden", "@trackFileNotFound": { "description": "Error - file doesn't exist" }, - "trackOpenInDeezer": "Open in Deezer", + "trackOpenInDeezer": "In Deezer öffnen", "@trackOpenInDeezer": { "description": "Action - open track in Deezer app" }, - "trackOpenInSpotify": "Open in Spotify", + "trackOpenInSpotify": "In Spotify öffnen", "@trackOpenInSpotify": { "description": "Action - open track in Spotify app" }, - "trackTrackName": "Track name", + "trackTrackName": "Name des Titels", "@trackTrackName": { "description": "Metadata label - track title" }, - "trackArtist": "Artist", + "trackArtist": "Künstler", "@trackArtist": { "description": "Metadata label - artist name" }, - "trackAlbumArtist": "Album artist", + "trackAlbumArtist": "Album Künstler", "@trackAlbumArtist": { "description": "Metadata label - album artist" }, @@ -1403,23 +1411,23 @@ "@trackAlbum": { "description": "Metadata label - album name" }, - "trackTrackNumber": "Track number", + "trackTrackNumber": "Titelnummer", "@trackTrackNumber": { "description": "Metadata label - track number" }, - "trackDiscNumber": "Disc number", + "trackDiscNumber": "CD-Nummer", "@trackDiscNumber": { "description": "Metadata label - disc number" }, - "trackDuration": "Duration", + "trackDuration": "Länge", "@trackDuration": { "description": "Metadata label - track length" }, - "trackAudioQuality": "Audio quality", + "trackAudioQuality": "Audioqualität", "@trackAudioQuality": { "description": "Metadata label - audio quality" }, - "trackReleaseDate": "Release date", + "trackReleaseDate": "Erscheinungsdatum", "@trackReleaseDate": { "description": "Metadata label - release date" }, @@ -1431,63 +1439,63 @@ "@trackLabel": { "description": "Metadata label - record label" }, - "trackCopyright": "Copyright", + "trackCopyright": "Urheberrecht", "@trackCopyright": { "description": "Metadata label - copyright information" }, - "trackDownloaded": "Downloaded", + "trackDownloaded": "Heruntergeladen", "@trackDownloaded": { "description": "Metadata label - download date" }, - "trackCopyLyrics": "Copy lyrics", + "trackCopyLyrics": "Lyrics kopieren", "@trackCopyLyrics": { "description": "Action - copy lyrics to clipboard" }, - "trackLyricsNotAvailable": "Lyrics not available for this track", + "trackLyricsNotAvailable": "Lyrics sind für diesen Titel nicht verfügbar", "@trackLyricsNotAvailable": { "description": "Message when lyrics not found" }, - "trackLyricsTimeout": "Request timed out. Try again later.", + "trackLyricsTimeout": "Anfrage Timeout. Versuche es später erneut.", "@trackLyricsTimeout": { "description": "Message when lyrics request times out" }, - "trackLyricsLoadFailed": "Failed to load lyrics", + "trackLyricsLoadFailed": "Fehler beim Laden der Lyrics", "@trackLyricsLoadFailed": { "description": "Message when lyrics loading fails" }, - "trackEmbedLyrics": "Embed Lyrics", + "trackEmbedLyrics": "Lyrics einbetten", "@trackEmbedLyrics": { "description": "Action - embed lyrics into audio file" }, - "trackLyricsEmbedded": "Lyrics embedded successfully", + "trackLyricsEmbedded": "Lyrics erfolgreich eingebettet", "@trackLyricsEmbedded": { "description": "Snackbar - lyrics saved to file" }, - "trackInstrumental": "Instrumental track", + "trackInstrumental": "Instrumentalspur", "@trackInstrumental": { "description": "Message when track is instrumental (no lyrics)" }, - "trackCopiedToClipboard": "Copied to clipboard", + "trackCopiedToClipboard": "In Zwischenablage kopiert", "@trackCopiedToClipboard": { "description": "Snackbar - content copied" }, - "trackDeleteConfirmTitle": "Remove from device?", + "trackDeleteConfirmTitle": "Vom Gerät entfernen?", "@trackDeleteConfirmTitle": { "description": "Delete confirmation title" }, - "trackDeleteConfirmMessage": "This will permanently delete the downloaded file and remove it from your history.", + "trackDeleteConfirmMessage": "Dies wird die heruntergeladene Datei dauerhaft löschen und sie aus deinem Verlauf entfernen.", "@trackDeleteConfirmMessage": { "description": "Delete confirmation message" }, - "dateToday": "Today", + "dateToday": "Heute", "@dateToday": { "description": "Relative date - today" }, - "dateYesterday": "Yesterday", + "dateYesterday": "Gestern", "@dateYesterday": { "description": "Relative date - yesterday" }, - "dateDaysAgo": "{count} days ago", + "dateDaysAgo": "Vor {count} Tagen", "@dateDaysAgo": { "description": "Relative date - days ago", "placeholders": { @@ -1496,7 +1504,7 @@ } } }, - "dateWeeksAgo": "{count} weeks ago", + "dateWeeksAgo": "Vor {count} Wochen", "@dateWeeksAgo": { "description": "Relative date - weeks ago", "placeholders": { @@ -1505,7 +1513,7 @@ } } }, - "dateMonthsAgo": "{count} months ago", + "dateMonthsAgo": "Vor {count} Monaten", "@dateMonthsAgo": { "description": "Relative date - months ago", "placeholders": { @@ -1514,15 +1522,15 @@ } } }, - "storeFilterAll": "All", + "storeFilterAll": "Alle", "@storeFilterAll": { "description": "Store filter - all extensions" }, - "storeFilterMetadata": "Metadata", + "storeFilterMetadata": "Metadaten", "@storeFilterMetadata": { "description": "Store filter - metadata providers" }, - "storeFilterDownload": "Download", + "storeFilterDownload": "Herunterladen", "@storeFilterDownload": { "description": "Store filter - download providers" }, @@ -1538,19 +1546,19 @@ "@storeFilterIntegration": { "description": "Store filter - integrations" }, - "storeClearFilters": "Clear filters", + "storeClearFilters": "Filter entfernen", "@storeClearFilters": { "description": "Button to clear all filters" }, - "extensionDefaultProvider": "Default (Deezer/Spotify)", + "extensionDefaultProvider": "Standard (Deezer/Spotify)", "@extensionDefaultProvider": { "description": "Default search provider option" }, - "extensionDefaultProviderSubtitle": "Use built-in search", + "extensionDefaultProviderSubtitle": "Eingebaute Suche verwenden", "@extensionDefaultProviderSubtitle": { "description": "Subtitle for default provider" }, - "extensionAuthor": "Author", + "extensionAuthor": "Entwickler", "@extensionAuthor": { "description": "Extension detail - author" }, @@ -1558,23 +1566,23 @@ "@extensionId": { "description": "Extension detail - unique ID" }, - "extensionError": "Error", + "extensionError": "Fehler", "@extensionError": { "description": "Extension detail - error message" }, - "extensionCapabilities": "Capabilities", + "extensionCapabilities": "Eigenschaften", "@extensionCapabilities": { "description": "Section header - extension features" }, - "extensionMetadataProvider": "Metadata Provider", + "extensionMetadataProvider": "Metadaten-Anbieter", "@extensionMetadataProvider": { "description": "Capability - provides metadata" }, - "extensionDownloadProvider": "Download Provider", + "extensionDownloadProvider": "Download-Anbieter", "@extensionDownloadProvider": { "description": "Capability - provides downloads" }, - "extensionLyricsProvider": "Lyrics Provider", + "extensionLyricsProvider": "Lyrics-Anbieter", "@extensionLyricsProvider": { "description": "Capability - provides lyrics" }, @@ -1582,7 +1590,7 @@ "@extensionUrlHandler": { "description": "Capability - handles URLs" }, - "extensionQualityOptions": "Quality Options", + "extensionQualityOptions": "Qualitätsoptionen", "@extensionQualityOptions": { "description": "Capability - quality selection" }, @@ -1590,35 +1598,35 @@ "@extensionPostProcessingHooks": { "description": "Capability - post-processing" }, - "extensionPermissions": "Permissions", + "extensionPermissions": "Berechtigungen", "@extensionPermissions": { "description": "Section header - required permissions" }, - "extensionSettings": "Settings", + "extensionSettings": "Einstellungen", "@extensionSettings": { "description": "Section header - extension settings" }, - "extensionRemoveButton": "Remove Extension", + "extensionRemoveButton": "Erweiterung entfernen", "@extensionRemoveButton": { "description": "Button to uninstall extension" }, - "extensionUpdated": "Updated", + "extensionUpdated": "Aktualisiert", "@extensionUpdated": { "description": "Extension detail - last update" }, - "extensionMinAppVersion": "Min App Version", + "extensionMinAppVersion": "Min App-Version", "@extensionMinAppVersion": { "description": "Extension detail - minimum app version" }, - "extensionCustomTrackMatching": "Custom Track Matching", + "extensionCustomTrackMatching": "Benutzerdefiniertes Track-Matching", "@extensionCustomTrackMatching": { "description": "Capability - custom track matching algorithm" }, - "extensionPostProcessing": "Post-Processing", + "extensionPostProcessing": "Post-processing", "@extensionPostProcessing": { "description": "Capability - post-download processing" }, - "extensionHooksAvailable": "{count} hook(s) available", + "extensionHooksAvailable": "{count} Hook(s) verfügbar", "@extensionHooksAvailable": { "description": "Post-processing hooks count", "placeholders": { @@ -1627,7 +1635,7 @@ } } }, - "extensionPatternsCount": "{count} pattern(s)", + "extensionPatternsCount": "{count} Muster", "@extensionPatternsCount": { "description": "URL patterns count", "placeholders": { @@ -1636,7 +1644,7 @@ } } }, - "extensionStrategy": "Strategy: {strategy}", + "extensionStrategy": "Strategie: {strategy}", "@extensionStrategy": { "description": "Track matching strategy name", "placeholders": { @@ -1645,79 +1653,79 @@ } } }, - "extensionsProviderPrioritySection": "Provider Priority", + "extensionsProviderPrioritySection": "Provider-Priorität", "@extensionsProviderPrioritySection": { "description": "Section header - provider priority" }, - "extensionsInstalledSection": "Installed Extensions", + "extensionsInstalledSection": "Installierte Erweiterungen", "@extensionsInstalledSection": { "description": "Section header - installed extensions" }, - "extensionsNoExtensions": "No extensions installed", + "extensionsNoExtensions": "Keine Erweiterungen installiert", "@extensionsNoExtensions": { "description": "Empty state - no extensions" }, - "extensionsNoExtensionsSubtitle": "Install .spotiflac-ext files to add new providers", + "extensionsNoExtensionsSubtitle": "Installiere .spotiflac-ext Dateien um neue Anbieter hinzuzufügen", "@extensionsNoExtensionsSubtitle": { "description": "Empty state subtitle" }, - "extensionsInstallButton": "Install Extension", + "extensionsInstallButton": "Erweiterung installieren", "@extensionsInstallButton": { "description": "Button to install extension from file" }, - "extensionsInfoTip": "Extensions can add new metadata and download providers. Only install extensions from trusted sources.", + "extensionsInfoTip": "Erweiterungen können neue Metadaten und Download-Anbieter hinzufügen. Installiere nur Erweiterungen von vertrauenswürdigen Quellen.", "@extensionsInfoTip": { "description": "Security warning about extensions" }, - "extensionsInstalledSuccess": "Extension installed successfully", + "extensionsInstalledSuccess": "Erweiterung erfolgreich installiert", "@extensionsInstalledSuccess": { "description": "Success message after install" }, - "extensionsDownloadPriority": "Download Priority", + "extensionsDownloadPriority": "Download-Priorität", "@extensionsDownloadPriority": { "description": "Setting - download provider order" }, - "extensionsDownloadPrioritySubtitle": "Set download service order", + "extensionsDownloadPrioritySubtitle": "Download-Service-Reihenfolge festlegen", "@extensionsDownloadPrioritySubtitle": { "description": "Subtitle for download priority" }, - "extensionsNoDownloadProvider": "No extensions with download provider", + "extensionsNoDownloadProvider": "Keine Erweiterungen mit Download-Provider", "@extensionsNoDownloadProvider": { "description": "Empty state - no download providers" }, - "extensionsMetadataPriority": "Metadata Priority", + "extensionsMetadataPriority": "Metadaten Priorität", "@extensionsMetadataPriority": { "description": "Setting - metadata provider order" }, - "extensionsMetadataPrioritySubtitle": "Set search & metadata source order", + "extensionsMetadataPrioritySubtitle": "Reihenfolge der Such- und Metadaten quellen festlegen", "@extensionsMetadataPrioritySubtitle": { "description": "Subtitle for metadata priority" }, - "extensionsNoMetadataProvider": "No extensions with metadata provider", + "extensionsNoMetadataProvider": "Keine Erweiterungen mit Metadaten-Anbieter", "@extensionsNoMetadataProvider": { "description": "Empty state - no metadata providers" }, - "extensionsSearchProvider": "Search Provider", + "extensionsSearchProvider": "Such-Provider", "@extensionsSearchProvider": { "description": "Setting - search provider selection" }, - "extensionsNoCustomSearch": "No extensions with custom search", + "extensionsNoCustomSearch": "Keine Erweiterungen mit benutzerdefinierter Suche", "@extensionsNoCustomSearch": { "description": "Empty state - no search providers" }, - "extensionsSearchProviderDescription": "Choose which service to use for searching tracks", + "extensionsSearchProviderDescription": "Wähle den Dienst für die Suche von Titel", "@extensionsSearchProviderDescription": { "description": "Search provider setting description" }, - "extensionsCustomSearch": "Custom search", + "extensionsCustomSearch": "Benutzerdefinierte Suche", "@extensionsCustomSearch": { "description": "Label for custom search provider" }, - "extensionsErrorLoading": "Error loading extension", + "extensionsErrorLoading": "Fehler beim Laden der Erweiterung", "@extensionsErrorLoading": { "description": "Error message when extension fails to load" }, - "qualityFlacLossless": "FLAC Lossless", + "qualityFlacLossless": "FLAC Verlustfrei", "@qualityFlacLossless": { "description": "Quality option - CD quality FLAC" }, @@ -1729,7 +1737,7 @@ "@qualityHiResFlac": { "description": "Quality option - high resolution FLAC" }, - "qualityHiResFlacSubtitle": "24-bit / up to 96kHz", + "qualityHiResFlacSubtitle": "24-Bit / bis 96kHz", "@qualityHiResFlacSubtitle": { "description": "Technical spec for hi-res" }, @@ -1737,27 +1745,35 @@ "@qualityHiResFlacMax": { "description": "Quality option - maximum resolution FLAC" }, - "qualityHiResFlacMaxSubtitle": "24-bit / up to 192kHz", + "qualityHiResFlacMaxSubtitle": "24-Bit / bis 192kHz", "@qualityHiResFlacMaxSubtitle": { "description": "Technical spec for hi-res max" }, - "qualityNote": "Actual quality depends on track availability from the service", + "qualityNote": "Die eigentliche Qualität hängt von der Verfügbarkeit des Dienstes ab", "@qualityNote": { "description": "Note about quality availability" }, - "youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.", + "youtubeQualityNote": "YouTube bietet nur verlustbehaftete Audioqualität. Deswegen ist es kein Teil des verlustfreien Fallbacks.", "@youtubeQualityNote": { "description": "Note for YouTube service explaining lossy-only quality" }, - "downloadAskBeforeDownload": "Ask Before Download", + "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": "Qualität vor Download fragen", "@downloadAskBeforeDownload": { "description": "Setting - show quality picker" }, - "downloadDirectory": "Download Directory", + "downloadDirectory": "Downloadverzeichnis", "@downloadDirectory": { "description": "Setting - download folder" }, - "downloadSeparateSinglesFolder": "Separate Singles Folder", + "downloadSeparateSinglesFolder": "Singles Ordner trennen", "@downloadSeparateSinglesFolder": { "description": "Setting - separate folder for singles" }, @@ -1773,7 +1789,7 @@ "@downloadUsePrimaryArtistOnly": { "description": "Setting - strip featured artists from folder name" }, - "downloadUsePrimaryArtistOnlyEnabled": "Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)", + "downloadUsePrimaryArtistOnlyEnabled": "Vorgestellte Künstler aus dem Ordnernamen entfernt (z.B. Justin Bieber, Quavo → Justin Bieber)", "@downloadUsePrimaryArtistOnlyEnabled": { "description": "Subtitle when primary artist only is enabled" }, @@ -1781,27 +1797,27 @@ "@downloadUsePrimaryArtistOnlyDisabled": { "description": "Subtitle when primary artist only is disabled" }, - "downloadSelectQuality": "Select Quality", + "downloadSelectQuality": "Qualität wählen", "@downloadSelectQuality": { "description": "Dialog title - choose audio quality" }, - "downloadFrom": "Download From", + "downloadFrom": "Herunterladen von", "@downloadFrom": { "description": "Label - download source" }, - "appearanceAmoledDark": "AMOLED Dark", + "appearanceAmoledDark": "AMOLED Schwarz", "@appearanceAmoledDark": { "description": "Theme option - pure black" }, - "appearanceAmoledDarkSubtitle": "Pure black background", + "appearanceAmoledDarkSubtitle": "AMOLED Hintergrund", "@appearanceAmoledDarkSubtitle": { "description": "Subtitle for AMOLED dark" }, - "queueClearAll": "Clear All", + "queueClearAll": "Alles löschen", "@queueClearAll": { "description": "Button - clear all queue items" }, - "queueClearAllMessage": "Are you sure you want to clear all downloads?", + "queueClearAllMessage": "Bist du dir sicher, dass du alle Downloads löschen möchten?", "@queueClearAllMessage": { "description": "Clear queue confirmation" }, @@ -1809,27 +1825,27 @@ "@settingsAutoExportFailed": { "description": "Setting toggle for auto-export" }, - "settingsAutoExportFailedSubtitle": "Save failed downloads to TXT file automatically", + "settingsAutoExportFailedSubtitle": "Fehlgeschlagene Downloads automatisch in eine TXT-Datei speichern", "@settingsAutoExportFailedSubtitle": { "description": "Subtitle for auto-export setting" }, - "settingsDownloadNetwork": "Download Network", + "settingsDownloadNetwork": "Download Netzwerk", "@settingsDownloadNetwork": { "description": "Setting for network type preference" }, - "settingsDownloadNetworkAny": "WiFi + Mobile Data", + "settingsDownloadNetworkAny": "WLAN + Mobile Daten", "@settingsDownloadNetworkAny": { "description": "Network option - use any connection" }, - "settingsDownloadNetworkWifiOnly": "WiFi Only", + "settingsDownloadNetworkWifiOnly": "Nur WLAN", "@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": "Wähle aus, welches Netzwerk für Downloads verwendet werden soll. Wenn nur WLAN aktiviert wird, werden Downloads auf mobilen Daten angehalten.", "@settingsDownloadNetworkSubtitle": { "description": "Subtitle explaining network preference" }, - "albumFolderArtistAlbum": "Artist / Album", + "albumFolderArtistAlbum": "Künstler/Album", "@albumFolderArtistAlbum": { "description": "Album folder option" }, @@ -1841,15 +1857,15 @@ "@albumFolderArtistYearAlbum": { "description": "Album folder option with year" }, - "albumFolderArtistYearAlbumSubtitle": "Albums/Artist Name/[2005] Album Name/", + "albumFolderArtistYearAlbumSubtitle": "Albums/Künster Name/[2005] Album Name/", "@albumFolderArtistYearAlbumSubtitle": { "description": "Folder structure example" }, - "albumFolderAlbumOnly": "Album Only", + "albumFolderAlbumOnly": "Nur Alben", "@albumFolderAlbumOnly": { "description": "Album folder option" }, - "albumFolderAlbumOnlySubtitle": "Albums/Album Name/", + "albumFolderAlbumOnlySubtitle": "Alben/Album Name/", "@albumFolderAlbumOnlySubtitle": { "description": "Folder structure example" }, @@ -1869,11 +1885,11 @@ "@albumFolderArtistAlbumSinglesSubtitle": { "description": "Folder structure example" }, - "downloadedAlbumDeleteSelected": "Delete Selected", + "downloadedAlbumDeleteSelected": "Ausgewählte löschen", "@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": "{count} {count, plural, one {Titel} other{Titel}} aus diesem Album löschen?\n\nDadurch werden auch die Dateien aus dem Speicher gelöscht.", "@downloadedAlbumDeleteMessage": { "description": "Delete confirmation with count", "placeholders": { @@ -1882,7 +1898,7 @@ } } }, - "downloadedAlbumSelectedCount": "{count} selected", + "downloadedAlbumSelectedCount": "{count} ausgewählt", "@downloadedAlbumSelectedCount": { "description": "Selection count indicator", "placeholders": { @@ -1891,15 +1907,15 @@ } } }, - "downloadedAlbumAllSelected": "All tracks selected", + "downloadedAlbumAllSelected": "Alle Titel sind ausgewählt", "@downloadedAlbumAllSelected": { "description": "Status - all items selected" }, - "downloadedAlbumTapToSelect": "Tap tracks to select", + "downloadedAlbumTapToSelect": "Tippe auf Titel zum Auswählen", "@downloadedAlbumTapToSelect": { "description": "Selection hint" }, - "downloadedAlbumDeleteCount": "Delete {count} {count, plural, =1{track} other{tracks}}", + "downloadedAlbumDeleteCount": "Lösche {count} {count, plural, one {Titel} other{Titel}}", "@downloadedAlbumDeleteCount": { "description": "Delete button text with count", "placeholders": { @@ -1922,7 +1938,7 @@ } } }, - "recentTypeArtist": "Artist", + "recentTypeArtist": "Künstler", "@recentTypeArtist": { "description": "Recent access item type - artist" }, @@ -1930,7 +1946,7 @@ "@recentTypeAlbum": { "description": "Recent access item type - album" }, - "recentTypeSong": "Song", + "recentTypeSong": "Titel", "@recentTypeSong": { "description": "Recent access item type - song/track" }, @@ -1938,11 +1954,11 @@ "@recentTypePlaylist": { "description": "Recent access item type - playlist" }, - "recentEmpty": "No recent items yet", + "recentEmpty": "Noch keine aktuellen Einträge", "@recentEmpty": { "description": "Empty state text for recent access list" }, - "recentShowAllDownloads": "Show All Downloads", + "recentShowAllDownloads": "Alle Downloads anzeigen", "@recentShowAllDownloads": { "description": "Button label to unhide hidden downloads in recent access" }, @@ -1956,15 +1972,15 @@ } } }, - "discographyDownload": "Download Discography", + "discographyDownload": "Diskographie herunterladen", "@discographyDownload": { "description": "Button - download artist discography" }, - "discographyDownloadAll": "Download All", + "discographyDownloadAll": "Alle Herunterladen", "@discographyDownloadAll": { "description": "Option - download entire discography" }, - "discographyDownloadAllSubtitle": "{count} tracks from {albumCount} releases", + "discographyDownloadAllSubtitle": "{count} Titel von {albumCount} Releases", "@discographyDownloadAllSubtitle": { "description": "Subtitle showing total tracks and albums", "placeholders": { @@ -1976,11 +1992,11 @@ } } }, - "discographyAlbumsOnly": "Albums Only", + "discographyAlbumsOnly": "Nur Alben", "@discographyAlbumsOnly": { "description": "Option - download only albums" }, - "discographyAlbumsOnlySubtitle": "{count} tracks from {albumCount} albums", + "discographyAlbumsOnlySubtitle": "{count} Titel von {albumCount} Albums", "@discographyAlbumsOnlySubtitle": { "description": "Subtitle showing album tracks count", "placeholders": { @@ -1992,11 +2008,11 @@ } } }, - "discographySinglesOnly": "Singles & EPs Only", + "discographySinglesOnly": "Nur Singles & EPs", "@discographySinglesOnly": { "description": "Option - download only singles" }, - "discographySinglesOnlySubtitle": "{count} tracks from {albumCount} singles", + "discographySinglesOnlySubtitle": "{count} Titel von {albumCount} Singles", "@discographySinglesOnlySubtitle": { "description": "Subtitle showing singles tracks count", "placeholders": { @@ -2008,7 +2024,7 @@ } } }, - "discographySelectAlbums": "Select Albums...", + "discographySelectAlbums": "Alben auswählen...", "@discographySelectAlbums": { "description": "Option - manually select albums to download" }, @@ -2016,7 +2032,7 @@ "@discographySelectAlbumsSubtitle": { "description": "Subtitle for select albums option" }, - "discographyFetchingTracks": "Fetching tracks...", + "discographyFetchingTracks": "Lade Titel...", "@discographyFetchingTracks": { "description": "Progress - fetching album tracks" }, @@ -2032,7 +2048,7 @@ } } }, - "discographySelectedCount": "{count} selected", + "discographySelectedCount": "{count} ausgewählt", "@discographySelectedCount": { "description": "Selection count badge", "placeholders": { @@ -2041,7 +2057,7 @@ } } }, - "discographyDownloadSelected": "Download Selected", + "discographyDownloadSelected": "Auswahl herunterladen", "@discographyDownloadSelected": { "description": "Button - download selected albums" }, @@ -2054,7 +2070,7 @@ } } }, - "discographySkippedDownloaded": "{added} added, {skipped} already downloaded", + "discographySkippedDownloaded": "{added} hinzugefügt, {skipped} bereits heruntergeladen", "@discographySkippedDownloaded": { "description": "Snackbar - with skipped tracks count", "placeholders": { @@ -2066,7 +2082,7 @@ } } }, - "discographyNoAlbums": "No albums available", + "discographyNoAlbums": "Es sind keine Alben verfügbar", "@discographyNoAlbums": { "description": "Error - no albums found for artist" }, @@ -2074,11 +2090,11 @@ "@discographyFailedToFetch": { "description": "Error - some albums failed to load" }, - "sectionStorageAccess": "Storage Access", + "sectionStorageAccess": "Speicherzugriff", "@sectionStorageAccess": { "description": "Section header for storage access settings" }, - "allFilesAccess": "All Files Access", + "allFilesAccess": "Zugriff auf alle Dateien", "@allFilesAccess": { "description": "Toggle for MANAGE_EXTERNAL_STORAGE permission" }, @@ -2090,19 +2106,19 @@ "@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": "Aktiviere die Option, wenn beim Speichern in benutzerdefinierten Ordnern Schreibfehler auftreten. Weil Android 13+ standardmäßig den Zugriff auf bestimmte Verzeichnisse einschränkt.", "@allFilesAccessDescription": { "description": "Description explaining when to enable all files access" }, - "allFilesAccessDeniedMessage": "Permission was denied. Please enable 'All files access' manually in system settings.", + "allFilesAccessDeniedMessage": "Zugriff verweigert. Bitte aktiviere \"Zugriff auf alle Dateien\" manuell in den Systemeinstellungen.", "@allFilesAccessDeniedMessage": { "description": "Message when permission is permanently denied" }, - "allFilesAccessDisabledMessage": "All Files Access disabled. The app will use limited storage access.", + "allFilesAccessDisabledMessage": "Zugriff auf alle Dateien ist deaktiviert. Die App verwendet nur begrenzten Zugriff auf den Speicher.", "@allFilesAccessDisabledMessage": { "description": "Snackbar message when user disables all files access" }, - "settingsLocalLibrary": "Local Library", + "settingsLocalLibrary": "Lokale Bibliothek", "@settingsLocalLibrary": { "description": "Settings menu item - local library" }, @@ -2110,7 +2126,7 @@ "@settingsLocalLibrarySubtitle": { "description": "Subtitle for local library settings" }, - "settingsCache": "Storage & Cache", + "settingsCache": "Speicher & Cache", "@settingsCache": { "description": "Settings menu item - cache management" }, @@ -2118,15 +2134,15 @@ "@settingsCacheSubtitle": { "description": "Subtitle for cache management menu" }, - "libraryTitle": "Local Library", + "libraryTitle": "Lokale Bibliothek", "@libraryTitle": { "description": "Library settings page title" }, - "libraryScanSettings": "Scan Settings", + "libraryScanSettings": "Scan Einstellungen", "@libraryScanSettings": { "description": "Section header for scan settings" }, - "libraryEnableLocalLibrary": "Enable Local Library", + "libraryEnableLocalLibrary": "Lokale Bibliothek aktivieren", "@libraryEnableLocalLibrary": { "description": "Toggle to enable library scanning" }, @@ -2134,11 +2150,11 @@ "@libraryEnableLocalLibrarySubtitle": { "description": "Subtitle for enable toggle" }, - "libraryFolder": "Library Folder", + "libraryFolder": "Bibliotheksordner", "@libraryFolder": { "description": "Folder selection setting" }, - "libraryFolderHint": "Tap to select folder", + "libraryFolderHint": "Tippe um Ordner auszuwählen", "@libraryFolderHint": { "description": "Placeholder when no folder selected" }, @@ -2146,59 +2162,68 @@ "@libraryShowDuplicateIndicator": { "description": "Toggle for duplicate indicator in search" }, - "libraryShowDuplicateIndicatorSubtitle": "Show when searching for existing tracks", + "libraryShowDuplicateIndicatorSubtitle": "Bei der Suche nach vorhandenen Titeln anzeigen", "@libraryShowDuplicateIndicatorSubtitle": { "description": "Subtitle for duplicate indicator toggle" }, - "libraryActions": "Actions", + "libraryActions": "Aktionen", "@libraryActions": { "description": "Section header for library actions" }, - "libraryScan": "Scan Library", + "libraryScan": "Bibliothek scannen", "@libraryScan": { "description": "Button to start library scan" }, - "libraryScanSubtitle": "Scan for audio files", + "libraryScanSubtitle": "Suche nach Audiodateien", "@libraryScanSubtitle": { "description": "Subtitle for scan button" }, - "libraryScanSelectFolderFirst": "Select a folder first", + "libraryScanSelectFolderFirst": "Wähle zuerst einen Ordner", "@libraryScanSelectFolderFirst": { "description": "Message when trying to scan without folder" }, - "libraryCleanupMissingFiles": "Cleanup Missing Files", + "libraryCleanupMissingFiles": "Fehlende Dateien bereinigen", "@libraryCleanupMissingFiles": { "description": "Button to remove entries for missing files" }, - "libraryCleanupMissingFilesSubtitle": "Remove entries for files that no longer exist", + "libraryCleanupMissingFilesSubtitle": "Verlaufseinträge für Dateien löschen, die nicht mehr existieren", "@libraryCleanupMissingFilesSubtitle": { "description": "Subtitle for cleanup button" }, - "libraryClear": "Clear Library", + "libraryClear": "Bibliothek löschen", "@libraryClear": { "description": "Button to clear all library entries" }, - "libraryClearSubtitle": "Remove all scanned tracks", + "libraryClearSubtitle": "Alle gescannten Titel entfernen", "@libraryClearSubtitle": { "description": "Subtitle for clear button" }, - "libraryClearConfirmTitle": "Clear Library", + "libraryClearConfirmTitle": "Bibliothek löschen", "@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": "Dadurch werden alle gescannten Titel aus Ihrer Bibliothek entfernt. Ihre eigentlichen Musikdateien werden nicht gelöscht.", "@libraryClearConfirmMessage": { "description": "Dialog message for clear confirmation" }, - "libraryAbout": "About Local Library", + "libraryAbout": "Über die lokale Bibliothek", "@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": "Durchsucht deine bestehende Musiksammlung, um Duplikate beim Herunterladen zu erkennen. Unterstützt die Formate FLAC, M4A, MP3, Opus und OGG. Metadaten werden, sofern verfügbar, aus den Dateitags gelesen.", "@libraryAboutDescription": { "description": "Description of local library feature" }, - "libraryLastScanned": "Last scanned: {time}", + "libraryTracksUnit": "{count, plural, =1{1 Titel} other{{count} Titel}}", + "@libraryTracksUnit": { + "description": "Unit label for tracks count (without the number itself)", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "libraryLastScanned": "Zuletzt gescannt: {time}", "@libraryLastScanned": { "description": "Last scan time display", "placeholders": { @@ -2207,15 +2232,15 @@ } } }, - "libraryLastScannedNever": "Never", + "libraryLastScannedNever": "Nie", "@libraryLastScannedNever": { "description": "Shown when library has never been scanned" }, - "libraryScanning": "Scanning...", + "libraryScanning": "Scannen...", "@libraryScanning": { "description": "Status during scan" }, - "libraryScanProgress": "{progress}% of {total} files", + "libraryScanProgress": "{progress}% von {total} Dateien", "@libraryScanProgress": { "description": "Scan progress display", "placeholders": { @@ -2227,11 +2252,11 @@ } } }, - "libraryInLibrary": "In Library", + "libraryInLibrary": "In Bibliothek", "@libraryInLibrary": { "description": "Badge shown on tracks that exist in local library" }, - "libraryRemovedMissingFiles": "Removed {count} missing files from library", + "libraryRemovedMissingFiles": "Entfernte {count} fehlende Dateien aus der Bibliothek", "@libraryRemovedMissingFiles": { "description": "Snackbar after cleanup", "placeholders": { @@ -2240,59 +2265,59 @@ } } }, - "libraryCleared": "Library cleared", + "libraryCleared": "Bibliothek geleert", "@libraryCleared": { "description": "Snackbar after clearing library" }, - "libraryStorageAccessRequired": "Storage Access Required", + "libraryStorageAccessRequired": "Speicherzugriff erforderlich", "@libraryStorageAccessRequired": { "description": "Dialog title for storage permission" }, - "libraryStorageAccessMessage": "SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.", + "libraryStorageAccessMessage": "SpotiFLAC benötigt Speicherzugriff, um deine Musikbibliothek zu scannen. Bitte erteile die Berechtigung in den Einstellungen.", "@libraryStorageAccessMessage": { "description": "Dialog message for storage permission" }, - "libraryFolderNotExist": "Selected folder does not exist", + "libraryFolderNotExist": "Der ausgewählte Ordner existiert nicht", "@libraryFolderNotExist": { "description": "Error when folder doesn't exist" }, - "librarySourceDownloaded": "Downloaded", + "librarySourceDownloaded": "Heruntergeladen", "@librarySourceDownloaded": { "description": "Badge for tracks downloaded via SpotiFLAC" }, - "librarySourceLocal": "Local", + "librarySourceLocal": "Lokal", "@librarySourceLocal": { "description": "Badge for tracks from local library scan" }, - "libraryFilterAll": "All", + "libraryFilterAll": "Alle", "@libraryFilterAll": { "description": "Filter chip - show all library items" }, - "libraryFilterDownloaded": "Downloaded", + "libraryFilterDownloaded": "Heruntergeladen", "@libraryFilterDownloaded": { "description": "Filter chip - show only downloaded items" }, - "libraryFilterLocal": "Local", + "libraryFilterLocal": "Lokal", "@libraryFilterLocal": { "description": "Filter chip - show only local library items" }, - "libraryFilterTitle": "Filters", + "libraryFilterTitle": "Filter", "@libraryFilterTitle": { "description": "Filter bottom sheet title" }, - "libraryFilterReset": "Reset", + "libraryFilterReset": "Zurücksetzen", "@libraryFilterReset": { "description": "Reset all filters button" }, - "libraryFilterApply": "Apply", + "libraryFilterApply": "Anwenden", "@libraryFilterApply": { "description": "Apply filters button" }, - "libraryFilterSource": "Source", + "libraryFilterSource": "Quelle", "@libraryFilterSource": { "description": "Filter section - source type" }, - "libraryFilterQuality": "Quality", + "libraryFilterQuality": "Qualität", "@libraryFilterQuality": { "description": "Filter section - audio quality" }, @@ -2304,7 +2329,7 @@ "@libraryFilterQualityCD": { "description": "Filter option - CD quality audio" }, - "libraryFilterQualityLossy": "Lossy", + "libraryFilterQualityLossy": "Verlustbehaftet", "@libraryFilterQualityLossy": { "description": "Filter option - lossy compressed audio" }, @@ -2312,23 +2337,23 @@ "@libraryFilterFormat": { "description": "Filter section - file format" }, - "libraryFilterSort": "Sort", + "libraryFilterSort": "Sortieren", "@libraryFilterSort": { "description": "Filter section - sort order" }, - "libraryFilterSortLatest": "Latest", + "libraryFilterSortLatest": "Neuste", "@libraryFilterSortLatest": { "description": "Sort option - newest first" }, - "libraryFilterSortOldest": "Oldest", + "libraryFilterSortOldest": "Älteste", "@libraryFilterSortOldest": { "description": "Sort option - oldest first" }, - "timeJustNow": "Just now", + "timeJustNow": "Gerade eben", "@timeJustNow": { "description": "Relative time - less than a minute ago" }, - "timeMinutesAgo": "{count, plural, =1{1 minute ago} other{{count} minutes ago}}", + "timeMinutesAgo": "{count, plural, one {vor {count} Minute} other{vor {count} Minuten}}", "@timeMinutesAgo": { "description": "Relative time - minutes ago", "placeholders": { @@ -2337,7 +2362,7 @@ } } }, - "timeHoursAgo": "{count, plural, =1{1 hour ago} other{{count} hours ago}}", + "timeHoursAgo": "{count, plural, one {vor {count} Stunde} other{vor {count} Stunden}}", "@timeHoursAgo": { "description": "Relative time - hours ago", "placeholders": { @@ -2346,123 +2371,123 @@ } } }, - "tutorialWelcomeTitle": "Welcome to SpotiFLAC!", + "tutorialWelcomeTitle": "Willkommen bei 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": "Lass uns lernen, wie du deine Lieblingsmusik in verlustfreier Qualität herunterlädst. Dieses schnelle Tutorial zeigt dir die Grundlagen.", "@tutorialWelcomeDesc": { "description": "Tutorial welcome page description" }, - "tutorialWelcomeTip1": "Download music from Spotify, Deezer, or paste any supported URL", + "tutorialWelcomeTip1": "Lade Musik von Spotify, Deezer herunter oder jeden unterstützten Link einfügen", "@tutorialWelcomeTip1": { "description": "Tutorial welcome tip 1" }, - "tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music", + "tutorialWelcomeTip2": "Hole dir FLAC Audio von Tidal, Qobuz oder Amazon Musik", "@tutorialWelcomeTip2": { "description": "Tutorial welcome tip 2" }, - "tutorialWelcomeTip3": "Automatic metadata, cover art, and lyrics embedding", + "tutorialWelcomeTip3": "Automatische Metadaten, Cover und Lyrics einbetten", "@tutorialWelcomeTip3": { "description": "Tutorial welcome tip 3" }, - "tutorialSearchTitle": "Finding Music", + "tutorialSearchTitle": "Suche Musik", "@tutorialSearchTitle": { "description": "Tutorial search page title" }, - "tutorialSearchDesc": "There are two easy ways to find music you want to download.", + "tutorialSearchDesc": "Es gibt zwei einfache Möglichkeiten, Musik zu finden, die du herunterladen möchtest.", "@tutorialSearchDesc": { "description": "Tutorial search page description" }, - "tutorialDownloadTitle": "Downloading Music", + "tutorialDownloadTitle": "Musik wird heruntergeladen", "@tutorialDownloadTitle": { "description": "Tutorial download page title" }, - "tutorialDownloadDesc": "Downloading music is simple and fast. Here's how it works.", + "tutorialDownloadDesc": "Das Herunterladen von Musik ist einfach und schnell. So funktioniert es.", "@tutorialDownloadDesc": { "description": "Tutorial download page description" }, - "tutorialLibraryTitle": "Your Library", + "tutorialLibraryTitle": "Deine Bibliothek", "@tutorialLibraryTitle": { "description": "Tutorial library page title" }, - "tutorialLibraryDesc": "All your downloaded music is organized in the Library tab.", + "tutorialLibraryDesc": "Die gesamte heruntergeladene Musik ist in der Bibliothek organisiert.", "@tutorialLibraryDesc": { "description": "Tutorial library page description" }, - "tutorialLibraryTip1": "View download progress and queue in the Library tab", + "tutorialLibraryTip1": "Fortschritt und Warteschlange im Bibliothek‑Tab anzeigen", "@tutorialLibraryTip1": { "description": "Tutorial library tip 1" }, - "tutorialLibraryTip2": "Tap any track to play it with your music player", + "tutorialLibraryTip2": "Tippe auf einen Titel, um ihn mit deinem Musikplayer abzuspielen", "@tutorialLibraryTip2": { "description": "Tutorial library tip 2" }, - "tutorialLibraryTip3": "Switch between list and grid view for better browsing", + "tutorialLibraryTip3": "Wechsle zwischen Listen- und Gitteransicht für ein besseres Surfen", "@tutorialLibraryTip3": { "description": "Tutorial library tip 3" }, - "tutorialExtensionsTitle": "Extensions", + "tutorialExtensionsTitle": "Erweiterungen", "@tutorialExtensionsTitle": { "description": "Tutorial extensions page title" }, - "tutorialExtensionsDesc": "Extend the app's capabilities with community extensions.", + "tutorialExtensionsDesc": "Erweitere die Fähigkeiten der App mit Community-Erweiterungen.", "@tutorialExtensionsDesc": { "description": "Tutorial extensions page description" }, - "tutorialExtensionsTip1": "Browse the Store tab to discover useful extensions", + "tutorialExtensionsTip1": "Im Store Tab findest du nützliche Erweiterungen", "@tutorialExtensionsTip1": { "description": "Tutorial extensions tip 1" }, - "tutorialExtensionsTip2": "Add new download providers or search sources", + "tutorialExtensionsTip2": "Neue Download- oder Suchanbieter hinzufügen", "@tutorialExtensionsTip2": { "description": "Tutorial extensions tip 2" }, - "tutorialExtensionsTip3": "Get lyrics, enhanced metadata, and more features", + "tutorialExtensionsTip3": "Lyrics, erweiterte Metadaten und mehr Funktionen erhalten", "@tutorialExtensionsTip3": { "description": "Tutorial extensions tip 3" }, - "tutorialSettingsTitle": "Customize Your Experience", + "tutorialSettingsTitle": "Passe deine Benutzererfahrung an", "@tutorialSettingsTitle": { "description": "Tutorial settings page title" }, - "tutorialSettingsDesc": "Personalize the app in Settings to match your preferences.", + "tutorialSettingsDesc": "Personalisiere die App in den Einstellungen nach deiner Präferenz.", "@tutorialSettingsDesc": { "description": "Tutorial settings page description" }, - "tutorialSettingsTip1": "Change download location and folder organization", + "tutorialSettingsTip1": "Downloadverzeichnis und Ordnerorganisation ändern", "@tutorialSettingsTip1": { "description": "Tutorial settings tip 1" }, - "tutorialSettingsTip2": "Set default audio quality and format preferences", + "tutorialSettingsTip2": "Standard Audioqualität und Formateinstellungen festlegen", "@tutorialSettingsTip2": { "description": "Tutorial settings tip 2" }, - "tutorialSettingsTip3": "Customize app theme and appearance", + "tutorialSettingsTip3": "App-Design und Aussehen anpassen", "@tutorialSettingsTip3": { "description": "Tutorial settings tip 3" }, - "tutorialReadyMessage": "You're all set! Start downloading your favorite music now.", + "tutorialReadyMessage": "Das ist alles! Lade jetzt deine Lieblingsmusik herunter.", "@tutorialReadyMessage": { "description": "Tutorial completion message" }, - "libraryForceFullScan": "Force Full Scan", + "libraryForceFullScan": "Vollen Neu-Scan erzwingen", "@libraryForceFullScan": { "description": "Button to force a complete rescan of library" }, - "libraryForceFullScanSubtitle": "Rescan all files, ignoring cache", + "libraryForceFullScanSubtitle": "Alle Dateien erneut scannen und Cache ignorieren", "@libraryForceFullScanSubtitle": { "description": "Subtitle for force full scan button" }, - "cleanupOrphanedDownloads": "Cleanup Orphaned Downloads", + "cleanupOrphanedDownloads": "Verwaiste Downloads bereinigen", "@cleanupOrphanedDownloads": { "description": "Button to remove history entries for deleted files" }, - "cleanupOrphanedDownloadsSubtitle": "Remove history entries for files that no longer exist", + "cleanupOrphanedDownloadsSubtitle": "Verlaufseinträge für Dateien löschen, die nicht mehr existieren", "@cleanupOrphanedDownloadsSubtitle": { "description": "Subtitle for orphaned cleanup button" }, - "cleanupOrphanedDownloadsResult": "Removed {count} orphaned entries from history", + "cleanupOrphanedDownloadsResult": "Entfernte {count} verwaiste Einträge aus dem Verlauf", "@cleanupOrphanedDownloadsResult": { "description": "Snackbar after orphan cleanup", "placeholders": { @@ -2471,23 +2496,23 @@ } } }, - "cleanupOrphanedDownloadsNone": "No orphaned entries found", + "cleanupOrphanedDownloadsNone": "Keine verwaisten Einträge gefunden", "@cleanupOrphanedDownloadsNone": { "description": "Snackbar when no orphans found" }, - "cacheTitle": "Storage & Cache", + "cacheTitle": "Speicher & Cache", "@cacheTitle": { "description": "Cache management page title" }, - "cacheSummaryTitle": "Cache overview", + "cacheSummaryTitle": "Cache-Übersicht", "@cacheSummaryTitle": { "description": "Heading for cache summary card" }, - "cacheSummarySubtitle": "Clearing cache will not remove downloaded music files.", + "cacheSummarySubtitle": "Das Leeren des Caches entfernt nicht heruntergeladene Musikdateien.", "@cacheSummarySubtitle": { "description": "Helper text for cache summary card" }, - "cacheEstimatedTotal": "Estimated cache usage: {size}", + "cacheEstimatedTotal": "Geschätzte Cache-Größe: {size}", "@cacheEstimatedTotal": { "description": "Total cache size shown in summary", "placeholders": { @@ -2496,71 +2521,71 @@ } } }, - "cacheSectionStorage": "Cached Data", + "cacheSectionStorage": "Zwischengespeicherte Daten", "@cacheSectionStorage": { "description": "Section header for cache entries" }, - "cacheSectionMaintenance": "Maintenance", + "cacheSectionMaintenance": "Wartung", "@cacheSectionMaintenance": { "description": "Section header for cleanup actions" }, - "cacheAppDirectory": "App cache directory", + "cacheAppDirectory": "App-Cache Verzeichnis", "@cacheAppDirectory": { "description": "Cache item title for app cache directory" }, - "cacheAppDirectoryDesc": "HTTP responses, WebView data, and other temporary app data.", + "cacheAppDirectoryDesc": "HTTP-Antworten, WebView Daten und andere temporäre App-Daten.", "@cacheAppDirectoryDesc": { "description": "Description of what app cache directory contains" }, - "cacheTempDirectory": "Temporary directory", + "cacheTempDirectory": "Temporäres Verzeichnis", "@cacheTempDirectory": { "description": "Cache item title for temporary files directory" }, - "cacheTempDirectoryDesc": "Temporary files from downloads and audio conversion.", + "cacheTempDirectoryDesc": "Temporäre Dateien von Downloads und Audio-Konvertierung.", "@cacheTempDirectoryDesc": { "description": "Description of what temporary directory contains" }, - "cacheCoverImage": "Cover image cache", + "cacheCoverImage": "Cover-Cache", "@cacheCoverImage": { "description": "Cache item title for persistent cover images" }, - "cacheCoverImageDesc": "Downloaded album and track cover art. Will re-download when viewed.", + "cacheCoverImageDesc": "Album- und Titelcover heruntergeladen. Werden erneut heruntergeladen.", "@cacheCoverImageDesc": { "description": "Description of what cover image cache contains" }, - "cacheLibraryCover": "Library cover cache", + "cacheLibraryCover": "Bibliotheks-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": "Cover aus lokalen Musikdateien extrahiert. Wird beim nächsten Scannen neu extrahiert.", "@cacheLibraryCoverDesc": { "description": "Description of what library cover cache contains" }, - "cacheExploreFeed": "Explore feed cache", + "cacheExploreFeed": "Feed-Cache entdecken", "@cacheExploreFeed": { "description": "Cache item title for explore home feed cache" }, - "cacheExploreFeedDesc": "Explore tab content (new releases, trending). Will refresh on next visit.", + "cacheExploreFeedDesc": "Startseiten-Inhalt (neue Releases, Trends). Wird bei einem Neustart aktualisiert.", "@cacheExploreFeedDesc": { "description": "Description of what explore feed cache contains" }, - "cacheTrackLookup": "Track lookup cache", + "cacheTrackLookup": "Titel Such-Cache", "@cacheTrackLookup": { "description": "Cache item title for track ID lookup cache" }, - "cacheTrackLookupDesc": "Spotify/Deezer track ID lookups. Clearing may slow next few searches.", + "cacheTrackLookupDesc": "Spotify/Deezer Track-ID-Lookups. Das Löschen kann die nächsten Suchergebnisse verlangsamen.", "@cacheTrackLookupDesc": { "description": "Description of what track lookup cache contains" }, - "cacheCleanupUnusedDesc": "Remove orphaned download history and library entries for missing files.", + "cacheCleanupUnusedDesc": "Verwaisten Downloadverlauf und Bibliothekseinträge für fehlende Dateien entfernen.", "@cacheCleanupUnusedDesc": { "description": "Description of what cleanup unused data does" }, - "cacheNoData": "No cached data", + "cacheNoData": "Keine gecachten Daten", "@cacheNoData": { "description": "Label when cache category has no data" }, - "cacheSizeWithFiles": "{size} in {count} files", + "cacheSizeWithFiles": "{size} in {count} Dateien", "@cacheSizeWithFiles": { "description": "Cache size and file count", "placeholders": { @@ -2581,7 +2606,7 @@ } } }, - "cacheEntries": "{count} entries", + "cacheEntries": "{count} Einträge", "@cacheEntries": { "description": "Track cache entry count", "placeholders": { @@ -2590,7 +2615,7 @@ } } }, - "cacheClearSuccess": "Cleared: {target}", + "cacheClearSuccess": "Entfernt: {target}", "@cacheClearSuccess": { "description": "Snackbar after clearing selected cache", "placeholders": { @@ -2599,11 +2624,11 @@ } } }, - "cacheClearConfirmTitle": "Clear cache?", + "cacheClearConfirmTitle": "Cache leeren?", "@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": "Dies löscht zwischengespeicherte Daten in {target}. Die Musikdateien werden nicht gelöscht.", "@cacheClearConfirmMessage": { "description": "Dialog message before clearing selected cache", "placeholders": { @@ -2612,27 +2637,27 @@ } } }, - "cacheClearAllConfirmTitle": "Clear all cache?", + "cacheClearAllConfirmTitle": "Gesamten Cache leeren?", "@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": "Dadurch werden alle Cache-Kategorien auf dieser Seite gelöscht. Heruntergeladene Musikdateien werden nicht gelöscht.", "@cacheClearAllConfirmMessage": { "description": "Dialog message before clearing all caches" }, - "cacheClearAll": "Clear all cache", + "cacheClearAll": "Gesamten Cache leeren", "@cacheClearAll": { "description": "Button label to clear all caches" }, - "cacheCleanupUnused": "Cleanup unused data", + "cacheCleanupUnused": "Unbenutzte Daten bereinigen", "@cacheCleanupUnused": { "description": "Action title for cleaning unused entries" }, - "cacheCleanupUnusedSubtitle": "Remove orphaned download history and missing library entries", + "cacheCleanupUnusedSubtitle": "Verwaisten Downloadverlauf und fehlende Bibliothekseinträge löschen", "@cacheCleanupUnusedSubtitle": { "description": "Subtitle for cleanup unused data action" }, - "cacheCleanupResult": "Cleanup completed: {downloadCount} orphaned downloads, {libraryCount} missing library entries", + "cacheCleanupResult": "Bereinigung: {downloadCount} verwaiste Downloads, {libraryCount} fehlende Bibliothekseinträge", "@cacheCleanupResult": { "description": "Snackbar after unused data cleanup", "placeholders": { @@ -2644,39 +2669,39 @@ } } }, - "cacheRefreshStats": "Refresh stats", + "cacheRefreshStats": "Statistik aktualisieren", "@cacheRefreshStats": { "description": "Button label to refresh cache statistics" }, - "trackSaveCoverArt": "Save Cover Art", + "trackSaveCoverArt": "Cover speichern", "@trackSaveCoverArt": { "description": "Menu action - save album cover art as file" }, - "trackSaveCoverArtSubtitle": "Save album art as .jpg file", + "trackSaveCoverArtSubtitle": "Albumcover als .jpg Datei speichern", "@trackSaveCoverArtSubtitle": { "description": "Subtitle for save cover art action" }, - "trackSaveLyrics": "Save Lyrics (.lrc)", + "trackSaveLyrics": "Lyrics als .lrc speichern", "@trackSaveLyrics": { "description": "Menu action - save lyrics as .lrc file" }, - "trackSaveLyricsSubtitle": "Fetch and save lyrics as .lrc file", + "trackSaveLyricsSubtitle": "Lade Lyrics als .lrc Datei", "@trackSaveLyricsSubtitle": { "description": "Subtitle for save lyrics action" }, - "trackSaveLyricsProgress": "Saving lyrics...", + "trackSaveLyricsProgress": "Speichere Lyrics...", "@trackSaveLyricsProgress": { "description": "Snackbar while saving lyrics to file" }, - "trackReEnrich": "Re-enrich", + "trackReEnrich": "Neu-anreichern", "@trackReEnrich": { "description": "Menu action - re-embed metadata into audio file" }, - "trackReEnrichOnlineSubtitle": "Search metadata online and embed into file", + "trackReEnrichOnlineSubtitle": "Metadaten online suchen und in Datei einbinden", "@trackReEnrichOnlineSubtitle": { "description": "Subtitle for re-enrich metadata action for local items" }, - "trackEditMetadata": "Edit Metadata", + "trackEditMetadata": "Metadaten bearbeiten", "@trackEditMetadata": { "description": "Menu action - edit embedded metadata" }, @@ -2693,7 +2718,7 @@ "@trackCoverNoSource": { "description": "Snackbar when no cover art URL or embedded cover" }, - "trackLyricsSaved": "Lyrics saved to {fileName}", + "trackLyricsSaved": "Lyrics in {fileName} gespeichert", "@trackLyricsSaved": { "description": "Snackbar after lyrics saved", "placeholders": { @@ -2702,23 +2727,23 @@ } } }, - "trackReEnrichProgress": "Re-enriching metadata...", + "trackReEnrichProgress": "Metadaten neu anreichern...", "@trackReEnrichProgress": { "description": "Snackbar while re-enriching metadata" }, - "trackReEnrichSearching": "Searching metadata online...", + "trackReEnrichSearching": "Suche Metadaten online...", "@trackReEnrichSearching": { "description": "Snackbar while searching metadata from internet for local items" }, - "trackReEnrichSuccess": "Metadata re-enriched successfully", + "trackReEnrichSuccess": "Metadaten erfolgreich neu angereichert", "@trackReEnrichSuccess": { "description": "Snackbar after successful re-enrichment" }, - "trackReEnrichFfmpegFailed": "FFmpeg metadata embed failed", + "trackReEnrichFfmpegFailed": "FFmpeg Metadaten-Einbettung fehlgeschlagen", "@trackReEnrichFfmpegFailed": { "description": "Snackbar when FFmpeg embed fails for MP3/Opus" }, - "trackSaveFailed": "Failed: {error}", + "trackSaveFailed": "Fehler: {error}", "@trackSaveFailed": { "description": "Snackbar when save operation fails", "placeholders": { @@ -2727,19 +2752,19 @@ } } }, - "trackConvertFormat": "Convert Format", + "trackConvertFormat": "Format konvertieren", "@trackConvertFormat": { "description": "Menu item - convert audio format" }, - "trackConvertFormatSubtitle": "Convert to MP3 or Opus", + "trackConvertFormatSubtitle": "In MP3 oder Opus konvertieren", "@trackConvertFormatSubtitle": { "description": "Subtitle for convert format menu item" }, - "trackConvertTitle": "Convert Audio", + "trackConvertTitle": "Audio konvertieren", "@trackConvertTitle": { "description": "Title of convert bottom sheet" }, - "trackConvertTargetFormat": "Target Format", + "trackConvertTargetFormat": "Zielformat", "@trackConvertTargetFormat": { "description": "Label for format selection" }, @@ -2747,11 +2772,11 @@ "@trackConvertBitrate": { "description": "Label for bitrate selection" }, - "trackConvertConfirmTitle": "Confirm Conversion", + "trackConvertConfirmTitle": "Konvertierung bestätigen", "@trackConvertConfirmTitle": { "description": "Confirmation dialog title" }, - "trackConvertConfirmMessage": "Convert from {sourceFormat} to {targetFormat} at {bitrate}?\n\nThe original file will be deleted after conversion.", + "trackConvertConfirmMessage": "Konvertieren von {sourceFormat} in {targetFormat} bei {bitrate}?\n\nDie Originaldatei wird nach der Konvertierung gelöscht.", "@trackConvertConfirmMessage": { "description": "Confirmation dialog message", "placeholders": { @@ -2766,11 +2791,11 @@ } } }, - "trackConvertConverting": "Converting audio...", + "trackConvertConverting": "Konvertiere Audio...", "@trackConvertConverting": { "description": "Snackbar while converting" }, - "trackConvertSuccess": "Converted to {format} successfully", + "trackConvertSuccess": "Konvertiert in {format} erfolgreich", "@trackConvertSuccess": { "description": "Snackbar after successful conversion", "placeholders": { @@ -2779,11 +2804,288 @@ } } }, - "trackConvertFailed": "Conversion failed", + "trackConvertFailed": "Konvertierung fehlgeschlagen", "@trackConvertFailed": { "description": "Snackbar when conversion fails" }, - "downloadedAlbumDownloadedCount": "{count} downloaded", + "actionCreate": "Erstellen", + "@actionCreate": { + "description": "Generic action button - create" + }, + "collectionFoldersTitle": "Meine Ordner", + "@collectionFoldersTitle": { + "description": "Library section title for custom folders" + }, + "collectionWishlist": "Wunschliste", + "@collectionWishlist": { + "description": "Custom folder for saved tracks to download later" + }, + "collectionLoved": "Lieblingssongs", + "@collectionLoved": { + "description": "Custom folder for favorite tracks" + }, + "collectionPlaylists": "Playlisten", + "@collectionPlaylists": { + "description": "Custom user playlists folder" + }, + "collectionPlaylist": "Playlist", + "@collectionPlaylist": { + "description": "Single playlist label" + }, + "collectionAddToPlaylist": "Zur Playlist hinzufügen", + "@collectionAddToPlaylist": { + "description": "Action to add a track to user playlist" + }, + "collectionCreatePlaylist": "Playlist erstellen", + "@collectionCreatePlaylist": { + "description": "Action to create a new playlist" + }, + "collectionNoPlaylistsYet": "Noch keine Playlists", + "@collectionNoPlaylistsYet": { + "description": "Empty state title when user has no playlists" + }, + "collectionNoPlaylistsSubtitle": "Playlist erstellen, um Titel zu kategorisieren", + "@collectionNoPlaylistsSubtitle": { + "description": "Empty state subtitle when user has no playlists" + }, + "collectionPlaylistTracks": "{count, plural, =1{1 Titel} other{{count} Titel}}", + "@collectionPlaylistTracks": { + "description": "Track count label for custom playlists", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "collectionAddedToPlaylist": "Zu \"{playlistName} \" hinzugefügt", + "@collectionAddedToPlaylist": { + "description": "Snackbar after adding track to playlist", + "placeholders": { + "playlistName": { + "type": "String" + } + } + }, + "collectionAlreadyInPlaylist": "Bereits in \"{playlistName}\"", + "@collectionAlreadyInPlaylist": { + "description": "Snackbar when track already exists in playlist", + "placeholders": { + "playlistName": { + "type": "String" + } + } + }, + "collectionPlaylistCreated": "Playlist erstellt", + "@collectionPlaylistCreated": { + "description": "Snackbar after creating playlist" + }, + "collectionPlaylistNameHint": "Playlist-Name", + "@collectionPlaylistNameHint": { + "description": "Hint text for playlist name input" + }, + "collectionPlaylistNameRequired": "Playlist-Name ist erforderlich", + "@collectionPlaylistNameRequired": { + "description": "Validation error for empty playlist name" + }, + "collectionRenamePlaylist": "Playlist umbenennen", + "@collectionRenamePlaylist": { + "description": "Action to rename playlist" + }, + "collectionDeletePlaylist": "Playlist löschen", + "@collectionDeletePlaylist": { + "description": "Action to delete playlist" + }, + "collectionDeletePlaylistMessage": "Willst du \"{playlistName}\" und alle darin enthaltenen Titel löschen?", + "@collectionDeletePlaylistMessage": { + "description": "Confirmation message for deleting playlist", + "placeholders": { + "playlistName": { + "type": "String" + } + } + }, + "collectionPlaylistDeleted": "Playlist gelöscht", + "@collectionPlaylistDeleted": { + "description": "Snackbar after deleting playlist" + }, + "collectionPlaylistRenamed": "Playlist umbenannt", + "@collectionPlaylistRenamed": { + "description": "Snackbar after renaming playlist" + }, + "collectionWishlistEmptyTitle": "Wunschliste ist leer", + "@collectionWishlistEmptyTitle": { + "description": "Wishlist empty state title" + }, + "collectionWishlistEmptySubtitle": "Tippe auf das + bei den Titeln, um sie zum späteren Herunterladen zu speichern", + "@collectionWishlistEmptySubtitle": { + "description": "Wishlist empty state subtitle" + }, + "collectionLovedEmptyTitle": "Lieblingssongs sind leer", + "@collectionLovedEmptyTitle": { + "description": "Loved empty state title" + }, + "collectionLovedEmptySubtitle": "Tippe auf das Herz, um deine Favoriten zu behalten", + "@collectionLovedEmptySubtitle": { + "description": "Loved empty state subtitle" + }, + "collectionPlaylistEmptyTitle": "Die Playlist ist leer", + "@collectionPlaylistEmptyTitle": { + "description": "Playlist empty state title" + }, + "collectionPlaylistEmptySubtitle": "Drücke lange + auf einem beliebigen Titel, um ihn hier hinzuzufügen", + "@collectionPlaylistEmptySubtitle": { + "description": "Playlist empty state subtitle" + }, + "collectionRemoveFromPlaylist": "Von Playlist entfernen", + "@collectionRemoveFromPlaylist": { + "description": "Tooltip for removing track from playlist" + }, + "collectionRemoveFromFolder": "Aus Ordner entfernen", + "@collectionRemoveFromFolder": { + "description": "Tooltip for removing track from wishlist/loved folder" + }, + "collectionRemoved": "\"{trackName}\" entfernt", + "@collectionRemoved": { + "description": "Snackbar after removing a track from a collection", + "placeholders": { + "trackName": { + "type": "String" + } + } + }, + "collectionAddedToLoved": "\"{trackName}\" zu Lieblingssongs hinzugefügt", + "@collectionAddedToLoved": { + "description": "Snackbar after adding track to loved folder", + "placeholders": { + "trackName": { + "type": "String" + } + } + }, + "collectionRemovedFromLoved": "\"{trackName}\" aus Lieblingssongs entfernt", + "@collectionRemovedFromLoved": { + "description": "Snackbar after removing track from loved folder", + "placeholders": { + "trackName": { + "type": "String" + } + } + }, + "collectionAddedToWishlist": "\"{trackName}\" zur Wunschliste hinzugefügt", + "@collectionAddedToWishlist": { + "description": "Snackbar after adding track to wishlist", + "placeholders": { + "trackName": { + "type": "String" + } + } + }, + "collectionRemovedFromWishlist": "\"{trackName}\" aus der Wunschliste entfernt", + "@collectionRemovedFromWishlist": { + "description": "Snackbar after removing track from wishlist", + "placeholders": { + "trackName": { + "type": "String" + } + } + }, + "trackOptionAddToLoved": "Zu Lieblingssongs hinzufügen", + "@trackOptionAddToLoved": { + "description": "Bottom sheet action label - add track to loved folder" + }, + "trackOptionRemoveFromLoved": "Aus Lieblingssongs entfernt", + "@trackOptionRemoveFromLoved": { + "description": "Bottom sheet action label - remove track from loved folder" + }, + "trackOptionAddToWishlist": "Zur Wunschliste hinzufügen", + "@trackOptionAddToWishlist": { + "description": "Bottom sheet action label - add track to wishlist" + }, + "trackOptionRemoveFromWishlist": "Von der Wunschliste entfernen", + "@trackOptionRemoveFromWishlist": { + "description": "Bottom sheet action label - remove track from wishlist" + }, + "collectionPlaylistChangeCover": "Coverbild ändern", + "@collectionPlaylistChangeCover": { + "description": "Bottom sheet action to pick a custom cover image for a playlist" + }, + "collectionPlaylistRemoveCover": "Cover entfernen", + "@collectionPlaylistRemoveCover": { + "description": "Bottom sheet action to remove custom cover image from a playlist" + }, + "selectionShareCount": "Teile {count} {count, plural, one {Titel} other{Titel}}", + "@selectionShareCount": { + "description": "Share button text with count in selection mode", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "selectionShareNoFiles": "Keine teilbare Dateien gefunden", + "@selectionShareNoFiles": { + "description": "Snackbar when no selected files exist on disk" + }, + "selectionConvertCount": "Konvertiere {count} {count, plural, one {Titel} other{Titel}}", + "@selectionConvertCount": { + "description": "Convert button text with count in selection mode", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "selectionConvertNoConvertible": "Keine konvertierbare Titel ausgewählt", + "@selectionConvertNoConvertible": { + "description": "Snackbar when no selected tracks support conversion" + }, + "selectionBatchConvertConfirmTitle": "Batch-Konvertierung", + "@selectionBatchConvertConfirmTitle": { + "description": "Confirmation dialog title for batch conversion" + }, + "selectionBatchConvertConfirmMessage": "Konvertiere {count} {format} {count, plural, one {Titel} other{Titel}} zu {bitrate}?\n\nOriginaldateien werden nach der Konvertierung gelöscht.", + "@selectionBatchConvertConfirmMessage": { + "description": "Confirmation dialog message for batch conversion", + "placeholders": { + "count": { + "type": "int" + }, + "format": { + "type": "String" + }, + "bitrate": { + "type": "String" + } + } + }, + "selectionBatchConvertProgress": "Konvertiere {current} von {total}...", + "@selectionBatchConvertProgress": { + "description": "Snackbar during batch conversion progress", + "placeholders": { + "current": { + "type": "int" + }, + "total": { + "type": "int" + } + } + }, + "selectionBatchConvertSuccess": "{success} von {total} Titeln in {format} konvertiert", + "@selectionBatchConvertSuccess": { + "description": "Snackbar after batch conversion completes", + "placeholders": { + "success": { + "type": "int" + }, + "total": { + "type": "int" + }, + "format": { + "type": "String" + } + } + }, + "downloadedAlbumDownloadedCount": "{count} heruntergeladen", "@downloadedAlbumDownloadedCount": { "description": "Downloaded tracks count badge", "placeholders": { @@ -2792,7 +3094,7 @@ } } }, - "downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available", + "downloadUseAlbumArtistForFoldersAlbumSubtitle": "Künstlerordner verwenden den Album-Interpreten, wenn verfügbar", "@downloadUseAlbumArtistForFoldersAlbumSubtitle": { "description": "Subtitle when Album Artist is used for folder naming" }, @@ -2800,4 +3102,4 @@ "@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 ca416fa0..03313d7b 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -450,7 +450,7 @@ "@aboutSpotiSaverDesc": { "description": "Credit for SpotiSaver API" }, - "aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.", + "aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.", "@aboutAppDescription": { "description": "App description in header card" }, @@ -897,6 +897,18 @@ "@errorNoTracksFound": { "description": "Error - search returned no results" }, + "errorUrlNotRecognized": "Link not recognized", + "@errorUrlNotRecognized": { + "description": "Error title - URL not handled by any extension or service" + }, + "errorUrlNotRecognizedMessage": "This link is not supported. Make sure the URL is correct and a compatible extension is installed.", + "@errorUrlNotRecognizedMessage": { + "description": "Error message - URL not recognized explanation" + }, + "errorUrlFetchFailed": "Failed to load content from this link. Please try again.", + "@errorUrlFetchFailed": { + "description": "Error message - generic URL fetch failure" + }, "errorMissingExtensionSource": "Cannot load {item}: missing extension source", "@errorMissingExtensionSource": { "description": "Error - extension source not available", @@ -1003,6 +1015,14 @@ "@folderOrganizationNone": { "description": "Folder option - flat structure" }, + "folderOrganizationByPlaylist": "By Playlist", + "@folderOrganizationByPlaylist": { + "description": "Folder option - playlist folders" + }, + "folderOrganizationByPlaylistSubtitle": "Separate folder for each playlist", + "@folderOrganizationByPlaylistSubtitle": { + "description": "Subtitle for playlist folder option" + }, "folderOrganizationByArtist": "By Artist", "@folderOrganizationByArtist": { "description": "Folder option - artist folders" @@ -1097,7 +1117,7 @@ }, "providerBuiltIn": "Built-in", "@providerBuiltIn": { - "description": "Label for built-in providers (Tidal/Qobuz/Amazon)" + "description": "Label for built-in providers (Tidal/Qobuz)" }, "providerExtension": "Extension", "@providerExtension": { @@ -2383,7 +2403,7 @@ "@tutorialWelcomeTip1": { "description": "Tutorial welcome tip 1" }, - "tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music", + "tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer", "@tutorialWelcomeTip2": { "description": "Tutorial welcome tip 2" }, @@ -2808,6 +2828,90 @@ "@trackConvertFailed": { "description": "Snackbar when conversion fails" }, + "cueSplitTitle": "Split CUE Sheet", + "@cueSplitTitle": { + "description": "Title for CUE split bottom sheet" + }, + "cueSplitSubtitle": "Split CUE+FLAC into individual tracks", + "@cueSplitSubtitle": { + "description": "Subtitle for CUE split menu item" + }, + "cueSplitAlbum": "Album: {album}", + "@cueSplitAlbum": { + "description": "Album name in CUE split sheet", + "placeholders": { + "album": { + "type": "String" + } + } + }, + "cueSplitArtist": "Artist: {artist}", + "@cueSplitArtist": { + "description": "Artist name in CUE split sheet", + "placeholders": { + "artist": { + "type": "String" + } + } + }, + "cueSplitTrackCount": "{count} tracks", + "@cueSplitTrackCount": { + "description": "Number of tracks in CUE sheet", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "cueSplitConfirmTitle": "Split CUE Album", + "@cueSplitConfirmTitle": { + "description": "CUE split confirmation dialog title" + }, + "cueSplitConfirmMessage": "Split \"{album}\" into {count} individual FLAC files?\n\nFiles will be saved to the same directory.", + "@cueSplitConfirmMessage": { + "description": "CUE split confirmation dialog message", + "placeholders": { + "album": { + "type": "String" + }, + "count": { + "type": "int" + } + } + }, + "cueSplitSplitting": "Splitting CUE sheet... ({current}/{total})", + "@cueSplitSplitting": { + "description": "Snackbar while splitting CUE", + "placeholders": { + "current": { + "type": "int" + }, + "total": { + "type": "int" + } + } + }, + "cueSplitSuccess": "Split into {count} tracks successfully", + "@cueSplitSuccess": { + "description": "Snackbar after successful CUE split", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "cueSplitFailed": "CUE split failed", + "@cueSplitFailed": { + "description": "Snackbar when CUE split fails" + }, + "cueSplitNoAudioFile": "Audio file not found for this CUE sheet", + "@cueSplitNoAudioFile": { + "description": "Error when CUE audio file is missing" + }, + "cueSplitButton": "Split into Tracks", + "@cueSplitButton": { + "description": "Button text to start CUE splitting" + }, "actionCreate": "Create", "@actionCreate": { "description": "Generic action button - create" diff --git a/lib/l10n/arb/app_es.arb b/lib/l10n/arb/app_es.arb index 497c11bc..45733fc8 100644 --- a/lib/l10n/arb/app_es.arb +++ b/lib/l10n/arb/app_es.arb @@ -402,7 +402,7 @@ "@aboutDabMusicDesc": { "description": "Credit for DAB Music API" }, - "aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.", + "aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.", "@aboutAppDescription": { "description": "App description in header card" }, @@ -1005,7 +1005,7 @@ }, "providerBuiltIn": "Built-in", "@providerBuiltIn": { - "description": "Label for built-in providers (Tidal/Qobuz/Amazon)" + "description": "Label for built-in providers (Tidal/Qobuz)" }, "providerExtension": "Extension", "@providerExtension": { diff --git a/lib/l10n/arb/app_es_ES.arb b/lib/l10n/arb/app_es_ES.arb index 304b9cd8..f7d481c3 100644 --- a/lib/l10n/arb/app_es_ES.arb +++ b/lib/l10n/arb/app_es_ES.arb @@ -450,7 +450,7 @@ "@aboutSpotiSaverDesc": { "description": "Credit for SpotiSaver API" }, - "aboutAppDescription": "Descarga pistas de Spotify con calidad sin pérdida de Tidal, Qobuz y Amazon Music.", + "aboutAppDescription": "Descarga pistas de Spotify con calidad sin pérdida de Tidal y Qobuz.", "@aboutAppDescription": { "description": "App description in header card" }, @@ -1089,7 +1089,7 @@ }, "providerBuiltIn": "Integrado", "@providerBuiltIn": { - "description": "Label for built-in providers (Tidal/Qobuz/Amazon)" + "description": "Label for built-in providers (Tidal/Qobuz)" }, "providerExtension": "Extensión", "@providerExtension": { @@ -2358,7 +2358,7 @@ "@tutorialWelcomeTip1": { "description": "Tutorial welcome tip 1" }, - "tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music", + "tutorialWelcomeTip2": "Obtén audio en calidad FLAC de Tidal, Qobuz o Deezer", "@tutorialWelcomeTip2": { "description": "Tutorial welcome tip 2" }, diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index f7e9c2d1..3ddb731a 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -991,6 +991,14 @@ "@filenameFormat": { "description": "Setting title - filename pattern" }, + "filenameShowAdvancedTags": "Show advanced tags", + "@filenameShowAdvancedTags": { + "description": "Toggle label for showing advanced filename tags" + }, + "filenameShowAdvancedTagsDescription": "Enable formatted tags for track padding and date patterns", + "@filenameShowAdvancedTagsDescription": { + "description": "Description for advanced filename tag toggle" + }, "folderOrganizationNone": "No organization", "@folderOrganizationNone": { "description": "Folder option - flat structure" @@ -1749,6 +1757,14 @@ "@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" @@ -2198,6 +2214,15 @@ "@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" + } + } + }, "libraryLastScanned": "Last scanned: {time}", "@libraryLastScanned": { "description": "Last scan time display", @@ -2783,6 +2808,283 @@ "@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", @@ -2800,4 +3102,4 @@ "@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 064202f2..33040826 100644 --- a/lib/l10n/arb/app_hi.arb +++ b/lib/l10n/arb/app_hi.arb @@ -991,6 +991,14 @@ "@filenameFormat": { "description": "Setting title - filename pattern" }, + "filenameShowAdvancedTags": "Show advanced tags", + "@filenameShowAdvancedTags": { + "description": "Toggle label for showing advanced filename tags" + }, + "filenameShowAdvancedTagsDescription": "Enable formatted tags for track padding and date patterns", + "@filenameShowAdvancedTagsDescription": { + "description": "Description for advanced filename tag toggle" + }, "folderOrganizationNone": "No organization", "@folderOrganizationNone": { "description": "Folder option - flat structure" @@ -1749,6 +1757,14 @@ "@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" @@ -2198,6 +2214,15 @@ "@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" + } + } + }, "libraryLastScanned": "Last scanned: {time}", "@libraryLastScanned": { "description": "Last scan time display", @@ -2783,6 +2808,283 @@ "@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", @@ -2800,4 +3102,4 @@ "@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 0d7caf67..de9d90d0 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -9,7 +9,7 @@ "@navHome": { "description": "Bottom navigation - Home tab" }, - "navLibrary": "Library", + "navLibrary": "Pustaka", "@navLibrary": { "description": "Bottom navigation - Library tab" }, @@ -49,7 +49,7 @@ "@historyFilterSingles": { "description": "Filter chip - show singles only" }, - "historySearchHint": "Search history...", + "historySearchHint": "Cari riwayat...", "@historySearchHint": { "description": "Search bar placeholder in history" }, @@ -125,7 +125,7 @@ "@appearanceHistoryViewList": { "description": "List layout option" }, - "appearanceHistoryViewGrid": "Grid", + "appearanceHistoryViewGrid": "Kisi", "@appearanceHistoryViewGrid": { "description": "Grid layout option" }, @@ -154,7 +154,7 @@ "@optionsSwitchBack": { "description": "Hint to switch back to built-in providers" }, - "optionsAutoFallback": "Auto Fallback", + "optionsAutoFallback": "Cadangan Otomatis", "@optionsAutoFallback": { "description": "Auto-retry with other services" }, @@ -267,7 +267,7 @@ "@optionsSpotifyCredentials": { "description": "Spotify API credentials setting" }, - "optionsSpotifyCredentialsConfigured": "Client ID: {clientId}...", + "optionsSpotifyCredentialsConfigured": "ID Klien: {clientId}...", "@optionsSpotifyCredentialsConfigured": { "description": "Shows configured client ID preview", "placeholders": { @@ -284,7 +284,7 @@ "@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": "Pencarian Spotify akan dihentikan pada 3 Maret 2026 karena perubahan API Spotify. Silakan beralih ke Deezer.", "@optionsSpotifyDeprecationWarning": { "description": "Warning about Spotify API deprecation" }, @@ -358,7 +358,7 @@ "@aboutLogoArtist": { "description": "Role description for logo artist" }, - "aboutTranslators": "Translators", + "aboutTranslators": "Penerjemah", "@aboutTranslators": { "description": "Section for translators" }, @@ -394,23 +394,23 @@ "@aboutFeatureRequestSubtitle": { "description": "Subtitle for feature request" }, - "aboutTelegramChannel": "Telegram Channel", + "aboutTelegramChannel": "Saluran Telegram", "@aboutTelegramChannel": { "description": "Link to Telegram channel" }, - "aboutTelegramChannelSubtitle": "Announcements and updates", + "aboutTelegramChannelSubtitle": "Pengumuman dan pembaruan", "@aboutTelegramChannelSubtitle": { "description": "Subtitle for Telegram channel" }, - "aboutTelegramChat": "Telegram Community", + "aboutTelegramChat": "Komunitas Telegram", "@aboutTelegramChat": { "description": "Link to Telegram chat group" }, - "aboutTelegramChatSubtitle": "Chat with other users", + "aboutTelegramChatSubtitle": "Berbincang dengan pengguna lain", "@aboutTelegramChatSubtitle": { "description": "Subtitle for Telegram chat" }, - "aboutSocial": "Social", + "aboutSocial": "Sosial", "@aboutSocial": { "description": "Section for social links" }, @@ -430,7 +430,7 @@ "@aboutSachinsenalDesc": { "description": "Credit description for sachinsenal0x64" }, - "aboutSjdonadoDesc": "Creator of I Don't Have Spotify (IDHS). The fallback link resolver that saves the day!", + "aboutSjdonadoDesc": "Pencipta I Don't Have Spotify (IDHS). Penyelesai tautan cadangan yang menyelamatkan keadaan!", "@aboutSjdonadoDesc": { "description": "Credit description for sjdonado" }, @@ -446,7 +446,7 @@ "@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": "Tidal perangkat streaming FLAC resolusi tinggi. Bagian penting dari teka-teki tanpa kehilangan kualitas!", "@aboutSpotiSaverDesc": { "description": "Credit for SpotiSaver API" }, @@ -579,7 +579,7 @@ "@setupIosEmptyFolderWarning": { "description": "iOS folder selection warning" }, - "setupIcloudNotSupported": "iCloud Drive is not supported. Please use the app Documents folder.", + "setupIcloudNotSupported": "iCloud Drive tidak didukung. Silakan gunakan folder Dokumen di aplikasi.", "@setupIcloudNotSupported": { "description": "Error when user selects iCloud Drive on iOS" }, @@ -742,7 +742,7 @@ "description": "Dialog title - import CSV playlist" }, "dialogImportPlaylistMessage": "Ditemukan {count} lagu di CSV. Tambahkan ke antrian unduhan?", - "csvImportTracks": "{count} tracks from CSV", + "csvImportTracks": "{count} trek dari CSV", "@csvImportTracks": { "description": "Label shown in quality picker for CSV import", "placeholders": { @@ -786,7 +786,7 @@ } } }, - "snackbarAlreadyInLibrary": "\"{trackName}\" already exists in your library", + "snackbarAlreadyInLibrary": "\"{trackName}\" sudah ada di perpustakaan Anda", "@snackbarAlreadyInLibrary": { "description": "Snackbar - track already exists in local library", "placeholders": { @@ -897,6 +897,18 @@ "@errorNoTracksFound": { "description": "Error - search returned no results" }, + "errorUrlNotRecognized": "Link tidak dikenali", + "@errorUrlNotRecognized": { + "description": "Error title - URL not handled by any extension or service" + }, + "errorUrlNotRecognizedMessage": "Link ini tidak didukung. Pastikan URL benar dan ekstensi yang kompatibel sudah terpasang.", + "@errorUrlNotRecognizedMessage": { + "description": "Error message - URL not recognized explanation" + }, + "errorUrlFetchFailed": "Gagal memuat konten dari link ini. Silakan coba lagi.", + "@errorUrlFetchFailed": { + "description": "Error message - generic URL fetch failure" + }, "errorMissingExtensionSource": "Tidak dapat memuat {item}: sumber ekstensi tidak ada", "@errorMissingExtensionSource": { "description": "Error - extension source not available", @@ -991,11 +1003,11 @@ "@filenameFormat": { "description": "Setting title - filename pattern" }, - "filenameShowAdvancedTags": "Tampilkan tag lanjutan", + "filenameShowAdvancedTags": "Show advanced tags", "@filenameShowAdvancedTags": { "description": "Toggle label for showing advanced filename tags" }, - "filenameShowAdvancedTagsDescription": "Aktifkan tag format untuk padding nomor lagu dan pola tanggal", + "filenameShowAdvancedTagsDescription": "Enable formatted tags for track padding and date patterns", "@filenameShowAdvancedTagsDescription": { "description": "Description for advanced filename tag toggle" }, @@ -1757,11 +1769,11 @@ "@youtubeQualityNote": { "description": "Note for YouTube service explaining lossy-only quality" }, - "youtubeOpusBitrateTitle": "Bitrate Opus YouTube", + "youtubeOpusBitrateTitle": "YouTube Opus Bitrate", "@youtubeOpusBitrateTitle": { "description": "Title for YouTube Opus bitrate setting" }, - "youtubeMp3BitrateTitle": "Bitrate MP3 YouTube", + "youtubeMp3BitrateTitle": "YouTube MP3 Bitrate", "@youtubeMp3BitrateTitle": { "description": "Title for YouTube MP3 bitrate setting" }, @@ -2214,7 +2226,7 @@ "@libraryAboutDescription": { "description": "Description of local library feature" }, - "libraryTracksUnit": "{count, plural, =1{trek} other{trek}}", + "libraryTracksUnit": "{count, plural, =1{track} other{tracks}}", "@libraryTracksUnit": { "description": "Unit label for tracks count (without the number itself)", "placeholders": { @@ -2808,11 +2820,11 @@ "@trackConvertFailed": { "description": "Snackbar when conversion fails" }, - "actionCreate": "Buat", + "actionCreate": "Create", "@actionCreate": { "description": "Generic action button - create" }, - "collectionFoldersTitle": "Folder saya", + "collectionFoldersTitle": "My folders", "@collectionFoldersTitle": { "description": "Library section title for custom folders" }, @@ -2824,7 +2836,7 @@ "@collectionLoved": { "description": "Custom folder for favorite tracks" }, - "collectionPlaylists": "Playlist", + "collectionPlaylists": "Playlists", "@collectionPlaylists": { "description": "Custom user playlists folder" }, @@ -2832,23 +2844,23 @@ "@collectionPlaylist": { "description": "Single playlist label" }, - "collectionAddToPlaylist": "Tambahkan ke playlist", + "collectionAddToPlaylist": "Add to playlist", "@collectionAddToPlaylist": { "description": "Action to add a track to user playlist" }, - "collectionCreatePlaylist": "Buat playlist", + "collectionCreatePlaylist": "Create playlist", "@collectionCreatePlaylist": { "description": "Action to create a new playlist" }, - "collectionNoPlaylistsYet": "Belum ada playlist", + "collectionNoPlaylistsYet": "No playlists yet", "@collectionNoPlaylistsYet": { "description": "Empty state title when user has no playlists" }, - "collectionNoPlaylistsSubtitle": "Buat playlist untuk mulai mengategorikan lagu", + "collectionNoPlaylistsSubtitle": "Create a playlist to start categorizing tracks", "@collectionNoPlaylistsSubtitle": { "description": "Empty state subtitle when user has no playlists" }, - "collectionPlaylistTracks": "{count, plural, =1{1 lagu} other{{count} lagu}}", + "collectionPlaylistTracks": "{count, plural, =1{1 track} other{{count} tracks}}", "@collectionPlaylistTracks": { "description": "Track count label for custom playlists", "placeholders": { @@ -2857,7 +2869,7 @@ } } }, - "collectionAddedToPlaylist": "Ditambahkan ke \"{playlistName}\"", + "collectionAddedToPlaylist": "Added to \"{playlistName}\"", "@collectionAddedToPlaylist": { "description": "Snackbar after adding track to playlist", "placeholders": { @@ -2866,7 +2878,7 @@ } } }, - "collectionAlreadyInPlaylist": "Sudah ada di \"{playlistName}\"", + "collectionAlreadyInPlaylist": "Already in \"{playlistName}\"", "@collectionAlreadyInPlaylist": { "description": "Snackbar when track already exists in playlist", "placeholders": { @@ -2875,27 +2887,27 @@ } } }, - "collectionPlaylistCreated": "Playlist berhasil dibuat", + "collectionPlaylistCreated": "Playlist created", "@collectionPlaylistCreated": { "description": "Snackbar after creating playlist" }, - "collectionPlaylistNameHint": "Nama playlist", + "collectionPlaylistNameHint": "Playlist name", "@collectionPlaylistNameHint": { "description": "Hint text for playlist name input" }, - "collectionPlaylistNameRequired": "Nama playlist wajib diisi", + "collectionPlaylistNameRequired": "Playlist name is required", "@collectionPlaylistNameRequired": { "description": "Validation error for empty playlist name" }, - "collectionRenamePlaylist": "Ubah nama playlist", + "collectionRenamePlaylist": "Rename playlist", "@collectionRenamePlaylist": { "description": "Action to rename playlist" }, - "collectionDeletePlaylist": "Hapus playlist", + "collectionDeletePlaylist": "Delete playlist", "@collectionDeletePlaylist": { "description": "Action to delete playlist" }, - "collectionDeletePlaylistMessage": "Hapus \"{playlistName}\" beserta semua lagunya?", + "collectionDeletePlaylistMessage": "Delete \"{playlistName}\" and all tracks inside it?", "@collectionDeletePlaylistMessage": { "description": "Confirmation message for deleting playlist", "placeholders": { @@ -2904,47 +2916,47 @@ } } }, - "collectionPlaylistDeleted": "Playlist dihapus", + "collectionPlaylistDeleted": "Playlist deleted", "@collectionPlaylistDeleted": { "description": "Snackbar after deleting playlist" }, - "collectionPlaylistRenamed": "Nama playlist diperbarui", + "collectionPlaylistRenamed": "Playlist renamed", "@collectionPlaylistRenamed": { "description": "Snackbar after renaming playlist" }, - "collectionWishlistEmptyTitle": "Wishlist masih kosong", + "collectionWishlistEmptyTitle": "Wishlist is empty", "@collectionWishlistEmptyTitle": { "description": "Wishlist empty state title" }, - "collectionWishlistEmptySubtitle": "Tap + di lagu untuk menyimpan yang ingin diunduh nanti", + "collectionWishlistEmptySubtitle": "Tap + on tracks to save what you want to download later", "@collectionWishlistEmptySubtitle": { "description": "Wishlist empty state subtitle" }, - "collectionLovedEmptyTitle": "Folder Loved masih kosong", + "collectionLovedEmptyTitle": "Loved folder is empty", "@collectionLovedEmptyTitle": { "description": "Loved empty state title" }, - "collectionLovedEmptySubtitle": "Tap love di lagu untuk menyimpan favoritmu", + "collectionLovedEmptySubtitle": "Tap love on tracks to keep your favorites", "@collectionLovedEmptySubtitle": { "description": "Loved empty state subtitle" }, - "collectionPlaylistEmptyTitle": "Playlist masih kosong", + "collectionPlaylistEmptyTitle": "Playlist is empty", "@collectionPlaylistEmptyTitle": { "description": "Playlist empty state title" }, - "collectionPlaylistEmptySubtitle": "Tekan lama tombol + pada lagu untuk menambahkannya ke sini", + "collectionPlaylistEmptySubtitle": "Long-press + on any track to add it here", "@collectionPlaylistEmptySubtitle": { "description": "Playlist empty state subtitle" }, - "collectionRemoveFromPlaylist": "Hapus dari playlist", + "collectionRemoveFromPlaylist": "Remove from playlist", "@collectionRemoveFromPlaylist": { "description": "Tooltip for removing track from playlist" }, - "collectionRemoveFromFolder": "Hapus dari folder", + "collectionRemoveFromFolder": "Remove from folder", "@collectionRemoveFromFolder": { "description": "Tooltip for removing track from wishlist/loved folder" }, - "collectionRemoved": "\"{trackName}\" dihapus", + "collectionRemoved": "\"{trackName}\" removed", "@collectionRemoved": { "description": "Snackbar after removing a track from a collection", "placeholders": { @@ -2953,7 +2965,7 @@ } } }, - "collectionAddedToLoved": "\"{trackName}\" ditambahkan ke Loved", + "collectionAddedToLoved": "\"{trackName}\" added to Loved", "@collectionAddedToLoved": { "description": "Snackbar after adding track to loved folder", "placeholders": { @@ -2962,7 +2974,7 @@ } } }, - "collectionRemovedFromLoved": "\"{trackName}\" dihapus dari Loved", + "collectionRemovedFromLoved": "\"{trackName}\" removed from Loved", "@collectionRemovedFromLoved": { "description": "Snackbar after removing track from loved folder", "placeholders": { @@ -2971,7 +2983,7 @@ } } }, - "collectionAddedToWishlist": "\"{trackName}\" ditambahkan ke Wishlist", + "collectionAddedToWishlist": "\"{trackName}\" added to Wishlist", "@collectionAddedToWishlist": { "description": "Snackbar after adding track to wishlist", "placeholders": { @@ -2980,7 +2992,7 @@ } } }, - "collectionRemovedFromWishlist": "\"{trackName}\" dihapus dari Wishlist", + "collectionRemovedFromWishlist": "\"{trackName}\" removed from Wishlist", "@collectionRemovedFromWishlist": { "description": "Snackbar after removing track from wishlist", "placeholders": { @@ -2989,31 +3001,31 @@ } } }, - "trackOptionAddToLoved": "Tambahkan ke Loved", + "trackOptionAddToLoved": "Add to Loved", "@trackOptionAddToLoved": { "description": "Bottom sheet action label - add track to loved folder" }, - "trackOptionRemoveFromLoved": "Hapus dari Loved", + "trackOptionRemoveFromLoved": "Remove from Loved", "@trackOptionRemoveFromLoved": { "description": "Bottom sheet action label - remove track from loved folder" }, - "trackOptionAddToWishlist": "Tambahkan ke Wishlist", + "trackOptionAddToWishlist": "Add to Wishlist", "@trackOptionAddToWishlist": { "description": "Bottom sheet action label - add track to wishlist" }, - "trackOptionRemoveFromWishlist": "Hapus dari Wishlist", + "trackOptionRemoveFromWishlist": "Remove from Wishlist", "@trackOptionRemoveFromWishlist": { "description": "Bottom sheet action label - remove track from wishlist" }, - "collectionPlaylistChangeCover": "Ubah gambar sampul", + "collectionPlaylistChangeCover": "Change cover image", "@collectionPlaylistChangeCover": { "description": "Bottom sheet action to pick a custom cover image for a playlist" }, - "collectionPlaylistRemoveCover": "Hapus gambar sampul", + "collectionPlaylistRemoveCover": "Remove cover image", "@collectionPlaylistRemoveCover": { "description": "Bottom sheet action to remove custom cover image from a playlist" }, - "selectionShareCount": "Bagikan {count} {count, plural, =1{trek} other{trek}}", + "selectionShareCount": "Share {count} {count, plural, =1{track} other{tracks}}", "@selectionShareCount": { "description": "Share button text with count in selection mode", "placeholders": { @@ -3022,11 +3034,11 @@ } } }, - "selectionShareNoFiles": "Tidak ada file yang dapat dibagikan", + "selectionShareNoFiles": "No shareable files found", "@selectionShareNoFiles": { "description": "Snackbar when no selected files exist on disk" }, - "selectionConvertCount": "Konversi {count} {count, plural, =1{trek} other{trek}}", + "selectionConvertCount": "Convert {count} {count, plural, =1{track} other{tracks}}", "@selectionConvertCount": { "description": "Convert button text with count in selection mode", "placeholders": { @@ -3035,15 +3047,15 @@ } } }, - "selectionConvertNoConvertible": "Tidak ada trek yang dapat dikonversi dipilih", + "selectionConvertNoConvertible": "No convertible tracks selected", "@selectionConvertNoConvertible": { "description": "Snackbar when no selected tracks support conversion" }, - "selectionBatchConvertConfirmTitle": "Konversi Massal", + "selectionBatchConvertConfirmTitle": "Batch Convert", "@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": "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": { @@ -3058,7 +3070,7 @@ } } }, - "selectionBatchConvertProgress": "Mengonversi {current} dari {total}...", + "selectionBatchConvertProgress": "Converting {current} of {total}...", "@selectionBatchConvertProgress": { "description": "Snackbar during batch conversion progress", "placeholders": { @@ -3070,7 +3082,7 @@ } } }, - "selectionBatchConvertSuccess": "Berhasil mengonversi {success} dari {total} trek ke {format}", + "selectionBatchConvertSuccess": "Converted {success} of {total} tracks to {format}", "@selectionBatchConvertSuccess": { "description": "Snackbar after batch conversion completes", "placeholders": { @@ -3102,4 +3114,4 @@ "@downloadUseAlbumArtistForFoldersTrackSubtitle": { "description": "Subtitle when Track Artist is used for folder naming" } -} +} \ No newline at end of file diff --git a/lib/l10n/arb/app_ja.arb b/lib/l10n/arb/app_ja.arb index b23b0de3..268d8950 100644 --- a/lib/l10n/arb/app_ja.arb +++ b/lib/l10n/arb/app_ja.arb @@ -9,7 +9,7 @@ "@navHome": { "description": "Bottom navigation - Home tab" }, - "navLibrary": "Library", + "navLibrary": "ライブラリ", "@navLibrary": { "description": "Bottom navigation - Library tab" }, @@ -198,7 +198,7 @@ "@optionsConcurrentSequential": { "description": "Download one at a time" }, - "optionsConcurrentParallel": "{count} parallel downloads", + "optionsConcurrentParallel": "{count} 件の分割ダウンロード", "@optionsConcurrentParallel": { "description": "Multiple parallel downloads", "placeholders": { @@ -991,6 +991,14 @@ "@filenameFormat": { "description": "Setting title - filename pattern" }, + "filenameShowAdvancedTags": "Show advanced tags", + "@filenameShowAdvancedTags": { + "description": "Toggle label for showing advanced filename tags" + }, + "filenameShowAdvancedTagsDescription": "Enable formatted tags for track padding and date patterns", + "@filenameShowAdvancedTagsDescription": { + "description": "Description for advanced filename tag toggle" + }, "folderOrganizationNone": "構成がありません", "@folderOrganizationNone": { "description": "Folder option - flat structure" @@ -1455,7 +1463,7 @@ "@trackLyricsLoadFailed": { "description": "Message when lyrics loading fails" }, - "trackEmbedLyrics": "Embed Lyrics", + "trackEmbedLyrics": "歌詞を埋め込む", "@trackEmbedLyrics": { "description": "Action - embed lyrics into audio file" }, @@ -1749,6 +1757,14 @@ "@youtubeQualityNote": { "description": "Note for YouTube service explaining lossy-only quality" }, + "youtubeOpusBitrateTitle": "YouTube Opus のビットレート", + "@youtubeOpusBitrateTitle": { + "description": "Title for YouTube Opus bitrate setting" + }, + "youtubeMp3BitrateTitle": "YouTube MP3 のビットレート", + "@youtubeMp3BitrateTitle": { + "description": "Title for YouTube MP3 bitrate setting" + }, "downloadAskBeforeDownload": "ダウンロード前に確認する", "@downloadAskBeforeDownload": { "description": "Setting - show quality picker" @@ -1805,7 +1821,7 @@ "@queueClearAllMessage": { "description": "Clear queue confirmation" }, - "settingsAutoExportFailed": "Auto-export failed downloads", + "settingsAutoExportFailed": "ダウンロードの自動エクスポートに失敗しました", "@settingsAutoExportFailed": { "description": "Setting toggle for auto-export" }, @@ -1813,15 +1829,15 @@ "@settingsAutoExportFailedSubtitle": { "description": "Subtitle for auto-export setting" }, - "settingsDownloadNetwork": "Download Network", + "settingsDownloadNetwork": "ダウンロードネットワーク", "@settingsDownloadNetwork": { "description": "Setting for network type preference" }, - "settingsDownloadNetworkAny": "WiFi + Mobile Data", + "settingsDownloadNetworkAny": "Wi-Fi + モバイルデータ", "@settingsDownloadNetworkAny": { "description": "Network option - use any connection" }, - "settingsDownloadNetworkWifiOnly": "WiFi Only", + "settingsDownloadNetworkWifiOnly": "Wi-Fi のみ", "@settingsDownloadNetworkWifiOnly": { "description": "Network option - only use WiFi" }, @@ -1861,7 +1877,7 @@ "@albumFolderYearAlbumSubtitle": { "description": "Folder structure example" }, - "albumFolderArtistAlbumSingles": "Artist / Album + Singles", + "albumFolderArtistAlbumSingles": "アーティスト / アルバム + シングル", "@albumFolderArtistAlbumSingles": { "description": "Album folder option with singles inside artist" }, @@ -1942,7 +1958,7 @@ "@recentEmpty": { "description": "Empty state text for recent access list" }, - "recentShowAllDownloads": "Show All Downloads", + "recentShowAllDownloads": "すべてのダウンロードを表示", "@recentShowAllDownloads": { "description": "Button label to unhide hidden downloads in recent access" }, @@ -2074,11 +2090,11 @@ "@discographyFailedToFetch": { "description": "Error - some albums failed to load" }, - "sectionStorageAccess": "Storage Access", + "sectionStorageAccess": "ストレージアクセス", "@sectionStorageAccess": { "description": "Section header for storage access settings" }, - "allFilesAccess": "All Files Access", + "allFilesAccess": "すべてのファイルへのアクセス", "@allFilesAccess": { "description": "Toggle for MANAGE_EXTERNAL_STORAGE permission" }, @@ -2102,7 +2118,7 @@ "@allFilesAccessDisabledMessage": { "description": "Snackbar message when user disables all files access" }, - "settingsLocalLibrary": "Local Library", + "settingsLocalLibrary": "ローカルライブラリ", "@settingsLocalLibrary": { "description": "Settings menu item - local library" }, @@ -2110,7 +2126,7 @@ "@settingsLocalLibrarySubtitle": { "description": "Subtitle for local library settings" }, - "settingsCache": "Storage & Cache", + "settingsCache": "ストレージとキャッシュ", "@settingsCache": { "description": "Settings menu item - cache management" }, @@ -2118,15 +2134,15 @@ "@settingsCacheSubtitle": { "description": "Subtitle for cache management menu" }, - "libraryTitle": "Local Library", + "libraryTitle": "ローカルライブラリ", "@libraryTitle": { "description": "Library settings page title" }, - "libraryScanSettings": "Scan Settings", + "libraryScanSettings": "スキャン設定", "@libraryScanSettings": { "description": "Section header for scan settings" }, - "libraryEnableLocalLibrary": "Enable Local Library", + "libraryEnableLocalLibrary": "ローカルライブラリを有効", "@libraryEnableLocalLibrary": { "description": "Toggle to enable library scanning" }, @@ -2134,11 +2150,11 @@ "@libraryEnableLocalLibrarySubtitle": { "description": "Subtitle for enable toggle" }, - "libraryFolder": "Library Folder", + "libraryFolder": "ライブラリのフォルダ", "@libraryFolder": { "description": "Folder selection setting" }, - "libraryFolderHint": "Tap to select folder", + "libraryFolderHint": "タップでフォルダを選択", "@libraryFolderHint": { "description": "Placeholder when no folder selected" }, @@ -2150,15 +2166,15 @@ "@libraryShowDuplicateIndicatorSubtitle": { "description": "Subtitle for duplicate indicator toggle" }, - "libraryActions": "Actions", + "libraryActions": "アクション", "@libraryActions": { "description": "Section header for library actions" }, - "libraryScan": "Scan Library", + "libraryScan": "ライブラリをスキャン", "@libraryScan": { "description": "Button to start library scan" }, - "libraryScanSubtitle": "Scan for audio files", + "libraryScanSubtitle": "オーディオファイルをスキャン", "@libraryScanSubtitle": { "description": "Subtitle for scan button" }, @@ -2174,7 +2190,7 @@ "@libraryCleanupMissingFilesSubtitle": { "description": "Subtitle for cleanup button" }, - "libraryClear": "Clear Library", + "libraryClear": "ライブラリを消去", "@libraryClear": { "description": "Button to clear all library entries" }, @@ -2182,7 +2198,7 @@ "@libraryClearSubtitle": { "description": "Subtitle for clear button" }, - "libraryClearConfirmTitle": "Clear Library", + "libraryClearConfirmTitle": "ライブラリを消去", "@libraryClearConfirmTitle": { "description": "Dialog title for clear confirmation" }, @@ -2190,7 +2206,7 @@ "@libraryClearConfirmMessage": { "description": "Dialog message for clear confirmation" }, - "libraryAbout": "About Local Library", + "libraryAbout": "ローカルライブラリについて", "@libraryAbout": { "description": "Section header for about info" }, @@ -2198,7 +2214,16 @@ "@libraryAboutDescription": { "description": "Description of local library feature" }, - "libraryLastScanned": "Last scanned: {time}", + "libraryTracksUnit": "{count, plural, =1{track} other{tracks}}", + "@libraryTracksUnit": { + "description": "Unit label for tracks count (without the number itself)", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "libraryLastScanned": "最終スキャン: {time}", "@libraryLastScanned": { "description": "Last scan time display", "placeholders": { @@ -2211,7 +2236,7 @@ "@libraryLastScannedNever": { "description": "Shown when library has never been scanned" }, - "libraryScanning": "Scanning...", + "libraryScanning": "スキャン中...", "@libraryScanning": { "description": "Status during scan" }, @@ -2227,7 +2252,7 @@ } } }, - "libraryInLibrary": "In Library", + "libraryInLibrary": "ライブラリ内", "@libraryInLibrary": { "description": "Badge shown on tracks that exist in local library" }, @@ -2244,7 +2269,7 @@ "@libraryCleared": { "description": "Snackbar after clearing library" }, - "libraryStorageAccessRequired": "Storage Access Required", + "libraryStorageAccessRequired": "ストレージアクセスが必要です", "@libraryStorageAccessRequired": { "description": "Dialog title for storage permission" }, @@ -2256,47 +2281,47 @@ "@libraryFolderNotExist": { "description": "Error when folder doesn't exist" }, - "librarySourceDownloaded": "Downloaded", + "librarySourceDownloaded": "ダウンロード済み", "@librarySourceDownloaded": { "description": "Badge for tracks downloaded via SpotiFLAC" }, - "librarySourceLocal": "Local", + "librarySourceLocal": "ローカル", "@librarySourceLocal": { "description": "Badge for tracks from local library scan" }, - "libraryFilterAll": "All", + "libraryFilterAll": "すべて", "@libraryFilterAll": { "description": "Filter chip - show all library items" }, - "libraryFilterDownloaded": "Downloaded", + "libraryFilterDownloaded": "ダウンロード済み", "@libraryFilterDownloaded": { "description": "Filter chip - show only downloaded items" }, - "libraryFilterLocal": "Local", + "libraryFilterLocal": "ローカル", "@libraryFilterLocal": { "description": "Filter chip - show only local library items" }, - "libraryFilterTitle": "Filters", + "libraryFilterTitle": "フィルター", "@libraryFilterTitle": { "description": "Filter bottom sheet title" }, - "libraryFilterReset": "Reset", + "libraryFilterReset": "リセット", "@libraryFilterReset": { "description": "Reset all filters button" }, - "libraryFilterApply": "Apply", + "libraryFilterApply": "適用", "@libraryFilterApply": { "description": "Apply filters button" }, - "libraryFilterSource": "Source", + "libraryFilterSource": "ソース", "@libraryFilterSource": { "description": "Filter section - source type" }, - "libraryFilterQuality": "Quality", + "libraryFilterQuality": "品質", "@libraryFilterQuality": { "description": "Filter section - audio quality" }, - "libraryFilterQualityHiRes": "Hi-Res (24bit)", + "libraryFilterQualityHiRes": "ハイレゾ (24bit)", "@libraryFilterQualityHiRes": { "description": "Filter option - high resolution audio" }, @@ -2308,7 +2333,7 @@ "@libraryFilterQualityLossy": { "description": "Filter option - lossy compressed audio" }, - "libraryFilterFormat": "Format", + "libraryFilterFormat": "形式", "@libraryFilterFormat": { "description": "Filter section - file format" }, @@ -2328,7 +2353,7 @@ "@timeJustNow": { "description": "Relative time - less than a minute ago" }, - "timeMinutesAgo": "{count, plural, =1{1 minute ago} other{{count} minutes ago}}", + "timeMinutesAgo": "{count, plural, =1{1 分前} other{{count} 分前}}", "@timeMinutesAgo": { "description": "Relative time - minutes ago", "placeholders": { @@ -2337,7 +2362,7 @@ } } }, - "timeHoursAgo": "{count, plural, =1{1 hour ago} other{{count} hours ago}}", + "timeHoursAgo": "{count, plural, =1{1 時間前} other{{count} 時間前}}", "@timeHoursAgo": { "description": "Relative time - hours ago", "placeholders": { @@ -2346,7 +2371,7 @@ } } }, - "tutorialWelcomeTitle": "Welcome to SpotiFLAC!", + "tutorialWelcomeTitle": "SpotiFLAC へようこそ!", "@tutorialWelcomeTitle": { "description": "Tutorial welcome page title" }, @@ -2374,7 +2399,7 @@ "@tutorialSearchDesc": { "description": "Tutorial search page description" }, - "tutorialDownloadTitle": "Downloading Music", + "tutorialDownloadTitle": "音楽をダウンロード中", "@tutorialDownloadTitle": { "description": "Tutorial download page title" }, @@ -2382,7 +2407,7 @@ "@tutorialDownloadDesc": { "description": "Tutorial download page description" }, - "tutorialLibraryTitle": "Your Library", + "tutorialLibraryTitle": "あなたのライブラリ", "@tutorialLibraryTitle": { "description": "Tutorial library page title" }, @@ -2402,7 +2427,7 @@ "@tutorialLibraryTip3": { "description": "Tutorial library tip 3" }, - "tutorialExtensionsTitle": "Extensions", + "tutorialExtensionsTitle": "拡張", "@tutorialExtensionsTitle": { "description": "Tutorial extensions page title" }, @@ -2446,7 +2471,7 @@ "@tutorialReadyMessage": { "description": "Tutorial completion message" }, - "libraryForceFullScan": "Force Full Scan", + "libraryForceFullScan": "強制フルスキャン", "@libraryForceFullScan": { "description": "Button to force a complete rescan of library" }, @@ -2475,11 +2500,11 @@ "@cleanupOrphanedDownloadsNone": { "description": "Snackbar when no orphans found" }, - "cacheTitle": "Storage & Cache", + "cacheTitle": "ストレージとキャッシュ", "@cacheTitle": { "description": "Cache management page title" }, - "cacheSummaryTitle": "Cache overview", + "cacheSummaryTitle": "キャッシュの概要", "@cacheSummaryTitle": { "description": "Heading for cache summary card" }, @@ -2496,15 +2521,15 @@ } } }, - "cacheSectionStorage": "Cached Data", + "cacheSectionStorage": "キャッシュ済みデータ", "@cacheSectionStorage": { "description": "Section header for cache entries" }, - "cacheSectionMaintenance": "Maintenance", + "cacheSectionMaintenance": "メンテナンス", "@cacheSectionMaintenance": { "description": "Section header for cleanup actions" }, - "cacheAppDirectory": "App cache directory", + "cacheAppDirectory": "アプリキャッシュのディレクトリ", "@cacheAppDirectory": { "description": "Cache item title for app cache directory" }, @@ -2512,7 +2537,7 @@ "@cacheAppDirectoryDesc": { "description": "Description of what app cache directory contains" }, - "cacheTempDirectory": "Temporary directory", + "cacheTempDirectory": "一時ディレクトリ", "@cacheTempDirectory": { "description": "Cache item title for temporary files directory" }, @@ -2520,7 +2545,7 @@ "@cacheTempDirectoryDesc": { "description": "Description of what temporary directory contains" }, - "cacheCoverImage": "Cover image cache", + "cacheCoverImage": "カバー画像のキャッシュ", "@cacheCoverImage": { "description": "Cache item title for persistent cover images" }, @@ -2528,7 +2553,7 @@ "@cacheCoverImageDesc": { "description": "Description of what cover image cache contains" }, - "cacheLibraryCover": "Library cover cache", + "cacheLibraryCover": "ライブラリのカバーキャッシュ", "@cacheLibraryCover": { "description": "Cache item title for local library cover art images" }, @@ -2556,7 +2581,7 @@ "@cacheCleanupUnusedDesc": { "description": "Description of what cleanup unused data does" }, - "cacheNoData": "No cached data", + "cacheNoData": "キャッシュデータはありません", "@cacheNoData": { "description": "Label when cache category has no data" }, @@ -2581,7 +2606,7 @@ } } }, - "cacheEntries": "{count} entries", + "cacheEntries": "{count} 個のエントリ", "@cacheEntries": { "description": "Track cache entry count", "placeholders": { @@ -2590,7 +2615,7 @@ } } }, - "cacheClearSuccess": "Cleared: {target}", + "cacheClearSuccess": "消去済み: {target}", "@cacheClearSuccess": { "description": "Snackbar after clearing selected cache", "placeholders": { @@ -2599,7 +2624,7 @@ } } }, - "cacheClearConfirmTitle": "Clear cache?", + "cacheClearConfirmTitle": "キャッシュを消去しますか?", "@cacheClearConfirmTitle": { "description": "Dialog title before clearing one cache category" }, @@ -2612,7 +2637,7 @@ } } }, - "cacheClearAllConfirmTitle": "Clear all cache?", + "cacheClearAllConfirmTitle": "すべてのキャッシュを消去しますか?", "@cacheClearAllConfirmTitle": { "description": "Dialog title before clearing all caches" }, @@ -2620,11 +2645,11 @@ "@cacheClearAllConfirmMessage": { "description": "Dialog message before clearing all caches" }, - "cacheClearAll": "Clear all cache", + "cacheClearAll": "すべてのキャッシュを消去", "@cacheClearAll": { "description": "Button label to clear all caches" }, - "cacheCleanupUnused": "Cleanup unused data", + "cacheCleanupUnused": "未使用のデータを削除", "@cacheCleanupUnused": { "description": "Action title for cleaning unused entries" }, @@ -2644,11 +2669,11 @@ } } }, - "cacheRefreshStats": "Refresh stats", + "cacheRefreshStats": "状態を更新", "@cacheRefreshStats": { "description": "Button label to refresh cache statistics" }, - "trackSaveCoverArt": "Save Cover Art", + "trackSaveCoverArt": "カバー画像を保存", "@trackSaveCoverArt": { "description": "Menu action - save album cover art as file" }, @@ -2656,7 +2681,7 @@ "@trackSaveCoverArtSubtitle": { "description": "Subtitle for save cover art action" }, - "trackSaveLyrics": "Save Lyrics (.lrc)", + "trackSaveLyrics": "歌詞を保存 (.lrc)", "@trackSaveLyrics": { "description": "Menu action - save lyrics as .lrc file" }, @@ -2676,7 +2701,7 @@ "@trackReEnrichOnlineSubtitle": { "description": "Subtitle for re-enrich metadata action for local items" }, - "trackEditMetadata": "Edit Metadata", + "trackEditMetadata": "メタデータを編集", "@trackEditMetadata": { "description": "Menu action - edit embedded metadata" }, @@ -2718,7 +2743,7 @@ "@trackReEnrichFfmpegFailed": { "description": "Snackbar when FFmpeg embed fails for MP3/Opus" }, - "trackSaveFailed": "Failed: {error}", + "trackSaveFailed": "失敗: {error}", "@trackSaveFailed": { "description": "Snackbar when save operation fails", "placeholders": { @@ -2727,27 +2752,27 @@ } } }, - "trackConvertFormat": "Convert Format", + "trackConvertFormat": "変換の形式", "@trackConvertFormat": { "description": "Menu item - convert audio format" }, - "trackConvertFormatSubtitle": "Convert to MP3 or Opus", + "trackConvertFormatSubtitle": "MP3 または Opus に変換", "@trackConvertFormatSubtitle": { "description": "Subtitle for convert format menu item" }, - "trackConvertTitle": "Convert Audio", + "trackConvertTitle": "オーディオを変換", "@trackConvertTitle": { "description": "Title of convert bottom sheet" }, - "trackConvertTargetFormat": "Target Format", + "trackConvertTargetFormat": "ターゲットの形式", "@trackConvertTargetFormat": { "description": "Label for format selection" }, - "trackConvertBitrate": "Bitrate", + "trackConvertBitrate": "ビットレート", "@trackConvertBitrate": { "description": "Label for bitrate selection" }, - "trackConvertConfirmTitle": "Confirm Conversion", + "trackConvertConfirmTitle": "変換を確認", "@trackConvertConfirmTitle": { "description": "Confirmation dialog title" }, @@ -2766,7 +2791,7 @@ } } }, - "trackConvertConverting": "Converting audio...", + "trackConvertConverting": "オーディオを変換中...", "@trackConvertConverting": { "description": "Snackbar while converting" }, @@ -2779,10 +2804,287 @@ } } }, - "trackConvertFailed": "Conversion failed", + "trackConvertFailed": "変換に失敗しました", "@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} 個をダウンロード済み", "@downloadedAlbumDownloadedCount": { "description": "Downloaded tracks count badge", @@ -2800,4 +3102,4 @@ "@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 22b488a3..f31eb1b9 100644 --- a/lib/l10n/arb/app_ko.arb +++ b/lib/l10n/arb/app_ko.arb @@ -49,7 +49,7 @@ "@historyFilterSingles": { "description": "Filter chip - show singles only" }, - "historySearchHint": "Search history...", + "historySearchHint": "검색 기록...", "@historySearchHint": { "description": "Search bar placeholder in history" }, @@ -57,43 +57,43 @@ "@settingsTitle": { "description": "Settings screen title" }, - "settingsDownload": "Download", + "settingsDownload": "다운로드", "@settingsDownload": { "description": "Settings section - download options" }, - "settingsAppearance": "Appearance", + "settingsAppearance": "외관", "@settingsAppearance": { "description": "Settings section - visual customization" }, - "settingsOptions": "Options", + "settingsOptions": "옵션", "@settingsOptions": { "description": "Settings section - app options" }, - "settingsExtensions": "Extensions", + "settingsExtensions": "확장 기능", "@settingsExtensions": { "description": "Settings section - extension management" }, - "settingsAbout": "About", + "settingsAbout": "정보", "@settingsAbout": { "description": "Settings section - app info" }, - "downloadTitle": "Download", + "downloadTitle": "다운로드", "@downloadTitle": { "description": "Download settings page title" }, - "downloadAskQualitySubtitle": "Show quality picker for each download", + "downloadAskQualitySubtitle": "다운로드를 할 때마다 품질을 선택하도록 합니다", "@downloadAskQualitySubtitle": { "description": "Subtitle for ask quality toggle" }, - "downloadFilenameFormat": "Filename Format", + "downloadFilenameFormat": "파일 이름 형식", "@downloadFilenameFormat": { "description": "Setting for output filename pattern" }, - "downloadFolderOrganization": "Folder Organization", + "downloadFolderOrganization": "폴더 분류 형식", "@downloadFolderOrganization": { "description": "Setting for folder structure" }, - "appearanceTitle": "Appearance", + "appearanceTitle": "외관", "@appearanceTitle": { "description": "Appearance settings page title" }, @@ -113,11 +113,11 @@ "@appearanceDynamicColor": { "description": "Material You dynamic colors" }, - "appearanceDynamicColorSubtitle": "Use colors from your wallpaper", + "appearanceDynamicColorSubtitle": "배경 화면을 참고하여 강조 색상이 지정됩니다", "@appearanceDynamicColorSubtitle": { "description": "Subtitle for dynamic color" }, - "appearanceHistoryView": "History View", + "appearanceHistoryView": "기록 정렬 방식", "@appearanceHistoryView": { "description": "Layout style for history" }, @@ -129,19 +129,19 @@ "@appearanceHistoryViewGrid": { "description": "Grid layout option" }, - "optionsTitle": "Options", + "optionsTitle": "옵션", "@optionsTitle": { "description": "Options settings page title" }, - "optionsPrimaryProvider": "Primary Provider", + "optionsPrimaryProvider": "기본 제공자", "@optionsPrimaryProvider": { "description": "Main search provider setting" }, - "optionsPrimaryProviderSubtitle": "Service used when searching by track name.", + "optionsPrimaryProviderSubtitle": "음반 이름으로 검색할 때 사용되는 서비스", "@optionsPrimaryProviderSubtitle": { "description": "Subtitle for primary provider" }, - "optionsUsingExtension": "Using extension: {extensionName}", + "optionsUsingExtension": "확장 기능을 사용: {extensionName}", "@optionsUsingExtension": { "description": "Shows active extension name", "placeholders": { @@ -150,11 +150,11 @@ } } }, - "optionsSwitchBack": "Tap Deezer or Spotify to switch back from extension", + "optionsSwitchBack": "Deezer 또는 Spotify를 탭하여 확장 기능에서 다시 전환하세요.", "@optionsSwitchBack": { "description": "Hint to switch back to built-in providers" }, - "optionsAutoFallback": "Auto Fallback", + "optionsAutoFallback": "자동 재시도", "@optionsAutoFallback": { "description": "Auto-retry with other services" }, @@ -162,7 +162,7 @@ "@optionsAutoFallbackSubtitle": { "description": "Subtitle for auto fallback" }, - "optionsUseExtensionProviders": "Use Extension Providers", + "optionsUseExtensionProviders": "확장 기능 사용", "@optionsUseExtensionProviders": { "description": "Enable extension download providers" }, @@ -170,35 +170,35 @@ "@optionsUseExtensionProvidersOn": { "description": "Status when extension providers enabled" }, - "optionsUseExtensionProvidersOff": "Using built-in providers only", + "optionsUseExtensionProvidersOff": "기본으로 제공되는 기능만 사용", "@optionsUseExtensionProvidersOff": { "description": "Status when extension providers disabled" }, - "optionsEmbedLyrics": "Embed Lyrics", + "optionsEmbedLyrics": "가사 삽입", "@optionsEmbedLyrics": { "description": "Embed lyrics in audio files" }, - "optionsEmbedLyricsSubtitle": "Embed synced lyrics into FLAC files", + "optionsEmbedLyricsSubtitle": "FLAC 파일에 동기화된 가사를 삽입합니다", "@optionsEmbedLyricsSubtitle": { "description": "Subtitle for embed lyrics" }, - "optionsMaxQualityCover": "Max Quality Cover", + "optionsMaxQualityCover": "고품질 커버 이미지", "@optionsMaxQualityCover": { "description": "Download highest quality album art" }, - "optionsMaxQualityCoverSubtitle": "Download highest resolution cover art", + "optionsMaxQualityCoverSubtitle": "최고 품질의 커버 이미지를 다운로드", "@optionsMaxQualityCoverSubtitle": { "description": "Subtitle for max quality cover" }, - "optionsConcurrentDownloads": "Concurrent Downloads", + "optionsConcurrentDownloads": "동시 다운로드", "@optionsConcurrentDownloads": { "description": "Number of parallel downloads" }, - "optionsConcurrentSequential": "Sequential (1 at a time)", + "optionsConcurrentSequential": "순차 다운로드 (한 번에 하나)", "@optionsConcurrentSequential": { "description": "Download one at a time" }, - "optionsConcurrentParallel": "{count} parallel downloads", + "optionsConcurrentParallel": "{count}개 동시 다운로드", "@optionsConcurrentParallel": { "description": "Multiple parallel downloads", "placeholders": { @@ -207,63 +207,63 @@ } } }, - "optionsConcurrentWarning": "Parallel downloads may trigger rate limiting", + "optionsConcurrentWarning": "동시에 다수의 음반을 다운로드하면 속도 제한이 발생할 수 있습니다", "@optionsConcurrentWarning": { "description": "Warning about rate limits" }, - "optionsExtensionStore": "Extension Store", + "optionsExtensionStore": "확장 기능 스토어", "@optionsExtensionStore": { "description": "Show/hide store tab" }, - "optionsExtensionStoreSubtitle": "Show Store tab in navigation", + "optionsExtensionStoreSubtitle": "탐색 메뉴에 스토어 탭 표시", "@optionsExtensionStoreSubtitle": { "description": "Subtitle for extension store toggle" }, - "optionsCheckUpdates": "Check for Updates", + "optionsCheckUpdates": "업데이트 확인", "@optionsCheckUpdates": { "description": "Auto update check toggle" }, - "optionsCheckUpdatesSubtitle": "Notify when new version is available", + "optionsCheckUpdatesSubtitle": "새로운 버전이 출시되면 알림", "@optionsCheckUpdatesSubtitle": { "description": "Subtitle for update check" }, - "optionsUpdateChannel": "Update Channel", + "optionsUpdateChannel": "업데이트 채널", "@optionsUpdateChannel": { "description": "Stable vs preview releases" }, - "optionsUpdateChannelStable": "Stable releases only", + "optionsUpdateChannelStable": "안정적인 버전만 수령", "@optionsUpdateChannelStable": { "description": "Only stable updates" }, - "optionsUpdateChannelPreview": "Get preview releases", + "optionsUpdateChannelPreview": "미리보기 버전을 수령", "@optionsUpdateChannelPreview": { "description": "Include beta/preview updates" }, - "optionsUpdateChannelWarning": "Preview may contain bugs or incomplete features", + "optionsUpdateChannelWarning": "미리보기 버전은 불안정할 수 있습니다", "@optionsUpdateChannelWarning": { "description": "Warning about preview channel" }, - "optionsClearHistory": "Clear Download History", + "optionsClearHistory": "다운로드 기록 삭제", "@optionsClearHistory": { "description": "Delete all download history" }, - "optionsClearHistorySubtitle": "Remove all downloaded tracks from history", + "optionsClearHistorySubtitle": "기록에서 모든 다운로드 음반을 제거합니다", "@optionsClearHistorySubtitle": { "description": "Subtitle for clear history" }, - "optionsDetailedLogging": "Detailed Logging", + "optionsDetailedLogging": "상세 로깅", "@optionsDetailedLogging": { "description": "Enable verbose logs for debugging" }, - "optionsDetailedLoggingOn": "Detailed logs are being recorded", + "optionsDetailedLoggingOn": "상세한 로그가 기록되고 있습니다", "@optionsDetailedLoggingOn": { "description": "Status when logging enabled" }, - "optionsDetailedLoggingOff": "Enable for bug reports", + "optionsDetailedLoggingOff": "버그 신고를 위한 기능입니다", "@optionsDetailedLoggingOff": { "description": "Status when logging disabled" }, - "optionsSpotifyCredentials": "Spotify Credentials", + "optionsSpotifyCredentials": "Spotify 자격 증명", "@optionsSpotifyCredentials": { "description": "Spotify API credentials setting" }, @@ -276,23 +276,23 @@ } } }, - "optionsSpotifyCredentialsRequired": "Required - tap to configure", + "optionsSpotifyCredentialsRequired": "탭하여 설정", "@optionsSpotifyCredentialsRequired": { "description": "Prompt to set up credentials" }, - "optionsSpotifyWarning": "Spotify requires your own API credentials. Get them free from developer.spotify.com", + "optionsSpotifyWarning": "Spotify는 사용자 고유의 API 자격 증명을 요구합니다. 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": "Spotify API 변경으로 인해 Spotify 검색 기능은 2026년 3월 3일부터 더 이상 지원되지 않습니다. Deezer로 전환해 주세요", "@optionsSpotifyDeprecationWarning": { "description": "Warning about Spotify API deprecation" }, - "extensionsTitle": "Extensions", + "extensionsTitle": "확장 기능", "@extensionsTitle": { "description": "Extensions page title" }, - "extensionsDisabled": "Disabled", + "extensionsDisabled": "비활성화", "@extensionsDisabled": { "description": "Extension status - inactive" }, @@ -314,59 +314,59 @@ } } }, - "extensionsUninstall": "Uninstall", + "extensionsUninstall": "삭제", "@extensionsUninstall": { "description": "Uninstall extension button" }, - "storeTitle": "Extension Store", + "storeTitle": "확장 기능 스토어", "@storeTitle": { "description": "Store screen title" }, - "storeSearch": "Search extensions...", + "storeSearch": "확장 기능 검색", "@storeSearch": { "description": "Store search placeholder" }, - "storeInstall": "Install", + "storeInstall": "설치", "@storeInstall": { "description": "Install extension button" }, - "storeInstalled": "Installed", + "storeInstalled": "설치됨", "@storeInstalled": { "description": "Already installed badge" }, - "storeUpdate": "Update", + "storeUpdate": "업데이트", "@storeUpdate": { "description": "Update available button" }, - "aboutTitle": "About", + "aboutTitle": "정보", "@aboutTitle": { "description": "About page title" }, - "aboutContributors": "Contributors", + "aboutContributors": "기여자", "@aboutContributors": { "description": "Section for contributors" }, - "aboutMobileDeveloper": "Mobile version developer", + "aboutMobileDeveloper": "모바일 버전 개발자", "@aboutMobileDeveloper": { "description": "Role description for mobile dev" }, - "aboutOriginalCreator": "Creator of the original SpotiFLAC", + "aboutOriginalCreator": "오리지널 SpotiFLAC 제작자", "@aboutOriginalCreator": { "description": "Role description for original creator" }, - "aboutLogoArtist": "The talented artist who created our beautiful app logo!", + "aboutLogoArtist": "아름다운 로고를 만들어주신 재능 있는 아티스트!", "@aboutLogoArtist": { "description": "Role description for logo artist" }, - "aboutTranslators": "Translators", + "aboutTranslators": "번역가들", "@aboutTranslators": { "description": "Section for translators" }, - "aboutSpecialThanks": "Special Thanks", + "aboutSpecialThanks": "특별 감사", "@aboutSpecialThanks": { "description": "Section for special thanks" }, - "aboutLinks": "Links", + "aboutLinks": "바로가기", "@aboutLinks": { "description": "Section for external links" }, @@ -374,23 +374,23 @@ "@aboutMobileSource": { "description": "Link to mobile GitHub repo" }, - "aboutPCSource": "PC source code", + "aboutPCSource": "PC 소스 코드", "@aboutPCSource": { "description": "Link to PC GitHub repo" }, - "aboutReportIssue": "Report an issue", + "aboutReportIssue": "문제 신고", "@aboutReportIssue": { "description": "Link to report bugs" }, - "aboutReportIssueSubtitle": "Report any problems you encounter", + "aboutReportIssueSubtitle": "발생하는 모든 문제를 신고하여 주세요.", "@aboutReportIssueSubtitle": { "description": "Subtitle for report issue" }, - "aboutFeatureRequest": "Feature request", + "aboutFeatureRequest": "기능 요청", "@aboutFeatureRequest": { "description": "Link to suggest features" }, - "aboutFeatureRequestSubtitle": "Suggest new features for the app", + "aboutFeatureRequestSubtitle": "앱의 새로운 기능을 제안하여 주세요.", "@aboutFeatureRequestSubtitle": { "description": "Subtitle for feature request" }, @@ -398,7 +398,7 @@ "@aboutTelegramChannel": { "description": "Link to Telegram channel" }, - "aboutTelegramChannelSubtitle": "Announcements and updates", + "aboutTelegramChannelSubtitle": "공지 및 업데이트 안내", "@aboutTelegramChannelSubtitle": { "description": "Subtitle for Telegram channel" }, @@ -406,11 +406,11 @@ "@aboutTelegramChat": { "description": "Link to Telegram chat group" }, - "aboutTelegramChatSubtitle": "Chat with other users", + "aboutTelegramChatSubtitle": "다른 이용자와 소통", "@aboutTelegramChatSubtitle": { "description": "Subtitle for Telegram chat" }, - "aboutSocial": "Social", + "aboutSocial": "소셜", "@aboutSocial": { "description": "Section for social links" }, @@ -422,15 +422,15 @@ "@aboutVersion": { "description": "Version info label" }, - "aboutBinimumDesc": "The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn't exist!", + "aboutBinimumDesc": "QQDL 및 HiFi API 개발자입니다. 이 API가 없었다면 Tidal 다운로드는 불가능했을 것입니다!", "@aboutBinimumDesc": { "description": "Credit description for binimum" }, - "aboutSachinsenalDesc": "The original HiFi project creator. The foundation of Tidal integration!", + "aboutSachinsenalDesc": "최초의 하이파이 프로젝트 창시자. 타이달 연동의 기반을 마련한 사람!", "@aboutSachinsenalDesc": { "description": "Credit description for sachinsenal0x64" }, - "aboutSjdonadoDesc": "Creator of I Don't Have Spotify (IDHS). The fallback link resolver that saves the day!", + "aboutSjdonadoDesc": "I Don't Have Spotify(IDHS) 개발자입니다. 위급 상황 발생 시 해결해 주는 대체 링크 해결 도구를 만들었습니다!", "@aboutSjdonadoDesc": { "description": "Credit description for sjdonado" }, @@ -438,7 +438,7 @@ "@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": "최고의 Qobuz 스트리밍 API입니다. 이 API가 없었다면 고해상도 다운로드는 불가능했을 겁니다!", "@aboutDabMusicDesc": { "description": "Credit for DAB Music API" }, @@ -446,31 +446,31 @@ "@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": "Tidal Hi-Res FLAC 스트리밍 엔드포인트. 무손실 음원 재생의 핵심 요소!", "@aboutSpotiSaverDesc": { "description": "Credit for SpotiSaver API" }, - "aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.", + "aboutAppDescription": "Tidal, Qobuz, Amazon Music에서 Spotify 트랙을 무손실 음질로 다운로드하세요.", "@aboutAppDescription": { "description": "App description in header card" }, - "artistAlbums": "Albums", + "artistAlbums": "앨범", "@artistAlbums": { "description": "Section header for artist albums" }, - "artistSingles": "Singles & EPs", + "artistSingles": "싱글 및 EP", "@artistSingles": { "description": "Section header for singles/EPs" }, - "artistCompilations": "Compilations", + "artistCompilations": "편집", "@artistCompilations": { "description": "Section header for compilations" }, - "artistPopular": "Popular", + "artistPopular": "인기순", "@artistPopular": { "description": "Section header for popular/top tracks" }, - "artistMonthlyListeners": "{count} monthly listeners", + "artistMonthlyListeners": "월간 청취자: {count}", "@artistMonthlyListeners": { "description": "Monthly listener count display", "placeholders": { @@ -480,47 +480,47 @@ } } }, - "trackMetadataService": "Service", + "trackMetadataService": "제공업체", "@trackMetadataService": { "description": "Metadata field - download service used" }, - "trackMetadataPlay": "Play", + "trackMetadataPlay": "재생", "@trackMetadataPlay": { "description": "Action button - play track" }, - "trackMetadataShare": "Share", + "trackMetadataShare": "공유", "@trackMetadataShare": { "description": "Action button - share track" }, - "trackMetadataDelete": "Delete", + "trackMetadataDelete": "삭제", "@trackMetadataDelete": { "description": "Action button - delete track" }, - "setupGrantPermission": "Grant Permission", + "setupGrantPermission": "권한을 제공해 주세요.", "@setupGrantPermission": { "description": "Button to request permission" }, - "setupSkip": "Skip for now", + "setupSkip": "다음에 할래요", "@setupSkip": { "description": "Skip current step button" }, - "setupStorageAccessRequired": "Storage Access Required", + "setupStorageAccessRequired": "스토리지 접근 권한 필요", "@setupStorageAccessRequired": { "description": "Title when storage access needed" }, - "setupStorageAccessMessageAndroid11": "Android 11+ requires \"All files access\" permission to save files to your chosen download folder.", + "setupStorageAccessMessageAndroid11": "Android 11 이상 버전에서는 선택한 다운로드 폴더에 파일을 저장하려면 \"모든 파일 접근\" 권한이 필요합니다.", "@setupStorageAccessMessageAndroid11": { "description": "Android 11+ specific explanation" }, - "setupOpenSettings": "Open Settings", + "setupOpenSettings": "설정으로 이동", "@setupOpenSettings": { "description": "Button to open system settings" }, - "setupPermissionDeniedMessage": "Permission denied. Please grant all permissions to continue.", + "setupPermissionDeniedMessage": "권한이 거부되었습니다. 계속하려면 모든 권한을 허용해 주세요.", "@setupPermissionDeniedMessage": { "description": "Error when permission denied" }, - "setupPermissionRequired": "{permissionType} Permission Required", + "setupPermissionRequired": "{permissionType} 권한 필요", "@setupPermissionRequired": { "description": "Generic permission required title", "placeholders": { @@ -530,7 +530,7 @@ } } }, - "setupPermissionRequiredMessage": "{permissionType} permission is required for the best experience. You can change this later in Settings.", + "setupPermissionRequiredMessage": "최상의 사용 경험을 위해 {permissionType} 권한이 필요합니다. 설정에서 나중에 변경할 수 있습니다.", "@setupPermissionRequiredMessage": { "description": "Generic permission required message", "placeholders": { @@ -539,175 +539,175 @@ } } }, - "setupUseDefaultFolder": "Use Default Folder?", + "setupUseDefaultFolder": "기본 폴더를 사용하시겠습니까?", "@setupUseDefaultFolder": { "description": "Dialog title for default folder" }, - "setupNoFolderSelected": "No folder selected. Would you like to use the default Music folder?", + "setupNoFolderSelected": "선택된 폴더가 없습니다. 기본 음악 폴더를 사용하시겠습니까?", "@setupNoFolderSelected": { "description": "Prompt when no folder selected" }, - "setupUseDefault": "Use Default", + "setupUseDefault": "기본값 사용", "@setupUseDefault": { "description": "Button to use default folder" }, - "setupDownloadLocationTitle": "Download Location", + "setupDownloadLocationTitle": "다운로드 경로", "@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": "iOS에서는 다운로드한 파일이 앱의 문서 폴더에 저장됩니다. 파일 앱을 통해 해당 파일에 접근할 수 있습니다.", "@setupDownloadLocationIosMessage": { "description": "iOS-specific folder info" }, - "setupAppDocumentsFolder": "App Documents Folder", + "setupAppDocumentsFolder": "앱 문서 폴더", "@setupAppDocumentsFolder": { "description": "iOS documents folder option" }, - "setupAppDocumentsFolderSubtitle": "Recommended - accessible via Files app", + "setupAppDocumentsFolderSubtitle": "권장 사항 - 파일 앱을 통해 접근 가능", "@setupAppDocumentsFolderSubtitle": { "description": "Subtitle for documents folder" }, - "setupChooseFromFiles": "Choose from Files", + "setupChooseFromFiles": "파일 탐색기에서 선택", "@setupChooseFromFiles": { "description": "iOS file picker option" }, - "setupChooseFromFilesSubtitle": "Select iCloud or other location", + "setupChooseFromFilesSubtitle": "iCloud 또는 다른 위치를 선택하세요", "@setupChooseFromFilesSubtitle": { "description": "Subtitle for file picker" }, - "setupIosEmptyFolderWarning": "iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.", + "setupIosEmptyFolderWarning": "iOS 제한 사항: 빈 폴더는 선택할 수 없습니다. 파일이 하나 이상 있는 폴더를 선택하세요.", "@setupIosEmptyFolderWarning": { "description": "iOS folder selection warning" }, - "setupIcloudNotSupported": "iCloud Drive is not supported. Please use the app Documents folder.", + "setupIcloudNotSupported": "iCloud Drive는 지원되지 않습니다. 앱의 문서 폴더를 사용해 주세요.", "@setupIcloudNotSupported": { "description": "Error when user selects iCloud Drive on iOS" }, - "setupDownloadInFlac": "Download Spotify tracks in FLAC", + "setupDownloadInFlac": "Spotify 음악을 FLAC 형식으로 다운로드하세요.", "@setupDownloadInFlac": { "description": "App tagline in setup" }, - "setupStorageGranted": "Storage Permission Granted!", + "setupStorageGranted": "저장소 접근 권한이 부여되었습니다!", "@setupStorageGranted": { "description": "Success message for storage permission" }, - "setupStorageRequired": "Storage Permission Required", + "setupStorageRequired": "저장소 접근 권한이 필요합니다.", "@setupStorageRequired": { "description": "Title when storage permission needed" }, - "setupStorageDescription": "SpotiFLAC needs storage permission to save your downloaded music files.", + "setupStorageDescription": "SpotiFLAC은 다운로드한 음악 파일을 저장하기 위해 저장소 접근 권한이 필요합니다.", "@setupStorageDescription": { "description": "Explanation for storage permission" }, - "setupNotificationGranted": "Notification Permission Granted!", + "setupNotificationGranted": "알림 권한이 부여되었습니다!", "@setupNotificationGranted": { "description": "Success message for notification permission" }, - "setupNotificationEnable": "Enable Notifications", + "setupNotificationEnable": "알림 활성화", "@setupNotificationEnable": { "description": "Button to enable notifications" }, - "setupFolderChoose": "Choose Download Folder", + "setupFolderChoose": "다운로드 폴더를 선택하세요", "@setupFolderChoose": { "description": "Button to choose folder" }, - "setupFolderDescription": "Select a folder where your downloaded music will be saved.", + "setupFolderDescription": "다운로드한 음악 파일이 저장될 폴더를 선택하세요.", "@setupFolderDescription": { "description": "Explanation for folder selection" }, - "setupSelectFolder": "Select Folder", + "setupSelectFolder": "폴더 선택", "@setupSelectFolder": { "description": "Button to select folder" }, - "setupEnableNotifications": "Enable Notifications", + "setupEnableNotifications": "알림 활성화", "@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": "알림으로 다운로드 진행 상황을 확인하세요. 앱이 백그라운드에서 실행 중일 때 다운로드 상태와 완료 여부를 확인할 수 있습니다.", "@setupNotificationBackgroundDescription": { "description": "Detailed notification explanation" }, - "setupSkipForNow": "Skip for now", + "setupSkipForNow": "다음에 할래요.", "@setupSkipForNow": { "description": "Skip button text" }, - "setupNext": "Next", + "setupNext": "다음", "@setupNext": { "description": "Next button text" }, - "setupGetStarted": "Get Started", + "setupGetStarted": "시작하기", "@setupGetStarted": { "description": "Final setup button" }, - "setupAllowAccessToManageFiles": "Please enable \"Allow access to manage all files\" in the next screen.", + "setupAllowAccessToManageFiles": "다음 화면에서 \"모든 파일 관리 권한 허용\"을 활성화해 주세요.", "@setupAllowAccessToManageFiles": { "description": "Instruction for file access permission" }, - "dialogCancel": "Cancel", + "dialogCancel": "취소", "@dialogCancel": { "description": "Dialog button - cancel action" }, - "dialogSave": "Save", + "dialogSave": "저장", "@dialogSave": { "description": "Dialog button - save changes" }, - "dialogDelete": "Delete", + "dialogDelete": "삭제", "@dialogDelete": { "description": "Dialog button - delete item" }, - "dialogRetry": "Retry", + "dialogRetry": "재시도", "@dialogRetry": { "description": "Dialog button - retry action" }, - "dialogClear": "Clear", + "dialogClear": "지우기", "@dialogClear": { "description": "Dialog button - clear items" }, - "dialogDone": "Done", + "dialogDone": "완료", "@dialogDone": { "description": "Dialog button - action completed" }, - "dialogImport": "Import", + "dialogImport": "불러오기", "@dialogImport": { "description": "Dialog button - import data" }, - "dialogDiscard": "Discard", + "dialogDiscard": "취소", "@dialogDiscard": { "description": "Dialog button - discard changes" }, - "dialogRemove": "Remove", + "dialogRemove": "제거", "@dialogRemove": { "description": "Dialog button - remove item" }, - "dialogUninstall": "Uninstall", + "dialogUninstall": "삭제", "@dialogUninstall": { "description": "Dialog button - uninstall extension" }, - "dialogDiscardChanges": "Discard Changes?", + "dialogDiscardChanges": "변경사항 취소", "@dialogDiscardChanges": { "description": "Dialog title - unsaved changes warning" }, - "dialogUnsavedChanges": "You have unsaved changes. Do you want to discard them?", + "dialogUnsavedChanges": "저장되지 않은 변경 사항이 있습니다. 삭제하시겠습니까?", "@dialogUnsavedChanges": { "description": "Dialog message - unsaved changes" }, - "dialogClearAll": "Clear All", + "dialogClearAll": "모두 제거:", "@dialogClearAll": { "description": "Dialog title - clear all items" }, - "dialogRemoveExtension": "Remove Extension", + "dialogRemoveExtension": "확장 프로그램 제거", "@dialogRemoveExtension": { "description": "Dialog title - uninstall extension" }, - "dialogRemoveExtensionMessage": "Are you sure you want to remove this extension? This cannot be undone.", + "dialogRemoveExtensionMessage": "이 확장 프로그램을 정말로 제거하시겠습니까? 이 작업은 되돌릴 수 없습니다.", "@dialogRemoveExtensionMessage": { "description": "Dialog message - uninstall confirmation" }, - "dialogUninstallExtension": "Uninstall Extension?", + "dialogUninstallExtension": "확장 프로그램을 제거하시겠습니까?", "@dialogUninstallExtension": { "description": "Dialog title - uninstall extension" }, - "dialogUninstallExtensionMessage": "Are you sure you want to remove {extensionName}?", + "dialogUninstallExtensionMessage": "{extensionName}을 정말로 삭제하시겠습니까?", "@dialogUninstallExtensionMessage": { "description": "Dialog message - uninstall specific extension", "placeholders": { @@ -716,19 +716,19 @@ } } }, - "dialogClearHistoryTitle": "Clear History", + "dialogClearHistoryTitle": "기록 삭제", "@dialogClearHistoryTitle": { "description": "Dialog title - clear download history" }, - "dialogClearHistoryMessage": "Are you sure you want to clear all download history? This cannot be undone.", + "dialogClearHistoryMessage": "다운로드 기록을 모두 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.", "@dialogClearHistoryMessage": { "description": "Dialog message - clear history confirmation" }, - "dialogDeleteSelectedTitle": "Delete Selected", + "dialogDeleteSelectedTitle": "선택한 항목 삭제", "@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": "기록에서 {count} {count, plural, =1{track} other{tracks}}를 삭제하시겠습니까?", "@dialogDeleteSelectedMessage": { "description": "Dialog message - delete selected tracks", "placeholders": { @@ -737,12 +737,12 @@ } } }, - "dialogImportPlaylistTitle": "Import Playlist", + "dialogImportPlaylistTitle": "재생 목록 가져오기", "@dialogImportPlaylistTitle": { "description": "Dialog title - import CSV playlist" }, - "dialogImportPlaylistMessage": "Found {count} tracks in CSV. Add them to download queue?", - "csvImportTracks": "{count} tracks from CSV", + "dialogImportPlaylistMessage": "CSV 파일에서 {count}개의 트랙을 찾았습니다. 다운로드 대기열에 추가하시겠습니까?", + "csvImportTracks": "CSV 파일의 트랙: {count}", "@csvImportTracks": { "description": "Label shown in quality picker for CSV import", "placeholders": { @@ -759,7 +759,7 @@ } } }, - "snackbarAddedToQueue": "Added \"{trackName}\" to queue", + "snackbarAddedToQueue": "\"{trackName}\"(을)를 대기열에 추가했습니다.", "@snackbarAddedToQueue": { "description": "Snackbar - track added to download queue", "placeholders": { @@ -768,7 +768,7 @@ } } }, - "snackbarAddedTracksToQueue": "Added {count} tracks to queue", + "snackbarAddedTracksToQueue": "대기열에 {count}개의 트랙을 추가했습니다.", "@snackbarAddedTracksToQueue": { "description": "Snackbar - multiple tracks added to queue", "placeholders": { @@ -777,7 +777,7 @@ } } }, - "snackbarAlreadyDownloaded": "\"{trackName}\" already downloaded", + "snackbarAlreadyDownloaded": "\"{trackName}\"(은)는 이미 다운로드되었습니다.", "@snackbarAlreadyDownloaded": { "description": "Snackbar - track already exists", "placeholders": { @@ -786,7 +786,7 @@ } } }, - "snackbarAlreadyInLibrary": "\"{trackName}\" already exists in your library", + "snackbarAlreadyInLibrary": "라이브러리에 \"{trackName}\"(은)는 이미 존재합니다.", "@snackbarAlreadyInLibrary": { "description": "Snackbar - track already exists in local library", "placeholders": { @@ -795,19 +795,19 @@ } } }, - "snackbarHistoryCleared": "History cleared", + "snackbarHistoryCleared": "기록 삭제됨", "@snackbarHistoryCleared": { "description": "Snackbar - history deleted" }, - "snackbarCredentialsSaved": "Credentials saved", + "snackbarCredentialsSaved": "자격 증명이 저장되었습니다.", "@snackbarCredentialsSaved": { "description": "Snackbar - Spotify credentials saved" }, - "snackbarCredentialsCleared": "Credentials cleared", + "snackbarCredentialsCleared": "자격 증명이 제거되었습니다.", "@snackbarCredentialsCleared": { "description": "Snackbar - Spotify credentials removed" }, - "snackbarDeletedTracks": "Deleted {count} {count, plural, =1{track} other{tracks}}", + "snackbarDeletedTracks": "{count}{count, plural,=1{track}other{tracks}} 제거됨", "@snackbarDeletedTracks": { "description": "Snackbar - tracks deleted", "placeholders": { @@ -816,7 +816,7 @@ } } }, - "snackbarCannotOpenFile": "Cannot open file: {error}", + "snackbarCannotOpenFile": "파일을 열 수 없습니다: {error}", "@snackbarCannotOpenFile": { "description": "Snackbar - file open error", "placeholders": { @@ -825,7 +825,7 @@ } } }, - "snackbarFillAllFields": "Please fill all fields", + "snackbarFillAllFields": "모든 항목을 입력해 주세요.", "@snackbarFillAllFields": { "description": "Snackbar - validation error" }, @@ -833,7 +833,7 @@ "@snackbarViewQueue": { "description": "Snackbar action - view download queue" }, - "snackbarUrlCopied": "{platform} URL copied to clipboard", + "snackbarUrlCopied": "{platform} 링크가 클립보드에 저장됨", "@snackbarUrlCopied": { "description": "Snackbar - URL copied", "placeholders": { @@ -843,23 +843,23 @@ } } }, - "snackbarFileNotFound": "File not found", + "snackbarFileNotFound": "파일을 찾을 수 없음", "@snackbarFileNotFound": { "description": "Snackbar - file doesn't exist" }, - "snackbarSelectExtFile": "Please select a .spotiflac-ext file", + "snackbarSelectExtFile": ".spotiflac-ext 확장자 파일을 선택", "@snackbarSelectExtFile": { "description": "Snackbar - wrong file type selected" }, - "snackbarProviderPrioritySaved": "Provider priority saved", + "snackbarProviderPrioritySaved": "제공자 우선순위 저장됨", "@snackbarProviderPrioritySaved": { "description": "Snackbar - provider order saved" }, - "snackbarMetadataProviderSaved": "Metadata provider priority saved", + "snackbarMetadataProviderSaved": "메타데이터 제공자 우선순위 저장됨", "@snackbarMetadataProviderSaved": { "description": "Snackbar - metadata provider order saved" }, - "snackbarExtensionInstalled": "{extensionName} installed.", + "snackbarExtensionInstalled": "{extensionName}(이)가 설치됨", "@snackbarExtensionInstalled": { "description": "Snackbar - extension installed successfully", "placeholders": { @@ -868,7 +868,7 @@ } } }, - "snackbarExtensionUpdated": "{extensionName} updated.", + "snackbarExtensionUpdated": "{extensionName}(이)가 설치됨.", "@snackbarExtensionUpdated": { "description": "Snackbar - extension updated successfully", "placeholders": { @@ -877,11 +877,11 @@ } } }, - "snackbarFailedToInstall": "Failed to install extension", + "snackbarFailedToInstall": "확장 프로그램 설치 실패", "@snackbarFailedToInstall": { "description": "Snackbar - extension install error" }, - "snackbarFailedToUpdate": "Failed to update extension", + "snackbarFailedToUpdate": "확장 프로그램 업데이트 실패", "@snackbarFailedToUpdate": { "description": "Snackbar - extension update error" }, @@ -889,15 +889,15 @@ "@errorRateLimited": { "description": "Error title - too many requests" }, - "errorRateLimitedMessage": "Too many requests. Please wait a moment before searching again.", + "errorRateLimitedMessage": "요청이 너무 많습니다. 잠시 후 다시 검색해 주세요.", "@errorRateLimitedMessage": { "description": "Error message - rate limit explanation" }, - "errorNoTracksFound": "No tracks found", + "errorNoTracksFound": "트랙을 찾을 수 없습니다", "@errorNoTracksFound": { "description": "Error - search returned no results" }, - "errorMissingExtensionSource": "Cannot load {item}: missing extension source", + "errorMissingExtensionSource": "확장 소스가 누락되어, {item}(을)를 로드할 수 없습니다", "@errorMissingExtensionSource": { "description": "Error - extension source not available", "placeholders": { @@ -906,35 +906,35 @@ } } }, - "actionPause": "Pause", + "actionPause": "멈추기", "@actionPause": { "description": "Action button - pause download" }, - "actionResume": "Resume", + "actionResume": "재시작", "@actionResume": { "description": "Action button - resume download" }, - "actionCancel": "Cancel", + "actionCancel": "취소", "@actionCancel": { "description": "Action button - cancel operation" }, - "actionSelectAll": "Select All", + "actionSelectAll": "모두 선택", "@actionSelectAll": { "description": "Action button - select all items" }, - "actionDeselect": "Deselect", + "actionDeselect": "선택 해제", "@actionDeselect": { "description": "Action button - deselect all" }, - "actionRemoveCredentials": "Remove Credentials", + "actionRemoveCredentials": "자격 증명 제거", "@actionRemoveCredentials": { "description": "Action button - delete Spotify credentials" }, - "actionSaveCredentials": "Save Credentials", + "actionSaveCredentials": "자격 증명 저장", "@actionSaveCredentials": { "description": "Action button - save Spotify credentials" }, - "selectionSelected": "{count} selected", + "selectionSelected": "{count}개 선택됨", "@selectionSelected": { "description": "Selection count indicator", "placeholders": { @@ -943,15 +943,15 @@ } } }, - "selectionAllSelected": "All tracks selected", + "selectionAllSelected": "모든 트랙 선택됨", "@selectionAllSelected": { "description": "Status - all items selected" }, - "selectionSelectToDelete": "Select tracks to delete", + "selectionSelectToDelete": "삭제할 트랙을 선택", "@selectionSelectToDelete": { "description": "Placeholder when nothing selected" }, - "progressFetchingMetadata": "Fetching metadata... {current}/{total}", + "progressFetchingMetadata": "메타데이터 가져오는 중... {current}/{total}", "@progressFetchingMetadata": { "description": "Progress indicator - loading track info", "placeholders": { @@ -963,35 +963,43 @@ } } }, - "progressReadingCsv": "Reading CSV...", + "progressReadingCsv": "CSV 파일을 읽는 중...", "@progressReadingCsv": { "description": "Progress indicator - parsing CSV file" }, - "searchSongs": "Songs", + "searchSongs": "곡들", "@searchSongs": { "description": "Search result category - songs" }, - "searchArtists": "Artists", + "searchArtists": "아티스트들", "@searchArtists": { "description": "Search result category - artists" }, - "searchAlbums": "Albums", + "searchAlbums": "앨범들", "@searchAlbums": { "description": "Search result category - albums" }, - "searchPlaylists": "Playlists", + "searchPlaylists": "재생목록들", "@searchPlaylists": { "description": "Search result category - playlists" }, - "tooltipPlay": "Play", + "tooltipPlay": "재생", "@tooltipPlay": { "description": "Tooltip - play button" }, - "filenameFormat": "Filename Format", + "filenameFormat": "", "@filenameFormat": { "description": "Setting title - filename pattern" }, - "folderOrganizationNone": "No organization", + "filenameShowAdvancedTags": "고급 태그 표시", + "@filenameShowAdvancedTags": { + "description": "Toggle label for showing advanced filename tags" + }, + "filenameShowAdvancedTagsDescription": "트랙 패딩 및 날짜 패턴에 대한 서식 있는 태그를 활성화합니다.", + "@filenameShowAdvancedTagsDescription": { + "description": "Description for advanced filename tag toggle" + }, + "folderOrganizationNone": "정리하지 않음", "@folderOrganizationNone": { "description": "Folder option - flat structure" }, @@ -1139,31 +1147,31 @@ "@logShareLogs": { "description": "Share button tooltip" }, - "logClearLogs": "Clear logs", + "logClearLogs": "로그 제거", "@logClearLogs": { "description": "Clear button tooltip" }, - "logClearLogsTitle": "Clear Logs", + "logClearLogsTitle": "로그 제거", "@logClearLogsTitle": { "description": "Clear logs dialog title" }, - "logClearLogsMessage": "Are you sure you want to clear all logs?", + "logClearLogsMessage": "모든 로그를 삭제하시겠습니까?", "@logClearLogsMessage": { "description": "Clear logs confirmation message" }, - "logFilterBySeverity": "Filter logs by severity", + "logFilterBySeverity": "심각성에 따라 로그 분류", "@logFilterBySeverity": { "description": "Filter dialog title" }, - "logNoLogsYet": "No logs yet", + "logNoLogsYet": "어떠한 로그도 없음", "@logNoLogsYet": { "description": "Empty state title" }, - "logNoLogsYetSubtitle": "Logs will appear here as you use the app", + "logNoLogsYetSubtitle": "앱을 사용하는 동안 로그가 여기에 표시됩니다.", "@logNoLogsYetSubtitle": { "description": "Empty state subtitle" }, - "logEntriesFiltered": "Entries ({count} filtered)", + "logEntriesFiltered": "({count} filtered)개 항목 필터링", "@logEntriesFiltered": { "description": "Log count with filter active", "placeholders": { @@ -1172,7 +1180,7 @@ } } }, - "logEntries": "Entries ({count})", + "logEntries": "항목 수: ({count})", "@logEntries": { "description": "Total log count", "placeholders": { @@ -1181,11 +1189,11 @@ } } }, - "credentialsTitle": "Spotify Credentials", + "credentialsTitle": "Spotify 자격 증명", "@credentialsTitle": { "description": "Credentials dialog title" }, - "credentialsDescription": "Enter your Client ID and Secret to use your own Spotify application quota.", + "credentialsDescription": "Spotify 애플리케이션 할당량을 사용하려면 클라이언트 ID와 비밀키를 입력하세요.", "@credentialsDescription": { "description": "Credentials dialog explanation" }, @@ -1193,43 +1201,43 @@ "@credentialsClientId": { "description": "Client ID field label - DO NOT TRANSLATE" }, - "credentialsClientIdHint": "Paste Client ID", + "credentialsClientIdHint": "Client ID를 붙여넣으세요", "@credentialsClientIdHint": { "description": "Client ID placeholder" }, - "credentialsClientSecret": "Client Secret", + "credentialsClientSecret": "비밀키", "@credentialsClientSecret": { "description": "Client Secret field label - DO NOT TRANSLATE" }, - "credentialsClientSecretHint": "Paste Client Secret", + "credentialsClientSecretHint": "비밀키를 붙여넣으세요", "@credentialsClientSecretHint": { "description": "Client Secret placeholder" }, - "channelStable": "Stable", + "channelStable": "안정", "@channelStable": { "description": "Update channel - stable releases" }, - "channelPreview": "Preview", + "channelPreview": "베타", "@channelPreview": { "description": "Update channel - beta/preview releases" }, - "sectionSearchSource": "Search Source", + "sectionSearchSource": "검색 소스", "@sectionSearchSource": { "description": "Settings section header" }, - "sectionDownload": "Download", + "sectionDownload": "다운로드", "@sectionDownload": { "description": "Settings section header" }, - "sectionPerformance": "Performance", + "sectionPerformance": "성능", "@sectionPerformance": { "description": "Settings section header" }, - "sectionApp": "App", + "sectionApp": "앱", "@sectionApp": { "description": "Settings section header" }, - "sectionData": "Data", + "sectionData": "데이터", "@sectionData": { "description": "Settings section header" }, @@ -1237,55 +1245,55 @@ "@sectionDebug": { "description": "Settings section header" }, - "sectionService": "Service", + "sectionService": "서비스", "@sectionService": { "description": "Settings section header" }, - "sectionAudioQuality": "Audio Quality", + "sectionAudioQuality": "오디오 품질", "@sectionAudioQuality": { "description": "Settings section header" }, - "sectionFileSettings": "File Settings", + "sectionFileSettings": "파일 설정", "@sectionFileSettings": { "description": "Settings section header" }, - "sectionLyrics": "Lyrics", + "sectionLyrics": "가사", "@sectionLyrics": { "description": "Settings section header" }, - "lyricsMode": "Lyrics Mode", + "lyricsMode": "가사 설정", "@lyricsMode": { "description": "Setting - how to save lyrics" }, - "lyricsModeDescription": "Choose how lyrics are saved with your downloads", + "lyricsModeDescription": "다운로드한 파일에 가사를 저장하는 방법을 선택하세요.", "@lyricsModeDescription": { "description": "Lyrics mode picker description" }, - "lyricsModeEmbed": "Embed in file", + "lyricsModeEmbed": "파일에 포함", "@lyricsModeEmbed": { "description": "Lyrics mode option - embed in audio file" }, - "lyricsModeEmbedSubtitle": "Lyrics stored inside FLAC metadata", + "lyricsModeEmbedSubtitle": "FLAC 메타데이터 내에 저장됩니다.", "@lyricsModeEmbedSubtitle": { "description": "Subtitle for embed option" }, - "lyricsModeExternal": "External .lrc file", + "lyricsModeExternal": "외부 .lrc 파일", "@lyricsModeExternal": { "description": "Lyrics mode option - separate LRC file" }, - "lyricsModeExternalSubtitle": "Separate .lrc file for players like Samsung Music", + "lyricsModeExternalSubtitle": "삼성 뮤직과 같은 플레이어용 별도 .lrc 파일", "@lyricsModeExternalSubtitle": { "description": "Subtitle for external option" }, - "lyricsModeBoth": "Both", + "lyricsModeBoth": "둘 다", "@lyricsModeBoth": { "description": "Lyrics mode option - embed and external" }, - "lyricsModeBothSubtitle": "Embed and save .lrc file", + "lyricsModeBothSubtitle": ".lrc 파일을 삽입하고 저장합니다.", "@lyricsModeBothSubtitle": { "description": "Subtitle for both option" }, - "sectionColor": "Color", + "sectionColor": "색상", "@sectionColor": { "description": "Settings section header" }, @@ -1749,6 +1757,14 @@ "@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" @@ -2198,6 +2214,15 @@ "@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" + } + } + }, "libraryLastScanned": "Last scanned: {time}", "@libraryLastScanned": { "description": "Last scan time display", @@ -2783,6 +2808,283 @@ "@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", @@ -2800,4 +3102,4 @@ "@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 1fd0bec6..f2b8bb02 100644 --- a/lib/l10n/arb/app_nl.arb +++ b/lib/l10n/arb/app_nl.arb @@ -991,6 +991,14 @@ "@filenameFormat": { "description": "Setting title - filename pattern" }, + "filenameShowAdvancedTags": "Show advanced tags", + "@filenameShowAdvancedTags": { + "description": "Toggle label for showing advanced filename tags" + }, + "filenameShowAdvancedTagsDescription": "Enable formatted tags for track padding and date patterns", + "@filenameShowAdvancedTagsDescription": { + "description": "Description for advanced filename tag toggle" + }, "folderOrganizationNone": "No organization", "@folderOrganizationNone": { "description": "Folder option - flat structure" @@ -1749,6 +1757,14 @@ "@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" @@ -2198,6 +2214,15 @@ "@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" + } + } + }, "libraryLastScanned": "Last scanned: {time}", "@libraryLastScanned": { "description": "Last scan time display", @@ -2783,6 +2808,283 @@ "@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", @@ -2800,4 +3102,4 @@ "@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 503247dd..27a0b5e2 100644 --- a/lib/l10n/arb/app_pt.arb +++ b/lib/l10n/arb/app_pt.arb @@ -402,7 +402,7 @@ "@aboutDabMusicDesc": { "description": "Credit for DAB Music API" }, - "aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.", + "aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.", "@aboutAppDescription": { "description": "App description in header card" }, @@ -1005,7 +1005,7 @@ }, "providerBuiltIn": "Built-in", "@providerBuiltIn": { - "description": "Label for built-in providers (Tidal/Qobuz/Amazon)" + "description": "Label for built-in providers (Tidal/Qobuz)" }, "providerExtension": "Extension", "@providerExtension": { diff --git a/lib/l10n/arb/app_pt_PT.arb b/lib/l10n/arb/app_pt_PT.arb index 8d844396..73190e72 100644 --- a/lib/l10n/arb/app_pt_PT.arb +++ b/lib/l10n/arb/app_pt_PT.arb @@ -450,7 +450,7 @@ "@aboutSpotiSaverDesc": { "description": "Credit for SpotiSaver API" }, - "aboutAppDescription": "Baixe faixas do Spotify em qualidade sem perdas do Tidal, Qobuz e Amazon Music.", + "aboutAppDescription": "Baixe faixas do Spotify em qualidade sem perdas do Tidal e Qobuz.", "@aboutAppDescription": { "description": "App description in header card" }, @@ -1089,7 +1089,7 @@ }, "providerBuiltIn": "Embutido", "@providerBuiltIn": { - "description": "Label for built-in providers (Tidal/Qobuz/Amazon)" + "description": "Label for built-in providers (Tidal/Qobuz)" }, "providerExtension": "Extensão", "@providerExtension": { @@ -2358,7 +2358,7 @@ "@tutorialWelcomeTip1": { "description": "Tutorial welcome tip 1" }, - "tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music", + "tutorialWelcomeTip2": "Obtenha áudio em qualidade FLAC do Tidal, Qobuz ou Deezer", "@tutorialWelcomeTip2": { "description": "Tutorial welcome tip 2" }, diff --git a/lib/l10n/arb/app_ru.arb b/lib/l10n/arb/app_ru.arb index 4ea4e618..10f1fce3 100644 --- a/lib/l10n/arb/app_ru.arb +++ b/lib/l10n/arb/app_ru.arb @@ -77,7 +77,7 @@ "@settingsAbout": { "description": "Settings section - app info" }, - "downloadTitle": "Скачивание", + "downloadTitle": "Скачать", "@downloadTitle": { "description": "Download settings page title" }, @@ -174,11 +174,11 @@ "@optionsUseExtensionProvidersOff": { "description": "Status when extension providers disabled" }, - "optionsEmbedLyrics": "Вставить текст песни", + "optionsEmbedLyrics": "Вписать текст песни", "@optionsEmbedLyrics": { "description": "Embed lyrics in audio files" }, - "optionsEmbedLyricsSubtitle": "Вставить синхронизированные тексты в FLAC файлы", + "optionsEmbedLyricsSubtitle": "Вписать синхронизированные тексты во FLAC файлы", "@optionsEmbedLyricsSubtitle": { "description": "Subtitle for embed lyrics" }, @@ -422,7 +422,7 @@ "@aboutVersion": { "description": "Version info label" }, - "aboutBinimumDesc": "Создатель QQDL & HiFi API. Без этого API загрузки Tidal не существовали бы!", + "aboutBinimumDesc": "Создатель QQDL & HiFi API. Без него API загрузки Tidal не существовали бы!", "@aboutBinimumDesc": { "description": "Credit description for binimum" }, @@ -728,7 +728,7 @@ "@dialogDeleteSelectedTitle": { "description": "Dialog title - delete selected items" }, - "dialogDeleteSelectedMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other {треков}} из истории?\n\nЭто также удалит файлы из хранилища.", + "dialogDeleteSelectedMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other{треков}} из истории?\n\nЭто также удалит файлы из хранилища.", "@dialogDeleteSelectedMessage": { "description": "Dialog message - delete selected tracks", "placeholders": { @@ -742,7 +742,7 @@ "description": "Dialog title - import CSV playlist" }, "dialogImportPlaylistMessage": "Найдено {count} треков в CSV. Добавить их в очередь загрузки?", - "csvImportTracks": "{count} треков из CSV", + "csvImportTracks": "{count} трек(-ов) из CSV", "@csvImportTracks": { "description": "Label shown in quality picker for CSV import", "placeholders": { @@ -807,7 +807,7 @@ "@snackbarCredentialsCleared": { "description": "Snackbar - Spotify credentials removed" }, - "snackbarDeletedTracks": "Удалено {count} {count, plural, one {трек} few {трека} many {треков} other {треков}}", + "snackbarDeletedTracks": "Удалено {count} {count, plural, one {трек} few {трека} many {треков} other{треков}}", "@snackbarDeletedTracks": { "description": "Snackbar - tracks deleted", "placeholders": { @@ -991,6 +991,14 @@ "@filenameFormat": { "description": "Setting title - filename pattern" }, + "filenameShowAdvancedTags": "Показать расширенные теги", + "@filenameShowAdvancedTags": { + "description": "Toggle label for showing advanced filename tags" + }, + "filenameShowAdvancedTagsDescription": "Включить форматированные теги для отслеживания заполнения и шаблонов дат", + "@filenameShowAdvancedTagsDescription": { + "description": "Description for advanced filename tag toggle" + }, "folderOrganizationNone": "Без организации", "@folderOrganizationNone": { "description": "Folder option - flat structure" @@ -1261,7 +1269,7 @@ "@lyricsModeDescription": { "description": "Lyrics mode picker description" }, - "lyricsModeEmbed": "Вставить в файл", + "lyricsModeEmbed": "Вписать в файл", "@lyricsModeEmbed": { "description": "Lyrics mode option - embed in audio file" }, @@ -1281,7 +1289,7 @@ "@lyricsModeBoth": { "description": "Lyrics mode option - embed and external" }, - "lyricsModeBothSubtitle": "Вставить и сохранить файл .lrc", + "lyricsModeBothSubtitle": "Вписать и сохранить .lrc файл", "@lyricsModeBothSubtitle": { "description": "Subtitle for both option" }, @@ -1455,7 +1463,7 @@ "@trackLyricsLoadFailed": { "description": "Message when lyrics loading fails" }, - "trackEmbedLyrics": "Вставить текст песни", + "trackEmbedLyrics": "Вписать текст песни", "@trackEmbedLyrics": { "description": "Action - embed lyrics into audio file" }, @@ -1749,6 +1757,14 @@ "@youtubeQualityNote": { "description": "Note for YouTube service explaining lossy-only quality" }, + "youtubeOpusBitrateTitle": "Битрейт YouTube Opus", + "@youtubeOpusBitrateTitle": { + "description": "Title for YouTube Opus bitrate setting" + }, + "youtubeMp3BitrateTitle": "Битрейт YouTube MP3", + "@youtubeMp3BitrateTitle": { + "description": "Title for YouTube MP3 bitrate setting" + }, "downloadAskBeforeDownload": "Спрашивать перед скачиванием", "@downloadAskBeforeDownload": { "description": "Setting - show quality picker" @@ -1769,7 +1785,7 @@ "@downloadUseAlbumArtistForFolders": { "description": "Setting - choose whether artist folders use Album Artist or Track Artist" }, - "downloadUsePrimaryArtistOnly": "Primary artist only for folders", + "downloadUsePrimaryArtistOnly": "Основной исполнитель только для папок", "@downloadUsePrimaryArtistOnly": { "description": "Setting - strip featured artists from folder name" }, @@ -1777,7 +1793,7 @@ "@downloadUsePrimaryArtistOnlyEnabled": { "description": "Subtitle when primary artist only is enabled" }, - "downloadUsePrimaryArtistOnlyDisabled": "Full artist string used for folder name", + "downloadUsePrimaryArtistOnlyDisabled": "Полная строка исполнителя, используемая для имени папки", "@downloadUsePrimaryArtistOnlyDisabled": { "description": "Subtitle when primary artist only is disabled" }, @@ -1817,7 +1833,7 @@ "@settingsDownloadNetwork": { "description": "Setting for network type preference" }, - "settingsDownloadNetworkAny": "WiFi и мобильная сеть", + "settingsDownloadNetworkAny": "WiFi и Мобильная сеть", "@settingsDownloadNetworkAny": { "description": "Network option - use any connection" }, @@ -1873,7 +1889,7 @@ "@downloadedAlbumDeleteSelected": { "description": "Button - delete selected tracks" }, - "downloadedAlbumDeleteMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other {треков}} из этого альбома?\n\nЭто также удалит файлы из хранилища.", + "downloadedAlbumDeleteMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other{треков}} из этого альбома?\n\nЭто также удалит файлы из хранилища.", "@downloadedAlbumDeleteMessage": { "description": "Delete confirmation with count", "placeholders": { @@ -1899,7 +1915,7 @@ "@downloadedAlbumTapToSelect": { "description": "Selection hint" }, - "downloadedAlbumDeleteCount": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other {треков}}", + "downloadedAlbumDeleteCount": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other{треков}}", "@downloadedAlbumDeleteCount": { "description": "Delete button text with count", "placeholders": { @@ -2198,6 +2214,15 @@ "@libraryAboutDescription": { "description": "Description of local library feature" }, + "libraryTracksUnit": "{count, plural, one {трек} few {трека} many {треков} other{треков}}", + "@libraryTracksUnit": { + "description": "Unit label for tracks count (without the number itself)", + "placeholders": { + "count": { + "type": "int" + } + } + }, "libraryLastScanned": "Последнее сканирование: {time}", "@libraryLastScanned": { "description": "Last scan time display", @@ -2304,7 +2329,7 @@ "@libraryFilterQualityCD": { "description": "Filter option - CD quality audio" }, - "libraryFilterQualityLossy": "С потерями", + "libraryFilterQualityLossy": "Lossy", "@libraryFilterQualityLossy": { "description": "Filter option - lossy compressed audio" }, @@ -2410,7 +2435,7 @@ "@tutorialExtensionsDesc": { "description": "Tutorial extensions page description" }, - "tutorialExtensionsTip1": "Browse the Store tab to discover useful extensions", + "tutorialExtensionsTip1": "Просмотрите вкладку Магазина, чтобы найти полезные расширения", "@tutorialExtensionsTip1": { "description": "Tutorial extensions tip 1" }, @@ -2418,7 +2443,7 @@ "@tutorialExtensionsTip2": { "description": "Tutorial extensions tip 2" }, - "tutorialExtensionsTip3": "Get lyrics, enhanced metadata, and more features", + "tutorialExtensionsTip3": "Получайте тексты песен, улучшенные метаданные и другие возможности", "@tutorialExtensionsTip3": { "description": "Tutorial extensions tip 3" }, @@ -2426,7 +2451,7 @@ "@tutorialSettingsTitle": { "description": "Tutorial settings page title" }, - "tutorialSettingsDesc": "Personalize the app in Settings to match your preferences.", + "tutorialSettingsDesc": "Персонализируйте приложение в Настройках, чтобы оно соответствовало вашим предпочтениям.", "@tutorialSettingsDesc": { "description": "Tutorial settings page description" }, @@ -2454,11 +2479,11 @@ "@libraryForceFullScanSubtitle": { "description": "Subtitle for force full scan button" }, - "cleanupOrphanedDownloads": "Cleanup Orphaned Downloads", + "cleanupOrphanedDownloads": "Очистка отложенных скачиваний", "@cleanupOrphanedDownloads": { "description": "Button to remove history entries for deleted files" }, - "cleanupOrphanedDownloadsSubtitle": "Remove history entries for files that no longer exist", + "cleanupOrphanedDownloadsSubtitle": "Удалить историю записи для файлов, которых больше не существует", "@cleanupOrphanedDownloadsSubtitle": { "description": "Subtitle for orphaned cleanup button" }, @@ -2471,7 +2496,7 @@ } } }, - "cleanupOrphanedDownloadsNone": "No orphaned entries found", + "cleanupOrphanedDownloadsNone": "Записей без описания не найдено", "@cleanupOrphanedDownloadsNone": { "description": "Snackbar when no orphans found" }, @@ -2483,11 +2508,11 @@ "@cacheSummaryTitle": { "description": "Heading for cache summary card" }, - "cacheSummarySubtitle": "Clearing cache will not remove downloaded music files.", + "cacheSummarySubtitle": "Очистка кэша не приведет к удалению загруженных музыкальных файлов.", "@cacheSummarySubtitle": { "description": "Helper text for cache summary card" }, - "cacheEstimatedTotal": "Estimated cache usage: {size}", + "cacheEstimatedTotal": "Приблизительное использование кэша: {size}", "@cacheEstimatedTotal": { "description": "Total cache size shown in summary", "placeholders": { @@ -2508,47 +2533,47 @@ "@cacheAppDirectory": { "description": "Cache item title for app cache directory" }, - "cacheAppDirectoryDesc": "HTTP responses, WebView data, and other temporary app data.", + "cacheAppDirectoryDesc": "HTTP-ответы, данные WebView и другие временные данные приложения.", "@cacheAppDirectoryDesc": { "description": "Description of what app cache directory contains" }, - "cacheTempDirectory": "Temporary directory", + "cacheTempDirectory": "Временная директория", "@cacheTempDirectory": { "description": "Cache item title for temporary files directory" }, - "cacheTempDirectoryDesc": "Temporary files from downloads and audio conversion.", + "cacheTempDirectoryDesc": "Временные файлы из загрузок и аудио конвертации.", "@cacheTempDirectoryDesc": { "description": "Description of what temporary directory contains" }, - "cacheCoverImage": "Cover image cache", + "cacheCoverImage": "Кэш обложек", "@cacheCoverImage": { "description": "Cache item title for persistent cover images" }, - "cacheCoverImageDesc": "Downloaded album and track cover art. Will re-download when viewed.", + "cacheCoverImageDesc": "Скачанный альбом и трек обложки. Будет заново скачан после просмотра.", "@cacheCoverImageDesc": { "description": "Description of what cover image cache contains" }, - "cacheLibraryCover": "Library cover cache", + "cacheLibraryCover": "Кэш обложек библиотеки", "@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": "Обложка извлечена из локальных музыкальных файлов. Будет повторно извлечено при следующем сканировании.", "@cacheLibraryCoverDesc": { "description": "Description of what library cover cache contains" }, - "cacheExploreFeed": "Explore feed cache", + "cacheExploreFeed": "Просмотреть кэш ленты", "@cacheExploreFeed": { "description": "Cache item title for explore home feed cache" }, - "cacheExploreFeedDesc": "Explore tab content (new releases, trending). Will refresh on next visit.", + "cacheExploreFeedDesc": "Изучите содержимое вкладки (новые релизы, тренды). Они обновятся при следующем посещении.", "@cacheExploreFeedDesc": { "description": "Description of what explore feed cache contains" }, - "cacheTrackLookup": "Track lookup cache", + "cacheTrackLookup": "Отслеживать кэш поиска", "@cacheTrackLookup": { "description": "Cache item title for track ID lookup cache" }, - "cacheTrackLookupDesc": "Spotify/Deezer track ID lookups. Clearing may slow next few searches.", + "cacheTrackLookupDesc": "Поиск ID трека в Spotify/Deezer. Очистка может замедлить следующие несколько поисков.", "@cacheTrackLookupDesc": { "description": "Description of what track lookup cache contains" }, @@ -2581,7 +2606,7 @@ } } }, - "cacheEntries": "{count} entries", + "cacheEntries": "{count} записей", "@cacheEntries": { "description": "Track cache entry count", "placeholders": { @@ -2603,7 +2628,7 @@ "@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": "Это очистит кэш для {target}. Загруженные музыкальные файлы не будут удалены.", "@cacheClearConfirmMessage": { "description": "Dialog message before clearing selected cache", "placeholders": { @@ -2632,7 +2657,7 @@ "@cacheCleanupUnusedSubtitle": { "description": "Subtitle for cleanup unused data action" }, - "cacheCleanupResult": "Cleanup completed: {downloadCount} orphaned downloads, {libraryCount} missing library entries", + "cacheCleanupResult": "Очистка завершена: {downloadCount} потерянных загрузок, {libraryCount} отсутствующих записей в библиотеке", "@cacheCleanupResult": { "description": "Snackbar after unused data cleanup", "placeholders": { @@ -2664,15 +2689,15 @@ "@trackSaveLyricsSubtitle": { "description": "Subtitle for save lyrics action" }, - "trackSaveLyricsProgress": "Saving lyrics...", + "trackSaveLyricsProgress": "Сохранение текста...", "@trackSaveLyricsProgress": { "description": "Snackbar while saving lyrics to file" }, - "trackReEnrich": "Re-enrich", + "trackReEnrich": "Обновить", "@trackReEnrich": { "description": "Menu action - re-embed metadata into audio file" }, - "trackReEnrichOnlineSubtitle": "Search metadata online and embed into file", + "trackReEnrichOnlineSubtitle": "Поиск в сети метаданных и встраивание в файл", "@trackReEnrichOnlineSubtitle": { "description": "Subtitle for re-enrich metadata action for local items" }, @@ -2702,7 +2727,7 @@ } } }, - "trackReEnrichProgress": "Re-enriching metadata...", + "trackReEnrichProgress": "Обновление метаданных...", "@trackReEnrichProgress": { "description": "Snackbar while re-enriching metadata" }, @@ -2710,7 +2735,7 @@ "@trackReEnrichSearching": { "description": "Snackbar while searching metadata from internet for local items" }, - "trackReEnrichSuccess": "Metadata re-enriched successfully", + "trackReEnrichSuccess": "Метаданные успешно обновлены", "@trackReEnrichSuccess": { "description": "Snackbar after successful re-enrichment" }, @@ -2727,31 +2752,31 @@ } } }, - "trackConvertFormat": "Convert Format", + "trackConvertFormat": "Переконвертировать формат", "@trackConvertFormat": { "description": "Menu item - convert audio format" }, - "trackConvertFormatSubtitle": "Convert to MP3 or Opus", + "trackConvertFormatSubtitle": "Конвертировать в MP3 или Opus", "@trackConvertFormatSubtitle": { "description": "Subtitle for convert format menu item" }, - "trackConvertTitle": "Convert Audio", + "trackConvertTitle": "Конвертировать аудио", "@trackConvertTitle": { "description": "Title of convert bottom sheet" }, - "trackConvertTargetFormat": "Target Format", + "trackConvertTargetFormat": "Целевой формат", "@trackConvertTargetFormat": { "description": "Label for format selection" }, - "trackConvertBitrate": "Bitrate", + "trackConvertBitrate": "Битрейт", "@trackConvertBitrate": { "description": "Label for bitrate selection" }, - "trackConvertConfirmTitle": "Confirm Conversion", + "trackConvertConfirmTitle": "Подтвердить конвертацию", "@trackConvertConfirmTitle": { "description": "Confirmation dialog title" }, - "trackConvertConfirmMessage": "Convert from {sourceFormat} to {targetFormat} at {bitrate}?\n\nThe original file will be deleted after conversion.", + "trackConvertConfirmMessage": "Конвертировать из {sourceFormat} в {targetFormat} {bitrate}?\n\nОригинальный файл будет удален после конвертации.", "@trackConvertConfirmMessage": { "description": "Confirmation dialog message", "placeholders": { @@ -2766,11 +2791,11 @@ } } }, - "trackConvertConverting": "Converting audio...", + "trackConvertConverting": "Конвертация аудио...", "@trackConvertConverting": { "description": "Snackbar while converting" }, - "trackConvertSuccess": "Converted to {format} successfully", + "trackConvertSuccess": "Успешно конвертировано в {format}", "@trackConvertSuccess": { "description": "Snackbar after successful conversion", "placeholders": { @@ -2779,10 +2804,287 @@ } } }, - "trackConvertFailed": "Conversion failed", + "trackConvertFailed": "Ошибка конвертации", "@trackConvertFailed": { "description": "Snackbar when conversion fails" }, + "actionCreate": "Создать", + "@actionCreate": { + "description": "Generic action button - create" + }, + "collectionFoldersTitle": "Мои папки", + "@collectionFoldersTitle": { + "description": "Library section title for custom folders" + }, + "collectionWishlist": "Список желаемого", + "@collectionWishlist": { + "description": "Custom folder for saved tracks to download later" + }, + "collectionLoved": "Любимые", + "@collectionLoved": { + "description": "Custom folder for favorite tracks" + }, + "collectionPlaylists": "Плейлисты", + "@collectionPlaylists": { + "description": "Custom user playlists folder" + }, + "collectionPlaylist": "Плейлист", + "@collectionPlaylist": { + "description": "Single playlist label" + }, + "collectionAddToPlaylist": "Добавить в плейлист", + "@collectionAddToPlaylist": { + "description": "Action to add a track to user playlist" + }, + "collectionCreatePlaylist": "Создать плейлист", + "@collectionCreatePlaylist": { + "description": "Action to create a new playlist" + }, + "collectionNoPlaylistsYet": "Плейлисты отсутствуют", + "@collectionNoPlaylistsYet": { + "description": "Empty state title when user has no playlists" + }, + "collectionNoPlaylistsSubtitle": "Создайте плейлист, чтобы начать классифицировать треки", + "@collectionNoPlaylistsSubtitle": { + "description": "Empty state subtitle when user has no playlists" + }, + "collectionPlaylistTracks": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} other {{count} треков}}", + "@collectionPlaylistTracks": { + "description": "Track count label for custom playlists", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "collectionAddedToPlaylist": "Добавлено в \"{playlistName}\"", + "@collectionAddedToPlaylist": { + "description": "Snackbar after adding track to playlist", + "placeholders": { + "playlistName": { + "type": "String" + } + } + }, + "collectionAlreadyInPlaylist": "Уже в \"{playlistName}\"", + "@collectionAlreadyInPlaylist": { + "description": "Snackbar when track already exists in playlist", + "placeholders": { + "playlistName": { + "type": "String" + } + } + }, + "collectionPlaylistCreated": "Плейлист создан", + "@collectionPlaylistCreated": { + "description": "Snackbar after creating playlist" + }, + "collectionPlaylistNameHint": "Название плейлиста", + "@collectionPlaylistNameHint": { + "description": "Hint text for playlist name input" + }, + "collectionPlaylistNameRequired": "Имя плейлиста обязательно", + "@collectionPlaylistNameRequired": { + "description": "Validation error for empty playlist name" + }, + "collectionRenamePlaylist": "Переименовать плейлист", + "@collectionRenamePlaylist": { + "description": "Action to rename playlist" + }, + "collectionDeletePlaylist": "Удалить плейлист", + "@collectionDeletePlaylist": { + "description": "Action to delete playlist" + }, + "collectionDeletePlaylistMessage": "Удалить \"{playlistName}\" и все треки внутри него?", + "@collectionDeletePlaylistMessage": { + "description": "Confirmation message for deleting playlist", + "placeholders": { + "playlistName": { + "type": "String" + } + } + }, + "collectionPlaylistDeleted": "Плейлист удалён", + "@collectionPlaylistDeleted": { + "description": "Snackbar after deleting playlist" + }, + "collectionPlaylistRenamed": "Плейлист переименован", + "@collectionPlaylistRenamed": { + "description": "Snackbar after renaming playlist" + }, + "collectionWishlistEmptyTitle": "Список желаний пуст", + "@collectionWishlistEmptyTitle": { + "description": "Wishlist empty state title" + }, + "collectionWishlistEmptySubtitle": "Нажмите + на треках, чтобы сохранить то, что вы хотите скачать позже", + "@collectionWishlistEmptySubtitle": { + "description": "Wishlist empty state subtitle" + }, + "collectionLovedEmptyTitle": "Папка Любимые пуста", + "@collectionLovedEmptyTitle": { + "description": "Loved empty state title" + }, + "collectionLovedEmptySubtitle": "Нажмите \"любовь\" на треках, чтобы сохранить ваши избранные", + "@collectionLovedEmptySubtitle": { + "description": "Loved empty state subtitle" + }, + "collectionPlaylistEmptyTitle": "Плейлист пуст", + "@collectionPlaylistEmptyTitle": { + "description": "Playlist empty state title" + }, + "collectionPlaylistEmptySubtitle": "Удерживайте + на любом треке, чтобы добавить его сюда", + "@collectionPlaylistEmptySubtitle": { + "description": "Playlist empty state subtitle" + }, + "collectionRemoveFromPlaylist": "Удалить из плейлиста", + "@collectionRemoveFromPlaylist": { + "description": "Tooltip for removing track from playlist" + }, + "collectionRemoveFromFolder": "Убрать из папки", + "@collectionRemoveFromFolder": { + "description": "Tooltip for removing track from wishlist/loved folder" + }, + "collectionRemoved": "\"{trackName}\" удалён", + "@collectionRemoved": { + "description": "Snackbar after removing a track from a collection", + "placeholders": { + "trackName": { + "type": "String" + } + } + }, + "collectionAddedToLoved": "\"{trackName}\" добавлен в Любимые", + "@collectionAddedToLoved": { + "description": "Snackbar after adding track to loved folder", + "placeholders": { + "trackName": { + "type": "String" + } + } + }, + "collectionRemovedFromLoved": "\"{trackName}\" удалено из Любимых", + "@collectionRemovedFromLoved": { + "description": "Snackbar after removing track from loved folder", + "placeholders": { + "trackName": { + "type": "String" + } + } + }, + "collectionAddedToWishlist": "\"{trackName}\" добавлен в список желаний", + "@collectionAddedToWishlist": { + "description": "Snackbar after adding track to wishlist", + "placeholders": { + "trackName": { + "type": "String" + } + } + }, + "collectionRemovedFromWishlist": "\"{trackName}\" удалён из списка желаний", + "@collectionRemovedFromWishlist": { + "description": "Snackbar after removing track from wishlist", + "placeholders": { + "trackName": { + "type": "String" + } + } + }, + "trackOptionAddToLoved": "Добавить в Любимое", + "@trackOptionAddToLoved": { + "description": "Bottom sheet action label - add track to loved folder" + }, + "trackOptionRemoveFromLoved": "Исключить из Любимых", + "@trackOptionRemoveFromLoved": { + "description": "Bottom sheet action label - remove track from loved folder" + }, + "trackOptionAddToWishlist": "Добавить в список желаний", + "@trackOptionAddToWishlist": { + "description": "Bottom sheet action label - add track to wishlist" + }, + "trackOptionRemoveFromWishlist": "Удалить из списка желаний", + "@trackOptionRemoveFromWishlist": { + "description": "Bottom sheet action label - remove track from wishlist" + }, + "collectionPlaylistChangeCover": "Изменить обложку", + "@collectionPlaylistChangeCover": { + "description": "Bottom sheet action to pick a custom cover image for a playlist" + }, + "collectionPlaylistRemoveCover": "Удалить обложку", + "@collectionPlaylistRemoveCover": { + "description": "Bottom sheet action to remove custom cover image from a playlist" + }, + "selectionShareCount": "Отправить {count} {count, plural, one {трек} few {трека} many {треков} other{треков}}", + "@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": "Конвертировать {count} {count, plural, one {трек} few {трека} many {треков} other{треков}}", + "@selectionConvertCount": { + "description": "Convert button text with count in selection mode", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "selectionConvertNoConvertible": "Не выбраны конвертируемые треки", + "@selectionConvertNoConvertible": { + "description": "Snackbar when no selected tracks support conversion" + }, + "selectionBatchConvertConfirmTitle": "Пакетная конвертация", + "@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": "Конвертация {current} из {total}...", + "@selectionBatchConvertProgress": { + "description": "Snackbar during batch conversion progress", + "placeholders": { + "current": { + "type": "int" + }, + "total": { + "type": "int" + } + } + }, + "selectionBatchConvertSuccess": "Конвертировано {success} треков {total} в {format}", + "@selectionBatchConvertSuccess": { + "description": "Snackbar after batch conversion completes", + "placeholders": { + "success": { + "type": "int" + }, + "total": { + "type": "int" + }, + "format": { + "type": "String" + } + } + }, "downloadedAlbumDownloadedCount": "{count} скачано", "@downloadedAlbumDownloadedCount": { "description": "Downloaded tracks count badge", @@ -2792,7 +3094,7 @@ } } }, - "downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available", + "downloadUseAlbumArtistForFoldersAlbumSubtitle": "Для папок исполнителей используется исполнитель альбома, если он указан", "@downloadUseAlbumArtistForFoldersAlbumSubtitle": { "description": "Subtitle when Album Artist is used for folder naming" }, @@ -2800,4 +3102,4 @@ "@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 b7b16c7f..7b808ec4 100644 --- a/lib/l10n/arb/app_tr.arb +++ b/lib/l10n/arb/app_tr.arb @@ -450,7 +450,7 @@ "@aboutSpotiSaverDesc": { "description": "Credit for SpotiSaver API" }, - "aboutAppDescription": "Spotify şarkılarını Tidal, Qobuz ve Amazon Music'den yüksek kalitede indir.", + "aboutAppDescription": "Spotify şarkılarını Tidal ve Qobuz'den yüksek kalitede indir.", "@aboutAppDescription": { "description": "App description in header card" }, @@ -1089,7 +1089,7 @@ }, "providerBuiltIn": "Dahili", "@providerBuiltIn": { - "description": "Label for built-in providers (Tidal/Qobuz/Amazon)" + "description": "Label for built-in providers (Tidal/Qobuz)" }, "providerExtension": "Eklenti", "@providerExtension": { @@ -2358,7 +2358,7 @@ "@tutorialWelcomeTip1": { "description": "Tutorial welcome tip 1" }, - "tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music", + "tutorialWelcomeTip2": "Tidal, Qobuz veya Deezer'den FLAC kalitesinde ses alın", "@tutorialWelcomeTip2": { "description": "Tutorial welcome tip 2" }, diff --git a/lib/l10n/arb/app_zh.arb b/lib/l10n/arb/app_zh.arb index f6f4895f..453c9f5c 100644 --- a/lib/l10n/arb/app_zh.arb +++ b/lib/l10n/arb/app_zh.arb @@ -402,7 +402,7 @@ "@aboutDabMusicDesc": { "description": "Credit for DAB Music API" }, - "aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.", + "aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.", "@aboutAppDescription": { "description": "App description in header card" }, @@ -1005,7 +1005,7 @@ }, "providerBuiltIn": "Built-in", "@providerBuiltIn": { - "description": "Label for built-in providers (Tidal/Qobuz/Amazon)" + "description": "Label for built-in providers (Tidal/Qobuz)" }, "providerExtension": "Extension", "@providerExtension": { diff --git a/lib/l10n/arb/app_zh_CN.arb b/lib/l10n/arb/app_zh_CN.arb index 67d8b58c..d7a9c1aa 100644 --- a/lib/l10n/arb/app_zh_CN.arb +++ b/lib/l10n/arb/app_zh_CN.arb @@ -991,6 +991,14 @@ "@filenameFormat": { "description": "Setting title - filename pattern" }, + "filenameShowAdvancedTags": "Show advanced tags", + "@filenameShowAdvancedTags": { + "description": "Toggle label for showing advanced filename tags" + }, + "filenameShowAdvancedTagsDescription": "Enable formatted tags for track padding and date patterns", + "@filenameShowAdvancedTagsDescription": { + "description": "Description for advanced filename tag toggle" + }, "folderOrganizationNone": "No organization", "@folderOrganizationNone": { "description": "Folder option - flat structure" @@ -1749,6 +1757,14 @@ "@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" @@ -2198,6 +2214,15 @@ "@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" + } + } + }, "libraryLastScanned": "Last scanned: {time}", "@libraryLastScanned": { "description": "Last scan time display", @@ -2783,6 +2808,283 @@ "@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", @@ -2800,4 +3102,4 @@ "@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 d230aa50..b51686c0 100644 --- a/lib/l10n/arb/app_zh_TW.arb +++ b/lib/l10n/arb/app_zh_TW.arb @@ -991,6 +991,14 @@ "@filenameFormat": { "description": "Setting title - filename pattern" }, + "filenameShowAdvancedTags": "Show advanced tags", + "@filenameShowAdvancedTags": { + "description": "Toggle label for showing advanced filename tags" + }, + "filenameShowAdvancedTagsDescription": "Enable formatted tags for track padding and date patterns", + "@filenameShowAdvancedTagsDescription": { + "description": "Description for advanced filename tag toggle" + }, "folderOrganizationNone": "No organization", "@folderOrganizationNone": { "description": "Folder option - flat structure" @@ -1749,6 +1757,14 @@ "@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" @@ -2198,6 +2214,15 @@ "@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" + } + } + }, "libraryLastScanned": "Last scanned: {time}", "@libraryLastScanned": { "description": "Last scan time display", @@ -2783,6 +2808,283 @@ "@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", @@ -2800,4 +3102,4 @@ "@downloadUseAlbumArtistForFoldersTrackSubtitle": { "description": "Subtitle when Track Artist is used for folder naming" } -} +} \ No newline at end of file diff --git a/lib/models/download_item.dart b/lib/models/download_item.dart index 4d8a650e..db55029b 100644 --- a/lib/models/download_item.dart +++ b/lib/models/download_item.dart @@ -34,6 +34,7 @@ class DownloadItem { final DownloadErrorType? errorType; final DateTime createdAt; final String? qualityOverride; // Override quality for this specific download + final String? playlistName; // Playlist context for folder organization const DownloadItem({ required this.id, @@ -48,6 +49,7 @@ class DownloadItem { this.errorType, required this.createdAt, this.qualityOverride, + this.playlistName, }); DownloadItem copyWith({ @@ -63,6 +65,7 @@ class DownloadItem { DownloadErrorType? errorType, DateTime? createdAt, String? qualityOverride, + String? playlistName, }) { return DownloadItem( id: id ?? this.id, @@ -77,6 +80,7 @@ class DownloadItem { errorType: errorType ?? this.errorType, createdAt: createdAt ?? this.createdAt, qualityOverride: qualityOverride ?? this.qualityOverride, + playlistName: playlistName ?? this.playlistName, ); } diff --git a/lib/models/download_item.g.dart b/lib/models/download_item.g.dart index 098290ce..961e6d6d 100644 --- a/lib/models/download_item.g.dart +++ b/lib/models/download_item.g.dart @@ -21,6 +21,7 @@ DownloadItem _$DownloadItemFromJson(Map json) => DownloadItem( errorType: $enumDecodeNullable(_$DownloadErrorTypeEnumMap, json['errorType']), createdAt: DateTime.parse(json['createdAt'] as String), qualityOverride: json['qualityOverride'] as String?, + playlistName: json['playlistName'] as String?, ); Map _$DownloadItemToJson(DownloadItem instance) => @@ -37,6 +38,7 @@ Map _$DownloadItemToJson(DownloadItem instance) => 'errorType': _$DownloadErrorTypeEnumMap[instance.errorType], 'createdAt': instance.createdAt.toIso8601String(), 'qualityOverride': instance.qualityOverride, + 'playlistName': instance.playlistName, }; const _$DownloadStatusEnumMap = { diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 545e7440..67e3e9ac 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -55,17 +55,16 @@ class AppSettings { final String songLinkRegion; // SongLink userCountry region code used for platform lookup - // Local Library Settings final bool localLibraryEnabled; // Enable local library scanning final String localLibraryPath; // Path to scan for audio files + final String + localLibraryBookmark; // Base64-encoded iOS security-scoped bookmark final bool localLibraryShowDuplicates; // Show indicator when searching for existing tracks - // Tutorial/Onboarding final bool hasCompletedTutorial; // Track if user has completed the app tutorial - // Lyrics Provider Settings final List lyricsProviders; // Ordered list of enabled lyrics provider IDs final bool @@ -77,7 +76,6 @@ 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') @@ -106,7 +104,7 @@ class AppSettings { this.askQualityBeforeDownload = true, this.spotifyClientId = '', this.spotifyClientSecret = '', - this.useCustomSpotifyCredentials = true, + this.useCustomSpotifyCredentials = false, this.metadataSource = 'deezer', this.enableLogging = false, this.useExtensionProviders = true, @@ -124,13 +122,11 @@ class AppSettings { this.downloadNetworkMode = 'any', this.networkCompatibilityMode = false, this.songLinkRegion = 'US', - // Local Library defaults this.localLibraryEnabled = false, this.localLibraryPath = '', + this.localLibraryBookmark = '', this.localLibraryShowDuplicates = true, - // Tutorial default this.hasCompletedTutorial = false, - // Lyrics providers default order this.lyricsProviders = const [ 'lrclib', 'spotify_api', @@ -143,7 +139,6 @@ class AppSettings { this.lyricsIncludeRomanizationNetease = false, this.lyricsMultiPersonWordByWord = false, this.musixmatchLanguage = '', - // Version upgrade tracking this.lastSeenVersion = '', }); @@ -154,7 +149,7 @@ class AppSettings { String? downloadDirectory, String? storageMode, String? downloadTreeUri, - bool? autoFallback, + bool? autoFallback, bool? embedMetadata, bool? embedLyrics, bool? maxQualityCover, @@ -191,19 +186,16 @@ class AppSettings { String? downloadNetworkMode, bool? networkCompatibilityMode, String? songLinkRegion, - // Local Library bool? localLibraryEnabled, String? localLibraryPath, + String? localLibraryBookmark, bool? localLibraryShowDuplicates, - // Tutorial bool? hasCompletedTutorial, - // Lyrics providers List? lyricsProviders, bool? lyricsIncludeTranslationNetease, bool? lyricsIncludeRomanizationNetease, bool? lyricsMultiPersonWordByWord, String? musixmatchLanguage, - // Version upgrade tracking String? lastSeenVersion, }) { return AppSettings( @@ -259,14 +251,12 @@ class AppSettings { networkCompatibilityMode: networkCompatibilityMode ?? this.networkCompatibilityMode, songLinkRegion: songLinkRegion ?? this.songLinkRegion, - // Local Library localLibraryEnabled: localLibraryEnabled ?? this.localLibraryEnabled, localLibraryPath: localLibraryPath ?? this.localLibraryPath, + localLibraryBookmark: localLibraryBookmark ?? this.localLibraryBookmark, localLibraryShowDuplicates: localLibraryShowDuplicates ?? this.localLibraryShowDuplicates, - // Tutorial hasCompletedTutorial: hasCompletedTutorial ?? this.hasCompletedTutorial, - // Lyrics providers lyricsProviders: lyricsProviders ?? this.lyricsProviders, lyricsIncludeTranslationNetease: lyricsIncludeTranslationNetease ?? @@ -277,7 +267,6 @@ class AppSettings { lyricsMultiPersonWordByWord: lyricsMultiPersonWordByWord ?? this.lyricsMultiPersonWordByWord, musixmatchLanguage: musixmatchLanguage ?? this.musixmatchLanguage, - // Version upgrade tracking lastSeenVersion: lastSeenVersion ?? this.lastSeenVersion, ); } diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index 933178e3..c3eecb50 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -33,7 +33,7 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( spotifyClientId: json['spotifyClientId'] as String? ?? '', spotifyClientSecret: json['spotifyClientSecret'] as String? ?? '', useCustomSpotifyCredentials: - json['useCustomSpotifyCredentials'] as bool? ?? true, + json['useCustomSpotifyCredentials'] as bool? ?? false, metadataSource: json['metadataSource'] as String? ?? 'deezer', enableLogging: json['enableLogging'] as bool? ?? false, useExtensionProviders: json['useExtensionProviders'] as bool? ?? true, @@ -55,6 +55,7 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( songLinkRegion: json['songLinkRegion'] as String? ?? 'US', localLibraryEnabled: json['localLibraryEnabled'] as bool? ?? false, localLibraryPath: json['localLibraryPath'] as String? ?? '', + localLibraryBookmark: json['localLibraryBookmark'] as String? ?? '', localLibraryShowDuplicates: json['localLibraryShowDuplicates'] as bool? ?? true, hasCompletedTutorial: json['hasCompletedTutorial'] as bool? ?? false, @@ -128,6 +129,7 @@ Map _$AppSettingsToJson( 'songLinkRegion': instance.songLinkRegion, 'localLibraryEnabled': instance.localLibraryEnabled, 'localLibraryPath': instance.localLibraryPath, + 'localLibraryBookmark': instance.localLibraryBookmark, 'localLibraryShowDuplicates': instance.localLibraryShowDuplicates, 'hasCompletedTutorial': instance.hasCompletedTutorial, 'lyricsProviders': instance.lyricsProviders, diff --git a/lib/models/track.dart b/lib/models/track.dart index 244a7a65..e4d850c2 100644 --- a/lib/models/track.dart +++ b/lib/models/track.dart @@ -21,6 +21,7 @@ class Track { final ServiceAvailability? availability; final String? source; final String? albumType; + final int? totalTracks; final String? itemType; const Track({ @@ -41,10 +42,21 @@ class Track { this.availability, this.source, this.albumType, + this.totalTracks, this.itemType, }); - bool get isSingle => albumType == 'single' || albumType == 'ep'; + bool get isSingle { + switch (albumType?.toLowerCase()) { + case 'single': + return true; + case 'ep': + final count = totalTracks; + return count == null || count <= 1; + default: + return false; + } + } bool get isAlbumItem => itemType == 'album'; diff --git a/lib/models/track.g.dart b/lib/models/track.g.dart index f640cfe7..5e361ab6 100644 --- a/lib/models/track.g.dart +++ b/lib/models/track.g.dart @@ -28,6 +28,7 @@ Track _$TrackFromJson(Map json) => Track( ), source: json['source'] as String?, albumType: json['albumType'] as String?, + totalTracks: (json['totalTracks'] as num?)?.toInt(), itemType: json['itemType'] as String?, ); @@ -49,6 +50,7 @@ Map _$TrackToJson(Track instance) => { 'availability': instance.availability, 'source': instance.source, 'albumType': instance.albumType, + 'totalTracks': instance.totalTracks, 'itemType': instance.itemType, }; diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 4f2059db..18348f96 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -251,9 +251,11 @@ class DownloadHistoryState { class DownloadHistoryNotifier extends Notifier { static const int _safRepairBatchSize = 20; static const int _safRepairMaxPerLaunch = 60; + static const int _audioMetadataBackfillMaxPerLaunch = 24; final HistoryDatabase _db = HistoryDatabase.instance; bool _isLoaded = false; bool _isSafRepairInProgress = false; + bool _isAudioMetadataBackfillInProgress = false; @override DownloadHistoryState build() { @@ -298,9 +300,19 @@ class DownloadHistoryNotifier extends Notifier { maxItems: _safRepairMaxPerLaunch, ); await cleanupOrphanedDownloads(); + await _backfillAudioMetadata( + state.items, + maxItems: _audioMetadataBackfillMaxPerLaunch, + ); }); } else { - Future.microtask(() => cleanupOrphanedDownloads()); + Future.microtask(() async { + await cleanupOrphanedDownloads(); + await _backfillAudioMetadata( + state.items, + maxItems: _audioMetadataBackfillMaxPerLaunch, + ); + }); } } catch (e, stack) { _historyLog.e('Failed to load history from database: $e', e, stack); @@ -429,6 +441,157 @@ class DownloadHistoryNotifier extends Notifier { } } + int? _readPositiveInt(dynamic value) { + if (value == null) return null; + if (value is num) { + final asInt = value.toInt(); + return asInt > 0 ? asInt : null; + } + final parsed = int.tryParse(value.toString()); + if (parsed == null || parsed <= 0) return null; + return parsed; + } + + bool _supportsAudioMetadataProbe(String filePath) { + final trimmed = filePath.trim().toLowerCase(); + if (trimmed.isEmpty) return false; + if (trimmed.startsWith('content://')) return true; + return trimmed.endsWith('.flac') || + trimmed.endsWith('.m4a') || + trimmed.endsWith('.aac') || + trimmed.endsWith('.mp3') || + trimmed.endsWith('.opus') || + trimmed.endsWith('.ogg'); + } + + bool _shouldBackfillAudioMetadata(DownloadHistoryItem item) { + if (!_supportsAudioMetadataProbe(item.filePath)) { + return false; + } + + final trimmedPath = item.filePath.trim().toLowerCase(); + final hasResolvedSpecs = + item.bitDepth != null && + item.bitDepth! > 0 && + item.sampleRate != null && + item.sampleRate! > 0; + final needsLosslessSpecProbe = + !hasResolvedSpecs && + (trimmedPath.endsWith('.flac') || + trimmedPath.endsWith('.m4a') || + trimmedPath.endsWith('.aac') || + trimmedPath.startsWith('content://')); + + if (hasResolvedSpecs && !isPlaceholderQualityLabel(item.quality)) { + return false; + } + + return needsLosslessSpecProbe || + isPlaceholderQualityLabel(item.quality) || + normalizeOptionalString(item.quality) == null; + } + + Future?> _probeAudioMetadata( + String filePath, { + String? fallbackQuality, + }) async { + if (!_supportsAudioMetadataProbe(filePath)) { + return null; + } + + try { + final result = await PlatformBridge.readFileMetadata(filePath); + if (result['error'] != null) { + return null; + } + + final bitDepth = _readPositiveInt(result['bit_depth']); + final sampleRate = _readPositiveInt(result['sample_rate']); + final quality = buildDisplayAudioQuality( + bitDepth: bitDepth, + sampleRate: sampleRate, + storedQuality: fallbackQuality, + ); + + if (quality == null && bitDepth == null && sampleRate == null) { + return null; + } + + return { + 'quality': quality, + 'bitDepth': bitDepth, + 'sampleRate': sampleRate, + }; + } catch (e) { + _historyLog.d('Audio metadata probe failed for $filePath: $e'); + return null; + } + } + + Future _backfillAudioMetadata( + List items, { + required int maxItems, + }) async { + if (_isAudioMetadataBackfillInProgress || items.isEmpty) { + return; + } + _isAudioMetadataBackfillInProgress = true; + + try { + var refreshedCount = 0; + + for (final item in items) { + if (refreshedCount >= maxItems) { + break; + } + if (!_shouldBackfillAudioMetadata(item)) { + continue; + } + + final probed = await _probeAudioMetadata( + item.filePath, + fallbackQuality: item.quality, + ); + if (probed == null) { + continue; + } + + final resolvedQuality = normalizeOptionalString( + probed['quality'] as String?, + ); + final resolvedBitDepth = probed['bitDepth'] as int?; + final resolvedSampleRate = probed['sampleRate'] as int?; + + final qualityChanged = + resolvedQuality != null && resolvedQuality != item.quality; + final bitDepthChanged = + resolvedBitDepth != null && resolvedBitDepth != item.bitDepth; + final sampleRateChanged = + resolvedSampleRate != null && resolvedSampleRate != item.sampleRate; + + if (!qualityChanged && !bitDepthChanged && !sampleRateChanged) { + continue; + } + + await updateAudioMetadataForItem( + id: item.id, + quality: resolvedQuality, + bitDepth: resolvedBitDepth, + sampleRate: resolvedSampleRate, + ); + refreshedCount++; + } + + if (refreshedCount > 0) { + _historyLog.i( + 'Audio metadata backfill refreshed $refreshedCount items', + ); + } + } finally { + _isAudioMetadataBackfillInProgress = false; + } + } + Future reloadFromStorage() async { await _loadFromDatabase(); } @@ -509,6 +672,39 @@ class DownloadHistoryNotifier extends Notifier { return DownloadHistoryItem.fromJson(json); } + Future updateAudioMetadataForItem({ + required String id, + String? quality, + int? bitDepth, + int? sampleRate, + }) async { + final index = state.items.indexWhere((item) => item.id == id); + if (index < 0) return; + + final current = state.items[index]; + final updated = current.copyWith( + quality: quality, + bitDepth: bitDepth, + sampleRate: sampleRate, + ); + + if (updated.quality == current.quality && + updated.bitDepth == current.bitDepth && + updated.sampleRate == current.sampleRate) { + return; + } + + final updatedItems = [...state.items]; + updatedItems[index] = updated; + state = state.copyWith(items: updatedItems); + await _db.updateAudioMetadata( + id, + newQuality: quality, + newBitDepth: bitDepth, + newSampleRate: sampleRate, + ); + } + Future updateMetadataForItem({ required String id, required String trackName, @@ -592,10 +788,8 @@ class DownloadHistoryNotifier extends Notifier { return 0; } - // Delete from database final deletedCount = await _db.deleteByIds(orphanedIds); - // Update in-memory state final orphanedSet = orphanedIds.toSet(); state = state.copyWith( items: state.items @@ -1379,6 +1573,7 @@ class DownloadQueueNotifier extends Notifier { bool useAlbumArtistForFolders = true, bool usePrimaryArtistOnly = false, bool filterContributingArtistsInAlbumArtist = false, + String? playlistName, }) async { String baseDir = state.outputDir; final normalizedAlbumArtist = normalizeOptionalString(track.albumArtist); @@ -1453,6 +1648,11 @@ class DownloadQueueNotifier extends Notifier { String subPath = ''; switch (folderOrganization) { + case 'playlist': + if (playlistName != null && playlistName.isNotEmpty) { + subPath = _sanitizeFolderName(playlistName); + } + break; case 'artist': final artistName = _sanitizeFolderName(folderArtist); subPath = artistName; @@ -1531,6 +1731,7 @@ class DownloadQueueNotifier extends Notifier { bool useAlbumArtistForFolders = true, bool usePrimaryArtistOnly = false, bool filterContributingArtistsInAlbumArtist = false, + String? playlistName, }) async { final normalizedAlbumArtist = normalizeOptionalString(track.albumArtist); var folderArtist = useAlbumArtistForFolders @@ -1582,6 +1783,11 @@ class DownloadQueueNotifier extends Notifier { } switch (folderOrganization) { + case 'playlist': + if (playlistName != null && playlistName.isNotEmpty) { + return _sanitizeFolderName(playlistName); + } + return ''; case 'artist': return _sanitizeFolderName(folderArtist); case 'album': @@ -1596,18 +1802,12 @@ class DownloadQueueNotifier extends Notifier { } String _determineOutputExt(String quality, String service) { - // YouTube provider - lossy only (Opus or MP3) if (service.toLowerCase() == 'youtube') { if (quality.toLowerCase().contains('mp3')) { return '.mp3'; } return '.opus'; } - // Amazon stream is delivered as MP4/M4A container (may contain FLAC audio), - // so SAF should keep .m4a before decrypt/convert pipeline. - if (service.toLowerCase() == 'amazon') { - return '.m4a'; - } if (service.toLowerCase() == 'tidal' && quality == 'HIGH') { return '.m4a'; } @@ -1712,7 +1912,7 @@ class DownloadQueueNotifier extends Notifier { ); } - String addToQueue(Track track, String service, {String? qualityOverride}) { + String addToQueue(Track track, String service, {String? qualityOverride, String? playlistName}) { final settings = ref.read(settingsProvider); updateSettings(settings); @@ -1724,6 +1924,7 @@ class DownloadQueueNotifier extends Notifier { service: service, createdAt: DateTime.now(), qualityOverride: qualityOverride, + playlistName: playlistName, ); state = state.copyWith(items: [...state.items, item]); @@ -1740,6 +1941,7 @@ class DownloadQueueNotifier extends Notifier { List tracks, String service, { String? qualityOverride, + String? playlistName, }) { final settings = ref.read(settingsProvider); updateSettings(settings); @@ -1754,6 +1956,7 @@ class DownloadQueueNotifier extends Notifier { service: service, createdAt: DateTime.now(), qualityOverride: qualityOverride, + playlistName: playlistName, ); }).toList(); @@ -2159,6 +2362,7 @@ class DownloadQueueNotifier extends Notifier { deezerId: baseTrack.deezerId, availability: baseTrack.availability, albumType: baseTrack.albumType, + totalTracks: baseTrack.totalTracks, source: baseTrack.source, ); } @@ -2903,7 +3107,6 @@ class DownloadQueueNotifier extends Notifier { failedCount: _failedInSession, ); - // Auto-export failed downloads if enabled final settings = ref.read(settingsProvider); if (settings.autoExportFailedDownloads && _failedInSession > 0) { final exportPath = await exportFailedDownloads(); @@ -3072,6 +3275,8 @@ class DownloadQueueNotifier extends Notifier { albumType: (data['album_type'] as String?) ?? trackToDownload.albumType, + totalTracks: + data['total_tracks'] as int? ?? trackToDownload.totalTracks, source: trackToDownload.source, ); _log.d( @@ -3130,6 +3335,7 @@ class DownloadQueueNotifier extends Notifier { usePrimaryArtistOnly: settings.usePrimaryArtistOnly, filterContributingArtistsInAlbumArtist: settings.filterContributingArtistsInAlbumArtist, + playlistName: item.playlistName, ) : ''; String? appOutputDir; @@ -3144,6 +3350,7 @@ class DownloadQueueNotifier extends Notifier { usePrimaryArtistOnly: settings.usePrimaryArtistOnly, filterContributingArtistsInAlbumArtist: settings.filterContributingArtistsInAlbumArtist, + playlistName: item.playlistName, ); var effectiveOutputDir = initialOutputDir; var effectiveSafMode = isSafMode; @@ -3206,7 +3413,6 @@ class DownloadQueueNotifier extends Notifier { !trackToDownload.id.startsWith('deezer:') && !trackToDownload.id.startsWith('extension:')) { try { - // Extract clean Spotify ID (remove spotify: prefix if present) String spotifyId = trackToDownload.id; if (spotifyId.startsWith('spotify:track:')) { spotifyId = spotifyId.split(':').last; @@ -3285,6 +3491,7 @@ class DownloadQueueNotifier extends Notifier { deezerId: deezerTrackId, availability: trackToDownload.availability, albumType: trackToDownload.albumType, + totalTracks: trackToDownload.totalTracks, source: trackToDownload.source, ); _log.d( @@ -3506,11 +3713,8 @@ class DownloadQueueNotifier extends Notifier { final decryptionKey = (result['decryption_key'] as String?)?.trim() ?? ''; - if (!wasExisting && - decryptionKey.isNotEmpty && - filePath != null && - actualService == 'amazon') { - _log.i('Amazon encrypted stream detected, decrypting via FFmpeg...'); + if (!wasExisting && decryptionKey.isNotEmpty && filePath != null) { + _log.i('Encrypted stream detected, decrypting via FFmpeg...'); updateItemStatus(item.id, DownloadStatus.downloading, progress: 0.9); if (effectiveSafMode && isContentUri(filePath)) { @@ -3539,7 +3743,7 @@ class DownloadQueueNotifier extends Notifier { updateItemStatus( item.id, DownloadStatus.failed, - error: 'Failed to decrypt Amazon stream', + error: 'Failed to decrypt encrypted stream', errorType: DownloadErrorType.unknown, ); return; @@ -3564,7 +3768,7 @@ class DownloadQueueNotifier extends Notifier { ); if (newUri == null) { - _log.e('Failed to write decrypted Amazon stream back to SAF'); + _log.e('Failed to write decrypted stream back to SAF'); updateItemStatus( item.id, DownloadStatus.failed, @@ -3579,7 +3783,7 @@ class DownloadQueueNotifier extends Notifier { } filePath = newUri; finalSafFileName = newFileName; - _log.i('Amazon SAF decryption completed'); + _log.i('SAF decryption completed'); } finally { try { await File(tempPath).delete(); @@ -3601,7 +3805,7 @@ class DownloadQueueNotifier extends Notifier { updateItemStatus( item.id, DownloadStatus.failed, - error: 'Failed to decrypt Amazon stream', + error: 'Failed to decrypt encrypted stream', errorType: DownloadErrorType.unknown, ); try { @@ -3610,7 +3814,7 @@ class DownloadQueueNotifier extends Notifier { return; } filePath = decryptedPath; - _log.i('Amazon local decryption completed'); + _log.i('Local decryption completed'); } } @@ -3832,7 +4036,6 @@ class DownloadQueueNotifier extends Notifier { } } } else { - // Local file path flow (original) if (quality == 'HIGH') { final tidalHighFormat = settings.tidalHighFormat; _log.i( @@ -4049,10 +4252,9 @@ class DownloadQueueNotifier extends Notifier { !effectiveSafMode && isFlacFile && !wasExisting && - actualService == 'amazon' && decryptionKey.isNotEmpty) { _log.d( - 'Local FLAC after Amazon decrypt detected, embedding metadata and cover...', + 'Local FLAC after decrypt detected, embedding metadata and cover...', ); try { updateItemStatus( @@ -4112,7 +4314,6 @@ class DownloadQueueNotifier extends Notifier { final isContentUriPath = isContentUri(filePath); if (isContentUriPath && effectiveSafMode) { - // SAF mode: copy to temp, embed, write back final tempPath = await _copySafToTemp(filePath); if (tempPath != null) { try { @@ -4133,7 +4334,6 @@ class DownloadQueueNotifier extends Notifier { copyright: backendCopyright, ); } - // Write back to SAF final ext = isMp3File ? '.mp3' : '.opus'; final newFileName = '${safBaseName ?? 'track'}$ext'; final newUri = await _writeTempToSaf( @@ -4162,7 +4362,6 @@ class DownloadQueueNotifier extends Notifier { } } } else { - // Non-SAF mode: embed directly try { if (isMp3File) { await _embedMetadataToMp3( @@ -4347,6 +4546,50 @@ class DownloadQueueNotifier extends Notifier { normalizeOptionalString(copyright) ?? normalizeOptionalString(existingInHistory?.copyright); + int? finalBitDepth = backendBitDepth; + int? finalSampleRate = backendSampleRate; + final lowerFilePath = filePath.toLowerCase(); + final canProbeFinalMetadata = + filePath.startsWith('content://') || + lowerFilePath.endsWith('.flac') || + lowerFilePath.endsWith('.m4a') || + lowerFilePath.endsWith('.aac') || + lowerFilePath.endsWith('.mp3') || + lowerFilePath.endsWith('.opus') || + lowerFilePath.endsWith('.ogg'); + + if (canProbeFinalMetadata) { + try { + final metadata = await PlatformBridge.readFileMetadata(filePath); + if (metadata['error'] == null) { + final probedBitDepth = metadata['bit_depth'] is num + ? (metadata['bit_depth'] as num).toInt() + : int.tryParse(metadata['bit_depth']?.toString() ?? ''); + final probedSampleRate = metadata['sample_rate'] is num + ? (metadata['sample_rate'] as num).toInt() + : int.tryParse(metadata['sample_rate']?.toString() ?? ''); + + if (probedBitDepth != null && probedBitDepth > 0) { + finalBitDepth = probedBitDepth; + } + if (probedSampleRate != null && probedSampleRate > 0) { + finalSampleRate = probedSampleRate; + } + + final resolvedQuality = buildDisplayAudioQuality( + bitDepth: finalBitDepth, + sampleRate: finalSampleRate, + storedQuality: actualQuality, + ); + if (resolvedQuality != null) { + actualQuality = resolvedQuality; + } + } + } catch (e) { + _log.d('Final audio metadata probe failed for $filePath: $e'); + } + } + _log.d('Saving to history - coverUrl: ${trackToDownload.coverUrl}'); final historyAlbumArtist = @@ -4354,9 +4597,12 @@ class DownloadQueueNotifier extends Notifier { ? resolvedAlbumArtist : null; - final isMp3 = filePath.endsWith('.mp3'); - final historyBitDepth = isMp3 ? null : backendBitDepth; - final historySampleRate = isMp3 ? null : backendSampleRate; + final isLossyOutput = + lowerFilePath.endsWith('.mp3') || + lowerFilePath.endsWith('.opus') || + lowerFilePath.endsWith('.ogg'); + final historyBitDepth = isLossyOutput ? null : finalBitDepth; + final historySampleRate = isLossyOutput ? null : finalSampleRate; ref .read(downloadHistoryProvider.notifier) diff --git a/lib/providers/extension_provider.dart b/lib/providers/extension_provider.dart index 1b55f744..58d14ee5 100644 --- a/lib/providers/extension_provider.dart +++ b/lib/providers/extension_provider.dart @@ -757,7 +757,6 @@ class ExtensionNotifier extends Notifier { Future loadProviderPriority() async { try { - // Load from SharedPreferences first (persisted) final prefs = await SharedPreferences.getInstance(); final savedJson = prefs.getString(_providerPriorityKey); @@ -768,10 +767,8 @@ class ExtensionNotifier extends Notifier { 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); @@ -787,11 +784,9 @@ class ExtensionNotifier extends Notifier { 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(sanitized)); - // Sync to Go backend await PlatformBridge.setProviderPriority(sanitized); state = state.copyWith(providerPriority: sanitized); _log.d('Saved provider priority: $sanitized'); @@ -811,7 +806,7 @@ class ExtensionNotifier extends Notifier { } } - for (final provider in const ['tidal', 'qobuz', 'amazon', 'deezer']) { + for (final provider in const ['tidal', 'qobuz', 'deezer']) { if (!result.contains(provider)) { result.add(provider); } @@ -822,20 +817,25 @@ class ExtensionNotifier extends Notifier { 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; - priority = saved.map((e) => e as String).toList(); + priority = _sanitizeMetadataProviderPriority( + saved.map((e) => e as String).toList(), + ); _log.d('Loaded metadata provider priority from prefs: $priority'); - // Sync to Go backend + await prefs.setString( + _metadataProviderPriorityKey, + jsonEncode(priority), + ); await PlatformBridge.setMetadataProviderPriority(priority); } else { - // Fallback to Go backend default - priority = await PlatformBridge.getMetadataProviderPriority(); + priority = _sanitizeMetadataProviderPriority( + await PlatformBridge.getMetadataProviderPriority(), + ); _log.d('Using default metadata provider priority: $priority'); } @@ -847,14 +847,16 @@ class ExtensionNotifier extends Notifier { Future setMetadataProviderPriority(List priority) async { try { - // Save to SharedPreferences for persistence final prefs = await SharedPreferences.getInstance(); - await prefs.setString(_metadataProviderPriorityKey, jsonEncode(priority)); + final sanitized = _sanitizeMetadataProviderPriority(priority); + await prefs.setString( + _metadataProviderPriorityKey, + jsonEncode(sanitized), + ); - // Sync to Go backend - await PlatformBridge.setMetadataProviderPriority(priority); - state = state.copyWith(metadataProviderPriority: priority); - _log.d('Saved metadata provider priority: $priority'); + await PlatformBridge.setMetadataProviderPriority(sanitized); + state = state.copyWith(metadataProviderPriority: sanitized); + _log.d('Saved metadata provider priority: $sanitized'); } catch (e) { _log.e('Failed to set metadata provider priority: $e'); state = state.copyWith(error: e.toString()); @@ -880,7 +882,7 @@ class ExtensionNotifier extends Notifier { } List getAllDownloadProviders() { - final providers = ['tidal', 'qobuz', 'amazon', 'deezer']; + final providers = ['tidal', 'qobuz', 'deezer']; for (final ext in state.extensions) { if (ext.enabled && ext.hasDownloadProvider) { providers.add(ext.id); @@ -890,7 +892,7 @@ class ExtensionNotifier extends Notifier { } List getAllMetadataProviders() { - final providers = ['deezer', 'spotify']; + final providers = ['deezer']; for (final ext in state.extensions) { if (ext.enabled && ext.hasMetadataProvider) { providers.add(ext.id); @@ -899,6 +901,23 @@ class ExtensionNotifier extends Notifier { return providers; } + List _sanitizeMetadataProviderPriority(List input) { + final allowed = getAllMetadataProviders().toSet(); + final result = []; + + for (final provider in input) { + if (allowed.contains(provider) && !result.contains(provider)) { + result.add(provider); + } + } + + if (!result.contains('deezer')) { + result.insert(0, 'deezer'); + } + + return result; + } + List get searchProviders { return state.extensions .where((ext) => ext.enabled && ext.hasCustomSearch) diff --git a/lib/providers/local_library_provider.dart b/lib/providers/local_library_provider.dart index afa25f66..1dd10dd7 100644 --- a/lib/providers/local_library_provider.dart +++ b/lib/providers/local_library_provider.dart @@ -9,6 +9,7 @@ import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/notification_service.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/logger.dart'; +import 'package:spotiflac_android/utils/path_match_keys.dart'; final _log = AppLogger('LocalLibrary'); @@ -193,74 +194,11 @@ class LocalLibraryNotifier extends Notifier { await _loadFromDatabase(); } - Set _buildPathMatchKeys(String? filePath) { - final raw = filePath?.trim() ?? ''; - if (raw.isEmpty) return const {}; - - final cleaned = raw.startsWith('EXISTS:') ? raw.substring(7) : raw; - final keys = {}; - - void addNormalized(String value) { - final trimmed = value.trim(); - if (trimmed.isEmpty) return; - keys.add(trimmed); - keys.add(trimmed.toLowerCase()); - if (trimmed.contains('\\')) { - final slash = trimmed.replaceAll('\\', '/'); - keys.add(slash); - keys.add(slash.toLowerCase()); - } - if (trimmed.contains('%')) { - try { - final decoded = Uri.decodeFull(trimmed); - keys.add(decoded); - 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); - - return keys; - } - bool _isDownloadedPath(String? filePath, Set downloadedPathKeys) { if (filePath == null || filePath.isEmpty || downloadedPathKeys.isEmpty) { return false; } - final candidateKeys = _buildPathMatchKeys(filePath); + final candidateKeys = buildPathMatchKeys(filePath); for (final key in candidateKeys) { if (downloadedPathKeys.contains(key)) { return true; @@ -272,6 +210,7 @@ class LocalLibraryNotifier extends Notifier { Future startScan( String folderPath, { bool forceFullScan = false, + String? iosBookmark, }) async { if (state.isScanning) { _log.w('Scan already in progress'); @@ -316,8 +255,28 @@ class LocalLibraryNotifier extends Notifier { _startProgressPolling(); + // On iOS, start accessing the security-scoped bookmark so the Go backend + // can read files outside the app sandbox. + String? resolvedPath; + bool didStartSecurityAccess = false; + if (Platform.isIOS && iosBookmark != null && iosBookmark.isNotEmpty) { + resolvedPath = await PlatformBridge.startAccessingIosBookmark( + iosBookmark, + ); + if (resolvedPath != null) { + didStartSecurityAccess = true; + _log.i('Started iOS security-scoped access: $resolvedPath'); + } else { + _log.w( + 'Failed to start iOS security-scoped access, ' + 'falling back to original path', + ); + } + } + final effectiveFolderPath = resolvedPath ?? folderPath; + try { - final isSaf = folderPath.startsWith('content://'); + final isSaf = effectiveFolderPath.startsWith('content://'); // Get all file paths from download history to exclude them. // Merge DB + in-memory state to avoid race when a fresh download has not @@ -334,7 +293,7 @@ class LocalLibraryNotifier extends Notifier { }; final downloadedPathKeys = {}; for (final path in allHistoryPaths) { - downloadedPathKeys.addAll(_buildPathMatchKeys(path)); + downloadedPathKeys.addAll(buildPathMatchKeys(path)); } _log.i( 'Excluding ${allHistoryPaths.length} downloaded files from library scan ' @@ -344,8 +303,8 @@ class LocalLibraryNotifier extends Notifier { if (forceFullScan) { // Full scan path - ignores existing data final results = isSaf - ? await PlatformBridge.scanSafTree(folderPath) - : await PlatformBridge.scanLibraryFolder(folderPath); + ? await PlatformBridge.scanSafTree(effectiveFolderPath) + : await PlatformBridge.scanLibraryFolder(effectiveFolderPath); if (_scanCancelRequested) { state = state.copyWith(isScanning: false, scanWasCancelled: true); await _showScanCancelledNotification(); @@ -424,12 +383,12 @@ class LocalLibraryNotifier extends Notifier { final Map result; if (isSaf) { result = await PlatformBridge.scanSafTreeIncremental( - folderPath, + effectiveFolderPath, existingFiles, ); } else { result = await PlatformBridge.scanLibraryFolderIncremental( - folderPath, + effectiveFolderPath, existingFiles, ); } @@ -553,6 +512,10 @@ class LocalLibraryNotifier extends Notifier { state = state.copyWith(isScanning: false, scanWasCancelled: false); await _showScanFailedNotification(e.toString()); } finally { + if (didStartSecurityAccess) { + await PlatformBridge.stopAccessingIosBookmark(); + _log.i('Stopped iOS security-scoped access'); + } _stopProgressPolling(); } } @@ -807,12 +770,27 @@ class LocalLibraryNotifier extends Notifier { return decoded; } - Future cleanupMissingFiles() async { - final removed = await _db.cleanupMissingFiles(); - if (removed > 0) { - await reloadFromStorage(); + Future cleanupMissingFiles({String? iosBookmark}) async { + bool didStartSecurityAccess = false; + if (Platform.isIOS && iosBookmark != null && iosBookmark.isNotEmpty) { + final resolved = await PlatformBridge.startAccessingIosBookmark( + iosBookmark, + ); + if (resolved != null) { + didStartSecurityAccess = true; + } + } + try { + final removed = await _db.cleanupMissingFiles(); + if (removed > 0) { + await reloadFromStorage(); + } + return removed; + } finally { + if (didStartSecurityAccess) { + await PlatformBridge.stopAccessingIosBookmark(); + } } - return removed; } Future clearLibrary() async { diff --git a/lib/providers/playback_provider.dart b/lib/providers/playback_provider.dart index c35f6a00..bcaa14c0 100644 --- a/lib/providers/playback_provider.dart +++ b/lib/providers/playback_provider.dart @@ -24,6 +24,9 @@ class PlaybackController extends Notifier { String coverUrl = '', Track? track, }) async { + if (isCueVirtualPath(path)) { + throw Exception(cueVirtualTrackRequiresSplitMessage); + } _log.d('Opening external player for "$title" by $artist: $path'); await openFile(path); } @@ -32,11 +35,16 @@ class PlaybackController extends Notifier { if (tracks.isEmpty) return; final orderedTracks = _orderedTracksFromStartIndex(tracks, startIndex); + var skippedCueVirtualTrack = false; for (final track in orderedTracks) { final resolvedPath = await _resolveTrackPath(track); if (resolvedPath == null) { continue; } + if (isCueVirtualPath(resolvedPath)) { + skippedCueVirtualTrack = true; + continue; + } _log.d( 'Opening first available external track for list playback: ' @@ -46,6 +54,10 @@ class PlaybackController extends Notifier { return; } + if (skippedCueVirtualTrack) { + throw Exception(cueVirtualTrackRequiresSplitMessage); + } + throw Exception( 'No local audio file is available to open. Download the track first.', ); diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 133bdeec..ed44fea4 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -9,7 +9,7 @@ import 'package:spotiflac_android/utils/logger.dart'; const _settingsKey = 'app_settings'; const _migrationVersionKey = 'settings_migration_version'; -const _currentMigrationVersion = 4; +const _currentMigrationVersion = 5; const _spotifyClientSecretKey = 'spotify_client_secret'; final _log = AppLogger('SettingsProvider'); @@ -41,9 +41,7 @@ class SettingsNotifier extends Notifier { await _normalizeSongLinkRegionIfNeeded(); } - await _loadSpotifyClientSecret(prefs); - - _applySpotifyCredentials(); + await _retireBuiltInSpotifyProvider(); LogBuffer.loggingEnabled = state.enableLogging; @@ -105,6 +103,17 @@ class SettingsNotifier extends Notifier { } state = state.copyWith(lyricsProviders: updatedProviders); } + if (state.metadataSource != 'deezer' || + state.spotifyClientId.isNotEmpty || + state.spotifyClientSecret.isNotEmpty || + state.useCustomSpotifyCredentials) { + state = state.copyWith( + metadataSource: 'deezer', + spotifyClientId: '', + spotifyClientSecret: '', + useCustomSpotifyCredentials: false, + ); + } state = state.copyWith(lastSeenVersion: AppInfo.version); await prefs.setInt(_migrationVersionKey, _currentMigrationVersion); await _saveSettings(); @@ -193,49 +202,28 @@ class SettingsNotifier extends Notifier { await _saveSettings(); } - Future _loadSpotifyClientSecret(SharedPreferences prefs) async { + Future _retireBuiltInSpotifyProvider() async { final storedSecret = await _secureStorage.read( key: _spotifyClientSecretKey, ); - final prefsSecret = state.spotifyClientSecret; - - if ((storedSecret == null || storedSecret.isEmpty) && - prefsSecret.isNotEmpty) { - await _secureStorage.write( - key: _spotifyClientSecretKey, - value: prefsSecret, - ); - } - - final effectiveSecret = (storedSecret != null && storedSecret.isNotEmpty) - ? storedSecret - : (prefsSecret.isNotEmpty ? prefsSecret : ''); - - if (effectiveSecret != state.spotifyClientSecret) { - state = state.copyWith(spotifyClientSecret: effectiveSecret); - } - - if (prefsSecret.isNotEmpty) { - await _saveSettings(); - } - } - - Future _storeSpotifyClientSecret(String secret) async { - if (secret.isEmpty) { + if (storedSecret != null && storedSecret.isNotEmpty) { await _secureStorage.delete(key: _spotifyClientSecretKey); - } else { - await _secureStorage.write(key: _spotifyClientSecretKey, value: secret); } - } - Future _applySpotifyCredentials() async { - if (state.spotifyClientId.isNotEmpty && - state.spotifyClientSecret.isNotEmpty) { - await PlatformBridge.setSpotifyCredentials( - state.spotifyClientId, - state.spotifyClientSecret, - ); + if (state.metadataSource == 'deezer' && + state.spotifyClientId.isEmpty && + state.spotifyClientSecret.isEmpty && + !state.useCustomSpotifyCredentials) { + return; } + + state = state.copyWith( + metadataSource: 'deezer', + spotifyClientId: '', + spotifyClientSecret: '', + useCustomSpotifyCredentials: false, + ); + await _saveSettings(); } void setDefaultService(String service) { @@ -396,45 +384,9 @@ class SettingsNotifier extends Notifier { _saveSettings(); } - void setSpotifyClientId(String clientId) { - state = state.copyWith(spotifyClientId: clientId); - _saveSettings(); - } - - Future setSpotifyClientSecret(String clientSecret) async { - state = state.copyWith(spotifyClientSecret: clientSecret); - await _storeSpotifyClientSecret(clientSecret); - _saveSettings(); - } - - Future setSpotifyCredentials( - String clientId, - String clientSecret, - ) async { - state = state.copyWith( - spotifyClientId: clientId, - spotifyClientSecret: clientSecret, - ); - await _storeSpotifyClientSecret(clientSecret); - _saveSettings(); - _applySpotifyCredentials(); - } - - Future clearSpotifyCredentials() async { - state = state.copyWith(spotifyClientId: '', spotifyClientSecret: ''); - await _storeSpotifyClientSecret(''); - _saveSettings(); - _applySpotifyCredentials(); - } - - void setUseCustomSpotifyCredentials(bool enabled) { - state = state.copyWith(useCustomSpotifyCredentials: enabled); - _saveSettings(); - _applySpotifyCredentials(); - } - void setMetadataSource(String source) { - state = state.copyWith(metadataSource: source); + final normalized = source == 'deezer' ? 'deezer' : 'deezer'; + state = state.copyWith(metadataSource: normalized); _saveSettings(); } @@ -532,6 +484,19 @@ class SettingsNotifier extends Notifier { _saveSettings(); } + void setLocalLibraryBookmark(String bookmark) { + state = state.copyWith(localLibraryBookmark: bookmark); + _saveSettings(); + } + + void setLocalLibraryPathAndBookmark(String path, String bookmark) { + state = state.copyWith( + localLibraryPath: path, + localLibraryBookmark: bookmark, + ); + _saveSettings(); + } + void setLocalLibraryShowDuplicates(bool show) { state = state.copyWith(localLibraryShowDuplicates: show); _saveSettings(); diff --git a/lib/providers/track_provider.dart b/lib/providers/track_provider.dart index 32022b81..1c3974c2 100644 --- a/lib/providers/track_provider.dart +++ b/lib/providers/track_provider.dart @@ -204,7 +204,6 @@ class TrackNotifier extends Notifier { state = TrackState(isLoading: true, hasSearchText: state.hasSearchText); try { - // Step 1: Check for extension URL handlers first (handles YT Music, etc.) final extensionHandler = await PlatformBridge.findURLHandler(url); if (extensionHandler != null) { _log.i('Found extension URL handler: $extensionHandler for URL: $url'); @@ -215,7 +214,6 @@ class TrackNotifier extends Notifier { result = await PlatformBridge.handleURLWithExtension(url); if (!_isRequestValid(requestId)) return; - // Check if we got valid data if (result != null && result['type'] == 'track' && result['track'] != null) { @@ -321,7 +319,6 @@ class TrackNotifier extends Notifier { } } - // Step 2: Try Deezer URL parsing if (url.contains('deezer.com') || url.contains('deezer.page.link')) { _log.i('Detected Deezer URL, parsing...'); final parsed = await PlatformBridge.parseDeezerUrl(url); @@ -387,7 +384,6 @@ class TrackNotifier extends Notifier { return; } - // Step 3: Try Tidal URL parsing if (url.contains('tidal.com')) { _log.i('Detected Tidal URL, parsing...'); final parsed = await PlatformBridge.parseTidalUrl(url); @@ -461,7 +457,20 @@ class TrackNotifier extends Notifier { return; } - // Step 4: Fall back to Spotify parsing + // If URL doesn't match any known service, it's unrecognized + final isSpotifyUrl = + url.contains('open.spotify.com') || + url.contains('spotify.link') || + url.startsWith('spotify:'); + if (!isSpotifyUrl) { + state = TrackState( + isLoading: false, + error: 'url_not_recognized', + hasSearchText: state.hasSearchText, + ); + return; + } + final parsed = await PlatformBridge.parseSpotifyUrl(url); if (!_isRequestValid(requestId)) return; @@ -538,11 +547,7 @@ class TrackNotifier extends Notifier { } } - Future search( - String query, { - String? metadataSource, - String? filterOverride, - }) async { + Future search(String query, {String? filterOverride}) async { final requestId = ++_currentRequestId; // Preserve selected filter during loading @@ -568,7 +573,7 @@ class TrackNotifier extends Notifier { searchProvider != null && searchProvider.isNotEmpty; - final source = metadataSource ?? 'deezer'; + const source = 'deezer'; _log.i( 'Search started: source=$source, query="$query", useExtensions=$useExtensions, filter=$currentFilter', @@ -594,32 +599,20 @@ class TrackNotifier extends Notifier { } } } catch (e) { - _log.w('Extension search failed, falling back to built-in: $e'); + _log.w('Extension search failed, falling back to Deezer: $e'); } } - if (source == 'deezer') { - _log.d('Calling Deezer search API...'); - results = await PlatformBridge.searchDeezerAll( - query, - trackLimit: 20, - artistLimit: 2, - filter: currentFilter, - ); - _log.i( - 'Deezer returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists, ${(results['albums'] as List?)?.length ?? 0} albums', - ); - } else { - _log.d('Calling Spotify search API...'); - results = await PlatformBridge.searchSpotifyAll( - query, - trackLimit: 20, - artistLimit: 2, - ); - _log.i( - 'Spotify returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists', - ); - } + _log.d('Calling Deezer search API...'); + results = await PlatformBridge.searchDeezerAll( + query, + trackLimit: 20, + artistLimit: 2, + filter: currentFilter, + ); + _log.i( + 'Deezer returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists, ${(results['albums'] as List?)?.length ?? 0} albums', + ); if (!_isRequestValid(requestId)) { _log.w('Search request cancelled (requestId=$requestId)'); @@ -823,6 +816,7 @@ class TrackNotifier extends Notifier { discNumber: track.discNumber, releaseDate: track.releaseDate, albumType: track.albumType, + totalTracks: track.totalTracks, source: track.source, availability: ServiceAvailability( tidal: availability['tidal'] as bool? ?? false, @@ -904,6 +898,8 @@ class TrackNotifier extends Notifier { trackNumber: data['track_number'] as int?, discNumber: data['disc_number'] as int?, releaseDate: data['release_date'] as String?, + albumType: data['album_type'] as String?, + totalTracks: data['total_tracks'] as int?, ); } @@ -926,6 +922,7 @@ class TrackNotifier extends Notifier { trackNumber: data['track_number'] as int?, discNumber: data['disc_number'] as int?, releaseDate: data['release_date']?.toString(), + totalTracks: data['total_tracks'] as int?, source: source ?? data['source']?.toString() ?? diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index a5411ae3..f3f22790 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -224,6 +224,8 @@ class _AlbumScreenState extends ConsumerState { trackNumber: data['track_number'] as int?, discNumber: data['disc_number'] as int?, releaseDate: data['release_date'] as String?, + albumType: data['album_type'] as String?, + totalTracks: data['total_tracks'] as int?, ); } @@ -305,7 +307,6 @@ class _AlbumScreenState extends ConsumerState { background: Stack( fit: StackFit.expand, children: [ - // Full-screen cover background (no blur, full resolution) if (widget.coverUrl != null) CachedNetworkImage( imageUrl: @@ -326,7 +327,6 @@ class _AlbumScreenState extends ConsumerState { color: colorScheme.onSurfaceVariant, ), ), - // Bottom gradient for readability Positioned( left: 0, right: 0, @@ -345,7 +345,6 @@ class _AlbumScreenState extends ConsumerState { ), ), ), - // Album info overlay at bottom Positioned( left: 20, right: 20, @@ -491,6 +490,7 @@ class _AlbumScreenState extends ConsumerState { }, ), leading: IconButton( + tooltip: MaterialLocalizations.of(context).backButtonTooltip, icon: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index 43cfa2eb..06fc5063 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -21,7 +21,6 @@ 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 { static final Map _cache = {}; static const Duration _ttl = Duration(minutes: 10); @@ -69,7 +68,6 @@ class _CacheEntry { }); } -/// Artist screen with Spotify-like design class ArtistScreen extends ConsumerStatefulWidget { final String artistId; final String artistName; @@ -296,7 +294,7 @@ class _ArtistScreenState extends ConsumerState { } } - Track _parseTrack(Map data) { + Track _parseTrack(Map data, {ArtistAlbum? album}) { int durationMs = 0; final durationValue = data['duration_ms']; if (durationValue is int) { @@ -309,18 +307,22 @@ class _ArtistScreenState extends ConsumerState { id: (data['spotify_id'] ?? data['id'] ?? '').toString(), name: (data['name'] ?? '').toString(), artistName: (data['artists'] ?? data['artist'] ?? '').toString(), - albumName: (data['album_name'] ?? data['album'] ?? '').toString(), - albumArtist: data['album_artist']?.toString(), + albumName: (data['album_name'] ?? data['album'] ?? album?.name ?? '') + .toString(), + albumArtist: data['album_artist']?.toString() ?? widget.artistName, artistId: (data['artist_id'] ?? data['artistId'])?.toString() ?? widget.artistId, - albumId: data['album_id']?.toString(), - coverUrl: (data['cover_url'] ?? data['images'])?.toString(), + albumId: data['album_id']?.toString() ?? album?.id, + coverUrl: (data['cover_url'] ?? data['images'] ?? album?.coverUrl) + ?.toString(), isrc: data['isrc']?.toString(), duration: (durationMs / 1000).round(), trackNumber: data['track_number'] as int?, discNumber: data['disc_number'] as int?, releaseDate: data['release_date']?.toString(), + albumType: data['album_type']?.toString() ?? album?.albumType, + totalTracks: data['total_tracks'] as int? ?? album?.totalTracks, source: data['provider_id']?.toString(), ); } @@ -345,7 +347,7 @@ class _ArtistScreenState extends ConsumerState { .where((a) => a.albumType == 'album') .toList(growable: false); _singlesBucket = albums - .where((a) => a.albumType == 'single') + .where((a) => a.albumType == 'single' || a.albumType == 'ep') .toList(growable: false); _compilationsBucket = albums .where((a) => a.albumType == 'compilation') @@ -416,6 +418,7 @@ class _ArtistScreenState extends ConsumerState { context.l10n.artistSingles, singles, colorScheme, + showTypeBadge: true, ), ), if (compilations.isNotEmpty) @@ -670,7 +673,9 @@ class _ArtistScreenState extends ConsumerState { List albums, ) { final albumsOnly = albums.where((a) => a.albumType == 'album').toList(); - final singles = albums.where((a) => a.albumType == 'single').toList(); + final singles = albums + .where((a) => a.albumType == 'single' || a.albumType == 'ep') + .toList(); final totalTracks = albums.fold(0, (sum, a) => sum + a.totalTracks); final albumTracks = albumsOnly.fold( @@ -717,7 +722,6 @@ class _ArtistScreenState extends ConsumerState { ), ), const Divider(height: 1), - // Options if (albums.isNotEmpty) _DiscographyOptionTile( icon: Icons.library_music, @@ -830,7 +834,7 @@ class _ArtistScreenState extends ConsumerState { int failedCount = 0; for (final album in albums) { - if (!_isFetchingDiscography) break; // Cancelled + if (!_isFetchingDiscography) break; try { final tracks = await _fetchAlbumTracks(album); @@ -942,7 +946,7 @@ class _ArtistScreenState extends ConsumerState { if (result != null && result['tracks'] != null) { final tracksList = result['tracks'] as List; return tracksList - .map((t) => _parseTrack(t as Map)) + .map((t) => _parseTrack(t as Map, album: album)) .toList(); } } else if (album.id.startsWith('deezer:')) { @@ -963,7 +967,7 @@ class _ArtistScreenState extends ConsumerState { if (result != null && result['tracks'] != null) { final tracksList = result['tracks'] as List; return tracksList - .map((t) => _parseTrack(t as Map)) + .map((t) => _parseTrack(t as Map, album: album)) .toList(); } @@ -972,7 +976,7 @@ class _ArtistScreenState extends ConsumerState { if (metadata['tracks'] != null) { final tracksList = metadata['tracks'] as List; return tracksList - .map((t) => _parseTrack(t as Map)) + .map((t) => _parseTrack(t as Map, album: album)) .toList(); } } @@ -1006,6 +1010,7 @@ class _ArtistScreenState extends ConsumerState { discNumber: data['disk_number'] as int? ?? data['disc_number'] as int?, releaseDate: album.releaseDate, albumType: album.albumType, + totalTracks: album.totalTracks, ); } @@ -1066,7 +1071,7 @@ class _ArtistScreenState extends ConsumerState { CachedNetworkImage( imageUrl: imageUrl, fit: BoxFit.cover, - alignment: Alignment.topCenter, // Show top of image (faces) + alignment: Alignment.topCenter, memCacheWidth: 800, cacheManager: CoverCacheManager.instance, placeholder: (context, url) => @@ -1155,7 +1160,6 @@ class _ArtistScreenState extends ConsumerState { ], ), ), - // Download Discography button (icon only, right-aligned) if (hasDiscography && !_isSelectionMode) ...[ const SizedBox(width: 12), Container( @@ -1188,6 +1192,7 @@ class _ArtistScreenState extends ConsumerState { ], ), leading: IconButton( + tooltip: MaterialLocalizations.of(context).backButtonTooltip, icon: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( @@ -1201,7 +1206,6 @@ class _ArtistScreenState extends ConsumerState { ); } - /// Build Popular tracks section like Spotify Widget _buildPopularSection(ColorScheme colorScheme) { if (_topTracks == null || _topTracks!.isEmpty) { return const SizedBox.shrink(); @@ -1416,7 +1420,6 @@ class _ArtistScreenState extends ConsumerState { ); } - /// Handle tap on popular track item void _handlePopularTrackTap(Track track, {required bool isQueued}) async { if (isQueued) return; @@ -1528,8 +1531,9 @@ class _ArtistScreenState extends ConsumerState { Widget _buildAlbumSection( String title, List albums, - ColorScheme colorScheme, - ) { + ColorScheme colorScheme, { + bool showTypeBadge = false, + }) { final sectionHeight = _artistAlbumSectionHeight(); final tileSize = _artistAlbumTileSize(); @@ -1560,6 +1564,7 @@ class _ArtistScreenState extends ConsumerState { colorScheme, tileSize: tileSize, sectionHeight: sectionHeight, + showTypeBadge: showTypeBadge, ), ); }, @@ -1574,47 +1579,65 @@ class _ArtistScreenState extends ConsumerState { ColorScheme colorScheme, { required double tileSize, required double sectionHeight, + bool showTypeBadge = false, }) { final isSelected = _selectedAlbumIds.contains(album.id); - return GestureDetector( - onTap: () { - if (_isSelectionMode) { - _toggleAlbumSelection(album.id); - } else { - _navigateToAlbum(album); - } - }, - onLongPress: () { - if (!_isSelectionMode) { - _enterSelectionMode(album.id); - } - }, - child: Container( - width: tileSize, - height: sectionHeight, - margin: const EdgeInsets.symmetric(horizontal: 4), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Stack( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: album.coverUrl != null - ? CachedNetworkImage( - imageUrl: album.coverUrl!, - width: tileSize, - height: tileSize, - fit: BoxFit.cover, - memCacheWidth: (tileSize * 2).round(), - cacheManager: CoverCacheManager.instance, - placeholder: (context, url) => Container( + return Semantics( + button: true, + selected: _isSelectionMode && isSelected, + label: _isSelectionMode + ? 'Select album ${album.name}' + : 'Open album ${album.name}', + child: GestureDetector( + onTap: () { + if (_isSelectionMode) { + _toggleAlbumSelection(album.id); + } else { + _navigateToAlbum(album); + } + }, + onLongPress: () { + if (!_isSelectionMode) { + _enterSelectionMode(album.id); + } + }, + child: Container( + width: tileSize, + height: sectionHeight, + margin: const EdgeInsets.symmetric(horizontal: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: album.coverUrl != null + ? CachedNetworkImage( + imageUrl: album.coverUrl!, width: tileSize, height: tileSize, - color: colorScheme.surfaceContainerHighest, - ), - errorWidget: (context, url, error) => Container( + fit: BoxFit.cover, + memCacheWidth: (tileSize * 2).round(), + cacheManager: CoverCacheManager.instance, + placeholder: (context, url) => Container( + width: tileSize, + height: tileSize, + color: colorScheme.surfaceContainerHighest, + ), + errorWidget: (context, url, error) => Container( + width: tileSize, + height: tileSize, + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.album, + color: colorScheme.onSurfaceVariant, + size: 40, + ), + ), + ) + : Container( width: tileSize, height: tileSize, color: colorScheme.surfaceContainerHighest, @@ -1624,99 +1647,110 @@ class _ArtistScreenState extends ConsumerState { size: 40, ), ), - ) - : Container( - width: tileSize, - height: tileSize, - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.album, - color: colorScheme.onSurfaceVariant, - size: 40, + ), + if (_isSelectionMode) + Positioned.fill( + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: isSelected + ? colorScheme.primary.withValues(alpha: 0.3) + : Colors.black.withValues(alpha: 0.1), + border: isSelected + ? Border.all(color: colorScheme.primary, width: 3) + : null, + ), + ), + ), + if (_isSelectionMode) + Positioned( + top: 8, + right: 8, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: 28, + height: 28, + decoration: BoxDecoration( + color: isSelected + ? colorScheme.primary + : colorScheme.surface.withValues(alpha: 0.9), + shape: BoxShape.circle, + border: Border.all( + color: isSelected + ? colorScheme.primary + : colorScheme.outline, + width: 2, ), ), - ), - // Selection overlay - if (_isSelectionMode) - Positioned.fill( - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - color: isSelected - ? colorScheme.primary.withValues(alpha: 0.3) - : Colors.black.withValues(alpha: 0.1), - border: isSelected - ? Border.all(color: colorScheme.primary, width: 3) + child: isSelected + ? Icon( + Icons.check, + color: colorScheme.onPrimary, + size: 18, + ) : null, ), ), - ), - // Checkbox - if (_isSelectionMode) - Positioned( - top: 8, - right: 8, - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - width: 28, - height: 28, - decoration: BoxDecoration( - color: isSelected - ? colorScheme.primary - : colorScheme.surface.withValues(alpha: 0.9), - shape: BoxShape.circle, - border: Border.all( - color: isSelected - ? colorScheme.primary - : colorScheme.outline, - width: 2, + if (showTypeBadge) + Positioned( + left: 6, + bottom: 6, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.7), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + album.albumType == 'ep' ? 'EP' : 'Single', + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.w600, + ), ), ), - child: isSelected - ? Icon( - Icons.check, - color: colorScheme.onPrimary, - size: 18, - ) - : null, ), - ), - ], - ), - const SizedBox(height: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: Text( - album.name, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w500, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(height: 2), - Text( - album.totalTracks > 0 - ? '${album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate} ${context.l10n.tracksCount(album.totalTracks)}' - : album.releaseDate.length >= 4 - ? album.releaseDate.substring(0, 4) - : album.releaseDate, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), ], ), - ), - ], + const SizedBox(height: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + album.name, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(height: 2), + Text( + album.totalTracks > 0 + ? '${album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate} ${context.l10n.tracksCount(album.totalTracks)}' + : album.releaseDate.length >= 4 + ? album.releaseDate.substring(0, 4) + : album.releaseDate, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), ), ), ); diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index 0db150eb..468d7aff 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -18,7 +18,6 @@ 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'; -/// Screen to display downloaded tracks from a specific album class DownloadedAlbumScreen extends ConsumerStatefulWidget { final String albumName; final String artistName; @@ -361,7 +360,6 @@ class _DownloadedAlbumScreenState extends ConsumerState { ); final tracks = _getAlbumTracks(allHistoryItems); - // Show empty state if no tracks found if (tracks.isEmpty) { return Scaffold( appBar: AppBar(title: Text(widget.albumName)), @@ -480,7 +478,6 @@ class _DownloadedAlbumScreenState extends ConsumerState { background: Stack( fit: StackFit.expand, children: [ - // Full-screen cover background if (embeddedCoverPath != null) Image.file( File(embeddedCoverPath), @@ -508,7 +505,6 @@ class _DownloadedAlbumScreenState extends ConsumerState { color: colorScheme.onSurfaceVariant, ), ), - // Bottom gradient for readability Positioned( left: 0, right: 0, @@ -527,7 +523,6 @@ class _DownloadedAlbumScreenState extends ConsumerState { ), ), ), - // Album info overlay at bottom Positioned( left: 20, right: 20, @@ -635,6 +630,7 @@ class _DownloadedAlbumScreenState extends ConsumerState { }, ), leading: IconButton( + tooltip: MaterialLocalizations.of(context).backButtonTooltip, icon: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( @@ -711,10 +707,8 @@ class _DownloadedAlbumScreenState extends ConsumerState { final discTracks = discMap[discNumber]; if (discTracks == null || discTracks.isEmpty) continue; - // Add disc separator children.add(_buildDiscSeparator(context, colorScheme, discNumber)); - // Add tracks for this disc for (final track in discTracks) { children.add( KeyedSubtree( @@ -858,6 +852,7 @@ class _DownloadedAlbumScreenState extends ConsumerState { trailing: _isSelectionMode ? null : IconButton( + tooltip: 'Play track', onPressed: () => _openFile(track), icon: Icon(Icons.play_arrow, color: colorScheme.primary), style: IconButton.styleFrom( @@ -897,7 +892,6 @@ class _DownloadedAlbumScreenState extends ConsumerState { return; } - // Share SAF content URIs via native intent if (safUris.isNotEmpty) { try { if (safUris.length == 1) { @@ -908,13 +902,11 @@ class _DownloadedAlbumScreenState extends ConsumerState { } 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, @@ -1336,6 +1328,9 @@ class _DownloadedAlbumScreenState extends ConsumerState { children: [ IconButton.filledTonal( onPressed: _exitSelectionMode, + tooltip: MaterialLocalizations.of( + context, + ).closeButtonTooltip, icon: const Icon(Icons.close), style: IconButton.styleFrom( backgroundColor: colorScheme.surfaceContainerHighest, @@ -1388,7 +1383,6 @@ class _DownloadedAlbumScreenState extends ConsumerState { ), const SizedBox(height: 12), - // Action buttons row: Share, Convert Row( children: [ Expanded( diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 6462d952..5663caa3 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -520,7 +520,6 @@ class _HomeTabState extends ConsumerState final settings = ref.read(settingsProvider); final extState = ref.read(extensionProvider); final searchProvider = settings.searchProvider; - // Use filterOverride if provided, otherwise read from state final selectedFilter = filterOverride ?? ref.read(trackProvider).selectedSearchFilter; @@ -535,7 +534,6 @@ class _HomeTabState extends ConsumerState extState.extensions.any((e) => e.id == searchProvider && e.enabled); if (isExtensionEnabled) { - // Build options with filter if selected Map? options; if (selectedFilter != null) { options = {'filter': selectedFilter}; @@ -551,11 +549,7 @@ class _HomeTabState extends ConsumerState } await ref .read(trackProvider.notifier) - .search( - query, - metadataSource: settings.metadataSource, - filterOverride: selectedFilter, - ); + .search(query, filterOverride: selectedFilter); } ref.read(settingsProvider.notifier).setHasSearchedBefore(); } @@ -585,12 +579,28 @@ class _HomeTabState extends ConsumerState if (url.isEmpty) return; if (url.startsWith('http') || url.startsWith('spotify:')) { await ref.read(trackProvider.notifier).fetchFromUrl(url); - _navigateToDetailIfNeeded(); + final trackState = ref.read(trackProvider); + if (trackState.error != null && mounted) { + final l10n = context.l10n; + final errorMsg = trackState.error!; + final isRateLimit = + errorMsg.contains('429') || + errorMsg.toLowerCase().contains('rate limit') || + errorMsg.toLowerCase().contains('too many requests'); + final displayMessage = errorMsg == 'url_not_recognized' + ? l10n.errorUrlNotRecognizedMessage + : isRateLimit + ? l10n.errorRateLimitedMessage + : l10n.errorUrlFetchFailed; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(displayMessage))); + ref.read(trackProvider.notifier).clear(); + } else { + _navigateToDetailIfNeeded(); + } } else { - final settings = ref.read(settingsProvider); - await ref - .read(trackProvider.notifier) - .search(url, metadataSource: settings.metadataSource); + await ref.read(trackProvider.notifier).search(url); } ref.read(settingsProvider.notifier).setHasSearchedBefore(); } @@ -1116,7 +1126,6 @@ class _HomeTabState extends ConsumerState ), ), - // Search filter bar (only shown when has search results) if (hasActualResults && !showRecentAccess) Consumer( builder: (context, ref, _) { @@ -1265,7 +1274,8 @@ class _HomeTabState extends ConsumerState (searchArtists != null && searchArtists.isNotEmpty) || (searchAlbums != null && searchAlbums.isNotEmpty) || (searchPlaylists != null && searchPlaylists.isNotEmpty) || - isLoading; + isLoading || + error != null; return SliverMainAxisGroup( slivers: _buildSearchResults( @@ -1286,8 +1296,8 @@ class _HomeTabState extends ConsumerState ), ], ), - ), // Close RefreshIndicator - ), // Close GestureDetector + ), + ), ); } @@ -1335,24 +1345,49 @@ class _HomeTabState extends ConsumerState ); return KeyedSubtree( key: ValueKey(item.id), - child: GestureDetector( - onTap: () => _navigateToMetadataScreen(item), - child: Container( - width: coverSize, - margin: const EdgeInsets.only(right: 12), - child: Column( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(12), - child: embeddedCoverPath != null - ? Image.file( - File(embeddedCoverPath), - width: coverSize, - height: coverSize, - fit: BoxFit.cover, - cacheWidth: (coverSize * 2).round(), - cacheHeight: (coverSize * 2).round(), - errorBuilder: (_, _, _) => Container( + child: Semantics( + button: true, + label: 'Open track ${item.trackName} by ${item.artistName}', + child: GestureDetector( + onTap: () => _navigateToMetadataScreen(item), + child: Container( + width: coverSize, + margin: const EdgeInsets.only(right: 12), + child: Column( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: embeddedCoverPath != null + ? Image.file( + File(embeddedCoverPath), + width: coverSize, + height: coverSize, + fit: BoxFit.cover, + cacheWidth: (coverSize * 2).round(), + cacheHeight: (coverSize * 2).round(), + errorBuilder: (_, _, _) => Container( + width: coverSize, + height: coverSize, + color: + colorScheme.surfaceContainerHighest, + child: Icon( + Icons.music_note, + color: colorScheme.onSurfaceVariant, + size: 32, + ), + ), + ) + : item.coverUrl != null + ? CachedNetworkImage( + imageUrl: item.coverUrl!, + width: coverSize, + height: coverSize, + fit: BoxFit.cover, + memCacheWidth: (coverSize * 2).round(), + memCacheHeight: (coverSize * 2).round(), + cacheManager: CoverCacheManager.instance, + ) + : Container( width: coverSize, height: coverSize, color: colorScheme.surfaceContainerHighest, @@ -1362,38 +1397,18 @@ class _HomeTabState extends ConsumerState size: 32, ), ), - ) - : item.coverUrl != null - ? CachedNetworkImage( - imageUrl: item.coverUrl!, - width: coverSize, - height: coverSize, - fit: BoxFit.cover, - memCacheWidth: (coverSize * 2).round(), - memCacheHeight: (coverSize * 2).round(), - cacheManager: CoverCacheManager.instance, - ) - : Container( - width: coverSize, - height: coverSize, - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.music_note, - color: colorScheme.onSurfaceVariant, - size: 32, - ), - ), - ), - const SizedBox(height: 6), - Text( - item.trackName, - style: Theme.of(context).textTheme.bodySmall - ?.copyWith(color: colorScheme.onSurfaceVariant), - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - ), - ], + ), + const SizedBox(height: 6), + Text( + item.trackName, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: colorScheme.onSurfaceVariant), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + ), + ], + ), ), ), ), @@ -1434,7 +1449,6 @@ class _HomeTabState extends ConsumerState return _buildExploreSection(sections[sectionIndex], colorScheme); } - // Bottom padding return const SizedBox(height: 16); }, childCount: totalCount), ), @@ -1498,31 +1512,45 @@ class _HomeTabState extends ConsumerState final cardSize = _exploreCardSize(context); final iconSize = cardSize * 0.3; - return GestureDetector( - onTap: () => _navigateToExploreItem(item), - child: SizedBox( - width: cardSize, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 6), - child: Column( - crossAxisAlignment: isArtist - ? CrossAxisAlignment.center - : CrossAxisAlignment.start, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular( - isArtist ? cardSize / 2 : 8, - ), - child: item.coverUrl != null && item.coverUrl!.isNotEmpty - ? CachedNetworkImage( - imageUrl: item.coverUrl!, - width: cardSize, - height: cardSize, - fit: BoxFit.cover, - memCacheWidth: (cardSize * 2).round(), - memCacheHeight: (cardSize * 2).round(), - cacheManager: CoverCacheManager.instance, - errorWidget: (context, url, error) => Container( + return Semantics( + button: true, + label: 'Open ${item.type} ${item.name}', + child: GestureDetector( + onTap: () => _navigateToExploreItem(item), + child: SizedBox( + width: cardSize, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: Column( + crossAxisAlignment: isArtist + ? CrossAxisAlignment.center + : CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular( + isArtist ? cardSize / 2 : 8, + ), + child: item.coverUrl != null && item.coverUrl!.isNotEmpty + ? CachedNetworkImage( + imageUrl: item.coverUrl!, + width: cardSize, + height: cardSize, + fit: BoxFit.cover, + memCacheWidth: (cardSize * 2).round(), + memCacheHeight: (cardSize * 2).round(), + cacheManager: CoverCacheManager.instance, + errorWidget: (context, url, error) => Container( + width: cardSize, + height: cardSize, + color: colorScheme.surfaceContainerHighest, + child: Icon( + _getIconForType(item.type), + color: colorScheme.onSurfaceVariant, + size: iconSize, + ), + ), + ) + : Container( width: cardSize, height: cardSize, color: colorScheme.surfaceContainerHighest, @@ -1532,42 +1560,32 @@ class _HomeTabState extends ConsumerState size: iconSize, ), ), - ) - : Container( - width: cardSize, - height: cardSize, - color: colorScheme.surfaceContainerHighest, - child: Icon( - _getIconForType(item.type), - color: colorScheme.onSurfaceVariant, - size: iconSize, - ), - ), - ), - const SizedBox(height: 8), - Text( - item.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: isArtist ? TextAlign.center : TextAlign.start, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontWeight: FontWeight.w500, - color: colorScheme.onSurface, ), - ), - if (item.artists.isNotEmpty && !isArtist) - ClickableArtistName( - artistName: item.artists, - coverUrl: item.coverUrl, - extensionId: item.providerId, + const SizedBox(height: 8), + Text( + item.name, maxLines: 1, overflow: TextOverflow.ellipsis, + textAlign: isArtist ? TextAlign.center : TextAlign.start, style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - fontSize: 11, + fontWeight: FontWeight.w500, + color: colorScheme.onSurface, ), ), - ], + if (item.artists.isNotEmpty && !isArtist) + ClickableArtistName( + artistName: item.artists, + coverUrl: item.coverUrl, + extensionId: item.providerId, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontSize: 11, + ), + ), + ], + ), ), ), ), @@ -2022,6 +2040,7 @@ class _HomeTabState extends ConsumerState ), ), IconButton( + tooltip: 'Dismiss', icon: Icon( Icons.close, size: 20, @@ -2218,11 +2237,14 @@ class _HomeTabState extends ConsumerState } Widget _buildErrorWidget(String error, ColorScheme colorScheme) { + final l10n = context.l10n; final isRateLimit = error.contains('429') || error.toLowerCase().contains('rate limit') || error.toLowerCase().contains('too many requests'); + final isUrlNotRecognized = error == 'url_not_recognized'; + if (isRateLimit) { return Card( elevation: 0, @@ -2239,7 +2261,7 @@ class _HomeTabState extends ConsumerState crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Rate Limited', + l10n.errorRateLimited, style: TextStyle( color: colorScheme.onErrorContainer, fontWeight: FontWeight.bold, @@ -2247,7 +2269,7 @@ class _HomeTabState extends ConsumerState ), const SizedBox(height: 4), Text( - 'Too many requests. Please wait a moment before searching again.', + l10n.errorRateLimitedMessage, style: TextStyle( color: colorScheme.onErrorContainer, fontSize: 12, @@ -2262,6 +2284,42 @@ class _HomeTabState extends ConsumerState ); } + if (isUrlNotRecognized) { + return Card( + elevation: 0, + color: colorScheme.errorContainer.withValues(alpha: 0.5), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Icon(Icons.link_off, color: colorScheme.error), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.errorUrlNotRecognized, + style: TextStyle( + color: colorScheme.error, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + l10n.errorUrlNotRecognizedMessage, + style: TextStyle(color: colorScheme.error, fontSize: 12), + ), + ], + ), + ), + ], + ), + ), + ); + } + return Card( elevation: 0, color: colorScheme.errorContainer.withValues(alpha: 0.5), @@ -2273,7 +2331,10 @@ class _HomeTabState extends ConsumerState Icon(Icons.error_outline, color: colorScheme.error), const SizedBox(width: 12), Expanded( - child: Text(error, style: TextStyle(color: colorScheme.error)), + child: Text( + l10n.errorUrlFetchFailed, + style: TextStyle(color: colorScheme.error), + ), ), ], ), @@ -2705,7 +2766,6 @@ class _HomeTabState extends ConsumerState scrollDirection: Axis.horizontal, child: Row( children: [ - // "All" chip (no filter) Padding( padding: const EdgeInsets.only(right: 8), child: FilterChip( @@ -2728,7 +2788,6 @@ class _HomeTabState extends ConsumerState ), ), ), - // Filter chips from extension ...filters.map((filter) { final isSelected = selectedFilter == filter.id; return Padding( @@ -2830,7 +2889,6 @@ class _HomeTabState extends ConsumerState prefixIcon: _SearchProviderDropdown( onProviderChanged: () { _lastSearchQuery = null; - // Reset filter when provider changes ref.read(trackProvider.notifier).setSearchFilter(null); setState(() {}); final text = _urlController.text.trim(); @@ -2904,9 +2962,6 @@ class _SearchProviderDropdown extends ConsumerWidget { final currentProvider = ref.watch( settingsProvider.select((s) => s.searchProvider), ); - final metadataSource = ref.watch( - settingsProvider.select((s) => s.metadataSource), - ); final extensions = ref.watch(extensionProvider.select((s) => s.extensions)); final colorScheme = Theme.of(context).colorScheme; @@ -2984,7 +3039,7 @@ class _SearchProviderDropdown extends ConsumerWidget { const SizedBox(width: 12), Expanded( child: Text( - metadataSource == 'spotify' ? 'Spotify' : 'Deezer', + 'Deezer', style: TextStyle( fontWeight: currentProvider == null || currentProvider.isEmpty @@ -4386,6 +4441,7 @@ class _QuickPicksPageViewState extends State<_QuickPicksPageView> { ), ), IconButton( + tooltip: MaterialLocalizations.of(context).showMenuTooltip, icon: Icon( Icons.more_vert, color: widget.colorScheme.onSurfaceVariant, diff --git a/lib/screens/library_playlists_screen.dart b/lib/screens/library_playlists_screen.dart index 2f07b435..503937f6 100644 --- a/lib/screens/library_playlists_screen.dart +++ b/lib/screens/library_playlists_screen.dart @@ -32,6 +32,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget { backgroundColor: colorScheme.surface, surfaceTintColor: Colors.transparent, leading: IconButton( + tooltip: MaterialLocalizations.of(context).backButtonTooltip, icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context), ), @@ -158,7 +159,6 @@ class LibraryPlaylistsScreen extends ConsumerWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - // Header: drag handle + thumbnail + playlist info Column( children: [ const SizedBox(height: 8), @@ -210,7 +210,6 @@ class LibraryPlaylistsScreen extends ConsumerWidget { color: colorScheme.outlineVariant.withValues(alpha: 0.5), ), - // Rename _PlaylistOptionTile( icon: Icons.edit_outlined, title: context.l10n.collectionRenamePlaylist, @@ -225,7 +224,6 @@ class LibraryPlaylistsScreen extends ConsumerWidget { }, ), - // Change cover _PlaylistOptionTile( icon: Icons.image_outlined, title: context.l10n.collectionPlaylistChangeCover, @@ -235,7 +233,6 @@ class LibraryPlaylistsScreen extends ConsumerWidget { }, ), - // Delete _PlaylistOptionTile( icon: Icons.delete_outline, iconColor: colorScheme.error, diff --git a/lib/screens/library_tracks_folder_screen.dart b/lib/screens/library_tracks_folder_screen.dart index d2442c22..d6d1fc5e 100644 --- a/lib/screens/library_tracks_folder_screen.dart +++ b/lib/screens/library_tracks_folder_screen.dart @@ -37,7 +37,6 @@ class _LibraryTracksFolderScreenState bool _showTitleInAppBar = false; final ScrollController _scrollController = ScrollController(); - // ── Multi-select state ── bool _isSelectionMode = false; final Set _selectedKeys = {}; @@ -145,8 +144,6 @@ class _LibraryTracksFolderScreenState return url; } - // ── Selection helpers ── - void _enterSelectionMode(String key) { HapticFeedback.mediumImpact(); setState(() { @@ -181,8 +178,6 @@ class _LibraryTracksFolderScreenState }); } - // ── Batch actions ── - Future _removeSelected(List entries) async { final keysToRemove = _selectedKeys.toSet(); if (keysToRemove.isEmpty) return; @@ -426,7 +421,6 @@ class _LibraryTracksFolderScreenState child: Column( mainAxisSize: MainAxisSize.min, children: [ - // Drag handle Container( width: 32, height: 4, @@ -437,11 +431,13 @@ class _LibraryTracksFolderScreenState ), ), - // Header: [X close] [count] [Select All / Deselect] Row( children: [ IconButton.filledTonal( onPressed: _exitSelectionMode, + tooltip: MaterialLocalizations.of( + context, + ).closeButtonTooltip, icon: const Icon(Icons.close), style: IconButton.styleFrom( backgroundColor: colorScheme.surfaceContainerHighest, @@ -493,7 +489,6 @@ class _LibraryTracksFolderScreenState const SizedBox(height: 12), - // Action buttons row Row( children: [ if (isWishlist) @@ -525,7 +520,6 @@ class _LibraryTracksFolderScreenState const SizedBox(height: 8), - // Remove button (full width, red) SizedBox( width: double.infinity, child: FilledButton.icon( @@ -714,7 +708,6 @@ class _LibraryTracksFolderScreenState ) else coverFallback, - // Bottom gradient for readability Positioned( left: 0, right: 0, @@ -733,7 +726,6 @@ class _LibraryTracksFolderScreenState ), ), ), - // Title and track count overlay Positioned( left: 20, right: 20, @@ -811,6 +803,9 @@ class _LibraryTracksFolderScreenState }, ), leading: IconButton( + tooltip: _isSelectionMode + ? MaterialLocalizations.of(context).closeButtonTooltip + : MaterialLocalizations.of(context).backButtonTooltip, icon: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( @@ -829,9 +824,8 @@ class _LibraryTracksFolderScreenState ); } - // ── Header actions ── - - Widget _buildHeaderActionPlaceholder() => const SizedBox(width: 48, height: 48); + Widget _buildHeaderActionPlaceholder() => + const SizedBox(width: 48, height: 48); Widget _buildDownloadAllCenterButton(List entries) { final tracks = entries.map((e) => e.track).toList(growable: false); @@ -1152,6 +1146,7 @@ class _CollectionTrackTile extends ConsumerWidget { trailing: isSelectionMode ? null : IconButton( + tooltip: MaterialLocalizations.of(context).showMenuTooltip, icon: Icon( Icons.more_vert, color: colorScheme.onSurfaceVariant, @@ -1263,7 +1258,6 @@ class _CollectionTrackTile extends ConsumerWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - // Header: drag handle + cover + track info Column( children: [ const SizedBox(height: 8), diff --git a/lib/screens/local_album_screen.dart b/lib/screens/local_album_screen.dart index 83298d72..9c560546 100644 --- a/lib/screens/local_album_screen.dart +++ b/lib/screens/local_album_screen.dart @@ -13,7 +13,6 @@ 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 { final String albumName; final String artistName; @@ -39,6 +38,14 @@ class _LocalAlbumScreenState extends ConsumerState { final ScrollController _scrollController = ScrollController(); late List _sortedTracksCache; late Map> _discGroupsCache; + + void _showCueVirtualTrackSnackBar() { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text(cueVirtualTrackRequiresSplitMessage), + ), + ); + } late List _sortedDiscNumbersCache; late bool _hasMultipleDiscsCache; String? _commonQualityCache; @@ -83,7 +90,6 @@ class _LocalAlbumScreenState extends ConsumerState { List _buildSortedTracks() { final tracks = List.from(widget.tracks); tracks.sort((a, b) { - // Sort by disc number first, then by track number final aDisc = a.discNumber ?? 1; final bDisc = b.discNumber ?? 1; if (aDisc != bDisc) return aDisc.compareTo(bDisc); @@ -180,9 +186,11 @@ class _LocalAlbumScreenState extends ConsumerState { for (final id in idsToDelete) { final item = tracksById[id]; if (item != null) { - try { - await deleteFile(item.filePath); - } catch (_) {} + if (!isCueVirtualPath(item.filePath)) { + try { + await deleteFile(item.filePath); + } catch (_) {} + } await libraryNotifier.removeItem(id); deletedCount++; } @@ -197,7 +205,6 @@ class _LocalAlbumScreenState extends ConsumerState { ), ); - // Go back if all tracks were deleted if (deletedCount == currentTracks.length) { Navigator.pop(context); } @@ -206,6 +213,10 @@ class _LocalAlbumScreenState extends ConsumerState { } Future _openFile(LocalLibraryItem track) async { + if (isCueVirtualPath(track.filePath)) { + _showCueVirtualTrackSnackBar(); + return; + } try { await ref .read(playbackProvider.notifier) @@ -233,7 +244,6 @@ class _LocalAlbumScreenState extends ConsumerState { final bottomPadding = MediaQuery.of(context).padding.bottom; final tracks = _sortedTracksCache; - // Show empty state if no tracks found if (tracks.isEmpty) { return Scaffold( appBar: AppBar(title: Text(widget.albumName)), @@ -326,7 +336,6 @@ class _LocalAlbumScreenState extends ConsumerState { background: Stack( fit: StackFit.expand, children: [ - // Full-screen cover background if (widget.coverPath != null) Image.file( File(widget.coverPath!), @@ -343,7 +352,6 @@ class _LocalAlbumScreenState extends ConsumerState { color: colorScheme.onSurfaceVariant, ), ), - // Bottom gradient for readability Positioned( left: 0, right: 0, @@ -362,7 +370,6 @@ class _LocalAlbumScreenState extends ConsumerState { ), ), ), - // Album info overlay at bottom Positioned( left: 20, right: 20, @@ -494,6 +501,7 @@ class _LocalAlbumScreenState extends ConsumerState { }, ), leading: IconButton( + tooltip: MaterialLocalizations.of(context).backButtonTooltip, icon: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( @@ -733,6 +741,7 @@ class _LocalAlbumScreenState extends ConsumerState { trailing: _isSelectionMode ? null : IconButton( + tooltip: 'Play track', onPressed: () => _openFile(track), icon: Icon(Icons.play_arrow, color: colorScheme.primary), style: IconButton.styleFrom( @@ -888,7 +897,6 @@ class _LocalAlbumScreenState extends ConsumerState { 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 = []; @@ -958,13 +966,18 @@ class _LocalAlbumScreenState extends ConsumerState { return; } - final localLibraryPath = ref.read(settingsProvider).localLibraryPath.trim(); + final settings = ref.read(settingsProvider); + final localLibraryPath = settings.localLibraryPath.trim(); + final iosBookmark = settings.localLibraryBookmark; try { if (localLibraryPath.isNotEmpty && !ref.read(localLibraryProvider).isScanning) { await ref .read(localLibraryProvider.notifier) - .startScan(localLibraryPath); + .startScan( + localLibraryPath, + iosBookmark: iosBookmark.isNotEmpty ? iosBookmark : null, + ); } else { await ref.read(localLibraryProvider.notifier).reloadFromStorage(); } @@ -988,7 +1001,6 @@ class _LocalAlbumScreenState extends ConsumerState { ).showSnackBar(SnackBar(content: Text(summary))); } - /// Show batch convert bottom sheet void _showBatchConvertSheet( BuildContext context, List allTracks, @@ -1261,7 +1273,6 @@ class _LocalAlbumScreenState extends ConsumerState { String? safTempPath; if (isSaf) { - // Copy SAF file to temp for conversion safTempPath = await PlatformBridge.copyContentUriToTemp( item.filePath, ); @@ -1296,7 +1307,6 @@ class _LocalAlbumScreenState extends ConsumerState { 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 @@ -1375,14 +1385,12 @@ class _LocalAlbumScreenState extends ConsumerState { 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 (_) {} @@ -1400,7 +1408,6 @@ class _LocalAlbumScreenState extends ConsumerState { } catch (_) {} } - // Reload local library to pick up converted files ref.read(localLibraryProvider.notifier).reloadFromStorage(); _exitSelectionMode(); @@ -1461,6 +1468,9 @@ class _LocalAlbumScreenState extends ConsumerState { children: [ IconButton.filledTonal( onPressed: _exitSelectionMode, + tooltip: MaterialLocalizations.of( + context, + ).closeButtonTooltip, icon: const Icon(Icons.close), style: IconButton.styleFrom( backgroundColor: colorScheme.surfaceContainerHighest, @@ -1513,7 +1523,6 @@ class _LocalAlbumScreenState extends ConsumerState { ), const SizedBox(height: 12), - // Action buttons row: Re-enrich, Convert Row( children: [ Expanded( diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index e103c6a1..b77fee99 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -102,13 +102,29 @@ class _MainShellState extends ConsumerState { if (_currentIndex != 0) { _onNavTap(0); } - ref.read(trackProvider.notifier).fetchFromUrl(url); ref.read(settingsProvider.notifier).setHasSearchedBefore(); if (mounted) { ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(context.l10n.loadingSharedLink))); } + await ref.read(trackProvider.notifier).fetchFromUrl(url); + final trackState = ref.read(trackProvider); + if (trackState.error != null && mounted) { + final l10n = context.l10n; + final errorMsg = trackState.error!; + final isRateLimit = errorMsg.contains('429') || + errorMsg.toLowerCase().contains('rate limit') || + errorMsg.toLowerCase().contains('too many requests'); + final displayMessage = errorMsg == 'url_not_recognized' + ? l10n.errorUrlNotRecognizedMessage + : isRateLimit + ? l10n.errorRateLimitedMessage + : l10n.errorUrlFetchFailed; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(displayMessage)), + ); + } } Future _checkForUpdates() async { diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index d005678d..37bf14cf 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -206,7 +206,6 @@ class _PlaylistScreenState extends ConsumerState { background: Stack( fit: StackFit.expand, children: [ - // Full-screen cover background if (widget.coverUrl != null) CachedNetworkImage( imageUrl: @@ -227,7 +226,6 @@ class _PlaylistScreenState extends ConsumerState { color: colorScheme.onSurfaceVariant, ), ), - // Bottom gradient for readability Positioned( left: 0, right: 0, @@ -246,7 +244,6 @@ class _PlaylistScreenState extends ConsumerState { ), ), ), - // Playlist info overlay at bottom Positioned( left: 20, right: 20, @@ -324,6 +321,7 @@ class _PlaylistScreenState extends ConsumerState { }, ), leading: IconButton( + tooltip: MaterialLocalizations.of(context).backButtonTooltip, icon: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( @@ -418,7 +416,7 @@ class _PlaylistScreenState extends ConsumerState { onSelect: (quality, service) { ref .read(downloadQueueProvider.notifier) - .addToQueue(track, service, qualityOverride: quality); + .addToQueue(track, service, qualityOverride: quality, playlistName: widget.playlistName); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(context.l10n.snackbarAddedToQueue(track.name)), @@ -429,15 +427,13 @@ class _PlaylistScreenState extends ConsumerState { } else { ref .read(downloadQueueProvider.notifier) - .addToQueue(track, settings.defaultService); + .addToQueue(track, settings.defaultService, playlistName: widget.playlistName); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))), ); } } - // ── Shuffle / Love / Download buttons ── - Widget _buildCircleButton({ required IconData icon, required String tooltip, @@ -590,7 +586,7 @@ class _PlaylistScreenState extends ConsumerState { onSelect: (quality, service) { ref .read(downloadQueueProvider.notifier) - .addMultipleToQueue(tracks, service, qualityOverride: quality); + .addMultipleToQueue(tracks, service, qualityOverride: quality, playlistName: widget.playlistName); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( @@ -603,7 +599,7 @@ class _PlaylistScreenState extends ConsumerState { } else { ref .read(downloadQueueProvider.notifier) - .addMultipleToQueue(tracks, settings.defaultService); + .addMultipleToQueue(tracks, settings.defaultService, playlistName: widget.playlistName); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)), diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index ad462f37..e05429c9 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -29,6 +29,8 @@ 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'; +import 'package:spotiflac_android/utils/path_match_keys.dart'; +import 'package:spotiflac_android/utils/string_utils.dart'; enum LibraryItemSource { downloaded, local } @@ -70,7 +72,11 @@ class UnifiedLibraryItem { albumName: item.albumName, coverUrl: item.coverUrl, filePath: item.filePath, - quality: item.quality, + quality: buildDisplayAudioQuality( + bitDepth: item.bitDepth, + sampleRate: item.sampleRate, + storedQuality: item.quality, + ), addedAt: item.downloadedAt, source: LibraryItemSource.downloaded, historyItem: item, @@ -80,15 +86,18 @@ class UnifiedLibraryItem { factory UnifiedLibraryItem.fromLocalLibrary(LocalLibraryItem item) { String? quality; if (item.bitrate != null && item.bitrate! > 0) { - // Lossy format with bitrate - final fmt = item.format?.toUpperCase() ?? ''; - quality = '$fmt ${item.bitrate}kbps'.trim(); + quality = buildDisplayAudioQuality( + bitrateKbps: item.bitrate, + format: item.format, + ); } 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'; + quality = buildDisplayAudioQuality( + bitDepth: item.bitDepth, + sampleRate: item.sampleRate, + ); } return UnifiedLibraryItem( id: 'local_${item.id}', @@ -211,7 +220,7 @@ class _GroupedAlbum { class _GroupedLocalAlbum { final String albumName; final String artistName; - final String? coverPath; // Local cover file path + final String? coverPath; final List tracks; final DateTime latestScanned; final String searchKey; @@ -229,12 +238,11 @@ class _GroupedLocalAlbum { class _HistoryStats { final Map albumCounts; - final Map localAlbumCounts; // For identifying local singles + final Map localAlbumCounts; final List<_GroupedAlbum> groupedAlbums; - final List<_GroupedLocalAlbum> groupedLocalAlbums; // Local library albums + final List<_GroupedLocalAlbum> groupedLocalAlbums; final int albumCount; final int singleTracks; - // Local library stats final int localAlbumCount; final int localSingleTracks; @@ -933,8 +941,6 @@ class _QueueTabState extends ConsumerState { overlay.insert(_playlistSelectionOverlayEntry!); } - // --- Playlist selection mode --- - void _enterPlaylistSelectionMode(String playlistId) { HapticFeedback.mediumImpact(); setState(() { @@ -1059,6 +1065,9 @@ class _QueueTabState extends ConsumerState { children: [ IconButton.filledTonal( onPressed: _exitPlaylistSelectionMode, + tooltip: MaterialLocalizations.of( + context, + ).closeButtonTooltip, icon: const Icon(Icons.close), style: IconButton.styleFrom( backgroundColor: colorScheme.surfaceContainerHighest, @@ -1202,11 +1211,9 @@ class _QueueTabState extends ConsumerState { await deleteFile(cleanPath); } catch (_) {} - // Remove from appropriate database if (item.source == LibraryItemSource.downloaded) { historyNotifier.removeFromHistory(item.historyItem!.id); } else { - // Remove from local library database await localLibraryDb.deleteByPath(item.filePath); } deletedCount++; @@ -2024,7 +2031,6 @@ class _QueueTabState extends ConsumerState { Map albumCounts, [ String searchQuery = '', ]) { - // First apply search filter var filteredItems = items; if (searchQuery.isNotEmpty) { final query = searchQuery; @@ -2034,7 +2040,6 @@ class _QueueTabState extends ConsumerState { }).toList(); } - // Then apply filter mode if (filterMode == 'all') return filteredItems; switch (filterMode) { @@ -2108,10 +2113,22 @@ class _QueueTabState extends ConsumerState { if (count > 1) albumCount++; } + final downloadedPathKeys = {}; + for (final item in items) { + downloadedPathKeys.addAll(buildPathMatchKeys(item.filePath)); + } + + final dedupedLocalItems = localItems + .where((item) { + final localPathKeys = buildPathMatchKeys(item.filePath); + return !localPathKeys.any(downloadedPathKeys.contains); + }) + .toList(growable: false); + // Calculate local library stats final localAlbumCounts = {}; final localAlbumMap = >{}; - for (final item in localItems) { + for (final item in dedupedLocalItems) { final key = '${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}'; localAlbumCounts[key] = (localAlbumCounts[key] ?? 0) + 1; @@ -2639,7 +2656,6 @@ class _QueueTabState extends ConsumerState { ), ), - // Search bar - always at top if (allHistoryItems.isNotEmpty || hasQueueItems || localLibraryItems.isNotEmpty) @@ -2838,8 +2854,24 @@ class _QueueTabState extends ConsumerState { .map((item) => UnifiedLibraryItem.fromLocalLibrary(item)) .toList(growable: false); - final merged = [...unifiedDownloaded, ...unifiedLocal] - ..sort((a, b) => b.addedAt.compareTo(a.addedAt)); + final downloadedPathKeys = {}; + for (final item in unifiedDownloaded) { + downloadedPathKeys.addAll(buildPathMatchKeys(item.filePath)); + } + + final dedupedUnifiedLocal = []; + for (final item in unifiedLocal) { + final localPathKeys = buildPathMatchKeys(item.filePath); + final overlapsDownloaded = localPathKeys.any(downloadedPathKeys.contains); + if (!overlapsDownloaded) { + dedupedUnifiedLocal.add(item); + } + } + + final merged = [ + ...unifiedDownloaded, + ...dedupedUnifiedLocal, + ]..sort((a, b) => b.addedAt.compareTo(a.addedAt)); _unifiedItemsCache[filterMode] = _UnifiedCacheEntry( historyItems: historyItems, @@ -3068,37 +3100,41 @@ class _QueueTabState extends ConsumerState { ), ); - return GestureDetector( - onTap: onTap, - onLongPress: onLongPress, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AspectRatio( - aspectRatio: 1, - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: cover, + return Semantics( + button: true, + label: 'Open $title, $count ${count == 1 ? 'item' : 'items'}', + child: 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, + 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, + ), + ), + ], + ), ), ); } @@ -3913,112 +3949,117 @@ class _QueueTabState extends ConsumerState { final embeddedCoverPath = _resolveDownloadedEmbeddedCoverPath( album.sampleFilePath, ); - return GestureDetector( - onTap: () => _navigateToDownloadedAlbum(album), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Stack( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(12), - child: embeddedCoverPath != null - ? Image.file( - File(embeddedCoverPath), - fit: BoxFit.cover, - width: double.infinity, - height: double.infinity, - cacheWidth: 300, - cacheHeight: 300, - errorBuilder: (context, error, stackTrace) => - Container( - color: colorScheme.surfaceContainerHighest, - child: Center( - child: Icon( - Icons.album, - color: colorScheme.onSurfaceVariant, - size: 48, + return Semantics( + button: true, + label: + 'Open album ${album.albumName} by ${album.artistName}, ${album.tracks.length} tracks', + child: GestureDetector( + onTap: () => _navigateToDownloadedAlbum(album), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: embeddedCoverPath != null + ? Image.file( + File(embeddedCoverPath), + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + cacheWidth: 300, + cacheHeight: 300, + errorBuilder: (context, error, stackTrace) => + Container( + color: colorScheme.surfaceContainerHighest, + child: Center( + child: Icon( + Icons.album, + color: colorScheme.onSurfaceVariant, + size: 48, + ), ), ), + ) + : album.coverUrl != null + ? CachedNetworkImage( + imageUrl: album.coverUrl!, + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + memCacheWidth: 300, + memCacheHeight: 300, + cacheManager: CoverCacheManager.instance, + ) + : Container( + color: colorScheme.surfaceContainerHighest, + child: Center( + child: Icon( + Icons.album, + color: colorScheme.onSurfaceVariant, + size: 48, ), - ) - : album.coverUrl != null - ? CachedNetworkImage( - imageUrl: album.coverUrl!, - fit: BoxFit.cover, - width: double.infinity, - height: double.infinity, - memCacheWidth: 300, - memCacheHeight: 300, - cacheManager: CoverCacheManager.instance, - ) - : Container( - color: colorScheme.surfaceContainerHighest, - child: Center( - child: Icon( - Icons.album, - color: colorScheme.onSurfaceVariant, - size: 48, ), ), - ), - ), - Positioned( - right: 8, - bottom: 8, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - decoration: BoxDecoration( - color: colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(12), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.music_note, - size: 12, - color: colorScheme.onPrimaryContainer, - ), - const SizedBox(width: 4), - Text( - '${album.tracks.length}', - style: TextStyle( + ), + Positioned( + right: 8, + bottom: 8, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.music_note, + size: 12, color: colorScheme.onPrimaryContainer, - fontSize: 12, - fontWeight: FontWeight.bold, ), - ), - ], + const SizedBox(width: 4), + Text( + '${album.tracks.length}', + style: TextStyle( + color: colorScheme.onPrimaryContainer, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ], + ), ), ), - ), - ], + ], + ), ), - ), - const SizedBox(height: 8), - Text( - album.albumName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), - ), - ClickableArtistName( - artistName: album.artistName, - coverUrl: album.coverUrl, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, + const SizedBox(height: 8), + Text( + album.albumName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), ), - ), - ], + ClickableArtistName( + artistName: album.artistName, + coverUrl: album.coverUrl, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), ), ); } @@ -4029,102 +4070,107 @@ class _QueueTabState extends ConsumerState { _GroupedLocalAlbum album, ColorScheme colorScheme, ) { - return GestureDetector( - onTap: () => _navigateToLocalAlbum(album), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Stack( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(12), - child: album.coverPath != null - ? Image.file( - File(album.coverPath!), - fit: BoxFit.cover, - width: double.infinity, - height: double.infinity, - cacheWidth: 300, - cacheHeight: 300, - errorBuilder: (context, error, stackTrace) => - Container( - color: colorScheme.surfaceContainerHighest, - child: Center( - child: Icon( - Icons.album, - color: colorScheme.onSurfaceVariant, - size: 48, + return Semantics( + button: true, + label: + 'Open local album ${album.albumName} by ${album.artistName}, ${album.tracks.length} tracks', + child: GestureDetector( + onTap: () => _navigateToLocalAlbum(album), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: album.coverPath != null + ? Image.file( + File(album.coverPath!), + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + cacheWidth: 300, + cacheHeight: 300, + errorBuilder: (context, error, stackTrace) => + Container( + color: colorScheme.surfaceContainerHighest, + child: Center( + child: Icon( + Icons.album, + color: colorScheme.onSurfaceVariant, + size: 48, + ), ), ), + ) + : Container( + color: colorScheme.surfaceContainerHighest, + child: Center( + child: Icon( + Icons.album, + color: colorScheme.onSurfaceVariant, + size: 48, ), - ) - : Container( - color: colorScheme.surfaceContainerHighest, - child: Center( - child: Icon( - Icons.album, - color: colorScheme.onSurfaceVariant, - size: 48, ), ), - ), - ), - // "Local" badge instead of track count - Positioned( - right: 8, - bottom: 8, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - decoration: BoxDecoration( - color: colorScheme.tertiaryContainer, - borderRadius: BorderRadius.circular(12), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.folder, - size: 12, - color: colorScheme.onTertiaryContainer, - ), - const SizedBox(width: 4), - Text( - '${album.tracks.length}', - style: TextStyle( + ), + // "Local" badge instead of track count + Positioned( + right: 8, + bottom: 8, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: colorScheme.tertiaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.folder, + size: 12, color: colorScheme.onTertiaryContainer, - fontSize: 12, - fontWeight: FontWeight.bold, ), - ), - ], + const SizedBox(width: 4), + Text( + '${album.tracks.length}', + style: TextStyle( + color: colorScheme.onTertiaryContainer, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ], + ), ), ), - ), - ], + ], + ), ), - ), - const SizedBox(height: 8), - Text( - album.albumName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), - ), - ClickableArtistName( - artistName: album.artistName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, + const SizedBox(height: 8), + Text( + album.albumName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), ), - ), - ], + ClickableArtistName( + artistName: album.artistName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), ), ); } @@ -4356,13 +4402,18 @@ class _QueueTabState extends ConsumerState { return; } - final localLibraryPath = ref.read(settingsProvider).localLibraryPath.trim(); + final settings = ref.read(settingsProvider); + final localLibraryPath = settings.localLibraryPath.trim(); + final iosBookmark = settings.localLibraryBookmark; try { if (localLibraryPath.isNotEmpty && !ref.read(localLibraryProvider).isScanning) { await ref .read(localLibraryProvider.notifier) - .startScan(localLibraryPath); + .startScan( + localLibraryPath, + iosBookmark: iosBookmark.isNotEmpty ? iosBookmark : null, + ); } else { await ref.read(localLibraryProvider.notifier).reloadFromStorage(); } @@ -4996,6 +5047,9 @@ class _QueueTabState extends ConsumerState { children: [ IconButton.filledTonal( onPressed: _exitSelectionMode, + tooltip: MaterialLocalizations.of( + context, + ).closeButtonTooltip, icon: const Icon(Icons.close), style: IconButton.styleFrom( backgroundColor: colorScheme.surfaceContainerHighest, @@ -5275,18 +5329,27 @@ class _QueueTabState extends ConsumerState { ), ); case DownloadStatus.finalizing: - return SizedBox( - width: 40, - height: 40, - child: Stack( - alignment: Alignment.center, - children: [ - CircularProgressIndicator( - strokeWidth: 3, - color: colorScheme.tertiary, - ), - Icon(Icons.edit_note, color: colorScheme.tertiary, size: 16), - ], + return Semantics( + label: 'Finalizing download', + child: SizedBox( + width: 40, + height: 40, + child: Stack( + alignment: Alignment.center, + children: [ + CircularProgressIndicator( + strokeWidth: 3, + color: colorScheme.tertiary, + ), + ExcludeSemantics( + child: Icon( + Icons.edit_note, + color: colorScheme.tertiary, + size: 16, + ), + ), + ], + ), ), ); case DownloadStatus.completed: @@ -5314,18 +5377,32 @@ class _QueueTabState extends ConsumerState { ), ) else - Icon(Icons.error_outline, color: colorScheme.error, size: 20), - const SizedBox(width: 4), - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: colorScheme.primaryContainer, - shape: BoxShape.circle, + Semantics( + label: 'Downloaded file missing', + child: ExcludeSemantics( + child: Icon( + Icons.error_outline, + color: colorScheme.error, + size: 20, + ), + ), ), - child: Icon( - Icons.check, - color: colorScheme.onPrimaryContainer, - size: 20, + const SizedBox(width: 4), + Semantics( + label: 'Download completed', + child: ExcludeSemantics( + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + shape: BoxShape.circle, + ), + child: Icon( + Icons.check, + color: colorScheme.onPrimaryContainer, + size: 20, + ), + ), ), ), ], @@ -5888,27 +5965,34 @@ class _QueueTabState extends ConsumerState { valueListenable: fileExistsListenable, builder: (context, fileExists, child) { return fileExists - ? GestureDetector( - 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( - color: colorScheme.primary, - shape: BoxShape.circle, + ? Semantics( + button: true, + label: + 'Play ${item.trackName} by ${item.artistName}', + child: GestureDetector( + onTap: () => _openFile( + item.filePath, + title: item.trackName, + artist: item.artistName, + album: item.albumName, + coverUrl: + item.coverUrl ?? + item.localCoverPath ?? + '', ), - child: Icon( - Icons.play_arrow, - color: colorScheme.onPrimary, - size: 16, + child: Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: colorScheme.primary, + shape: BoxShape.circle, + ), + child: ExcludeSemantics( + child: Icon( + Icons.play_arrow, + color: colorScheme.onPrimary, + size: 16, + ), + ), ), ), ) diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index a348ecac..09b2f656 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -27,10 +27,7 @@ class _SearchScreenState extends ConsumerState { _searchController = TextEditingController(text: widget.query); if (widget.query.isNotEmpty) { WidgetsBinding.instance.addPostFrameCallback((_) { - final settings = ref.read(settingsProvider); - ref - .read(trackProvider.notifier) - .search(widget.query, metadataSource: settings.metadataSource); + ref.read(trackProvider.notifier).search(widget.query); }); } } @@ -44,10 +41,7 @@ class _SearchScreenState extends ConsumerState { void _search() { final query = _searchController.text.trim(); if (query.isNotEmpty) { - final settings = ref.read(settingsProvider); - ref - .read(trackProvider.notifier) - .search(query, metadataSource: settings.metadataSource); + ref.read(trackProvider.notifier).search(query); } } @@ -84,7 +78,11 @@ class _SearchScreenState extends ConsumerState { autofocus: widget.query.isEmpty, ), actions: [ - IconButton(icon: const Icon(Icons.search), onPressed: _search), + IconButton( + tooltip: MaterialLocalizations.of(context).searchFieldLabel, + icon: const Icon(Icons.search), + onPressed: _search, + ), ], ), body: Column( diff --git a/lib/screens/settings/about_page.dart b/lib/screens/settings/about_page.dart index 5b5bc299..81fd1aa5 100644 --- a/lib/screens/settings/about_page.dart +++ b/lib/screens/settings/about_page.dart @@ -28,6 +28,7 @@ class AboutPage extends StatelessWidget { backgroundColor: colorScheme.surface, surfaceTintColor: Colors.transparent, leading: IconButton( + tooltip: MaterialLocalizations.of(context).backButtonTooltip, icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context), ), @@ -49,7 +50,7 @@ class AboutPage extends StatelessWidget { title: Text( context.l10n.aboutTitle, style: TextStyle( - fontSize: 20 + (8 * expandRatio), // 20 -> 28 + fontSize: 20 + (8 * expandRatio), fontWeight: FontWeight.bold, color: colorScheme.onSurface, ), @@ -462,7 +463,6 @@ class _ContributorItem extends StatelessWidget { } } -/// Translator data model class _Translator { final String name; final String crowdinUsername; @@ -477,7 +477,6 @@ class _Translator { }); } -/// Translators section with compact chip-style layout class _TranslatorsSection extends StatelessWidget { const _TranslatorsSection(); @@ -558,7 +557,6 @@ class _TranslatorsSection extends StatelessWidget { } } -/// Individual translator chip class _TranslatorChip extends StatelessWidget { final _Translator translator; diff --git a/lib/screens/settings/appearance_settings_page.dart b/lib/screens/settings/appearance_settings_page.dart index 4778e95f..4884328a 100644 --- a/lib/screens/settings/appearance_settings_page.dart +++ b/lib/screens/settings/appearance_settings_page.dart @@ -30,6 +30,7 @@ class AppearanceSettingsPage extends ConsumerWidget { backgroundColor: colorScheme.surface, surfaceTintColor: Colors.transparent, leading: IconButton( + tooltip: MaterialLocalizations.of(context).backButtonTooltip, icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context), ), @@ -148,7 +149,6 @@ class AppearanceSettingsPage extends ConsumerWidget { } } -/// A simplified preview of how the app looks with current settings class _ThemePreviewCard extends StatelessWidget { @override Widget build(BuildContext context) { @@ -348,11 +348,21 @@ class _ColorPalettePicker extends StatelessWidget { child: Row( children: _colors.map((color) { final isSelected = color.toARGB32() == currentColor; + final colorHex = color + .toARGB32() + .toRadixString(16) + .padLeft(8, '0') + .toUpperCase(); return Padding( padding: const EdgeInsets.only(right: 12), - child: GestureDetector( - onTap: () => onColorSelected(color), - child: _ColorPaletteItem(color: color, isSelected: isSelected), + child: Semantics( + button: true, + selected: isSelected, + label: 'Select accent color $colorHex', + child: GestureDetector( + onTap: () => onColorSelected(color), + child: _ColorPaletteItem(color: color, isSelected: isSelected), + ), ), ); }).toList(), @@ -423,7 +433,6 @@ class _ColorPaletteItem extends StatelessWidget { } } -/// Optimized app bar title with animation class _AppBarTitle extends StatelessWidget { final String title; final double topPadding; @@ -440,14 +449,14 @@ class _AppBarTitle extends StatelessWidget { final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)) .clamp(0.0, 1.0); - final leftPadding = 56 - (32 * expandRatio); // 56 -> 24 + 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), // 20 -> 28 + fontSize: 20 + (8 * expandRatio), fontWeight: FontWeight.bold, color: colorScheme.onSurface, ), diff --git a/lib/screens/settings/cache_management_page.dart b/lib/screens/settings/cache_management_page.dart index c01c685f..edb3eed3 100644 --- a/lib/screens/settings/cache_management_page.dart +++ b/lib/screens/settings/cache_management_page.dart @@ -9,6 +9,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/app_bar_layout.dart'; @@ -311,9 +312,12 @@ class _CacheManagementPageState extends ConsumerState { final orphanedDownloads = await ref .read(downloadHistoryProvider.notifier) .cleanupOrphanedDownloads(); + final iosBookmark = ref.read(settingsProvider).localLibraryBookmark; final missingLibraryEntries = await ref .read(localLibraryProvider.notifier) - .cleanupMissingFiles(); + .cleanupMissingFiles( + iosBookmark: iosBookmark.isNotEmpty ? iosBookmark : null, + ); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( @@ -384,11 +388,13 @@ class _CacheManagementPageState extends ConsumerState { backgroundColor: colorScheme.surface, surfaceTintColor: Colors.transparent, leading: IconButton( + tooltip: MaterialLocalizations.of(context).backButtonTooltip, icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context), ), actions: [ IconButton( + tooltip: 'Refresh', onPressed: _isBusy ? null : _refreshOverview, icon: const Icon(Icons.refresh), ), diff --git a/lib/screens/settings/donate_page.dart b/lib/screens/settings/donate_page.dart index 4f20d118..f822f5d3 100644 --- a/lib/screens/settings/donate_page.dart +++ b/lib/screens/settings/donate_page.dart @@ -24,6 +24,7 @@ class DonatePage extends StatelessWidget { backgroundColor: colorScheme.surface, surfaceTintColor: Colors.transparent, leading: IconButton( + tooltip: MaterialLocalizations.of(context).backButtonTooltip, icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context), ), @@ -56,17 +57,14 @@ class DonatePage extends StatelessWidget { padding: const EdgeInsets.all(16), child: Column( children: [ - // Donate links card _DonateLinksCard(colorScheme: colorScheme), const SizedBox(height: 24), - // Recent donors section _RecentDonorsCard(colorScheme: colorScheme), const SizedBox(height: 16), - // Combined notice card Card( elevation: 0, color: colorScheme.secondaryContainer.withValues( @@ -166,7 +164,7 @@ class _RecentDonorsCard extends StatelessWidget { @override Widget build(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; - const donorNames = []; + const donorNames = ['a fan']; // Match SettingsGroup color logic final cardColor = isDark @@ -218,13 +216,17 @@ class _RecentDonorsCard extends StatelessWidget { Icon( Icons.emoji_events_outlined, size: 32, - color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), + 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), + color: colorScheme.onSurfaceVariant.withValues( + alpha: 0.6, + ), ), ), ], @@ -471,9 +473,12 @@ class _CryptoWalletItem extends StatelessWidget { int _cr(String v) { int r = 0x1F; - for (final c in v.codeUnits) { r = (r * 31 + c) & 0x7FFFFFFF; } + for (final c in v.codeUnits) { + r = (r * 31 + c) & 0x7FFFFFFF; + } return r; } + // Highlighted supporters (hashes of names): none for now. const _cv = {}; @@ -490,16 +495,10 @@ class _SupporterChip extends StatelessWidget { const goldAccentColor = Color(0xFFB8860B); const goldDarkChipColor = Color(0xFF3A3000); - final chipColor = e - ? goldChipColor - : colorScheme.secondaryContainer; - final accentColor = e - ? goldAccentColor - : colorScheme.primary; + 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; + final effectiveChipColor = e && isDark ? goldDarkChipColor : chipColor; return Material( color: effectiveChipColor, @@ -536,9 +535,7 @@ class _SupporterChip extends StatelessWidget { Text( name, style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: e - ? accentColor - : colorScheme.onSecondaryContainer, + 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 9d154404..e856c0a2 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -23,7 +23,7 @@ class DownloadSettingsPage extends ConsumerStatefulWidget { } class _DownloadSettingsPageState extends ConsumerState { - static const _builtInServices = ['tidal', 'qobuz', 'amazon', 'deezer']; + static const _builtInServices = ['tidal', 'qobuz', 'deezer']; static const _songLinkRegions = [ 'AD', 'AE', @@ -315,6 +315,7 @@ class _DownloadSettingsPageState extends ConsumerState { backgroundColor: colorScheme.surface, surfaceTintColor: Colors.transparent, leading: IconButton( + tooltip: MaterialLocalizations.of(context).backButtonTooltip, icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context), ), @@ -326,7 +327,7 @@ class _DownloadSettingsPageState extends ConsumerState { ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)) .clamp(0.0, 1.0); - final leftPadding = 56 - (32 * expandRatio); // 56 -> 24 + final leftPadding = 56 - (32 * expandRatio); return FlexibleSpaceBar( expandedTitleScale: 1.0, titlePadding: EdgeInsets.only( @@ -336,7 +337,7 @@ class _DownloadSettingsPageState extends ConsumerState { title: Text( context.l10n.downloadTitle, style: TextStyle( - fontSize: 20 + (8 * expandRatio), // 20 -> 28 + fontSize: 20 + (8 * expandRatio), fontWeight: FontWeight.bold, color: colorScheme.onSurface, ), @@ -450,7 +451,7 @@ class _DownloadSettingsPageState extends ConsumerState { const SizedBox(width: 8), Expanded( child: Text( - 'Select Tidal, Qobuz, or Amazon above to configure quality', + 'Select Tidal or Qobuz above to configure quality', style: Theme.of(context).textTheme.bodySmall ?.copyWith( color: colorScheme.onSurfaceVariant, @@ -732,7 +733,6 @@ class _DownloadSettingsPageState extends ConsumerState { ), ), - // Download Network Mode SliverToBoxAdapter( child: SettingsSectionHeader(title: context.l10n.sectionDownload), ), @@ -790,7 +790,6 @@ class _DownloadSettingsPageState extends ConsumerState { ), ), - // All Files Access section (Android 13+ only) if (Platform.isAndroid && _androidSdkVersion >= 33) ...[ SliverToBoxAdapter( child: SettingsSectionHeader( @@ -1418,6 +1417,8 @@ class _DownloadSettingsPageState extends ConsumerState { String _getFolderOrganizationLabel(String value) { switch (value) { + case 'playlist': + return 'By Playlist'; case 'artist': return 'By Artist'; case 'album': @@ -1995,6 +1996,18 @@ class _DownloadSettingsPageState extends ConsumerState { Navigator.pop(context); }, ), + _FolderOption( + title: context.l10n.folderOrganizationByPlaylist, + subtitle: context.l10n.folderOrganizationByPlaylistSubtitle, + example: 'SpotiFLAC/Playlist Name/Track.flac', + isSelected: current == 'playlist', + onTap: () { + ref + .read(settingsProvider.notifier) + .setFolderOrganization('playlist'); + Navigator.pop(context); + }, + ), _FolderOption( title: context.l10n.folderOrganizationByArtist, subtitle: context.l10n.folderOrganizationByArtistSubtitle, @@ -2051,7 +2064,7 @@ class _ServiceSelector extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final extState = ref.watch(extensionProvider); - final builtInServiceIds = ['tidal', 'qobuz', 'amazon', 'deezer', 'youtube']; + final builtInServiceIds = ['tidal', 'qobuz', 'deezer', 'youtube']; final extensionProviders = extState.extensions .where((e) => e.enabled && e.hasDownloadProvider) @@ -2088,15 +2101,6 @@ class _ServiceSelector extends ConsumerWidget { ), ), const SizedBox(width: 8), - 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, diff --git a/lib/screens/settings/extension_detail_page.dart b/lib/screens/settings/extension_detail_page.dart index 2268a793..0a702946 100644 --- a/lib/screens/settings/extension_detail_page.dart +++ b/lib/screens/settings/extension_detail_page.dart @@ -15,7 +15,8 @@ class ExtensionDetailPage extends ConsumerStatefulWidget { const ExtensionDetailPage({super.key, required this.extensionId}); @override - ConsumerState createState() => _ExtensionDetailPageState(); + ConsumerState createState() => + _ExtensionDetailPageState(); } class _ExtensionDetailPageState extends ConsumerState { @@ -65,320 +66,373 @@ class _ExtensionDetailPageState extends ConsumerState { 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( - extension.displayName, - style: TextStyle( - fontSize: 20 + (8 * expandRatio), - fontWeight: FontWeight.bold, - color: colorScheme.onSurface, + expandedHeight: 120 + topPadding, + collapsedHeight: kToolbarHeight, + floating: false, + pinned: true, + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + leading: IconButton( + tooltip: MaterialLocalizations.of(context).backButtonTooltip, + 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( + extension.displayName, + style: TextStyle( + fontSize: 20 + (8 * expandRatio), + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + ); + }, + ), ), - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(16), - child: Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(20), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - width: 56, - height: 56, - decoration: BoxDecoration( - color: hasError - ? colorScheme.errorContainer - : colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(16), - ), - child: extension.iconPath != null && extension.iconPath!.isNotEmpty - ? ClipRRect( - borderRadius: BorderRadius.circular(16), - child: Image.file( - File(extension.iconPath!), - width: 56, - height: 56, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) => Icon( - hasError ? Icons.error_outline : Icons.extension, - size: 28, - color: hasError - ? colorScheme.error - : colorScheme.onPrimaryContainer, + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(16), + child: Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest.withValues( + alpha: 0.3, + ), + borderRadius: BorderRadius.circular(20), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: hasError + ? colorScheme.errorContainer + : colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(16), + ), + child: + extension.iconPath != null && + extension.iconPath!.isNotEmpty + ? ClipRRect( + borderRadius: BorderRadius.circular(16), + child: Image.file( + File(extension.iconPath!), + width: 56, + height: 56, + fit: BoxFit.cover, + errorBuilder: + (context, error, stackTrace) => Icon( + hasError + ? Icons.error_outline + : Icons.extension, + size: 28, + color: hasError + ? colorScheme.error + : colorScheme + .onPrimaryContainer, + ), ), + ) + : Icon( + hasError + ? Icons.error_outline + : Icons.extension, + size: 28, + color: hasError + ? colorScheme.error + : colorScheme.onPrimaryContainer, ), - ) - : Icon( - hasError ? Icons.error_outline : Icons.extension, - size: 28, - color: hasError - ? colorScheme.error - : colorScheme.onPrimaryContainer, - ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - extension.displayName, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - Text( - 'v${extension.version}', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], ), - ), - Switch( - value: extension.enabled, - onChanged: hasError - ? null - : (enabled) => ref - .read(extensionProvider.notifier) - .setExtensionEnabled(widget.extensionId, enabled), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + extension.displayName, + style: Theme.of(context).textTheme.titleLarge + ?.copyWith(fontWeight: FontWeight.bold), + ), + Text( + 'v${extension.version}', + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + Switch( + value: extension.enabled, + onChanged: hasError + ? null + : (enabled) => ref + .read(extensionProvider.notifier) + .setExtensionEnabled( + widget.extensionId, + enabled, + ), + ), + ], + ), + if (extension.description.isNotEmpty) ...[ + const SizedBox(height: 16), + Text( + extension.description, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: colorScheme.onSurfaceVariant), ), ], - ), - if (extension.description.isNotEmpty) ...[ const SizedBox(height: 16), - Text( - extension.description, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - const SizedBox(height: 16), - _InfoRow(label: context.l10n.extensionAuthor, value: extension.author), - _InfoRow(label: context.l10n.extensionId, value: extension.id), - _InfoRow(label: context.l10n.extensionsVersion(extension.version), value: ''), - if (hasError && extension.errorMessage != null) _InfoRow( - label: context.l10n.extensionError, - value: extension.errorMessage!, - isError: true, + label: context.l10n.extensionAuthor, + value: extension.author, ), - ], + _InfoRow( + label: context.l10n.extensionId, + value: extension.id, + ), + _InfoRow( + label: context.l10n.extensionsVersion( + extension.version, + ), + value: '', + ), + if (hasError && extension.errorMessage != null) + _InfoRow( + label: context.l10n.extensionError, + value: extension.errorMessage!, + isError: true, + ), + ], + ), ), ), ), - ), - SliverToBoxAdapter( - child: SettingsSectionHeader(title: context.l10n.extensionCapabilities), - ), - SliverToBoxAdapter( - child: SettingsGroup( - children: [ - _CapabilityItem( - icon: Icons.search, - title: context.l10n.extensionMetadataProvider, - enabled: extension.hasMetadataProvider, - ), - _CapabilityItem( - icon: Icons.download, - title: context.l10n.extensionDownloadProvider, - enabled: extension.hasDownloadProvider, - ), - _CapabilityItem( - icon: Icons.lyrics, - title: context.l10n.extensionLyricsProvider, - enabled: extension.hasLyricsProvider, - ), - _CapabilityItem( - icon: Icons.manage_search, - title: context.l10n.extensionsSearchProvider, - enabled: extension.hasCustomSearch, - subtitle: extension.searchBehavior?.placeholder, - ), - _CapabilityItem( - icon: Icons.compare_arrows, - title: context.l10n.extensionCustomTrackMatching, - enabled: extension.hasCustomMatching, - subtitle: extension.trackMatching?.strategy != null - ? context.l10n.extensionStrategy(extension.trackMatching!.strategy!) - : null, - ), - _CapabilityItem( - icon: Icons.auto_fix_high, - title: context.l10n.extensionPostProcessing, - enabled: extension.hasPostProcessing, - subtitle: extension.postProcessing?.hooks.isNotEmpty == true - ? context.l10n.extensionHooksAvailable(extension.postProcessing!.hooks.length) - : null, - ), - _CapabilityItem( - icon: Icons.link, - title: context.l10n.extensionUrlHandler, - enabled: extension.hasURLHandler, - subtitle: extension.urlHandler?.patterns.isNotEmpty == true - ? context.l10n.extensionPatternsCount(extension.urlHandler!.patterns.length) - : null, - showDivider: false, - ), - ], - ), - ), - - if (extension.hasURLHandler && extension.urlHandler!.patterns.isNotEmpty) ...[ SliverToBoxAdapter( - child: SettingsSectionHeader(title: context.l10n.extensionUrlHandler), + child: SettingsSectionHeader( + title: context.l10n.extensionCapabilities, + ), ), SliverToBoxAdapter( child: SettingsGroup( children: [ - _URLHandlerInfo( - patterns: extension.urlHandler!.patterns, + _CapabilityItem( + icon: Icons.search, + title: context.l10n.extensionMetadataProvider, + enabled: extension.hasMetadataProvider, + ), + _CapabilityItem( + icon: Icons.download, + title: context.l10n.extensionDownloadProvider, + enabled: extension.hasDownloadProvider, + ), + _CapabilityItem( + icon: Icons.lyrics, + title: context.l10n.extensionLyricsProvider, + enabled: extension.hasLyricsProvider, + ), + _CapabilityItem( + icon: Icons.manage_search, + title: context.l10n.extensionsSearchProvider, + enabled: extension.hasCustomSearch, + subtitle: extension.searchBehavior?.placeholder, + ), + _CapabilityItem( + icon: Icons.compare_arrows, + title: context.l10n.extensionCustomTrackMatching, + enabled: extension.hasCustomMatching, + subtitle: extension.trackMatching?.strategy != null + ? context.l10n.extensionStrategy( + extension.trackMatching!.strategy!, + ) + : null, + ), + _CapabilityItem( + icon: Icons.auto_fix_high, + title: context.l10n.extensionPostProcessing, + enabled: extension.hasPostProcessing, + subtitle: extension.postProcessing?.hooks.isNotEmpty == true + ? context.l10n.extensionHooksAvailable( + extension.postProcessing!.hooks.length, + ) + : null, + ), + _CapabilityItem( + icon: Icons.link, + title: context.l10n.extensionUrlHandler, + enabled: extension.hasURLHandler, + subtitle: extension.urlHandler?.patterns.isNotEmpty == true + ? context.l10n.extensionPatternsCount( + extension.urlHandler!.patterns.length, + ) + : null, + showDivider: false, ), ], ), ), - ], - if (extension.hasDownloadProvider && extension.qualityOptions.isNotEmpty) ...[ - SliverToBoxAdapter( - child: SettingsSectionHeader(title: context.l10n.extensionQualityOptions), - ), - SliverToBoxAdapter( - child: SettingsGroup( - children: extension.qualityOptions.asMap().entries.map((entry) { - final index = entry.key; - final quality = entry.value; - return _QualityOptionItem( - quality: quality, - showDivider: index < extension.qualityOptions.length - 1, - ); - }).toList(), - ), - ), - ], - - if (extension.hasPostProcessing && extension.postProcessing!.hooks.isNotEmpty) ...[ - SliverToBoxAdapter( - child: SettingsSectionHeader(title: context.l10n.extensionPostProcessingHooks), - ), - SliverToBoxAdapter( - child: SettingsGroup( - children: extension.postProcessing!.hooks.asMap().entries.map((entry) { - final index = entry.key; - final hook = entry.value; - return _PostProcessingHookItem( - hook: hook, - showDivider: index < extension.postProcessing!.hooks.length - 1, - ); - }).toList(), - ), - ), - ], - - if (extension.permissions.isNotEmpty) ...[ - SliverToBoxAdapter( - child: SettingsSectionHeader(title: context.l10n.extensionPermissions), - ), - SliverToBoxAdapter( - child: SettingsGroup( - children: extension.permissions.asMap().entries.map((entry) { - final index = entry.key; - final permission = entry.value; - return _PermissionItem( - permission: permission, - showDivider: index < extension.permissions.length - 1, - ); - }).toList(), - ), - ), - ], - - if (extension.settings.isNotEmpty) ...[ - SliverToBoxAdapter( - child: SettingsSectionHeader(title: context.l10n.extensionSettings), - ), - if (_isLoadingSettings) - const SliverToBoxAdapter( - child: Padding( - padding: EdgeInsets.all(32), - child: Center(child: CircularProgressIndicator()), + if (extension.hasURLHandler && + extension.urlHandler!.patterns.isNotEmpty) ...[ + SliverToBoxAdapter( + child: SettingsSectionHeader( + title: context.l10n.extensionUrlHandler, ), - ) - else + ), SliverToBoxAdapter( child: SettingsGroup( - children: extension.settings.asMap().entries.map((entry) { + children: [ + _URLHandlerInfo(patterns: extension.urlHandler!.patterns), + ], + ), + ), + ], + + if (extension.hasDownloadProvider && + extension.qualityOptions.isNotEmpty) ...[ + SliverToBoxAdapter( + child: SettingsSectionHeader( + title: context.l10n.extensionQualityOptions, + ), + ), + SliverToBoxAdapter( + child: SettingsGroup( + children: extension.qualityOptions.asMap().entries.map(( + entry, + ) { final index = entry.key; - final setting = entry.value; - return _SettingItem( - setting: setting, - value: _settings[setting.key] ?? setting.defaultValue, - showDivider: index < extension.settings.length - 1, - onChanged: (value) => _updateSetting(setting.key, value), - extensionId: widget.extensionId, + final quality = entry.value; + return _QualityOptionItem( + quality: quality, + showDivider: index < extension.qualityOptions.length - 1, ); }).toList(), ), ), - ], + ], - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(16), - child: OutlinedButton.icon( - onPressed: () => _confirmRemove(context), - icon: const Icon(Icons.delete_outline), - label: Text(context.l10n.extensionRemoveButton), - style: OutlinedButton.styleFrom( - foregroundColor: colorScheme.error, - side: BorderSide(color: colorScheme.error), - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), + if (extension.hasPostProcessing && + extension.postProcessing!.hooks.isNotEmpty) ...[ + SliverToBoxAdapter( + child: SettingsSectionHeader( + title: context.l10n.extensionPostProcessingHooks, + ), + ), + SliverToBoxAdapter( + child: SettingsGroup( + children: extension.postProcessing!.hooks.asMap().entries.map( + (entry) { + final index = entry.key; + final hook = entry.value; + return _PostProcessingHookItem( + hook: hook, + showDivider: + index < extension.postProcessing!.hooks.length - 1, + ); + }, + ).toList(), + ), + ), + ], + + if (extension.permissions.isNotEmpty) ...[ + SliverToBoxAdapter( + child: SettingsSectionHeader( + title: context.l10n.extensionPermissions, + ), + ), + SliverToBoxAdapter( + child: SettingsGroup( + children: extension.permissions.asMap().entries.map((entry) { + final index = entry.key; + final permission = entry.value; + return _PermissionItem( + permission: permission, + showDivider: index < extension.permissions.length - 1, + ); + }).toList(), + ), + ), + ], + + if (extension.settings.isNotEmpty) ...[ + SliverToBoxAdapter( + child: SettingsSectionHeader( + title: context.l10n.extensionSettings, + ), + ), + if (_isLoadingSettings) + const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.all(32), + child: Center(child: CircularProgressIndicator()), + ), + ) + else + SliverToBoxAdapter( + child: SettingsGroup( + children: extension.settings.asMap().entries.map((entry) { + final index = entry.key; + final setting = entry.value; + return _SettingItem( + setting: setting, + value: _settings[setting.key] ?? setting.defaultValue, + showDivider: index < extension.settings.length - 1, + onChanged: (value) => + _updateSetting(setting.key, value), + extensionId: widget.extensionId, + ); + }).toList(), + ), + ), + ], + + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(16), + child: OutlinedButton.icon( + onPressed: () => _confirmRemove(context), + icon: const Icon(Icons.delete_outline), + label: Text(context.l10n.extensionRemoveButton), + style: OutlinedButton.styleFrom( + foregroundColor: colorScheme.error, + side: BorderSide(color: colorScheme.error), + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), ), ), ), ), - ), - const SliverToBoxAdapter(child: SizedBox(height: 32)), - ], + const SliverToBoxAdapter(child: SizedBox(height: 32)), + ], + ), ), - ), ); } @@ -397,9 +451,7 @@ class _ExtensionDetailPageState extends ConsumerState { context: context, builder: (context) => AlertDialog( title: Text(context.l10n.dialogRemoveExtension), - content: Text( - context.l10n.dialogRemoveExtensionMessage, - ), + content: Text(context.l10n.dialogRemoveExtensionMessage), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), @@ -407,9 +459,7 @@ class _ExtensionDetailPageState extends ConsumerState { ), FilledButton( onPressed: () => Navigator.pop(context, true), - style: FilledButton.styleFrom( - backgroundColor: colorScheme.error, - ), + style: FilledButton.styleFrom(backgroundColor: colorScheme.error), child: Text(context.l10n.dialogRemove), ), ], @@ -504,10 +554,7 @@ class _CapabilityItem extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - title, - style: Theme.of(context).textTheme.bodyLarge, - ), + Text(title, style: Theme.of(context).textTheme.bodyLarge), if (subtitle != null && enabled) ...[ const SizedBox(height: 2), Text( @@ -544,18 +591,15 @@ class _PermissionItem extends StatelessWidget { final String permission; final bool showDivider; - const _PermissionItem({ - required this.permission, - this.showDivider = true, - }); + const _PermissionItem({required this.permission, this.showDivider = true}); @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - + IconData icon = Icons.security; String description = permission; - + if (permission.startsWith('network:')) { icon = Icons.language; description = 'Network access to: ${permission.substring(8)}'; @@ -658,7 +702,6 @@ class _SettingItemState extends State<_SettingItem> { ); } - // For button type, show a different layout if (widget.setting.type == 'button') { return Column( mainAxisSize: MainAxisSize.min, @@ -674,9 +717,8 @@ class _SettingItemState extends State<_SettingItem> { if (widget.setting.description != null) ...[ Text( widget.setting.description!, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: colorScheme.onSurfaceVariant), ), const SizedBox(height: 12), ], @@ -703,7 +745,8 @@ class _SettingItemState extends State<_SettingItem> { mainAxisSize: MainAxisSize.min, children: [ InkWell( - onTap: widget.setting.type == 'string' || widget.setting.type == 'number' + onTap: + widget.setting.type == 'string' || widget.setting.type == 'number' ? () => _showEditDialog(context) : null, child: Padding( @@ -722,18 +765,17 @@ class _SettingItemState extends State<_SettingItem> { const SizedBox(height: 2), Text( widget.setting.description!, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: colorScheme.onSurfaceVariant), ), ], - if (widget.setting.type == 'string' || widget.setting.type == 'number') ...[ + if (widget.setting.type == 'string' || + widget.setting.type == 'number') ...[ const SizedBox(height: 4), Text( widget.value?.toString() ?? 'Not set', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.primary, - ), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: colorScheme.primary), ), ], ], @@ -776,23 +818,23 @@ class _SettingItemState extends State<_SettingItem> { final success = result['success'] as bool? ?? false; if (!success) { final error = result['error'] as String? ?? 'Action failed'; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(error)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(error))); } else { final message = result['message'] as String?; if (message != null) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(message)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); } } } } catch (e) { if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Error: $e')), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Error: $e'))); } } finally { if (mounted) { @@ -802,7 +844,9 @@ class _SettingItemState extends State<_SettingItem> { } void _showEditDialog(BuildContext context) { - final controller = TextEditingController(text: widget.value?.toString() ?? ''); + final controller = TextEditingController( + text: widget.value?.toString() ?? '', + ); final colorScheme = Theme.of(context).colorScheme; showDialog( @@ -817,7 +861,9 @@ class _SettingItemState extends State<_SettingItem> { decoration: InputDecoration( hintText: widget.setting.description ?? 'Enter value', filled: true, - fillColor: colorScheme.surfaceContainerHighest.withValues(alpha: 0.3), + fillColor: colorScheme.surfaceContainerHighest.withValues( + alpha: 0.3, + ), border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none, @@ -849,15 +895,12 @@ class _PostProcessingHookItem extends StatelessWidget { final PostProcessingHook hook; final bool showDivider; - const _PostProcessingHookItem({ - required this.hook, - this.showDivider = true, - }); + const _PostProcessingHookItem({required this.hook, this.showDivider = true}); @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - + return Column( mainAxisSize: MainAxisSize.min, children: [ @@ -904,16 +947,20 @@ class _PostProcessingHookItem extends StatelessWidget { spacing: 4, children: hook.supportedFormats.map((format) { return Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), decoration: BoxDecoration( color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(4), ), child: Text( format.toUpperCase(), - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: colorScheme.onSurfaceVariant, + ), ), ); }).toList(), @@ -924,7 +971,10 @@ class _PostProcessingHookItem extends StatelessWidget { ), if (hook.defaultEnabled) Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), decoration: BoxDecoration( color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(8), @@ -952,19 +1002,15 @@ class _PostProcessingHookItem extends StatelessWidget { } } - - class _URLHandlerInfo extends StatelessWidget { final List patterns; - const _URLHandlerInfo({ - required this.patterns, - }); + const _URLHandlerInfo({required this.patterns}); @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - + return Padding( padding: const EdgeInsets.all(16), child: Column( @@ -1014,7 +1060,10 @@ class _URLHandlerInfo extends StatelessWidget { runSpacing: 8, children: patterns.map((pattern) { return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), decoration: BoxDecoration( color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), @@ -1022,11 +1071,7 @@ class _URLHandlerInfo extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon( - Icons.language, - size: 16, - color: colorScheme.primary, - ), + Icon(Icons.language, size: 16, color: colorScheme.primary), const SizedBox(width: 6), Text( pattern, @@ -1049,11 +1094,7 @@ class _URLHandlerInfo extends StatelessWidget { ), child: Row( children: [ - Icon( - Icons.info_outline, - size: 20, - color: colorScheme.primary, - ), + Icon(Icons.info_outline, size: 20, color: colorScheme.primary), const SizedBox(width: 12), Expanded( child: Text( @@ -1076,15 +1117,12 @@ class _QualityOptionItem extends StatelessWidget { final QualityOption quality; final bool showDivider; - const _QualityOptionItem({ - required this.quality, - this.showDivider = true, - }); + const _QualityOptionItem({required this.quality, this.showDivider = true}); @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - + return Column( mainAxisSize: MainAxisSize.min, children: [ @@ -1116,7 +1154,8 @@ class _QualityOptionItem extends StatelessWidget { fontWeight: FontWeight.w500, ), ), - if (quality.description != null && quality.description!.isNotEmpty) ...[ + if (quality.description != null && + quality.description!.isNotEmpty) ...[ const SizedBox(height: 2), Text( quality.description!, @@ -1138,7 +1177,10 @@ class _QualityOptionItem extends StatelessWidget { ), if (quality.settings.isNotEmpty) Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), decoration: BoxDecoration( color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), diff --git a/lib/screens/settings/extensions_page.dart b/lib/screens/settings/extensions_page.dart index d339e24d..196dd0aa 100644 --- a/lib/screens/settings/extensions_page.dart +++ b/lib/screens/settings/extensions_page.dart @@ -72,6 +72,7 @@ class _ExtensionsPageState extends ConsumerState { backgroundColor: colorScheme.surface, surfaceTintColor: Colors.transparent, leading: IconButton( + tooltip: MaterialLocalizations.of(context).backButtonTooltip, icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context), ), diff --git a/lib/screens/settings/library_settings_page.dart b/lib/screens/settings/library_settings_page.dart index d8d32dda..e71c1046 100644 --- a/lib/screens/settings/library_settings_page.dart +++ b/lib/screens/settings/library_settings_page.dart @@ -132,7 +132,23 @@ class _LibrarySettingsPageState extends ConsumerState { // Fallback for older devices final result = await FilePicker.platform.getDirectoryPath(); if (result != null) { - ref.read(settingsProvider.notifier).setLocalLibraryPath(result); + if (Platform.isIOS) { + // On iOS, create a security-scoped bookmark so we can access + // this folder across app restarts and from the Go backend. + final bookmark = + await PlatformBridge.createIosBookmarkFromPath(result); + if (bookmark != null && bookmark.isNotEmpty) { + ref + .read(settingsProvider.notifier) + .setLocalLibraryPathAndBookmark(result, bookmark); + } else { + // Bookmark creation failed; save path anyway (works for + // app-internal folders like Documents/). + ref.read(settingsProvider.notifier).setLocalLibraryPath(result); + } + } else { + ref.read(settingsProvider.notifier).setLocalLibraryPath(result); + } } } } @@ -140,6 +156,7 @@ class _LibrarySettingsPageState extends ConsumerState { Future _startScan({bool forceFullScan = false}) async { final settings = ref.read(settingsProvider); final libraryPath = settings.localLibraryPath; + final iosBookmark = settings.localLibraryBookmark; if (libraryPath.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( @@ -148,7 +165,14 @@ class _LibrarySettingsPageState extends ConsumerState { return; } - if (!libraryPath.startsWith('content://') && + // On iOS with a bookmark, try resolving the bookmark first to validate + // access instead of checking the path directly (which may fail outside + // the app sandbox). + if (Platform.isIOS && iosBookmark.isNotEmpty) { + // Bookmark will be resolved inside startScan; skip Directory.exists + // check since security-scoped paths are not accessible without the + // bookmark being activated. + } else if (!libraryPath.startsWith('content://') && !await Directory(libraryPath).exists()) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -158,9 +182,11 @@ class _LibrarySettingsPageState extends ConsumerState { return; } - await ref - .read(localLibraryProvider.notifier) - .startScan(libraryPath, forceFullScan: forceFullScan); + await ref.read(localLibraryProvider.notifier).startScan( + libraryPath, + forceFullScan: forceFullScan, + iosBookmark: iosBookmark.isNotEmpty ? iosBookmark : null, + ); } Future _cancelScan() async { @@ -200,9 +226,12 @@ class _LibrarySettingsPageState extends ConsumerState { } Future _cleanupMissingFiles() async { + final iosBookmark = ref.read(settingsProvider).localLibraryBookmark; final removed = await ref .read(localLibraryProvider.notifier) - .cleanupMissingFiles(); + .cleanupMissingFiles( + iosBookmark: iosBookmark.isNotEmpty ? iosBookmark : null, + ); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -230,6 +259,7 @@ class _LibrarySettingsPageState extends ConsumerState { backgroundColor: colorScheme.surface, surfaceTintColor: Colors.transparent, leading: IconButton( + tooltip: MaterialLocalizations.of(context).backButtonTooltip, icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context), ), @@ -271,7 +301,6 @@ class _LibrarySettingsPageState extends ConsumerState { ), ), - // Scan Settings Section SliverToBoxAdapter( child: SettingsSectionHeader( title: context.l10n.libraryScanSettings, @@ -442,7 +471,6 @@ class _LibrarySettingsPageState extends ConsumerState { ), ], - // Info Section SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), @@ -558,7 +586,6 @@ class _LibraryHeroCard extends StatelessWidget { clipBehavior: Clip.antiAlias, child: Stack( children: [ - // Background decorative elements Positioned( right: -20, top: -20, @@ -581,7 +608,6 @@ class _LibraryHeroCard extends StatelessWidget { ), ), - // Content Padding( padding: const EdgeInsets.all(24), child: Column( diff --git a/lib/screens/settings/log_screen.dart b/lib/screens/settings/log_screen.dart index 310d0805..611db06f 100644 --- a/lib/screens/settings/log_screen.dart +++ b/lib/screens/settings/log_screen.dart @@ -6,8 +6,10 @@ import 'package:spotiflac_android/utils/app_bar_layout.dart'; import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; -final RegExp _domainPattern = - RegExp(r'domain:\s*([^\s,]+)', caseSensitive: false); +final RegExp _domainPattern = RegExp( + r'domain:\s*([^\s,]+)', + caseSensitive: false, +); class LogScreen extends StatefulWidget { const LogScreen({super.key}); @@ -17,7 +19,6 @@ class LogScreen extends StatefulWidget { } class _LogScreenState extends State { - final ScrollController _scrollController = ScrollController(); final TextEditingController _searchController = TextEditingController(); String _selectedLevel = 'ALL'; @@ -74,7 +75,9 @@ class _LogScreenState extends State { SnackBar( content: Text(context.l10n.logCopied), behavior: SnackBarBehavior.floating, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), duration: const Duration(seconds: 2), ), ); @@ -83,7 +86,9 @@ class _LogScreenState extends State { void _shareLogs() async { final logs = await LogBuffer().exportWithDeviceInfo(); - SharePlus.instance.share(ShareParams(text: logs, subject: 'SpotiFLAC Logs')); + SharePlus.instance.share( + ShareParams(text: logs, subject: 'SpotiFLAC Logs'), + ); } void _clearLogs() { @@ -137,52 +142,58 @@ class _LogScreenState extends State { controller: _scrollController, 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), - ), - actions: [ - IconButton( - icon: Icon(_autoScroll ? Icons.vertical_align_bottom : Icons.vertical_align_center), - tooltip: _autoScroll ? 'Auto-scroll ON' : 'Auto-scroll OFF', - onPressed: () => setState(() => _autoScroll = !_autoScroll), + expandedHeight: 120 + topPadding, + collapsedHeight: kToolbarHeight, + floating: false, + pinned: true, + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + leading: IconButton( + tooltip: MaterialLocalizations.of(context).backButtonTooltip, + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.pop(context), ), - IconButton( - icon: const Icon(Icons.copy), - tooltip: 'Copy logs', - onPressed: _copyLogs, - ), - PopupMenuButton( - icon: const Icon(Icons.more_vert), - onSelected: (value) { - switch (value) { - case 'share': - _shareLogs(); - break; - case 'clear': - _clearLogs(); - break; - } - }, - itemBuilder: (context) => [ - PopupMenuItem( - value: 'share', - child: ListTile( - leading: const Icon(Icons.share), - title: Text(context.l10n.logShareLogs), - contentPadding: EdgeInsets.zero, - ), + actions: [ + IconButton( + icon: Icon( + _autoScroll + ? Icons.vertical_align_bottom + : Icons.vertical_align_center, ), - PopupMenuItem( - value: 'clear', - child: ListTile( - leading: const Icon(Icons.delete_outline), + tooltip: _autoScroll ? 'Auto-scroll ON' : 'Auto-scroll OFF', + onPressed: () => setState(() => _autoScroll = !_autoScroll), + ), + IconButton( + icon: const Icon(Icons.copy), + tooltip: 'Copy logs', + onPressed: _copyLogs, + ), + PopupMenuButton( + icon: const Icon(Icons.more_vert), + tooltip: MaterialLocalizations.of(context).showMenuTooltip, + onSelected: (value) { + switch (value) { + case 'share': + _shareLogs(); + break; + case 'clear': + _clearLogs(); + break; + } + }, + itemBuilder: (context) => [ + PopupMenuItem( + value: 'share', + child: ListTile( + leading: const Icon(Icons.share), + title: Text(context.l10n.logShareLogs), + contentPadding: EdgeInsets.zero, + ), + ), + PopupMenuItem( + value: 'clear', + child: ListTile( + leading: const Icon(Icons.delete_outline), title: Text(context.l10n.logClearLogs), contentPadding: EdgeInsets.zero, ), @@ -194,11 +205,17 @@ class _LogScreenState extends State { 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.logTitle, style: TextStyle( @@ -213,28 +230,40 @@ class _LogScreenState extends State { ), SliverToBoxAdapter( - child: SettingsSectionHeader(title: context.l10n.logFilterSection), + child: SettingsSectionHeader( + title: context.l10n.logFilterSection, + ), ), SliverToBoxAdapter( child: SettingsGroup( children: [ Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 12, + ), child: Row( children: [ - Icon(Icons.filter_list, color: colorScheme.onSurfaceVariant), + Icon( + Icons.filter_list, + color: colorScheme.onSurfaceVariant, + ), const SizedBox(width: 16), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(context.l10n.logFilterLevel, style: Theme.of(context).textTheme.bodyLarge), + Text( + context.l10n.logFilterLevel, + style: Theme.of(context).textTheme.bodyLarge, + ), const SizedBox(height: 2), Text( context.l10n.logFilterBySeverity, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith( + color: colorScheme.onSurfaceVariant, + ), ), ], ), @@ -248,8 +277,8 @@ class _LogScreenState extends State { child: Text( level, style: TextStyle( - color: level == 'ALL' - ? colorScheme.onSurface + color: level == 'ALL' + ? colorScheme.onSurface : _getLevelColor(level, colorScheme), fontWeight: FontWeight.w500, ), @@ -272,7 +301,10 @@ class _LogScreenState extends State { color: colorScheme.outlineVariant.withValues(alpha: 0.3), ), Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 12, + ), child: Row( children: [ Icon(Icons.search, color: colorScheme.onSurfaceVariant), @@ -295,6 +327,7 @@ class _LogScreenState extends State { fillColor: colorScheme.surfaceContainerHighest, suffixIcon: _searchQuery.isNotEmpty ? IconButton( + tooltip: 'Clear search', icon: const Icon(Icons.clear, size: 20), onPressed: () { _searchController.clear(); @@ -317,16 +350,16 @@ class _LogScreenState extends State { SliverToBoxAdapter( child: SettingsSectionHeader( - title: _selectedLevel != 'ALL' || _searchQuery.isNotEmpty + title: _selectedLevel != 'ALL' || _searchQuery.isNotEmpty ? context.l10n.logEntriesFiltered(logs.length) : context.l10n.logEntries(logs.length), ), ), - + SliverToBoxAdapter( child: _LogSummaryCard(logs: LogBuffer().entries), ), - + logs.isEmpty ? SliverToBoxAdapter( child: SettingsGroup( @@ -339,21 +372,26 @@ class _LogScreenState extends State { Icon( Icons.article_outlined, size: 48, - color: colorScheme.onSurfaceVariant.withValues(alpha: 0.5), + color: colorScheme.onSurfaceVariant.withValues( + alpha: 0.5, + ), ), const SizedBox(height: 16), Text( context.l10n.logNoLogsYet, - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + style: Theme.of(context).textTheme.bodyLarge + ?.copyWith( + color: colorScheme.onSurfaceVariant, + ), ), const SizedBox(height: 4), Text( context.l10n.logNoLogsYetSubtitle, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7), - ), + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith( + color: colorScheme.onSurfaceVariant + .withValues(alpha: 0.7), + ), ), ], ), @@ -408,7 +446,7 @@ class _LogEntryTile extends StatelessWidget { width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), decoration: BoxDecoration( - color: isError + color: isError ? colorScheme.errorContainer.withValues(alpha: 0.2) : null, ), @@ -427,7 +465,10 @@ class _LogEntryTile extends StatelessWidget { ), const SizedBox(width: 8), Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), decoration: BoxDecoration( color: levelColor.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(6), @@ -444,7 +485,10 @@ class _LogEntryTile extends StatelessWidget { if (entry.isFromGo) ...[ const SizedBox(width: 4), Container( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 2, + ), decoration: BoxDecoration( color: Colors.teal.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(4), @@ -519,9 +563,9 @@ class _LogSummaryCard extends StatelessWidget { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - + final analysis = _analyzeLogs(); - + if (!analysis.hasIssues) { return const SizedBox.shrink(); } @@ -530,7 +574,7 @@ class _LogSummaryCard extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: Card( elevation: 0, - color: analysis.hasISPBlocking + color: analysis.hasISPBlocking ? colorScheme.errorContainer.withValues(alpha: 0.5) : colorScheme.tertiaryContainer.withValues(alpha: 0.5), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), @@ -542,9 +586,13 @@ class _LogSummaryCard extends StatelessWidget { Row( children: [ Icon( - analysis.hasISPBlocking ? Icons.block : Icons.warning_amber_rounded, + analysis.hasISPBlocking + ? Icons.block + : Icons.warning_amber_rounded, size: 20, - color: analysis.hasISPBlocking ? colorScheme.error : colorScheme.tertiary, + color: analysis.hasISPBlocking + ? colorScheme.error + : colorScheme.tertiary, ), const SizedBox(width: 8), Text( @@ -557,19 +605,21 @@ class _LogSummaryCard extends StatelessWidget { ], ), const SizedBox(height: 12), - + if (analysis.hasISPBlocking) ...[ _IssueBadge( icon: Icons.block, label: 'ISP BLOCKING DETECTED', - description: 'Your ISP may be blocking access to download services', - suggestion: 'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8', + description: + 'Your ISP may be blocking access to download services', + suggestion: + 'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8', color: colorScheme.error, domains: analysis.blockedDomains, ), const SizedBox(height: 8), ], - + if (analysis.hasRateLimit) ...[ _IssueBadge( icon: Icons.speed, @@ -580,7 +630,7 @@ class _LogSummaryCard extends StatelessWidget { ), const SizedBox(height: 8), ], - + if (analysis.hasNetworkError && !analysis.hasISPBlocking) ...[ _IssueBadge( icon: Icons.wifi_off, @@ -591,17 +641,19 @@ class _LogSummaryCard extends StatelessWidget { ), const SizedBox(height: 8), ], - + if (analysis.hasNotFound) ...[ _IssueBadge( icon: Icons.search_off, label: 'TRACK NOT FOUND', - description: 'Some tracks could not be found on download services', - suggestion: 'The track may not be available in lossless quality', + description: + 'Some tracks could not be found on download services', + suggestion: + 'The track may not be available in lossless quality', color: colorScheme.onSurfaceVariant, ), ], - + const SizedBox(height: 12), Text( 'Total errors: ${analysis.errorCount}', @@ -639,7 +691,7 @@ class _LogSummaryCard extends StatelessWidget { combined.contains('connection reset') || combined.contains('connection refused')) { hasISPBlocking = true; - + final domainMatch = _domainPattern.firstMatch(combined); if (domainMatch != null) { blockedDomains.add(domainMatch.group(1)!); @@ -694,7 +746,12 @@ class _LogAnalysis { required this.blockedDomains, }); - bool get hasIssues => errorCount > 0 || hasISPBlocking || hasRateLimit || hasNetworkError || hasNotFound; + bool get hasIssues => + errorCount > 0 || + hasISPBlocking || + hasRateLimit || + hasNetworkError || + hasNotFound; } class _IssueBadge extends StatelessWidget { @@ -717,7 +774,7 @@ class _IssueBadge extends StatelessWidget { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - + return Container( width: double.infinity, padding: const EdgeInsets.all(12), @@ -746,9 +803,9 @@ class _IssueBadge extends StatelessWidget { const SizedBox(height: 6), Text( description, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurface, - ), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: colorScheme.onSurface), ), if (domains != null && domains!.isNotEmpty) ...[ const SizedBox(height: 4), @@ -765,7 +822,11 @@ class _IssueBadge extends StatelessWidget { Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(Icons.lightbulb_outline, size: 14, color: colorScheme.primary), + Icon( + Icons.lightbulb_outline, + size: 14, + color: colorScheme.primary, + ), const SizedBox(width: 4), Expanded( child: Text( diff --git a/lib/screens/settings/lyrics_provider_priority_page.dart b/lib/screens/settings/lyrics_provider_priority_page.dart index a33e8a50..426d82c8 100644 --- a/lib/screens/settings/lyrics_provider_priority_page.dart +++ b/lib/screens/settings/lyrics_provider_priority_page.dart @@ -122,8 +122,6 @@ class _LyricsProviderPriorityPageState ); } - // ── State mutations ── - void _enableProvider(String id) { setState(() => _enabledProviders.add(id)); _markChanged(); @@ -142,8 +140,6 @@ class _LyricsProviderPriorityPageState _markChanged(); } - // ── Save / Discard ── - Future _saveChanges() async { ref .read(settingsProvider.notifier) @@ -180,8 +176,6 @@ class _LyricsProviderPriorityPageState return result ?? false; } - // ── Provider metadata ── - static _LyricsProviderInfo _getLyricsProviderInfo(String id) { switch (id) { case 'spotify_api': @@ -230,10 +224,6 @@ class _LyricsProviderPriorityPageState } } -// ═══════════════════════════════════════════════════════════════════════════ -// Enabled provider card (reorderable) -// ═══════════════════════════════════════════════════════════════════════════ - class _EnabledProviderItem extends StatelessWidget { final String providerId; final _LyricsProviderInfo info; @@ -273,7 +263,6 @@ class _EnabledProviderItem extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( children: [ - // Numbered badge Container( width: 28, height: 28, @@ -296,10 +285,8 @@ class _EnabledProviderItem extends StatelessWidget { ), ), const SizedBox(width: 16), - // Icon Icon(info.icon, color: colorScheme.primary), const SizedBox(width: 12), - // Name + description Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -319,7 +306,6 @@ class _EnabledProviderItem extends StatelessWidget { ], ), ), - // Enable/disable switch SizedBox( height: 32, child: FittedBox( @@ -327,7 +313,6 @@ class _EnabledProviderItem extends StatelessWidget { ), ), const SizedBox(width: 4), - // Drag handle Icon(Icons.drag_handle, color: colorScheme.onSurfaceVariant), ], ), @@ -338,10 +323,6 @@ class _EnabledProviderItem extends StatelessWidget { } } -// ═══════════════════════════════════════════════════════════════════════════ -// Disabled provider card -// ═══════════════════════════════════════════════════════════════════════════ - class _DisabledProviderItem extends StatelessWidget { final String providerId; final _LyricsProviderInfo info; @@ -383,10 +364,8 @@ class _DisabledProviderItem extends StatelessWidget { // Empty space aligned with numbered badge const SizedBox(width: 28), const SizedBox(width: 16), - // Icon (muted) Icon(info.icon, color: colorScheme.outline), const SizedBox(width: 12), - // Name + description Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -407,7 +386,6 @@ class _DisabledProviderItem extends StatelessWidget { ], ), ), - // Switch SizedBox( height: 32, child: FittedBox( @@ -424,10 +402,6 @@ class _DisabledProviderItem extends StatelessWidget { } } -// ═══════════════════════════════════════════════════════════════════════════ -// Provider info model -// ═══════════════════════════════════════════════════════════════════════════ - class _LyricsProviderInfo { final String name; final String description; diff --git a/lib/screens/settings/metadata_provider_priority_page.dart b/lib/screens/settings/metadata_provider_priority_page.dart index 7631e27d..3c614784 100644 --- a/lib/screens/settings/metadata_provider_priority_page.dart +++ b/lib/screens/settings/metadata_provider_priority_page.dart @@ -228,13 +228,6 @@ class _MetadataProviderItem extends StatelessWidget { description: context.l10n.metadataNoRateLimits, isBuiltIn: true, ); - case 'spotify': - return _MetadataProviderInfo( - name: 'Spotify', - icon: Icons.music_note, - description: context.l10n.metadataMayRateLimit, - isBuiltIn: true, - ); default: return _MetadataProviderInfo( name: provider, diff --git a/lib/screens/settings/options_settings_page.dart b/lib/screens/settings/options_settings_page.dart index a5a6e07c..4a7618d0 100644 --- a/lib/screens/settings/options_settings_page.dart +++ b/lib/screens/settings/options_settings_page.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; -import 'package:spotiflac_android/models/settings.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; @@ -32,6 +31,7 @@ class OptionsSettingsPage extends ConsumerWidget { backgroundColor: colorScheme.surface, surfaceTintColor: Colors.transparent, leading: IconButton( + tooltip: MaterialLocalizations.of(context).backButtonTooltip, icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context), ), @@ -43,7 +43,7 @@ class OptionsSettingsPage extends ConsumerWidget { ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)) .clamp(0.0, 1.0); - final leftPadding = 56 - (32 * expandRatio); // 56 -> 24 + final leftPadding = 56 - (32 * expandRatio); return FlexibleSpaceBar( expandedTitleScale: 1.0, titlePadding: EdgeInsets.only( @@ -53,7 +53,7 @@ class OptionsSettingsPage extends ConsumerWidget { title: Text( context.l10n.optionsTitle, style: TextStyle( - fontSize: 20 + (8 * expandRatio), // 20 -> 28 + fontSize: 20 + (8 * expandRatio), fontWeight: FontWeight.bold, color: colorScheme.onSurface, ), @@ -72,68 +72,10 @@ class OptionsSettingsPage extends ConsumerWidget { child: SettingsGroup( children: [ _MetadataSourceSelector( - currentSource: settings.metadataSource, onChanged: (v) => ref .read(settingsProvider.notifier) .setMetadataSource(v), ), - if (settings.metadataSource == 'spotify') ...[ - if (settings.spotifyClientId.isEmpty) - Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), - child: Card( - color: Theme.of(context).colorScheme.errorContainer, - child: Padding( - padding: const EdgeInsets.all(12), - child: Row( - children: [ - Icon( - Icons.warning_amber_rounded, - color: Theme.of( - context, - ).colorScheme.onErrorContainer, - ), - const SizedBox(width: 12), - Expanded( - child: Text( - context.l10n.optionsSpotifyWarning, - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.onErrorContainer, - fontSize: 12, - ), - ), - ), - ], - ), - ), - ), - ), - SettingsItem( - icon: Icons.key, - title: context.l10n.optionsSpotifyCredentials, - subtitle: settings.spotifyClientId.isNotEmpty - ? context.l10n.optionsSpotifyCredentialsConfigured( - settings.spotifyClientId.length > 8 - ? settings.spotifyClientId.substring(0, 8) - : settings.spotifyClientId, - ) - : context.l10n.optionsSpotifyCredentialsRequired, - onTap: () => - _showSpotifyCredentialsDialog(context, ref, settings), - trailing: Icon( - settings.spotifyClientId.isNotEmpty - ? Icons.check_circle - : Icons.error_outline, - color: settings.spotifyClientId.isNotEmpty - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.error, - size: 20, - ), - showDivider: false, - ), - ], ], ), ), @@ -331,7 +273,6 @@ class OptionsSettingsPage extends ConsumerWidget { BuildContext context, WidgetRef ref, ) async { - // Show loading indicator showDialog( context: context, barrierDismissible: false, @@ -372,214 +313,6 @@ class OptionsSettingsPage extends ConsumerWidget { } } } - - void _showSpotifyCredentialsDialog( - BuildContext context, - WidgetRef ref, - AppSettings settings, - ) { - final clientIdController = TextEditingController( - text: settings.spotifyClientId, - ); - final clientSecretController = TextEditingController( - text: settings.spotifyClientSecret, - ); - final colorScheme = Theme.of(context).colorScheme; - - showModalBottomSheet( - context: context, - useRootNavigator: true, - isScrollControlled: true, - backgroundColor: colorScheme.surface, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(28)), - ), - builder: (context) => Padding( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom, - ), - child: SingleChildScrollView( - child: SafeArea( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Center( - child: Container( - width: 32, - height: 4, - margin: const EdgeInsets.only(bottom: 24), - decoration: BoxDecoration( - color: colorScheme.outlineVariant, - borderRadius: BorderRadius.circular(2), - ), - ), - ), - Text( - context.l10n.credentialsTitle, - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.bold, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 8), - Text( - context.l10n.credentialsDescription, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 32), - - TextField( - controller: clientIdController, - decoration: InputDecoration( - labelText: context.l10n.credentialsClientId, - hintText: context.l10n.credentialsClientIdHint, - filled: true, - fillColor: colorScheme.surfaceContainerHighest.withValues( - alpha: 0.3, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: BorderSide( - color: colorScheme.outlineVariant, - ), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: BorderSide( - color: colorScheme.outlineVariant, - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: BorderSide( - color: colorScheme.primary, - width: 2, - ), - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 16, - ), - prefixIcon: const Icon(Icons.person_outline), - ), - ), - const SizedBox(height: 16), - - TextField( - controller: clientSecretController, - obscureText: true, - decoration: InputDecoration( - labelText: context.l10n.credentialsClientSecret, - hintText: context.l10n.credentialsClientSecretHint, - filled: true, - fillColor: colorScheme.surfaceContainerHighest.withValues( - alpha: 0.3, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: BorderSide( - color: colorScheme.outlineVariant, - ), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: BorderSide( - color: colorScheme.outlineVariant, - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: BorderSide( - color: colorScheme.primary, - width: 2, - ), - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 16, - ), - prefixIcon: const Icon(Icons.lock_outline), - ), - ), - - const SizedBox(height: 32), - - FilledButton( - onPressed: () { - final clientId = clientIdController.text.trim(); - final clientSecret = clientSecretController.text.trim(); - - if (clientId.isNotEmpty && clientSecret.isNotEmpty) { - ref - .read(settingsProvider.notifier) - .setSpotifyCredentials(clientId, clientSecret); - Navigator.pop(context); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.snackbarCredentialsSaved, - ), - ), - ); - } else { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.snackbarFillAllFields), - ), - ); - } - }, - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - ), - child: Text( - context.l10n.actionSaveCredentials, - style: const TextStyle(fontWeight: FontWeight.bold), - ), - ), - - if (settings.spotifyClientId.isNotEmpty) ...[ - const SizedBox(height: 12), - TextButton( - onPressed: () { - ref - .read(settingsProvider.notifier) - .clearSpotifyCredentials(); - Navigator.pop(context); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.snackbarCredentialsCleared, - ), - ), - ); - }, - style: TextButton.styleFrom( - foregroundColor: colorScheme.error, - padding: const EdgeInsets.symmetric(vertical: 16), - ), - child: Text(context.l10n.actionRemoveCredentials), - ), - ], - - const SizedBox(height: 16), - ], - ), - ), - ), - ), - ), - ); - } } class _ConcurrentDownloadsItem extends StatelessWidget { @@ -875,12 +608,8 @@ class _ChannelChip extends StatelessWidget { } class _MetadataSourceSelector extends ConsumerWidget { - final String currentSource; final ValueChanged onChanged; - const _MetadataSourceSelector({ - required this.currentSource, - required this.onChanged, - }); + const _MetadataSourceSelector({required this.onChanged}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -930,7 +659,7 @@ class _MetadataSourceSelector extends ConsumerWidget { _SourceChip( icon: Icons.graphic_eq, label: 'Deezer', - isSelected: currentSource == 'deezer' && !hasExtensionSearch, + isSelected: !hasExtensionSearch, onTap: () { if (hasExtensionSearch) { ref.read(settingsProvider.notifier).setSearchProvider(null); @@ -938,18 +667,6 @@ class _MetadataSourceSelector extends ConsumerWidget { onChanged('deezer'); }, ), - const SizedBox(width: 12), - _SourceChip( - icon: Icons.music_note, - label: 'Spotify', - isSelected: currentSource == 'spotify' && !hasExtensionSearch, - onTap: () { - if (hasExtensionSearch) { - ref.read(settingsProvider.notifier).setSearchProvider(null); - } - onChanged('spotify'); - }, - ), ], ), if (hasExtensionSearch) ...[ @@ -964,7 +681,7 @@ class _MetadataSourceSelector extends ConsumerWidget { const SizedBox(width: 8), Expanded( child: Text( - context.l10n.optionsSwitchBack, + 'Tap Deezer to switch back from extension', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, ), @@ -973,27 +690,6 @@ class _MetadataSourceSelector extends ConsumerWidget { ], ), ], - if (currentSource == 'spotify' && !hasExtensionSearch) ...[ - const SizedBox(height: 12), - Row( - children: [ - Icon( - Icons.warning_amber_rounded, - size: 16, - color: colorScheme.error, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - context.l10n.optionsSpotifyDeprecationWarning, - 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 ed44ca46..e28ad7e3 100644 --- a/lib/screens/settings/provider_priority_page.dart +++ b/lib/screens/settings/provider_priority_page.dart @@ -66,6 +66,7 @@ class _ProviderPriorityPageState extends ConsumerState { backgroundColor: colorScheme.surface, surfaceTintColor: Colors.transparent, leading: IconButton( + tooltip: MaterialLocalizations.of(context).backButtonTooltip, icon: const Icon(Icons.arrow_back), onPressed: () async { if (_hasChanges) { @@ -333,12 +334,6 @@ class _ProviderItem extends StatelessWidget { ); case 'qobuz': 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', diff --git a/lib/screens/setup_screen.dart b/lib/screens/setup_screen.dart index ede9017d..7c74d92e 100644 --- a/lib/screens/setup_screen.dart +++ b/lib/screens/setup_screen.dart @@ -22,7 +22,6 @@ class _SetupScreenState extends ConsumerState { final PageController _pageController = PageController(); int _currentStep = 0; - // State variables bool _storagePermissionGranted = false; bool _notificationPermissionGranted = false; String? _selectedDirectory; @@ -474,7 +473,6 @@ class _SetupScreenState extends ConsumerState { Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - // Calculate progress final progress = (_currentStep + 1) / _totalSteps; return Scaffold( @@ -482,7 +480,6 @@ class _SetupScreenState extends ConsumerState { body: SafeArea( child: Column( children: [ - // Top Bar Padding( padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), child: Row( @@ -490,6 +487,9 @@ class _SetupScreenState extends ConsumerState { if (_currentStep > 0) IconButton.filledTonal( onPressed: _prevPage, + tooltip: MaterialLocalizations.of( + context, + ).backButtonTooltip, icon: const Icon(Icons.arrow_back), style: IconButton.styleFrom( backgroundColor: colorScheme.surfaceContainerHighest, @@ -497,9 +497,8 @@ class _SetupScreenState extends ConsumerState { ), ) else - const SizedBox(width: 48), // Spacer + const SizedBox(width: 48), const Spacer(), - // Progress Indicator SizedBox( width: 48, height: 48, @@ -530,7 +529,6 @@ class _SetupScreenState extends ConsumerState { ), ), - // Content Expanded( child: PageView( controller: _pageController, @@ -713,6 +711,7 @@ class _SetupScreenState extends ConsumerState { overflow: TextOverflow.ellipsis, ), trailing: IconButton( + tooltip: 'Change folder', icon: const Icon(Icons.edit), onPressed: _selectDirectory, ), diff --git a/lib/screens/store/extension_details_screen.dart b/lib/screens/store/extension_details_screen.dart index 86610974..efb2c7e4 100644 --- a/lib/screens/store/extension_details_screen.dart +++ b/lib/screens/store/extension_details_screen.dart @@ -17,7 +17,6 @@ class ExtensionDetailsScreen extends ConsumerStatefulWidget { class _ExtensionDetailsScreenState extends ConsumerState { - @override Widget build(BuildContext context) { final storeState = ref.watch(storeProvider); @@ -116,6 +115,7 @@ class _ExtensionDetailsScreenState ), ), leading: IconButton( + tooltip: MaterialLocalizations.of(context).backButtonTooltip, icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context), ), @@ -171,7 +171,7 @@ class _ExtensionDetailsScreenState color: colorScheme.onSurface, ), ), - const SizedBox(height: 4), + const SizedBox(height: 4), Text( context.l10n.extensionsAuthor(ext.author), style: Theme.of(context).textTheme.bodyLarge @@ -222,7 +222,9 @@ class _ExtensionDetailsScreenState FilledButton.icon( onPressed: () => _updateExtension(ext), icon: const Icon(Icons.update), - label: Text('${context.l10n.storeUpdate} v${ext.version}'), + label: Text( + '${context.l10n.storeUpdate} v${ext.version}', + ), style: FilledButton.styleFrom( minimumSize: const Size.fromHeight(52), shape: RoundedRectangleBorder( @@ -405,7 +407,8 @@ class _ExtensionDetailsScreenState StoreExtension ext, ColorScheme colorScheme, ) { - final isMetadataProvider = ext.category == 'metadata' || ext.category == 'integration'; + final isMetadataProvider = + ext.category == 'metadata' || ext.category == 'integration'; final isDownloadProvider = ext.category == 'download'; final isLyricsProvider = ext.category == 'lyrics'; final isUtility = ext.category == 'utility'; @@ -458,7 +461,7 @@ class _ExtensionDetailsScreenState final date = DateTime.parse(dateStr); final now = DateTime.now(); final diff = now.difference(date); - + if (diff.inDays == 0) { return context.l10n.dateToday; } else if (diff.inDays == 1) { @@ -560,7 +563,9 @@ class _ExtensionDetailsScreenState context: context, builder: (context) => AlertDialog( title: Text(context.l10n.dialogUninstallExtension), - content: Text(context.l10n.dialogUninstallExtensionMessage(ext.displayName)), + content: Text( + context.l10n.dialogUninstallExtensionMessage(ext.displayName), + ), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), @@ -718,10 +723,7 @@ class _CapabilityRow extends StatelessWidget { Expanded( child: Text( label, - style: TextStyle( - color: colorScheme.onSurface, - fontSize: 14, - ), + style: TextStyle(color: colorScheme.onSurface, fontSize: 14), ), ), Icon( diff --git a/lib/screens/store_tab.dart b/lib/screens/store_tab.dart index d8d83e7b..ff97c194 100644 --- a/lib/screens/store_tab.dart +++ b/lib/screens/store_tab.dart @@ -122,6 +122,7 @@ class _StoreTabState extends ConsumerState { prefixIcon: const Icon(Icons.search), suffixIcon: value.text.isNotEmpty ? IconButton( + tooltip: 'Clear search', icon: const Icon(Icons.clear), onPressed: () { _searchController.clear(); diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index c34e7400..d8693612 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -13,11 +13,14 @@ 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/local_library_provider.dart'; import 'package:spotiflac_android/providers/playback_provider.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/services/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/mime_utils.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; final _log = AppLogger('TrackMetadata'); @@ -66,6 +69,7 @@ class _TrackMetadataScreenState extends ConsumerState { bool _isInstrumental = false; // Track if detected as instrumental bool _isConverting = false; // Track convert operation in progress bool _hasMetadataChanges = false; + bool _hasLoadedResolvedAudioMetadata = false; Map? _editedMetadata; // Overrides after metadata edit String? _embeddedCoverPreviewPath; final ScrollController _scrollController = ScrollController(); @@ -213,10 +217,7 @@ class _TrackMetadataScreenState extends ConsumerState { } Future _checkFile() async { - var filePath = _filePath; - if (filePath.startsWith('EXISTS:')) { - filePath = filePath.substring(7); - } + final filePath = cleanFilePath; bool exists = false; int? size; @@ -240,6 +241,12 @@ class _TrackMetadataScreenState extends ConsumerState { if (mounted && exists && _lyrics == null && !_lyricsLoading) { _fetchLyrics(); } + if (mounted && + exists && + !_isLocalItem && + !_hasLoadedResolvedAudioMetadata) { + unawaited(_refreshResolvedAudioMetadataFromFile()); + } if (mounted && exists && !_hasPath(_embeddedCoverPreviewPath)) { final cachedPath = _getCachedEmbeddedCoverPreviewPathIfValid( _coverCacheKey, @@ -274,6 +281,61 @@ class _TrackMetadataScreenState extends ConsumerState { await _cleanupTempFileAndParent(path); } + Future _refreshResolvedAudioMetadataFromFile() async { + if (_isLocalItem || + _downloadItem == null || + _hasLoadedResolvedAudioMetadata) { + return; + } + + _hasLoadedResolvedAudioMetadata = true; + + try { + final metadata = await PlatformBridge.readFileMetadata(cleanFilePath); + if (metadata['error'] != null) { + return; + } + + final resolvedBitDepth = _readPositiveInt(metadata['bit_depth']); + final resolvedSampleRate = _readPositiveInt(metadata['sample_rate']); + final resolvedQuality = buildDisplayAudioQuality( + bitDepth: resolvedBitDepth ?? bitDepth, + sampleRate: resolvedSampleRate ?? sampleRate, + storedQuality: _quality, + ); + final shouldPersistResolvedAudioMetadata = + resolvedBitDepth != null || + resolvedSampleRate != null || + (isPlaceholderQualityLabel(_quality) && resolvedQuality != null); + + if ((resolvedBitDepth != null || + resolvedSampleRate != null || + isPlaceholderQualityLabel(_quality)) && + mounted) { + setState(() { + _editedMetadata = { + ...?_editedMetadata, + if (resolvedBitDepth != null) 'bit_depth': resolvedBitDepth, + if (resolvedSampleRate != null) 'sample_rate': resolvedSampleRate, + }; + }); + } + + if (shouldPersistResolvedAudioMetadata) { + await ref + .read(downloadHistoryProvider.notifier) + .updateAudioMetadataForItem( + id: _downloadItem!.id, + quality: resolvedQuality, + bitDepth: resolvedBitDepth, + sampleRate: resolvedSampleRate, + ); + } + } catch (e) { + _log.w('Failed to resolve audio metadata from file: $e'); + } + } + void _cleanupTempFileAndParentSync(String? path) { if (!_hasPath(path)) return; final file = File(path!); @@ -426,9 +488,13 @@ class _TrackMetadataScreenState extends ConsumerState { int? get duration => _isLocalItem ? _localLibraryItem!.duration : _downloadItem!.duration; int? get bitDepth => - _isLocalItem ? _localLibraryItem!.bitDepth : _downloadItem!.bitDepth; + _readPositiveInt(_editedMetadata?['bit_depth']) ?? + (_isLocalItem ? _localLibraryItem!.bitDepth : _downloadItem!.bitDepth); int? get sampleRate => - _isLocalItem ? _localLibraryItem!.sampleRate : _downloadItem!.sampleRate; + _readPositiveInt(_editedMetadata?['sample_rate']) ?? + (_isLocalItem + ? _localLibraryItem!.sampleRate + : _downloadItem!.sampleRate); int? get _localBitrate => _isLocalItem ? _localLibraryItem!.bitrate : null; String get _filePath => @@ -452,11 +518,91 @@ class _TrackMetadataScreenState extends ConsumerState { String? get _quality => _isLocalItem ? null : _downloadItem!.quality; - String get cleanFilePath { + int? _readPositiveInt(dynamic value) { + if (value == null) return null; + if (value is num) { + final asInt = value.toInt(); + return asInt > 0 ? asInt : null; + } + final parsed = int.tryParse(value.toString()); + if (parsed == null || parsed <= 0) return null; + return parsed; + } + + String? get _displayAudioQuality { + final fileName = _extractFileNameFromPathOrUri(cleanFilePath); + final fileExt = fileName.contains('.') + ? fileName.split('.').last.toUpperCase() + : null; + + return buildDisplayAudioQuality( + bitDepth: bitDepth, + sampleRate: sampleRate, + bitrateKbps: _isLocalItem ? _localBitrate : null, + format: _isLocalItem ? (_localLibraryItem!.format ?? fileExt) : fileExt, + storedQuality: _quality, + ); + } + + /// The raw file path, with EXISTS: prefix stripped but #trackNN preserved. + /// Use this when you need the full virtual path (e.g. for display or DB lookups). + String get rawFilePath { final path = _filePath; return path.startsWith('EXISTS:') ? path.substring(7) : path; } + /// The clean file path with both EXISTS: prefix and #trackNN suffix stripped. + /// Use this for actual filesystem/SAF operations. + String get cleanFilePath { + var path = _filePath; + if (path.startsWith('EXISTS:')) path = path.substring(7); + // Strip CUE virtual path suffix for filesystem operations + if (isCueVirtualPath(path)) path = stripCueTrackSuffix(path); + return path; + } + + bool get _isCueVirtualTrack => isCueVirtualPath(rawFilePath); + + String _cueVirtualTrackGuidance(BuildContext context) { + return 'This CUE track is virtual. Use ${context.l10n.cueSplitButton} first.'; + } + + void _showCueVirtualTrackSnackBar(BuildContext context) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(_cueVirtualTrackGuidance(context))), + ); + } + + void _hideCurrentSnackBar() { + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + } + + String get _l10nCueSplitFailed => context.l10n.cueSplitFailed; + String get _l10nCueSplitNoAudioFile => context.l10n.cueSplitNoAudioFile; + + String _l10nCueSplitSplitting(int current, int total) { + return context.l10n.cueSplitSplitting(current, total); + } + + String _l10nCueSplitSuccess(int count) { + return context.l10n.cueSplitSuccess(count); + } + + void _showSnackBarMessage(String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message)), + ); + } + + void _showLongSnackBarMessage(String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + duration: const Duration(seconds: 60), + ), + ); + } + String _formatPathForDisplay(String pathOrUri) { if (pathOrUri.isEmpty || !pathOrUri.startsWith('content://')) { return pathOrUri; @@ -558,6 +704,7 @@ class _TrackMetadataScreenState extends ConsumerState { }, ), leading: IconButton( + tooltip: MaterialLocalizations.of(context).backButtonTooltip, icon: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( @@ -570,6 +717,7 @@ class _TrackMetadataScreenState extends ConsumerState { ), actions: [ IconButton( + tooltip: MaterialLocalizations.of(context).showMenuTooltip, icon: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( @@ -627,7 +775,6 @@ class _TrackMetadataScreenState extends ConsumerState { return Stack( fit: StackFit.expand, children: [ - // Full-screen cover background if (_hasPath(_embeddedCoverPreviewPath)) Image.file( File(_embeddedCoverPreviewPath!), @@ -657,7 +804,6 @@ class _TrackMetadataScreenState extends ConsumerState { color: colorScheme.onSurfaceVariant, ), ), - // Bottom gradient for readability Positioned( left: 0, right: 0, @@ -676,7 +822,6 @@ class _TrackMetadataScreenState extends ConsumerState { ), ), ), - // Track info overlay at bottom Positioned( left: 20, right: 20, @@ -726,7 +871,8 @@ class _TrackMetadataScreenState extends ConsumerState { spacing: 8, runSpacing: 8, children: [ - if (_quality != null && _quality!.isNotEmpty) + if (_displayAudioQuality != null && + _displayAudioQuality!.isNotEmpty) Container( padding: const EdgeInsets.symmetric( horizontal: 12, @@ -737,7 +883,7 @@ class _TrackMetadataScreenState extends ConsumerState { borderRadius: BorderRadius.circular(20), ), child: Text( - _quality!, + _displayAudioQuality!, style: const TextStyle( color: Colors.white, fontWeight: FontWeight.w600, @@ -964,34 +1110,7 @@ class _TrackMetadataScreenState extends ConsumerState { } Widget _buildMetadataGrid(BuildContext context, ColorScheme colorScheme) { - // Determine audio quality string - prefer stored quality from download - String? audioQualityStr; - final fileName = _extractFileNameFromPathOrUri(cleanFilePath); - final fileExt = fileName.contains('.') - ? fileName.split('.').last.toUpperCase() - : ''; - - // Use stored quality from download history if available - if (_quality != null && _quality!.isNotEmpty) { - audioQualityStr = _quality; - } else if (_isLocalItem && _localBitrate != null && _localBitrate! > 0) { - // Lossy local file with bitrate info - final fmt = _localLibraryItem!.format?.toUpperCase() ?? fileExt; - audioQualityStr = '$fmt ${_localBitrate}kbps'; - } else if (bitDepth != null && bitDepth! > 0 && sampleRate != null) { - // Lossless file with actual bit depth (FLAC, ALAC) - final sampleRateKHz = (sampleRate! / 1000).toStringAsFixed(1); - audioQualityStr = '$bitDepth-bit/${sampleRateKHz}kHz'; - } else { - // Fallback based on file extension for legacy items - if (fileExt == 'MP3') { - audioQualityStr = 'MP3'; - } else if (fileExt == 'OPUS' || fileExt == 'OGG') { - audioQualityStr = 'Opus'; - } else if (fileExt == 'M4A' || fileExt == 'AAC') { - audioQualityStr = 'AAC'; - } - } + final audioQualityStr = _displayAudioQuality; final items = <_MetadataItem>[ _MetadataItem(context.l10n.trackTrackName, trackName), @@ -1088,12 +1207,13 @@ class _TrackMetadataScreenState extends ConsumerState { bool fileExists, int? fileSize, ) { - final displayFilePath = _formatPathForDisplay(cleanFilePath); - final fileName = _extractFileNameFromPathOrUri(cleanFilePath); + final displayFilePath = _formatPathForDisplay(rawFilePath); + final fileName = _extractFileNameFromPathOrUri(rawFilePath); final fileExtension = fileName.contains('.') ? fileName.split('.').last.toUpperCase() : 'Unknown'; - final lossyBitrateLabel = _extractLossyBitrateLabel(_quality); + final resolvedQuality = _displayAudioQuality; + final lossyBitrateLabel = _extractLossyBitrateLabel(resolvedQuality); return Card( elevation: 0, @@ -1223,7 +1343,11 @@ class _TrackMetadataScreenState extends ConsumerState { borderRadius: BorderRadius.circular(20), ), child: Text( - '$bitDepth-bit/${(sampleRate! / 1000).toStringAsFixed(1)}kHz', + buildDisplayAudioQuality( + bitDepth: bitDepth, + sampleRate: sampleRate, + ) ?? + '', style: TextStyle( color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, @@ -2295,7 +2419,7 @@ class _TrackMetadataScreenState extends ConsumerState { flex: 2, child: FilledButton.icon( onPressed: fileExists - ? () => _openFile(context, cleanFilePath) + ? () => _openFile(context, rawFilePath) : null, icon: const Icon(Icons.play_arrow), label: Text(context.l10n.trackMetadataPlay), @@ -2417,6 +2541,16 @@ class _TrackMetadataScreenState extends ConsumerState { _showConvertSheet(context); }, ), + if (_fileExists && _isCueFile) + ListTile( + leading: const Icon(Icons.call_split), + title: Text(context.l10n.cueSplitTitle), + subtitle: Text(context.l10n.cueSplitSubtitle), + onTap: () { + Navigator.pop(context); + _showCueSplitSheet(context); + }, + ), const Divider(height: 1), ListTile( leading: const Icon(Icons.share), @@ -2454,11 +2588,34 @@ class _TrackMetadataScreenState extends ConsumerState { lower.endsWith('.ogg'); } + /// Whether the current file is a CUE sheet (or CUE-referenced) + bool get _isCueFile { + // Check if the raw path has a CUE virtual path suffix + if (isCueVirtualPath(rawFilePath)) return true; + final lower = cleanFilePath.toLowerCase(); + if (lower.endsWith('.cue')) return true; + // Check if local library item has cue+ format + if (_isLocalItem && _localLibraryItem != null) { + final format = _localLibraryItem!.format ?? ''; + if (format.startsWith('cue+')) return true; + } + return false; + } + String get _currentFileFormat { + // For CUE tracks, use the format from the library item (e.g. "cue+flac") + if (_isCueFile && _isLocalItem && _localLibraryItem != null) { + final format = _localLibraryItem!.format ?? ''; + if (format.startsWith('cue+')) { + final audioFmt = format.substring(4).toUpperCase(); + return 'CUE+$audioFmt'; + } + } final lower = cleanFilePath.toLowerCase(); if (lower.endsWith('.flac')) return 'FLAC'; if (lower.endsWith('.mp3')) return 'MP3'; if (lower.endsWith('.opus') || lower.endsWith('.ogg')) return 'Opus'; + if (lower.endsWith('.cue')) return 'CUE'; return 'Unknown'; } @@ -2606,7 +2763,6 @@ class _TrackMetadataScreenState extends ConsumerState { ), const SizedBox(height: 20), - // Target format Text( context.l10n.trackConvertTargetFormat, style: Theme.of(context).textTheme.titleSmall?.copyWith( @@ -2639,7 +2795,6 @@ class _TrackMetadataScreenState extends ConsumerState { ), const SizedBox(height: 16), - // Bitrate Text( context.l10n.trackConvertBitrate, style: Theme.of(context).textTheme.titleSmall?.copyWith( @@ -2664,7 +2819,6 @@ class _TrackMetadataScreenState extends ConsumerState { ), const SizedBox(height: 24), - // Convert button SizedBox( width: double.infinity, child: FilledButton( @@ -2699,6 +2853,470 @@ class _TrackMetadataScreenState extends ConsumerState { ); } + void _showCueSplitSheet(BuildContext context) async { + // Strip the #trackNN suffix from virtual CUE paths to get the real .cue path + var cuePath = cleanFilePath; + final trackSuffix = RegExp(r'#track\d+$'); + if (trackSuffix.hasMatch(cuePath)) { + cuePath = cuePath.replaceFirst(trackSuffix, ''); + } + + // Show loading indicator + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Loading CUE sheet...')), + ); + + try { + final cueInfo = await PlatformBridge.parseCueSheet(cuePath); + + if (!mounted) return; + _hideCurrentSnackBar(); + + if (cueInfo.containsKey('error')) { + _showSnackBarMessage(_l10nCueSplitNoAudioFile); + return; + } + + final album = cueInfo['album'] as String? ?? 'Unknown Album'; + final artist = cueInfo['artist'] as String? ?? 'Unknown Artist'; + final audioPath = cueInfo['audio_path'] as String? ?? ''; + final genre = cueInfo['genre'] as String? ?? ''; + final date = cueInfo['date'] as String? ?? ''; + final tracksRaw = cueInfo['tracks'] as List? ?? []; + + if (audioPath.isEmpty) { + _showSnackBarMessage(_l10nCueSplitNoAudioFile); + return; + } + + final tracks = tracksRaw + .map((t) => CueSplitTrackInfo.fromJson(t as Map)) + .toList(); + + if (tracks.isEmpty) { + _showSnackBarMessage(_l10nCueSplitFailed); + return; + } + + if (!mounted) return; + + showModalBottomSheet( + context: this.context, + useRootNavigator: true, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (sheetContext) { + final colorScheme = Theme.of(sheetContext).colorScheme; + 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( + sheetContext.l10n.cueSplitTitle, + style: Theme.of(sheetContext).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + Text( + sheetContext.l10n.cueSplitAlbum(album), + style: Theme.of(sheetContext).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 4), + Text( + sheetContext.l10n.cueSplitArtist(artist), + style: Theme.of(sheetContext).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 4), + Text( + sheetContext.l10n.cueSplitTrackCount(tracks.length), + style: Theme.of(sheetContext).textTheme.bodyMedium?.copyWith( + color: colorScheme.primary, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 16), + // Track list preview (scrollable, max 200px) + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 200), + child: ListView.builder( + shrinkWrap: true, + itemCount: tracks.length, + itemBuilder: (context, index) { + final track = tracks[index]; + final duration = track.endSec > 0 + ? track.endSec - track.startSec + : 0.0; + final durationStr = duration > 0 + ? '${(duration ~/ 60).toString().padLeft(2, '0')}:${(duration.toInt() % 60).toString().padLeft(2, '0')}' + : ''; + return ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + leading: CircleAvatar( + radius: 14, + backgroundColor: colorScheme.primaryContainer, + child: Text( + '${track.number}', + style: TextStyle( + fontSize: 11, + color: colorScheme.onPrimaryContainer, + ), + ), + ), + title: Text( + track.title, + style: const TextStyle(fontSize: 13), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: track.artist.isNotEmpty + ? Text( + track.artist, + style: TextStyle( + fontSize: 11, + color: colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ) + : null, + trailing: durationStr.isNotEmpty + ? Text( + durationStr, + style: TextStyle( + fontSize: 12, + color: colorScheme.onSurfaceVariant, + ), + ) + : null, + ); + }, + ), + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: () { + Navigator.pop(sheetContext); + _confirmAndSplitCue( + context: this.context, + audioPath: audioPath, + album: album, + artist: artist, + genre: genre, + date: date, + tracks: tracks, + ); + }, + icon: const Icon(Icons.call_split), + label: Text(sheetContext.l10n.cueSplitButton), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + const SizedBox(height: 8), + ], + ), + ), + ); + }, + ); + } catch (e) { + if (!mounted) return; + _hideCurrentSnackBar(); + _showSnackBarMessage(_l10nCueSplitFailed); + _log.e('Failed to parse CUE sheet: $e'); + } + } + + void _confirmAndSplitCue({ + required BuildContext context, + required String audioPath, + required String album, + required String artist, + required String genre, + required String date, + required List tracks, + }) { + showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + title: Text(dialogContext.l10n.cueSplitConfirmTitle), + content: Text( + dialogContext.l10n.cueSplitConfirmMessage(album, tracks.length), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(dialogContext.l10n.dialogCancel), + ), + FilledButton( + onPressed: () { + Navigator.pop(dialogContext); + _performCueSplit( + audioPath: audioPath, + album: album, + artist: artist, + genre: genre, + date: date, + tracks: tracks, + ); + }, + child: Text(dialogContext.l10n.cueSplitButton), + ), + ], + ); + }, + ); + } + + Future _resolvePersistentCueSplitOutputDir() async { + final settings = ref.read(settingsProvider); + final queueState = ref.read(downloadQueueProvider); + final configuredOutputDir = queueState.outputDir.trim(); + if (settings.storageMode != 'saf' && + configuredOutputDir.isNotEmpty && + !isContentUri(configuredOutputDir)) { + final dir = Directory(configuredOutputDir); + await dir.create(recursive: true); + return dir; + } + + if (Platform.isAndroid) { + final externalDir = await getExternalStorageDirectory(); + if (externalDir != null) { + final musicDir = Directory( + '${externalDir.parent.parent.parent.parent.path}' + '${Platform.pathSeparator}Music' + '${Platform.pathSeparator}SpotiFLAC', + ); + await musicDir.create(recursive: true); + return musicDir; + } + } + + final docsDir = await getApplicationDocumentsDirectory(); + final fallbackDir = Directory( + '${docsDir.path}${Platform.pathSeparator}SpotiFLAC', + ); + await fallbackDir.create(recursive: true); + return fallbackDir; + } + + Future?> _exportCueSplitOutputsToSaf({ + required List outputPaths, + required String treeUri, + required String relativeDir, + }) async { + final exportedUris = []; + for (final path in outputPaths) { + final fileName = path.split(Platform.pathSeparator).last; + final safUri = await PlatformBridge.createSafFileFromPath( + treeUri: treeUri, + relativeDir: relativeDir, + fileName: fileName, + mimeType: audioMimeTypeForPath(path), + srcPath: path, + ); + if (safUri != null && safUri.isNotEmpty) { + exportedUris.add(safUri); + } + } + return exportedUris.isEmpty ? null : exportedUris; + } + + Future _performCueSplit({ + required String audioPath, + required String album, + required String artist, + required String genre, + required String date, + required List tracks, + }) async { + if (_isConverting) return; + setState(() => _isConverting = true); + + String? safTempAudioPath; + Directory? tempSplitDir; + try { + // For SAF content:// audio paths, copy to temp for FFmpeg processing + String workingAudioPath = audioPath; + final isSafSource = isContentUri(audioPath); + if (isSafSource) { + final tempPath = await PlatformBridge.copyContentUriToTemp(audioPath); + if (tempPath == null || tempPath.isEmpty) { + throw Exception('Failed to copy SAF audio file to temp'); + } + safTempAudioPath = tempPath; + workingAudioPath = tempPath; + } + + // Determine output directory + final String outputDir; + final treeUri = !_isLocalItem ? (_downloadItem?.downloadTreeUri ?? '') : ''; + final relativeDir = !_isLocalItem ? (_downloadItem?.safRelativeDir ?? '') : ''; + final writeBackToSaf = isSafSource && treeUri.isNotEmpty; + if (writeBackToSaf) { + final tempDir = await getTemporaryDirectory(); + tempSplitDir = Directory( + '${tempDir.path}${Platform.pathSeparator}' + 'cue_split_${DateTime.now().millisecondsSinceEpoch}', + ); + await tempSplitDir.create(recursive: true); + outputDir = tempSplitDir.path; + } else if (isSafSource) { + final persistentDir = await _resolvePersistentCueSplitOutputDir(); + outputDir = persistentDir.path; + } else { + outputDir = File(audioPath).parent.path; + } + + if (!mounted) return; + _showLongSnackBarMessage(_l10nCueSplitSplitting(1, tracks.length)); + + // Extract cover from audio file for embedding + String? coverPath; + try { + final tempDir = await getTemporaryDirectory(); + final coverOutput = + '${tempDir.path}${Platform.pathSeparator}cue_cover_${DateTime.now().millisecondsSinceEpoch}.jpg'; + final coverResult = await PlatformBridge.extractCoverToFile( + workingAudioPath, + coverOutput, + ); + if (coverResult['error'] == null) { + coverPath = coverOutput; + } + } catch (_) {} + + final albumMetadata = { + 'artist': artist, + 'album': album, + 'genre': genre, + 'date': date, + }; + + final outputPaths = await FFmpegService.splitCueToTracks( + audioPath: workingAudioPath, + outputDir: outputDir, + tracks: tracks, + albumMetadata: albumMetadata, + coverPath: coverPath, + onProgress: (current, total) { + if (mounted) { + _hideCurrentSnackBar(); + _showLongSnackBarMessage(_l10nCueSplitSplitting(current, total)); + } + }, + ); + + var finalOutputPaths = outputPaths; + + // Embed cover art into split FLAC files using Go backend + if (coverPath != null && finalOutputPaths != null) { + for (final path in finalOutputPaths) { + if (path.toLowerCase().endsWith('.flac')) { + try { + // Read existing metadata first + final metadata = await PlatformBridge.readFileMetadata(path); + if (metadata['error'] == null) { + final fields = { + 'cover_path': coverPath, + }; + // Preserve existing fields + for (final entry in metadata.entries) { + if (entry.key == 'error' || entry.value == null) continue; + final v = entry.value.toString().trim(); + if (v.isNotEmpty) { + fields[entry.key] = v; + } + } + await PlatformBridge.editFileMetadata(path, fields); + } + } catch (e) { + _log.w('Failed to embed cover to split track: $e'); + } + } + } + } + + if (writeBackToSaf && finalOutputPaths != null) { + final exportedUris = await _exportCueSplitOutputsToSaf( + outputPaths: finalOutputPaths, + treeUri: treeUri, + relativeDir: relativeDir, + ); + finalOutputPaths = exportedUris; + } + + // Cleanup cover temp + if (coverPath != null) { + try { + await File(coverPath).delete(); + } catch (_) {} + } + + if (mounted) { + _hideCurrentSnackBar(); + if (finalOutputPaths != null && finalOutputPaths.isNotEmpty) { + _showSnackBarMessage(_l10nCueSplitSuccess(finalOutputPaths.length)); + } else { + _showSnackBarMessage(_l10nCueSplitFailed); + } + } + } catch (e) { + _log.e('CUE split failed: $e'); + if (mounted) { + _hideCurrentSnackBar(); + _showSnackBarMessage(_l10nCueSplitFailed); + } + } finally { + // Cleanup SAF temp audio copy + if (safTempAudioPath != null) { + try { + await File(safTempAudioPath).delete(); + } catch (_) {} + } + if (tempSplitDir != null) { + try { + await tempSplitDir.delete(recursive: true); + } catch (_) {} + } + if (mounted) { + setState(() => _isConverting = false); + } + } + } + void _confirmAndConvert({ required BuildContext context, required String sourceFormat, @@ -2750,7 +3368,6 @@ class _TrackMetadataScreenState extends ConsumerState { SnackBar(content: Text(context.l10n.trackConvertConverting)), ); - // Step 1: Read metadata from file (fallback to known item metadata). final metadata = _buildFallbackMetadata(); try { final result = await PlatformBridge.readFileMetadata(cleanFilePath); @@ -2768,7 +3385,6 @@ class _TrackMetadataScreenState extends ConsumerState { _log.w('readFileMetadata threw, using fallback metadata: $e'); } - // Step 2: Extract cover art to temp file String? coverPath; try { final tempDir = await getTemporaryDirectory(); @@ -2783,7 +3399,6 @@ class _TrackMetadataScreenState extends ConsumerState { } } catch (_) {} - // Step 3: Handle SAF vs regular file String workingPath = cleanFilePath; final isSaf = _isSafFile; String? safTempPath; @@ -2803,7 +3418,6 @@ class _TrackMetadataScreenState extends ConsumerState { workingPath = safTempPath; } - // Step 4: Convert final newPath = await FFmpegService.convertAudioFormat( inputPath: workingPath, targetFormat: targetFormat.toLowerCase(), @@ -2838,7 +3452,6 @@ class _TrackMetadataScreenState extends ConsumerState { final newQuality = _buildConvertedQualityLabel(targetFormat, bitrate); - // Step 5: Handle SAF write-back if (isSaf) { final treeUri = _downloadItem?.downloadTreeUri; final relativeDir = _downloadItem?.safRelativeDir ?? ''; @@ -3055,16 +3668,18 @@ class _TrackMetadataScreenState extends ConsumerState { TextButton( onPressed: () async { if (_isLocalItem) { - // For local items, just delete the file - try { - await deleteFile(cleanFilePath); - } catch (e) { - debugPrint('Failed to delete file: $e'); + if (_isCueVirtualTrack && _localLibraryItem != null) { + await ref + .read(localLibraryProvider.notifier) + .removeItem(_localLibraryItem!.id); + } else { + try { + await deleteFile(cleanFilePath); + } catch (e) { + debugPrint('Failed to delete file: $e'); + } } - // Also remove from local library database - // ref.read(localLibraryProvider.notifier).removeItem(_localLibraryItem!.id); } else { - // Existing download history deletion logic try { await deleteFile(cleanFilePath); } catch (e) { @@ -3092,6 +3707,10 @@ class _TrackMetadataScreenState extends ConsumerState { } Future _openFile(BuildContext context, String filePath) async { + if (isCueVirtualPath(filePath)) { + _showCueVirtualTrackSnackBar(context); + return; + } try { await ref .read(playbackProvider.notifier) @@ -3124,6 +3743,11 @@ class _TrackMetadataScreenState extends ConsumerState { } Future _shareFile(BuildContext context) async { + if (_isCueVirtualTrack) { + _showCueVirtualTrackSnackBar(context); + return; + } + String sharePath = cleanFilePath; if (!await fileExists(sharePath)) { if (context.mounted) { @@ -3661,7 +4285,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { expand: false, builder: (context, scrollController) => Column( children: [ - // Handle bar Padding( padding: const EdgeInsets.only(top: 12, bottom: 8), child: Container( @@ -3673,7 +4296,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { ), ), ), - // Title row Padding( padding: const EdgeInsets.symmetric(horizontal: 24), child: Row( @@ -3698,7 +4320,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { ), ), const SizedBox(height: 12), - // Fields Expanded( child: ListView( controller: scrollController, diff --git a/lib/screens/tutorial_screen.dart b/lib/screens/tutorial_screen.dart index b93ed5b7..a944f6a8 100644 --- a/lib/screens/tutorial_screen.dart +++ b/lib/screens/tutorial_screen.dart @@ -90,7 +90,6 @@ class _TutorialScreenState extends ConsumerState { body: SafeArea( child: Column( children: [ - // Top Navigation Bar Padding( padding: EdgeInsets.symmetric( horizontal: topBarPaddingH, @@ -104,6 +103,9 @@ class _TutorialScreenState extends ConsumerState { opacity: _currentPage > 0 ? 1.0 : 0.0, child: IconButton.filledTonal( onPressed: _currentPage > 0 ? _prevPage : null, + tooltip: MaterialLocalizations.of( + context, + ).backButtonTooltip, icon: const Icon(Icons.arrow_back), style: IconButton.styleFrom( backgroundColor: colorScheme.surfaceContainerHighest, @@ -112,7 +114,6 @@ class _TutorialScreenState extends ConsumerState { ), ), - // Skip button TextButton( onPressed: _skipTutorial, style: TextButton.styleFrom( @@ -131,7 +132,6 @@ class _TutorialScreenState extends ConsumerState { ), ), - // Main Content Area Expanded( child: PageView( controller: _pageController, @@ -218,12 +218,10 @@ class _TutorialScreenState extends ConsumerState { ), ), - // Bottom Control Area Padding( padding: const EdgeInsets.all(24), child: Column( children: [ - // Expressive Page Indicators Row( mainAxisAlignment: MainAxisAlignment.center, children: List.generate(_totalPages, (index) { @@ -246,7 +244,6 @@ class _TutorialScreenState extends ConsumerState { }), ), SizedBox(height: bottomGap), - // Action Button SizedBox( width: double.infinity, height: actionButtonHeight, @@ -402,7 +399,6 @@ class _InteractiveSearchExampleState extends State<_InteractiveSearchExample> { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Search Input TextField( controller: _controller, onChanged: (value) { @@ -428,7 +424,6 @@ class _InteractiveSearchExampleState extends State<_InteractiveSearchExample> { ), ), - // Result Placeholder AnimatedSize( duration: const Duration(milliseconds: 400), curve: Curves.easeOutBack, @@ -541,7 +536,6 @@ class _InteractiveDownloadExampleState _isCompleted = true; }); - // Reset after a delay await Future.delayed(const Duration(seconds: 2)); if (mounted) { setState(() { @@ -622,40 +616,52 @@ class _InteractiveDownloadExampleState ), ), const SizedBox(width: 16), - GestureDetector( - onTap: _startDownload, - child: AnimatedContainer( - duration: const Duration(milliseconds: 300), - padding: EdgeInsets.all(buttonPadding), - decoration: BoxDecoration( - color: _isCompleted ? Colors.green : colorScheme.primary, - shape: BoxShape.circle, - boxShadow: [ - BoxShadow( - color: - (_isCompleted ? Colors.green : colorScheme.primary) - .withValues(alpha: 0.3), - blurRadius: 12, - offset: const Offset(0, 6), - ), - ], - ), - child: _isDownloading - ? SizedBox( - width: buttonIconSize, - height: buttonIconSize, - child: CircularProgressIndicator( - strokeWidth: 3, - color: colorScheme.onPrimary, - ), - ) - : Icon( - _isCompleted - ? Icons.check_rounded - : Icons.download_rounded, - color: colorScheme.onPrimary, - size: buttonIconSize, + Semantics( + button: true, + label: _isCompleted + ? 'Download completed' + : _isDownloading + ? 'Download in progress' + : 'Start download', + child: GestureDetector( + onTap: _startDownload, + child: AnimatedContainer( + duration: const Duration(milliseconds: 300), + padding: EdgeInsets.all(buttonPadding), + decoration: BoxDecoration( + color: _isCompleted ? Colors.green : colorScheme.primary, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: + (_isCompleted + ? Colors.green + : colorScheme.primary) + .withValues(alpha: 0.3), + blurRadius: 12, + offset: const Offset(0, 6), ), + ], + ), + child: _isDownloading + ? SizedBox( + width: buttonIconSize, + height: buttonIconSize, + child: CircularProgressIndicator( + strokeWidth: 3, + color: colorScheme.onPrimary, + ), + ) + : ExcludeSemantics( + child: Icon( + _isCompleted + ? Icons.check_rounded + : Icons.download_rounded, + color: colorScheme.onPrimary, + size: buttonIconSize, + ), + ), + ), ), ), ], diff --git a/lib/services/ffmpeg_service.dart b/lib/services/ffmpeg_service.dart index b352162f..8154f03c 100644 --- a/lib/services/ffmpeg_service.dart +++ b/lib/services/ffmpeg_service.dart @@ -198,7 +198,7 @@ class FFmpegService { final trimmedKey = decryptionKey.trim(); if (trimmedKey.isEmpty) return inputPath; - // Amazon encrypted streams are commonly MP4 container with FLAC audio. + // Encrypted streams are commonly MP4 container with FLAC audio. // Prefer FLAC output to avoid MP4 muxing errors during decrypt copy. final preferredExt = inputPath.toLowerCase().endsWith('.m4a') ? '.flac' @@ -217,7 +217,10 @@ class FFmpegService { required String key, }) { final audioMap = mapAudioOnly ? '-map 0:a ' : ''; - return '-v error -decryption_key "$key" -i "$inputPath" $audioMap-c copy "$outputPath" -y'; + // Force MOV demuxer: -decryption_key is only supported by the MOV/MP4 + // demuxer. The input may carry a .flac extension (SAF mode) while actually + // containing an encrypted M4A stream, so we must override auto-detection. + return '-v error -decryption_key "$key" -f mov -i "$inputPath" $audioMap-c copy "$outputPath" -y'; } final keyCandidates = _buildDecryptionKeyCandidates(trimmedKey); @@ -627,7 +630,7 @@ class FFmpegService { return null; } - static Future startAmazonLiveDecryptedStream({ + static Future startEncryptedLiveDecryptedStream({ required String encryptedStreamUrl, required String decryptionKey, String preferredFormat = 'flac', @@ -1225,7 +1228,6 @@ class FFmpegService { final extension = format == 'opus' ? '.opus' : '.mp3'; final outputPath = _buildOutputPath(inputPath, extension); - // Step 1: Convert audio String command; if (format == 'opus') { command = @@ -1245,7 +1247,6 @@ class FFmpegService { return null; } - // Step 2: Embed metadata + cover into the converted file. // Treat embed failure as conversion failure when metadata/cover was requested. final hasMetadata = metadata.values.any((v) => v.trim().isNotEmpty); final hasCover = coverPath != null && coverPath.trim().isNotEmpty; @@ -1281,7 +1282,6 @@ class FFmpegService { } } - // Step 3: Delete original if requested if (deleteOriginal) { try { await File(inputPath).delete(); @@ -1353,6 +1353,160 @@ class FFmpegService { return id3Map; } + + /// Split a CUE+audio file into individual track files using FFmpeg. + /// Each track is extracted with `-c copy` (no re-encoding) and metadata is embedded. + /// [audioPath] is the source audio file (FLAC, WAV, etc.) + /// [outputDir] is where individual track files will be saved + /// [tracks] is the list of track split info from the Go CUE parser + /// [albumMetadata] contains album-level metadata (artist, album, genre, date) + /// Returns list of output file paths on success, null on failure. + static Future?> splitCueToTracks({ + required String audioPath, + required String outputDir, + required List tracks, + required Map albumMetadata, + String? coverPath, + void Function(int current, int total)? onProgress, + }) async { + if (tracks.isEmpty) { + _log.e('No tracks to split'); + return null; + } + + final outputPaths = []; + final inputExt = audioPath.toLowerCase().split('.').last; + // For lossless formats, keep as FLAC; for others, keep original format + final outputExt = (inputExt == 'flac' || inputExt == 'wav' || inputExt == 'ape' || inputExt == 'wv') + ? 'flac' + : inputExt; + + for (var i = 0; i < tracks.length; i++) { + final track = tracks[i]; + onProgress?.call(i + 1, tracks.length); + + // Sanitize filename + final sanitizedTitle = track.title + .replaceAll(RegExp(r'[<>:"/\\|?*]'), '_') + .replaceAll(RegExp(r'\s+'), ' ') + .trim(); + final trackNumStr = track.number.toString().padLeft(2, '0'); + final outputFileName = '$trackNumStr - $sanitizedTitle.$outputExt'; + final outputPath = '$outputDir${Platform.pathSeparator}$outputFileName'; + + // Build FFmpeg command for this track + final StringBuffer cmdBuffer = StringBuffer(); + cmdBuffer.write('-i "$audioPath" '); + + // Time range + final startTime = _formatSecondsForFFmpeg(track.startSec); + cmdBuffer.write('-ss $startTime '); + + if (track.endSec > 0) { + final endTime = _formatSecondsForFFmpeg(track.endSec); + cmdBuffer.write('-to $endTime '); + } + + if (outputExt == 'flac') { + cmdBuffer.write('-c:a flac -compression_level 8 '); + } else { + cmdBuffer.write('-c:a copy '); + } + + // Metadata + final artist = track.artist.isNotEmpty ? track.artist : (albumMetadata['artist'] ?? ''); + final album = albumMetadata['album'] ?? ''; + final genre = albumMetadata['genre'] ?? ''; + final date = albumMetadata['date'] ?? ''; + + void addMeta(String key, String value) { + if (value.isNotEmpty) { + final sanitized = value.replaceAll('"', '\\"'); + cmdBuffer.write('-metadata $key="$sanitized" '); + } + } + + addMeta('TITLE', track.title); + addMeta('ARTIST', artist); + addMeta('ALBUM', album); + addMeta('ALBUMARTIST', albumMetadata['artist'] ?? ''); + addMeta('TRACKNUMBER', track.number.toString()); + addMeta('GENRE', genre); + addMeta('DATE', date); + if (track.isrc.isNotEmpty) addMeta('ISRC', track.isrc); + if (track.composer.isNotEmpty) addMeta('COMPOSER', track.composer); + + cmdBuffer.write('"$outputPath" -y'); + + final command = cmdBuffer.toString(); + _log.d('CUE split track ${track.number}: ${_previewCommandForLog(command)}'); + + final result = await _execute(command); + if (!result.success) { + _log.e('CUE split failed for track ${track.number}: ${result.output}'); + // Continue with remaining tracks instead of failing completely + continue; + } + + // Embed cover art if available (for FLAC output) + if (coverPath != null && coverPath.isNotEmpty && outputExt == 'flac') { + // Use the Go backend for FLAC cover embedding via PlatformBridge + // (handled by the caller) + } + + outputPaths.add(outputPath); + _log.i('CUE split: track ${track.number} -> $outputFileName'); + } + + if (outputPaths.isEmpty) { + _log.e('CUE split: no tracks were successfully extracted'); + return null; + } + + _log.i('CUE split complete: ${outputPaths.length}/${tracks.length} tracks'); + return outputPaths; + } + + static String _formatSecondsForFFmpeg(double seconds) { + if (seconds < 0) return '0'; + final hours = seconds ~/ 3600; + final mins = (seconds % 3600) ~/ 60; + final secs = seconds - (hours * 3600) - (mins * 60); + return '${hours.toString().padLeft(2, '0')}:${mins.toInt().toString().padLeft(2, '0')}:${secs.toStringAsFixed(3).padLeft(6, '0')}'; + } +} + +/// Track info for CUE splitting, passed from the CUE parser +class CueSplitTrackInfo { + final int number; + final String title; + final String artist; + final String isrc; + final String composer; + final double startSec; + final double endSec; + + CueSplitTrackInfo({ + required this.number, + required this.title, + required this.artist, + this.isrc = '', + this.composer = '', + required this.startSec, + required this.endSec, + }); + + factory CueSplitTrackInfo.fromJson(Map json) { + return CueSplitTrackInfo( + number: json['number'] as int? ?? 0, + title: json['title'] as String? ?? '', + artist: json['artist'] as String? ?? '', + isrc: json['isrc'] as String? ?? '', + composer: json['composer'] as String? ?? '', + startSec: (json['start_sec'] as num?)?.toDouble() ?? 0.0, + endSec: (json['end_sec'] as num?)?.toDouble() ?? -1.0, + ); + } } class FFmpegResult { diff --git a/lib/services/history_database.dart b/lib/services/history_database.dart index 5a3c1739..75bfaada 100644 --- a/lib/services/history_database.dart +++ b/lib/services/history_database.dart @@ -104,8 +104,6 @@ class HistoryDatabase { } } - // ==================== iOS Path Normalization ==================== - /// Pattern to match iOS container paths /// Example: /var/mobile/Containers/Data/Application/UUID-HERE/Documents/... static final _iosContainerPattern = RegExp( @@ -325,8 +323,6 @@ class HistoryDatabase { }; } - // ==================== CRUD Operations ==================== - /// Insert or update a history item Future upsert(Map json) async { final db = await database; @@ -502,6 +498,29 @@ class HistoryDatabase { await db.update('history', values, where: 'id = ?', whereArgs: [id]); } + Future updateAudioMetadata( + String id, { + String? newQuality, + int? newBitDepth, + int? newSampleRate, + }) async { + final db = await database; + final values = {}; + if (newQuality != null) { + values['quality'] = newQuality; + } + if (newBitDepth != null) { + values['bit_depth'] = newBitDepth; + } + if (newSampleRate != null) { + values['sample_rate'] = newSampleRate; + } + if (values.isEmpty) { + return; + } + await db.update('history', values, where: 'id = ?', whereArgs: [id]); + } + /// Get all file paths from download history /// Used to exclude downloaded files from local library scan Future> getAllFilePaths() async { diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index a0b35d54..c41207b9 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -20,51 +20,6 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - static Future> getSpotifyMetadata(String url) async { - _log.d('getSpotifyMetadata: $url'); - final result = await _channel.invokeMethod('getSpotifyMetadata', { - 'url': url, - }); - return jsonDecode(result as String) as Map; - } - - static Future> searchSpotify( - String query, { - int limit = 10, - }) async { - _log.d('searchSpotify: "$query" (limit: $limit)'); - final result = await _channel.invokeMethod('searchSpotify', { - 'query': query, - 'limit': limit, - }); - return jsonDecode(result as String) as Map; - } - - static Future> searchSpotifyAll( - String query, { - int trackLimit = 15, - int artistLimit = 3, - }) async { - _log.d('searchSpotifyAll: "$query"'); - final result = await _channel.invokeMethod('searchSpotifyAll', { - 'query': query, - 'track_limit': trackLimit, - 'artist_limit': artistLimit, - }); - 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, @@ -403,8 +358,6 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - // ==================== LYRICS PROVIDER SETTINGS ==================== - /// Sets the lyrics provider order. Providers not in the list are disabled. static Future setLyricsProviders(List providers) async { final providersJSON = jsonEncode(providers); @@ -519,21 +472,6 @@ class PlatformBridge { return result as bool; } - static Future setSpotifyCredentials( - String clientId, - String clientSecret, - ) async { - await _channel.invokeMethod('setSpotifyCredentials', { - 'client_id': clientId, - 'client_secret': clientSecret, - }); - } - - static Future hasSpotifyCredentials() async { - final result = await _channel.invokeMethod('hasSpotifyCredentials'); - return result as bool; - } - static Future preWarmTrackCache( List> tracks, ) async { @@ -1060,8 +998,6 @@ class PlatformBridge { } } - // ==================== LOCAL LIBRARY SCANNING ==================== - /// Set the directory for caching extracted cover art static Future setLibraryCoverCacheDir(String cacheDir) async { _log.i('setLibraryCoverCacheDir: $cacheDir'); @@ -1159,6 +1095,47 @@ class PlatformBridge { await _channel.invokeMethod('cancelLibraryScan'); } + // MARK: - iOS Security-Scoped Bookmark + + /// Create a security-scoped bookmark from a filesystem path picked by + /// FilePicker on iOS. Must be called while the picker session is still active. + /// Returns base64-encoded bookmark data, or null on failure. + static Future createIosBookmarkFromPath(String path) async { + try { + final result = await _channel.invokeMethod('createIosBookmarkFromPath', { + 'path': path, + }); + return result as String?; + } catch (e) { + _log.w('Failed to create iOS bookmark from path: $e'); + return null; + } + } + + /// Resolve a base64-encoded iOS security-scoped bookmark and start accessing + /// the resource. Returns the resolved filesystem path. + /// The resource stays accessed until [stopAccessingIosBookmark] is called. + static Future startAccessingIosBookmark(String bookmark) async { + try { + final result = await _channel.invokeMethod('startAccessingIosBookmark', { + 'bookmark': bookmark, + }); + return result as String?; + } catch (e) { + _log.w('Failed to start accessing iOS bookmark: $e'); + return null; + } + } + + /// Stop accessing the currently active iOS security-scoped resource. + static Future stopAccessingIosBookmark() async { + try { + await _channel.invokeMethod('stopAccessingIosBookmark'); + } catch (e) { + _log.w('Failed to stop accessing iOS bookmark: $e'); + } + } + /// Read metadata from a single audio file static Future?> readAudioMetadata( String filePath, @@ -1261,5 +1238,19 @@ class PlatformBridge { await _channel.invokeMethod('clearStoreCache'); } - // ==================== YOUTUBE / COBALT ==================== + /// Parse a .cue file and return split information (track listing, timing, metadata). + /// Returns a map with: cue_path, audio_path, album, artist, genre, date, tracks[] + /// Each track has: number, title, artist, isrc, composer, start_sec, end_sec + /// [audioDir] optionally overrides the directory for audio file resolution (used for SAF). + static Future> parseCueSheet( + String cuePath, { + String audioDir = '', + }) async { + _log.i('parseCueSheet: $cuePath (audioDir: $audioDir)'); + final result = await _channel.invokeMethod('parseCueSheet', { + 'cue_path': cuePath, + 'audio_dir': audioDir, + }); + return jsonDecode(result as String) as Map; + } } diff --git a/lib/services/share_intent_service.dart b/lib/services/share_intent_service.dart index 274bd234..4f8867b1 100644 --- a/lib/services/share_intent_service.dart +++ b/lib/services/share_intent_service.dart @@ -31,7 +31,12 @@ class ShareIntentService { // YouTube Music patterns static final RegExp _ytMusicUrlPattern = RegExp( - r'https?://music\.youtube\.com/(watch\?v=|playlist\?list=|channel/)[a-zA-Z0-9_-]+(\&[^\s]*)?', + r'https?://music\.youtube\.com/(watch\?v=|playlist\?list=|channel/|browse/)[a-zA-Z0-9_-]+([?&][^\s]*)?', + ); + + // Standard YouTube patterns (youtu.be short links and www.youtube.com/watch) + static final RegExp _youtubeUrlPattern = RegExp( + r'https?://(youtu\.be/[a-zA-Z0-9_-]+|www\.youtube\.com/watch\?v=[a-zA-Z0-9_-]+)([?&][^\s]*)?', ); final _sharedUrlController = StreamController.broadcast(); @@ -101,14 +106,15 @@ class ShareIntentService { _deezerShortLinkPattern, _tidalUrlPattern, _ytMusicUrlPattern, + _youtubeUrlPattern, ]; for (final pattern in patterns) { final match = pattern.firstMatch(text); if (match != null) { final fullUrl = match.group(0)!; - // Remove query params for cleaner URL (except for YT Music which needs them) - if (pattern == _ytMusicUrlPattern) { + // Keep query params for YouTube URLs (needed for ?v=, ?list=, etc.) + if (pattern == _ytMusicUrlPattern || pattern == _youtubeUrlPattern) { return fullUrl; } final queryIndex = fullUrl.indexOf('?'); diff --git a/lib/utils/file_access.dart b/lib/utils/file_access.dart index 1dd4f1fc..026fa57e 100644 --- a/lib/utils/file_access.dart +++ b/lib/utils/file_access.dart @@ -212,16 +212,39 @@ bool isContentUri(String? path) { return path != null && path.startsWith('content://'); } +/// Pattern matching CUE virtual path suffixes like #track01, #track12, etc. +final _cueTrackSuffix = RegExp(r'#track\d+$'); + +const cueVirtualTrackRequiresSplitMessage = + 'This CUE track is virtual. Use Split into Tracks first.'; + +/// Whether the path is a CUE virtual path (contains #trackNN suffix). +bool isCueVirtualPath(String? path) { + return path != null && _cueTrackSuffix.hasMatch(path); +} + +/// Strip the #trackNN suffix from a CUE virtual path to get the base .cue path. +/// Returns the path unchanged if it's not a CUE virtual path. +String stripCueTrackSuffix(String path) { + return path.replaceFirst(_cueTrackSuffix, ''); +} + Future fileExists(String? path) async { if (path == null || path.isEmpty) return false; - if (isContentUri(path)) { - return PlatformBridge.safExists(path); + // For CUE virtual paths, check if the base .cue file exists + final realPath = isCueVirtualPath(path) ? stripCueTrackSuffix(path) : path; + if (isContentUri(realPath)) { + return PlatformBridge.safExists(realPath); } - return File(path).exists(); + return File(realPath).exists(); } Future deleteFile(String? path) async { if (path == null || path.isEmpty) return; + // CUE virtual paths should NOT be deleted through this function — + // deleting album.cue would remove ALL tracks. Callers should handle + // CUE deletion specially (e.g. only delete when all tracks are removed). + if (isCueVirtualPath(path)) return; if (isContentUri(path)) { await PlatformBridge.safDelete(path); return; @@ -233,8 +256,10 @@ Future deleteFile(String? path) async { Future fileStat(String? path) async { if (path == null || path.isEmpty) return null; - if (isContentUri(path)) { - final stat = await PlatformBridge.safStat(path); + // For CUE virtual paths, stat the base .cue file + final realPath = isCueVirtualPath(path) ? stripCueTrackSuffix(path) : path; + if (isContentUri(realPath)) { + final stat = await PlatformBridge.safStat(realPath); final exists = stat['exists'] as bool? ?? true; if (!exists) return null; return FileAccessStat( @@ -245,18 +270,23 @@ Future fileStat(String? path) async { ); } - final stat = await FileStat.stat(path); + final stat = await FileStat.stat(realPath); if (stat.type == FileSystemEntityType.notFound) return null; return FileAccessStat(size: stat.size, modified: stat.modified); } Future openFile(String path) async { - if (isContentUri(path)) { - await PlatformBridge.openContentUri(path, mimeType: ''); + if (isCueVirtualPath(path)) { + throw Exception(cueVirtualTrackRequiresSplitMessage); + } + + final realPath = path; + if (isContentUri(realPath)) { + await PlatformBridge.openContentUri(realPath, mimeType: ''); return; } - final mimeType = audioMimeTypeForPath(path); - final result = await OpenFilex.open(path, type: mimeType); + final mimeType = audioMimeTypeForPath(realPath); + final result = await OpenFilex.open(realPath, type: mimeType); if (result.type != ResultType.done) { throw Exception(result.message); } diff --git a/lib/utils/path_match_keys.dart b/lib/utils/path_match_keys.dart new file mode 100644 index 00000000..ace140dd --- /dev/null +++ b/lib/utils/path_match_keys.dart @@ -0,0 +1,104 @@ +import 'dart:io'; + +const _androidStoragePathAliases = [ + '/storage/emulated/0', + '/storage/emulated/legacy', + '/storage/self/primary', + '/sdcard', + '/mnt/sdcard', +]; + +Set buildPathMatchKeys(String? filePath) { + final raw = filePath?.trim() ?? ''; + if (raw.isEmpty) return const {}; + + final cleaned = raw.startsWith('EXISTS:') ? raw.substring(7).trim() : raw; + if (cleaned.isEmpty) return const {}; + + final keys = {}; + final visited = {}; + + void addNormalized(String value) { + final trimmed = value.trim(); + if (trimmed.isEmpty) return; + if (!visited.add(trimmed)) return; + + keys.add(trimmed); + keys.add(trimmed.toLowerCase()); + + if (trimmed.contains('\\')) { + final slash = trimmed.replaceAll('\\', '/'); + if (slash != trimmed) { + addNormalized(slash); + } + } + + if (trimmed.contains('%')) { + try { + final decoded = Uri.decodeFull(trimmed); + if (decoded != trimmed) { + addNormalized(decoded); + } + } catch (_) {} + } + + Uri? parsed; + try { + parsed = Uri.parse(trimmed); + } catch (_) {} + + if (parsed != null && parsed.hasScheme) { + final withoutQueryOrFragment = parsed.replace( + query: null, + fragment: null, + ); + final uriString = withoutQueryOrFragment.toString(); + keys.add(uriString); + keys.add(uriString.toLowerCase()); + + if (parsed.scheme == 'file') { + try { + addNormalized(parsed.toFilePath()); + } catch (_) {} + } + } else if (trimmed.startsWith('/')) { + try { + final asFileUri = Uri.file(trimmed).toString(); + keys.add(asFileUri); + keys.add(asFileUri.toLowerCase()); + } catch (_) {} + } + + if (Platform.isAndroid) { + for (final alias in _androidEquivalentPaths(trimmed)) { + if (alias != trimmed) { + addNormalized(alias); + } + } + } + } + + addNormalized(cleaned); + return keys; +} + +Iterable _androidEquivalentPaths(String path) { + final normalized = path.replaceAll('\\', '/'); + final lower = normalized.toLowerCase(); + String? suffix; + + for (final prefix in _androidStoragePathAliases) { + if (lower == prefix) { + suffix = ''; + break; + } + final withSlash = '$prefix/'; + if (lower.startsWith(withSlash)) { + suffix = normalized.substring(prefix.length); + break; + } + } + + if (suffix == null) return const []; + return _androidStoragePathAliases.map((prefix) => '$prefix$suffix'); +} diff --git a/lib/utils/string_utils.dart b/lib/utils/string_utils.dart index 8394d430..9f4cc164 100644 --- a/lib/utils/string_utils.dart +++ b/lib/utils/string_utils.dart @@ -5,3 +5,47 @@ String? normalizeOptionalString(String? value) { if (trimmed.toLowerCase() == 'null') return null; return trimmed; } + +String formatSampleRateKHz(int sampleRate) { + final khz = sampleRate / 1000; + final precision = sampleRate % 1000 == 0 ? 0 : 1; + return '${khz.toStringAsFixed(precision)}kHz'; +} + +String? buildDisplayAudioQuality({ + int? bitDepth, + int? sampleRate, + int? bitrateKbps, + String? format, + String? storedQuality, +}) { + if (bitrateKbps != null && bitrateKbps > 0) { + final normalizedFormat = normalizeOptionalString(format)?.toUpperCase(); + return normalizedFormat != null + ? '$normalizedFormat ${bitrateKbps}kbps' + : '${bitrateKbps}kbps'; + } + + if (bitDepth != null && + bitDepth > 0 && + sampleRate != null && + sampleRate > 0) { + return '$bitDepth-bit/${formatSampleRateKHz(sampleRate)}'; + } + + return normalizeOptionalString(storedQuality); +} + +bool isPlaceholderQualityLabel(String? quality) { + final normalized = normalizeOptionalString(quality)?.toLowerCase(); + if (normalized == null) return false; + + return const { + 'best', + 'lossless', + 'hi-res', + 'hi-res-max', + 'high', + 'cd', + }.contains(normalized); +} diff --git a/lib/widgets/collapsing_header.dart b/lib/widgets/collapsing_header.dart index 276f8f43..04ee5213 100644 --- a/lib/widgets/collapsing_header.dart +++ b/lib/widgets/collapsing_header.dart @@ -32,6 +32,7 @@ class CollapsingHeader extends StatelessWidget { surfaceTintColor: Colors.transparent, leading: showBackButton ? IconButton( + tooltip: MaterialLocalizations.of(context).backButtonTooltip, icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context), ) @@ -39,7 +40,10 @@ class CollapsingHeader extends StatelessWidget { automaticallyImplyLeading: false, flexibleSpace: LayoutBuilder( builder: (context, constraints) { - final expandRatio = _calculateExpandRatio(constraints, topPadding); + final expandRatio = _calculateExpandRatio( + constraints, + topPadding, + ); final animation = AlwaysStoppedAnimation(expandRatio); return FlexibleSpaceBar( @@ -48,13 +52,22 @@ class CollapsingHeader extends StatelessWidget { title: Container( alignment: Alignment.bottomLeft, padding: EdgeInsets.only( - left: Tween(begin: showBackButton ? 56 : 24, end: 24).evaluate(animation), - bottom: Tween(begin: 16, end: 24).evaluate(animation), + left: Tween( + begin: showBackButton ? 56 : 24, + end: 24, + ).evaluate(animation), + bottom: Tween( + begin: 16, + end: 24, + ).evaluate(animation), ), child: Text( title, style: TextStyle( - fontSize: Tween(begin: 20, end: 28).evaluate(animation), + fontSize: Tween( + begin: 20, + end: 28, + ).evaluate(animation), fontWeight: FontWeight.bold, color: colorScheme.onSurface, ), @@ -142,8 +155,12 @@ class InfoCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(title, style: Theme.of(context).textTheme.bodyLarge), - Text(subtitle, style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant)), + Text( + subtitle, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), ], ), ], diff --git a/lib/widgets/donate_icons.dart b/lib/widgets/donate_icons.dart index 16e83ef8..21ba4195 100644 --- a/lib/widgets/donate_icons.dart +++ b/lib/widgets/donate_icons.dart @@ -28,14 +28,12 @@ class _KofiPainter extends CustomPainter { ..color = color ..style = PaintingStyle.fill; - // Cup body final cup = RRect.fromRectAndRadius( Rect.fromLTWH(s * 0.08, s * 0.28, s * 0.62, s * 0.52), Radius.circular(s * 0.12), ); canvas.drawRRect(cup, paint); - // Handle final handlePaint = Paint() ..color = color ..style = PaintingStyle.stroke diff --git a/lib/widgets/download_service_picker.dart b/lib/widgets/download_service_picker.dart index 881b6101..41c8c330 100644 --- a/lib/widgets/download_service_picker.dart +++ b/lib/widgets/download_service_picker.dart @@ -67,25 +67,14 @@ const _builtInServices = [ ), ], ), - BuiltInService( - id: 'amazon', - label: 'Amazon', - qualityOptions: [ - QualityOption( - id: 'LOSSLESS', - label: 'FLAC Best Available', - description: 'Amazon API delivers the best available lossless quality', - ), - ], - ), BuiltInService( id: 'deezer', label: 'Deezer', qualityOptions: [ QualityOption( id: 'FLAC', - label: 'FLAC Lossless', - description: '16-bit / 44.1kHz (CD Quality)', + label: 'FLAC Best Quality', + description: 'Up to 24-bit / 48kHz+', ), ], ), @@ -209,7 +198,6 @@ class _DownloadServicePickerState extends ConsumerState { return ext.qualityOptions; } - // Default fallback options return [ const QualityOption( id: 'DEFAULT', diff --git a/lib/widgets/priority_settings_scaffold.dart b/lib/widgets/priority_settings_scaffold.dart index f866b46f..acd9a664 100644 --- a/lib/widgets/priority_settings_scaffold.dart +++ b/lib/widgets/priority_settings_scaffold.dart @@ -61,6 +61,7 @@ class PrioritySettingsScaffold extends StatelessWidget { backgroundColor: colorScheme.surface, surfaceTintColor: Colors.transparent, leading: IconButton( + tooltip: MaterialLocalizations.of(context).backButtonTooltip, icon: const Icon(Icons.arrow_back), onPressed: () => _handleBack(context), ), diff --git a/lib/widgets/track_collection_quick_actions.dart b/lib/widgets/track_collection_quick_actions.dart index d21a67fe..b8f75708 100644 --- a/lib/widgets/track_collection_quick_actions.dart +++ b/lib/widgets/track_collection_quick_actions.dart @@ -36,6 +36,7 @@ class TrackCollectionQuickActions extends ConsumerWidget { final colorScheme = Theme.of(context).colorScheme; return IconButton( + tooltip: MaterialLocalizations.of(context).showMenuTooltip, icon: Icon( Icons.more_vert, color: colorScheme.onSurfaceVariant, diff --git a/pubspec.yaml b/pubspec.yaml index ff4f6473..638e579d 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 +description: Download Spotify tracks in FLAC from Tidal, Qobuz & Deezer publish_to: "none" -version: 3.7.1+104 +version: 3.7.2+105 environment: sdk: ^3.10.0 diff --git a/site/docs.html b/site/docs.html index 2ad5c7f6..6a148a93 100644 --- a/site/docs.html +++ b/site/docs.html @@ -965,7 +965,7 @@ skipBuiltInFallback boolean No -If true, don't fallback to built-in providers (Tidal/Qobuz/Amazon) when extension download fails +If true, don't fallback to built-in providers (Tidal/Qobuz/Deezer) when extension download fails minAppVersion diff --git a/site/index.html b/site/index.html index 572d1152..47999b34 100644 --- a/site/index.html +++ b/site/index.html @@ -4,12 +4,12 @@ SpotiFLAC Mobile - Lossless Music Downloader - + - + @@ -404,7 +404,7 @@

SpotiFLAC Mobile

-

Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music — no account required.

+

Download music in true lossless FLAC from Tidal, Qobuz & Deezer — no account required.

@@ -451,7 +451,7 @@

Multiple Providers

-

Download from Tidal, Qobuz, Amazon Music, and more. Automatic fallback if a source is unavailable.

+

Download from Tidal, Qobuz, Deezer, and more via extensions. Automatic fallback if a source is unavailable.

@@ -494,11 +494,11 @@
Why is my download failing with "Song not found"? -
The track may not be available on Tidal, Qobuz, or Amazon Music. Try enabling more download services in Settings > Download > Provider Priority, or install additional extensions from the Store.
+
The track may not be available on the streaming services. Try enabling more download services in Settings > Download > Provider Priority, or install additional extensions like Amazon Music from the Store.
Why are some tracks downloading in lower quality? -
Quality depends on what's available from the streaming service. Tidal offers up to 24-bit/192kHz, Qobuz up to 24-bit/192kHz, and Amazon up to 24-bit/48kHz. The app automatically selects the best available quality.
+
Quality depends on what's available from the streaming service and extensions. Built-in providers: Tidal offers up to 24-bit/192kHz, Qobuz up to 24-bit/192kHz, and Deezer up to 16-bit/44.1kHz.
Can I download entire playlists?