mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-03 03:15:51 +02:00
Compare commits
136 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ac4f555f6 | |||
| 098544393e | |||
| 29165da5ac | |||
| 9343583c69 | |||
| d82d255bae | |||
| 93a7042a84 | |||
| 5be5c869da | |||
| 8d45e023b2 | |||
| f2ae1398db | |||
| c2736a61fb | |||
| 76fe8dbc69 | |||
| 64408c8d8b | |||
| db55bb4693 | |||
| 9c6856b584 | |||
| a4899144c5 | |||
| 808083c938 | |||
| 7e41ab4460 | |||
| 75a2bec8d5 | |||
| c35857bb61 | |||
| 2c897992c5 | |||
| 7d5cb574c6 | |||
| c582f96cf6 | |||
| 8fab3f60a7 | |||
| c6e981b3a1 | |||
| f0c5c5660a | |||
| 9c647bb31b | |||
| e1e82ac586 | |||
| 585d6da98d | |||
| bc279dd7fd | |||
| f2fdead6d3 | |||
| f66ccb4741 | |||
| 32c10c2b23 | |||
| 05674d9586 | |||
| 11bda9aae5 | |||
| 02c803385c | |||
| 8fe7a1e756 | |||
| 4a61ffea8d | |||
| 91548691ad | |||
| ab2d671760 | |||
| 5532d0a7d9 | |||
| 277e7719d3 | |||
| d6cb9fc261 | |||
| e6a857335f | |||
| e82e3a8343 | |||
| 6d812c76c2 | |||
| 5af4bb7ade | |||
| 030d66fd65 | |||
| c929f8d0a6 | |||
| 6fb50cfc67 | |||
| ebcdcf40dc | |||
| 76a05e717b | |||
| 062ce31cf7 | |||
| 8675ab3215 | |||
| ad6ef2884a | |||
| 3ebb8a5e79 | |||
| 652b1b0821 | |||
| a202ca4865 | |||
| a2db5bef25 | |||
| a81fa1ead7 | |||
| e7315cbc7e | |||
| cd757f177f | |||
| 103c55c072 | |||
| 765caab6df | |||
| 72f4663dd5 | |||
| deb6d92b55 | |||
| 0222ea6ccb | |||
| 8c047600a0 | |||
| 57b5877fdc | |||
| 7ddf67a977 | |||
| 7af2212d11 | |||
| 5e13651ed9 | |||
| 08e9c8d463 | |||
| b3d93880b5 | |||
| 05e100a492 | |||
| a4e22de455 | |||
| d76d020cfe | |||
| 85bf3cfa84 | |||
| 8eec73d88c | |||
| b63dbbbfd5 | |||
| 8b16157047 | |||
| 6628682f97 | |||
| 5971ffc470 | |||
| baf95ec328 | |||
| 0a6590fafd | |||
| 22dd0ee0f6 | |||
| f9ad6046e8 | |||
| 8a21902fa1 | |||
| 016564eda7 | |||
| 5a8ff7db37 | |||
| cc08596adf | |||
| e83fd66023 | |||
| d49bab403d | |||
| a6bef63aa7 | |||
| 898e28c40c | |||
| 9fda7ef596 | |||
| 17ba1713ad | |||
| f4110204b1 | |||
| d2a183b52d | |||
| a8dcf3113c | |||
| 1f52a6c9e0 | |||
| adbed63196 | |||
| 33e20845f1 | |||
| 9a7096c301 | |||
| 4c365032ff | |||
| bbd32d40a6 | |||
| 73f4a91fa1 | |||
| 1e2e383794 | |||
| 3b70b071e3 | |||
| 838c0ea421 | |||
| b39ec41255 | |||
| d4d661d6d4 | |||
| 2092f078ec | |||
| 924569aefb | |||
| a5864e15f8 | |||
| 564dd8bf95 | |||
| b317f7cd76 | |||
| a3b49d2642 | |||
| 6f20620c97 | |||
| b6a055a01a | |||
| 44ac593ddc | |||
| ca4c2a661e | |||
| 8b3b39f390 | |||
| 915934e5dd | |||
| 42f15018ae | |||
| 3554a7b5b9 | |||
| f2941939b7 | |||
| 1a77ded997 | |||
| 05d25d4d7c | |||
| 7cc1fef989 | |||
| 4a966e5e52 | |||
| d8ba4549aa | |||
| 309568becc | |||
| dd9b6dbfe3 | |||
| 4692b48174 | |||
| db82fa3ae1 | |||
| 5c42507b12 |
@@ -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** → <b>text</b>
|
||||
# - `code` → <code>code</code>
|
||||
# - ### Header → <b>Header</b>
|
||||
# - 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/>/\>/g' | \
|
||||
sed 's/`\([^`]*\)`/<code>\1<\/code>/g' | \
|
||||
sed 's/\*\*\([^*]*\)\*\*/<b>\1<\/b>/g' | \
|
||||
sed 's/^### \(.*\)$/<b>\1<\/b>/g' | \
|
||||
sed 's/^## \(.*\)$/<b>\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
|
||||
|
||||
@@ -12,6 +12,9 @@ Thumbs.db
|
||||
# Kiro specs (development only)
|
||||
.kiro/
|
||||
|
||||
# Design assets (banners, mockups)
|
||||
design/
|
||||
|
||||
# Reference folder (development only)
|
||||
referensi/
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||
[](https://www.virustotal.com/gui/file/40f8f1914287dea317122a837f98b0ddf7af3205adc2f84a350d767e0a6a345c)
|
||||
[](https://crowdin.com/project/spotiflac-mobile)
|
||||
|
||||
<div align="center">
|
||||
|
||||
<img src="icon.png" width="128" />
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="assets/images/banner-readme-dark.png">
|
||||
<source media="(prefers-color-scheme: light)" srcset="assets/images/banner-readme-light.png">
|
||||
<img alt="SpotiFLAC Mobile" src="assets/images/banner-readme-light.png" width="650" height="auto">
|
||||
</picture>
|
||||
|
||||
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music — no account required.
|
||||
|
||||

|
||||

|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/17247">
|
||||
<img src="https://trendshift.io/api/badge/repositories/17247" alt="zarzet%2FSpotiFLAC-Mobile | Trendshift" width="250" height="55">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
### [Download](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||
|
||||
## Screenshots
|
||||
|
||||
<p align="center">
|
||||
@@ -24,6 +23,17 @@ Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music — no acc
|
||||
<img src="assets/images/4.jpg?v=2" width="200" />
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||
[](https://www.virustotal.com/gui/file/0a2bd2a033551983fc9fcd83f82fd912c83914fd1094cd8d1c7c6a68eb23233f)
|
||||
[](https://crowdin.com/project/spotiflac-mobile)
|
||||
|
||||
[](https://t.me/spotiflac)
|
||||
[](https://t.me/spotiflac_chat)
|
||||
|
||||
</div>
|
||||
|
||||
## 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 +53,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
|
||||
|
||||
[](https://t.me/spotiflac)
|
||||
[](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 +80,6 @@ _If this software is useful and brings you value, consider supporting the projec
|
||||
|
||||
[](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)
|
||||
|
||||
@@ -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<Pair<DocumentFile, String>>()
|
||||
// CUE files: (cueDoc, parentDir) — we need the parent to find sibling audio
|
||||
val cueFiles = mutableListOf<Pair<DocumentFile, DocumentFile>>()
|
||||
val visitedDirUris = mutableSetOf<String>()
|
||||
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<String>()
|
||||
|
||||
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<Triple<DocumentFile, String, Long>>() // doc, path, lastModified
|
||||
// CUE files to scan: (cueDoc, parentDir, lastModified)
|
||||
val cueFilesToScan = mutableListOf<Triple<DocumentFile, DocumentFile, Long>>()
|
||||
// Unchanged CUE files: (cueDoc, parentDir) — need to discover audio siblings for skip set
|
||||
val unchangedCueFiles = mutableListOf<Pair<DocumentFile, DocumentFile>>()
|
||||
val currentUris = mutableSetOf<String>()
|
||||
val visitedDirUris = mutableSetOf<String>()
|
||||
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<String, MutableList<String>>() // 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<Pair<DocumentFile, String>> = 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<String>()
|
||||
|
||||
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<String>("url") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getSpotifyMetadata(url)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"searchSpotify" -> {
|
||||
val query = call.argument<String>("query") ?: ""
|
||||
val limit = call.argument<Int>("limit") ?: 10
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.searchSpotify(query, limit.toLong())
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"searchSpotifyAll" -> {
|
||||
val query = call.argument<String>("query") ?: ""
|
||||
val trackLimit = call.argument<Int>("track_limit") ?: 15
|
||||
val artistLimit = call.argument<Int>("artist_limit") ?: 3
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.searchSpotifyAll(query, trackLimit.toLong(), artistLimit.toLong())
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"getSpotifyRelatedArtists" -> {
|
||||
val artistId = call.argument<String>("artist_id") ?: ""
|
||||
val limit = call.argument<Int>("limit") ?: 12
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getSpotifyRelatedArtists(artistId, limit.toLong())
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"checkAvailability" -> {
|
||||
val spotifyId = call.argument<String>("spotify_id") ?: ""
|
||||
val isrc = call.argument<String>("isrc") ?: ""
|
||||
@@ -2099,20 +2510,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
"isDownloadServiceRunning" -> {
|
||||
result.success(DownloadService.isServiceRunning())
|
||||
}
|
||||
"setSpotifyCredentials" -> {
|
||||
val clientId = call.argument<String>("client_id") ?: ""
|
||||
val clientSecret = call.argument<String>("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<String>("tracks") ?: "[]"
|
||||
withContext(Dispatchers.IO) {
|
||||
@@ -2239,13 +2636,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"getAmazonURLFromDeezerTrack" -> {
|
||||
val deezerTrackId = call.argument<String>("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<String>("cue_path") ?: ""
|
||||
val audioDir = call.argument<String>("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) {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
+105
@@ -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 = "<!-- 0 -->New Features" },
|
||||
{ message = "^fix", group = "<!-- 1 -->Bug Fixes" },
|
||||
{ message = "^perf", group = "<!-- 2 -->Performance" },
|
||||
{ message = "^refactor", group = "<!-- 3 -->Refactoring" },
|
||||
{ message = "^doc", group = "<!-- 4 -->Documentation" },
|
||||
{ message = "^style", group = "<!-- 5 -->Styling" },
|
||||
{ message = "^test", group = "<!-- 6 -->Testing" },
|
||||
{ message = "^chore\\(deps\\)", group = "<!-- 7 -->Dependencies" },
|
||||
{ message = "^chore\\(l10n\\)", skip = true },
|
||||
{ message = "^chore|^ci", group = "<!-- 8 -->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"
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+37
-241
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Package gobackend provides extension manifest parsing and validation
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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("")
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Package gobackend provides extension settings storage
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Package gobackend provides timeout execution for extension JS code
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
+119
-13
@@ -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++
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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"`
|
||||
|
||||
+5
-12
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+2
-12
@@ -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()
|
||||
|
||||
|
||||
+3
-29
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+7
-13
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
+138
-50
@@ -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]?
|
||||
@@ -157,38 +160,6 @@ import Gobackend // Import Go framework
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "getSpotifyMetadata":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let url = args["url"] as! String
|
||||
let response = GobackendGetSpotifyMetadata(url, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "searchSpotify":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let query = args["query"] as! String
|
||||
let limit = args["limit"] as? Int ?? 10
|
||||
let response = GobackendSearchSpotify(query, Int(limit), &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "searchSpotifyAll":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let query = args["query"] as! String
|
||||
let trackLimit = args["track_limit"] as? Int ?? 15
|
||||
let artistLimit = args["artist_limit"] as? Int ?? 3
|
||||
let response = GobackendSearchSpotifyAll(query, Int(trackLimit), Int(artistLimit), &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "getSpotifyRelatedArtists":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let artistId = args["artist_id"] as! String
|
||||
let limit = args["limit"] as? Int ?? 12
|
||||
let response = GobackendGetSpotifyRelatedArtists(artistId, Int(limit), &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "checkAvailability":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let spotifyId = args["spotify_id"] as! String
|
||||
@@ -492,13 +463,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
|
||||
@@ -514,17 +478,6 @@ import Gobackend // Import Go framework
|
||||
GobackendClearTrackCache()
|
||||
return nil
|
||||
|
||||
case "setSpotifyCredentials":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let clientId = args["client_id"] as! String
|
||||
let clientSecret = args["client_secret"] as! String
|
||||
GobackendSetSpotifyAPICredentials(clientId, clientSecret)
|
||||
return nil
|
||||
|
||||
case "hasSpotifyCredentials":
|
||||
let hasCredentials = GobackendCheckSpotifyCredentials()
|
||||
return hasCredentials
|
||||
|
||||
// Log methods
|
||||
case "getLogs":
|
||||
let response = GobackendGetLogs()
|
||||
@@ -922,6 +875,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 +926,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 +943,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 {
|
||||
|
||||
@@ -17,7 +17,6 @@ final _routerProvider = Provider<GoRouter>((ref) {
|
||||
settingsProvider.select((s) => s.hasCompletedTutorial),
|
||||
);
|
||||
|
||||
// Determine initial location based on app state
|
||||
String initialLocation;
|
||||
if (isFirstLaunch) {
|
||||
initialLocation = '/setup';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
+422
-345
File diff suppressed because it is too large
Load Diff
@@ -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';
|
||||
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
+285
-238
File diff suppressed because it is too large
Load Diff
@@ -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';
|
||||
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
+182
-107
@@ -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 =>
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
+2634
-2112
File diff suppressed because it is too large
Load Diff
+583
-281
File diff suppressed because it is too large
Load Diff
+107
-3
@@ -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"
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
+303
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+303
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+80
-68
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+378
-76
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+520
-218
File diff suppressed because it is too large
Load Diff
+303
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
+357
-55
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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": {
|
||||
|
||||
+303
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+303
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ DownloadItem _$DownloadItemFromJson(Map<String, dynamic> 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<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
|
||||
@@ -37,6 +38,7 @@ Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
|
||||
'errorType': _$DownloadErrorTypeEnumMap[instance.errorType],
|
||||
'createdAt': instance.createdAt.toIso8601String(),
|
||||
'qualityOverride': instance.qualityOverride,
|
||||
'playlistName': instance.playlistName,
|
||||
};
|
||||
|
||||
const _$DownloadStatusEnumMap = {
|
||||
|
||||
@@ -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<String>
|
||||
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<String>? 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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> _$AppSettingsToJson(
|
||||
'songLinkRegion': instance.songLinkRegion,
|
||||
'localLibraryEnabled': instance.localLibraryEnabled,
|
||||
'localLibraryPath': instance.localLibraryPath,
|
||||
'localLibraryBookmark': instance.localLibraryBookmark,
|
||||
'localLibraryShowDuplicates': instance.localLibraryShowDuplicates,
|
||||
'hasCompletedTutorial': instance.hasCompletedTutorial,
|
||||
'lyricsProviders': instance.lyricsProviders,
|
||||
|
||||
+13
-1
@@ -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';
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ Track _$TrackFromJson(Map<String, dynamic> 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<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
|
||||
'availability': instance.availability,
|
||||
'source': instance.source,
|
||||
'albumType': instance.albumType,
|
||||
'totalTracks': instance.totalTracks,
|
||||
'itemType': instance.itemType,
|
||||
};
|
||||
|
||||
|
||||
@@ -251,9 +251,11 @@ class DownloadHistoryState {
|
||||
class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
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<DownloadHistoryState> {
|
||||
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<DownloadHistoryState> {
|
||||
}
|
||||
}
|
||||
|
||||
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<Map<String, dynamic>?> _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<void> _backfillAudioMetadata(
|
||||
List<DownloadHistoryItem> 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<void> reloadFromStorage() async {
|
||||
await _loadFromDatabase();
|
||||
}
|
||||
@@ -509,6 +672,39 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
return DownloadHistoryItem.fromJson(json);
|
||||
}
|
||||
|
||||
Future<void> 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<void> updateMetadataForItem({
|
||||
required String id,
|
||||
required String trackName,
|
||||
@@ -592,10 +788,8 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
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<DownloadQueueState> {
|
||||
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<DownloadQueueState> {
|
||||
|
||||
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<DownloadQueueState> {
|
||||
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<DownloadQueueState> {
|
||||
}
|
||||
|
||||
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<DownloadQueueState> {
|
||||
}
|
||||
|
||||
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<DownloadQueueState> {
|
||||
);
|
||||
}
|
||||
|
||||
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<DownloadQueueState> {
|
||||
service: service,
|
||||
createdAt: DateTime.now(),
|
||||
qualityOverride: qualityOverride,
|
||||
playlistName: playlistName,
|
||||
);
|
||||
|
||||
state = state.copyWith(items: [...state.items, item]);
|
||||
@@ -1740,6 +1941,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
List<Track> tracks,
|
||||
String service, {
|
||||
String? qualityOverride,
|
||||
String? playlistName,
|
||||
}) {
|
||||
final settings = ref.read(settingsProvider);
|
||||
updateSettings(settings);
|
||||
@@ -1754,6 +1956,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
service: service,
|
||||
createdAt: DateTime.now(),
|
||||
qualityOverride: qualityOverride,
|
||||
playlistName: playlistName,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
@@ -2159,6 +2362,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
deezerId: baseTrack.deezerId,
|
||||
availability: baseTrack.availability,
|
||||
albumType: baseTrack.albumType,
|
||||
totalTracks: baseTrack.totalTracks,
|
||||
source: baseTrack.source,
|
||||
);
|
||||
}
|
||||
@@ -2903,7 +3107,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
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<DownloadQueueState> {
|
||||
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<DownloadQueueState> {
|
||||
usePrimaryArtistOnly: settings.usePrimaryArtistOnly,
|
||||
filterContributingArtistsInAlbumArtist:
|
||||
settings.filterContributingArtistsInAlbumArtist,
|
||||
playlistName: item.playlistName,
|
||||
)
|
||||
: '';
|
||||
String? appOutputDir;
|
||||
@@ -3144,6 +3350,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
usePrimaryArtistOnly: settings.usePrimaryArtistOnly,
|
||||
filterContributingArtistsInAlbumArtist:
|
||||
settings.filterContributingArtistsInAlbumArtist,
|
||||
playlistName: item.playlistName,
|
||||
);
|
||||
var effectiveOutputDir = initialOutputDir;
|
||||
var effectiveSafMode = isSafMode;
|
||||
@@ -3206,7 +3413,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
!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<DownloadQueueState> {
|
||||
deezerId: deezerTrackId,
|
||||
availability: trackToDownload.availability,
|
||||
albumType: trackToDownload.albumType,
|
||||
totalTracks: trackToDownload.totalTracks,
|
||||
source: trackToDownload.source,
|
||||
);
|
||||
_log.d(
|
||||
@@ -3506,11 +3713,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
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<DownloadQueueState> {
|
||||
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<DownloadQueueState> {
|
||||
);
|
||||
|
||||
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<DownloadQueueState> {
|
||||
}
|
||||
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<DownloadQueueState> {
|
||||
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<DownloadQueueState> {
|
||||
return;
|
||||
}
|
||||
filePath = decryptedPath;
|
||||
_log.i('Amazon local decryption completed');
|
||||
_log.i('Local decryption completed');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3832,7 +4036,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Local file path flow (original)
|
||||
if (quality == 'HIGH') {
|
||||
final tidalHighFormat = settings.tidalHighFormat;
|
||||
_log.i(
|
||||
@@ -4049,10 +4252,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
!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<DownloadQueueState> {
|
||||
|
||||
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<DownloadQueueState> {
|
||||
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<DownloadQueueState> {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Non-SAF mode: embed directly
|
||||
try {
|
||||
if (isMp3File) {
|
||||
await _embedMetadataToMp3(
|
||||
@@ -4347,6 +4546,50 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
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<DownloadQueueState> {
|
||||
? 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)
|
||||
|
||||
@@ -757,7 +757,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
|
||||
Future<void> 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<ExtensionState> {
|
||||
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<ExtensionState> {
|
||||
Future<void> setProviderPriority(List<String> 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<ExtensionState> {
|
||||
}
|
||||
}
|
||||
|
||||
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<ExtensionState> {
|
||||
|
||||
Future<void> loadMetadataProviderPriority() async {
|
||||
try {
|
||||
// Load from SharedPreferences first (persisted)
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final savedJson = prefs.getString(_metadataProviderPriorityKey);
|
||||
|
||||
List<String> priority;
|
||||
if (savedJson != null) {
|
||||
final saved = jsonDecode(savedJson) as List<dynamic>;
|
||||
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<ExtensionState> {
|
||||
|
||||
Future<void> setMetadataProviderPriority(List<String> 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<ExtensionState> {
|
||||
}
|
||||
|
||||
List<String> 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<ExtensionState> {
|
||||
}
|
||||
|
||||
List<String> 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<ExtensionState> {
|
||||
return providers;
|
||||
}
|
||||
|
||||
List<String> _sanitizeMetadataProviderPriority(List<String> input) {
|
||||
final allowed = getAllMetadataProviders().toSet();
|
||||
final result = <String>[];
|
||||
|
||||
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<Extension> get searchProviders {
|
||||
return state.extensions
|
||||
.where((ext) => ext.enabled && ext.hasCustomSearch)
|
||||
|
||||
@@ -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<LocalLibraryState> {
|
||||
await _loadFromDatabase();
|
||||
}
|
||||
|
||||
Set<String> _buildPathMatchKeys(String? filePath) {
|
||||
final raw = filePath?.trim() ?? '';
|
||||
if (raw.isEmpty) return const {};
|
||||
|
||||
final cleaned = raw.startsWith('EXISTS:') ? raw.substring(7) : raw;
|
||||
final keys = <String>{};
|
||||
|
||||
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<String> 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<LocalLibraryState> {
|
||||
Future<void> 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<LocalLibraryState> {
|
||||
|
||||
_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<LocalLibraryState> {
|
||||
};
|
||||
final downloadedPathKeys = <String>{};
|
||||
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<LocalLibraryState> {
|
||||
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<LocalLibraryState> {
|
||||
final Map<String, dynamic> 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<LocalLibraryState> {
|
||||
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<LocalLibraryState> {
|
||||
return decoded;
|
||||
}
|
||||
|
||||
Future<int> cleanupMissingFiles() async {
|
||||
final removed = await _db.cleanupMissingFiles();
|
||||
if (removed > 0) {
|
||||
await reloadFromStorage();
|
||||
Future<int> 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<void> clearLibrary() async {
|
||||
|
||||
@@ -24,6 +24,9 @@ class PlaybackController extends Notifier<PlaybackState> {
|
||||
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<PlaybackState> {
|
||||
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<PlaybackState> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (skippedCueVirtualTrack) {
|
||||
throw Exception(cueVirtualTrackRequiresSplitMessage);
|
||||
}
|
||||
|
||||
throw Exception(
|
||||
'No local audio file is available to open. Download the track first.',
|
||||
);
|
||||
|
||||
@@ -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<AppSettings> {
|
||||
await _normalizeSongLinkRegionIfNeeded();
|
||||
}
|
||||
|
||||
await _loadSpotifyClientSecret(prefs);
|
||||
|
||||
_applySpotifyCredentials();
|
||||
await _retireBuiltInSpotifyProvider();
|
||||
|
||||
LogBuffer.loggingEnabled = state.enableLogging;
|
||||
|
||||
@@ -105,6 +103,17 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
}
|
||||
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<AppSettings> {
|
||||
await _saveSettings();
|
||||
}
|
||||
|
||||
Future<void> _loadSpotifyClientSecret(SharedPreferences prefs) async {
|
||||
Future<void> _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<void> _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<void> _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<AppSettings> {
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setSpotifyClientId(String clientId) {
|
||||
state = state.copyWith(spotifyClientId: clientId);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
Future<void> setSpotifyClientSecret(String clientSecret) async {
|
||||
state = state.copyWith(spotifyClientSecret: clientSecret);
|
||||
await _storeSpotifyClientSecret(clientSecret);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
Future<void> setSpotifyCredentials(
|
||||
String clientId,
|
||||
String clientSecret,
|
||||
) async {
|
||||
state = state.copyWith(
|
||||
spotifyClientId: clientId,
|
||||
spotifyClientSecret: clientSecret,
|
||||
);
|
||||
await _storeSpotifyClientSecret(clientSecret);
|
||||
_saveSettings();
|
||||
_applySpotifyCredentials();
|
||||
}
|
||||
|
||||
Future<void> 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<AppSettings> {
|
||||
_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();
|
||||
|
||||
@@ -204,7 +204,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
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<TrackState> {
|
||||
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<TrackState> {
|
||||
}
|
||||
}
|
||||
|
||||
// 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<TrackState> {
|
||||
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<TrackState> {
|
||||
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<TrackState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> search(
|
||||
String query, {
|
||||
String? metadataSource,
|
||||
String? filterOverride,
|
||||
}) async {
|
||||
Future<void> search(String query, {String? filterOverride}) async {
|
||||
final requestId = ++_currentRequestId;
|
||||
|
||||
// Preserve selected filter during loading
|
||||
@@ -568,7 +573,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
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<TrackState> {
|
||||
}
|
||||
}
|
||||
} 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<TrackState> {
|
||||
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<TrackState> {
|
||||
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<TrackState> {
|
||||
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() ??
|
||||
|
||||
@@ -224,6 +224,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
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<AlbumScreen> {
|
||||
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<AlbumScreen> {
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
// Bottom gradient for readability
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
@@ -345,7 +345,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
// Album info overlay at bottom
|
||||
Positioned(
|
||||
left: 20,
|
||||
right: 20,
|
||||
@@ -491,6 +490,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
},
|
||||
),
|
||||
leading: IconButton(
|
||||
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
|
||||
icon: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
|
||||
+173
-139
@@ -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<String, _CacheEntry> _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<ArtistScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Track _parseTrack(Map<String, dynamic> data) {
|
||||
Track _parseTrack(Map<String, dynamic> data, {ArtistAlbum? album}) {
|
||||
int durationMs = 0;
|
||||
final durationValue = data['duration_ms'];
|
||||
if (durationValue is int) {
|
||||
@@ -309,18 +307,22 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
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<ArtistScreen> {
|
||||
.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<ArtistScreen> {
|
||||
context.l10n.artistSingles,
|
||||
singles,
|
||||
colorScheme,
|
||||
showTypeBadge: true,
|
||||
),
|
||||
),
|
||||
if (compilations.isNotEmpty)
|
||||
@@ -670,7 +673,9 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
List<ArtistAlbum> 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<int>(0, (sum, a) => sum + a.totalTracks);
|
||||
final albumTracks = albumsOnly.fold<int>(
|
||||
@@ -717,7 +722,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
// Options
|
||||
if (albums.isNotEmpty)
|
||||
_DiscographyOptionTile(
|
||||
icon: Icons.library_music,
|
||||
@@ -830,7 +834,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
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<ArtistScreen> {
|
||||
if (result != null && result['tracks'] != null) {
|
||||
final tracksList = result['tracks'] as List<dynamic>;
|
||||
return tracksList
|
||||
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
||||
.map((t) => _parseTrack(t as Map<String, dynamic>, album: album))
|
||||
.toList();
|
||||
}
|
||||
} else if (album.id.startsWith('deezer:')) {
|
||||
@@ -963,7 +967,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
if (result != null && result['tracks'] != null) {
|
||||
final tracksList = result['tracks'] as List<dynamic>;
|
||||
return tracksList
|
||||
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
||||
.map((t) => _parseTrack(t as Map<String, dynamic>, album: album))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@@ -972,7 +976,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
if (metadata['tracks'] != null) {
|
||||
final tracksList = metadata['tracks'] as List<dynamic>;
|
||||
return tracksList
|
||||
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
||||
.map((t) => _parseTrack(t as Map<String, dynamic>, album: album))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
@@ -1006,6 +1010,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
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<ArtistScreen> {
|
||||
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<ArtistScreen> {
|
||||
],
|
||||
),
|
||||
),
|
||||
// Download Discography button (icon only, right-aligned)
|
||||
if (hasDiscography && !_isSelectionMode) ...[
|
||||
const SizedBox(width: 12),
|
||||
Container(
|
||||
@@ -1188,6 +1192,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
],
|
||||
),
|
||||
leading: IconButton(
|
||||
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
|
||||
icon: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
@@ -1201,7 +1206,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
/// 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<ArtistScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
/// 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<ArtistScreen> {
|
||||
Widget _buildAlbumSection(
|
||||
String title,
|
||||
List<ArtistAlbum> albums,
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
ColorScheme colorScheme, {
|
||||
bool showTypeBadge = false,
|
||||
}) {
|
||||
final sectionHeight = _artistAlbumSectionHeight();
|
||||
final tileSize = _artistAlbumTileSize();
|
||||
|
||||
@@ -1560,6 +1564,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
colorScheme,
|
||||
tileSize: tileSize,
|
||||
sectionHeight: sectionHeight,
|
||||
showTypeBadge: showTypeBadge,
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -1574,47 +1579,65 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
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<ArtistScreen> {
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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<DownloadedAlbumScreen> {
|
||||
);
|
||||
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<DownloadedAlbumScreen> {
|
||||
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<DownloadedAlbumScreen> {
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
// Bottom gradient for readability
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
@@ -527,7 +523,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
// Album info overlay at bottom
|
||||
Positioned(
|
||||
left: 20,
|
||||
right: 20,
|
||||
@@ -635,6 +630,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
},
|
||||
),
|
||||
leading: IconButton(
|
||||
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
|
||||
icon: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
@@ -711,10 +707,8 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
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<DownloadedAlbumScreen> {
|
||||
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<DownloadedAlbumScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Share SAF content URIs via native intent
|
||||
if (safUris.isNotEmpty) {
|
||||
try {
|
||||
if (safUris.length == 1) {
|
||||
@@ -908,13 +902,11 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
} 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<DownloadHistoryItem> allTracks,
|
||||
@@ -1336,6 +1328,9 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
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<DownloadedAlbumScreen> {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Action buttons row: Share, Convert
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
|
||||
+188
-132
@@ -520,7 +520,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
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<HomeTab>
|
||||
extState.extensions.any((e) => e.id == searchProvider && e.enabled);
|
||||
|
||||
if (isExtensionEnabled) {
|
||||
// Build options with filter if selected
|
||||
Map<String, dynamic>? options;
|
||||
if (selectedFilter != null) {
|
||||
options = {'filter': selectedFilter};
|
||||
@@ -551,11 +549,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
}
|
||||
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<HomeTab>
|
||||
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<HomeTab>
|
||||
),
|
||||
),
|
||||
|
||||
// Search filter bar (only shown when has search results)
|
||||
if (hasActualResults && !showRecentAccess)
|
||||
Consumer(
|
||||
builder: (context, ref, _) {
|
||||
@@ -1265,7 +1274,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
(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<HomeTab>
|
||||
),
|
||||
],
|
||||
),
|
||||
), // Close RefreshIndicator
|
||||
), // Close GestureDetector
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1335,24 +1345,49 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
);
|
||||
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<HomeTab>
|
||||
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<HomeTab>
|
||||
return _buildExploreSection(sections[sectionIndex], colorScheme);
|
||||
}
|
||||
|
||||
// Bottom padding
|
||||
return const SizedBox(height: 16);
|
||||
}, childCount: totalCount),
|
||||
),
|
||||
@@ -1498,31 +1512,45 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
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<HomeTab>
|
||||
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<HomeTab>
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Dismiss',
|
||||
icon: Icon(
|
||||
Icons.close,
|
||||
size: 20,
|
||||
@@ -2218,11 +2237,14 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
}
|
||||
|
||||
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<HomeTab>
|
||||
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<HomeTab>
|
||||
),
|
||||
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<HomeTab>
|
||||
);
|
||||
}
|
||||
|
||||
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<HomeTab>
|
||||
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<HomeTab>
|
||||
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<HomeTab>
|
||||
),
|
||||
),
|
||||
),
|
||||
// Filter chips from extension
|
||||
...filters.map((filter) {
|
||||
final isSelected = selectedFilter == filter.id;
|
||||
return Padding(
|
||||
@@ -2830,7 +2889,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -37,7 +37,6 @@ class _LibraryTracksFolderScreenState
|
||||
bool _showTitleInAppBar = false;
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
// ── Multi-select state ──
|
||||
bool _isSelectionMode = false;
|
||||
final Set<String> _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<void> _removeSelected(List<CollectionTrackEntry> 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<CollectionTrackEntry> 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),
|
||||
|
||||
@@ -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<LocalAlbumScreen> {
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
late List<LocalLibraryItem> _sortedTracksCache;
|
||||
late Map<int, List<LocalLibraryItem>> _discGroupsCache;
|
||||
|
||||
void _showCueVirtualTrackSnackBar() {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(cueVirtualTrackRequiresSplitMessage),
|
||||
),
|
||||
);
|
||||
}
|
||||
late List<int> _sortedDiscNumbersCache;
|
||||
late bool _hasMultipleDiscsCache;
|
||||
String? _commonQualityCache;
|
||||
@@ -83,7 +90,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
List<LocalLibraryItem> _buildSortedTracks() {
|
||||
final tracks = List<LocalLibraryItem>.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<LocalAlbumScreen> {
|
||||
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<LocalAlbumScreen> {
|
||||
),
|
||||
);
|
||||
|
||||
// Go back if all tracks were deleted
|
||||
if (deletedCount == currentTracks.length) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
@@ -206,6 +213,10 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
}
|
||||
|
||||
Future<void> _openFile(LocalLibraryItem track) async {
|
||||
if (isCueVirtualPath(track.filePath)) {
|
||||
_showCueVirtualTrackSnackBar();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await ref
|
||||
.read(playbackProvider.notifier)
|
||||
@@ -233,7 +244,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
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<LocalAlbumScreen> {
|
||||
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<LocalAlbumScreen> {
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
// Bottom gradient for readability
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
@@ -362,7 +370,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
// Album info overlay at bottom
|
||||
Positioned(
|
||||
left: 20,
|
||||
right: 20,
|
||||
@@ -494,6 +501,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
},
|
||||
),
|
||||
leading: IconButton(
|
||||
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
|
||||
icon: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
@@ -733,6 +741,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
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<LocalAlbumScreen> {
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Batch re-enrich selected local tracks
|
||||
Future<void> _reEnrichSelected(List<LocalLibraryItem> allTracks) async {
|
||||
final tracksById = {for (final t in allTracks) t.id: t};
|
||||
final selected = <LocalLibraryItem>[];
|
||||
@@ -958,13 +966,18 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
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<LocalAlbumScreen> {
|
||||
).showSnackBar(SnackBar(content: Text(summary)));
|
||||
}
|
||||
|
||||
/// Show batch convert bottom sheet
|
||||
void _showBatchConvertSheet(
|
||||
BuildContext context,
|
||||
List<LocalLibraryItem> allTracks,
|
||||
@@ -1261,7 +1273,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
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<LocalAlbumScreen> {
|
||||
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<LocalAlbumScreen> {
|
||||
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<LocalAlbumScreen> {
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// Reload local library to pick up converted files
|
||||
ref.read(localLibraryProvider.notifier).reloadFromStorage();
|
||||
_exitSelectionMode();
|
||||
|
||||
@@ -1461,6 +1468,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
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<LocalAlbumScreen> {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Action buttons row: Re-enrich, Convert
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
|
||||
@@ -102,13 +102,29 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
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<void> _checkForUpdates() async {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user