mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-03 19:27:57 +02:00
Compare commits
186 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f327cd1f6 | |||
| 4f2e677e8b | |||
| 79a69f8f70 | |||
| bf0f4bdf3e | |||
| 5e1cc3ecb5 | |||
| d4b37edc2f | |||
| 9483614bc7 | |||
| a73f2e1a13 | |||
| 091e3fadd9 | |||
| 5340ca7b16 | |||
| 85d3e58a26 | |||
| 1125c757fe | |||
| 66d714d368 | |||
| 49c2501fbc | |||
| e487817f21 | |||
| d8bbeb1e67 | |||
| 9693616645 | |||
| 0423e36d34 | |||
| c8d605fdee | |||
| 03fd734048 | |||
| da9d64ccfd | |||
| 02e64b7a3c | |||
| a435009d4d | |||
| 9ca73a99a6 | |||
| 4974284760 | |||
| a0306bd345 | |||
| ea7e594c68 | |||
| d00a84f1b9 | |||
| 58b6203681 | |||
| d299144c47 | |||
| 40b224e5a1 | |||
| 7021e5493f | |||
| 68bbc8a259 | |||
| be94a59441 | |||
| 3a73aee1b7 | |||
| c91154ea3e | |||
| 4f365ca7fe | |||
| 98fdc0ed7c | |||
| 12be560cb8 | |||
| 4cf885a52e | |||
| c57c8a4267 | |||
| 2ca6c737c0 | |||
| 2a451ec2a3 | |||
| 346e79b247 | |||
| 497ba342c0 | |||
| aca0bbb819 | |||
| 2df8fd6282 | |||
| 999317eba1 | |||
| 16991476ed | |||
| ba33639818 | |||
| 23cab16471 | |||
| 0a892011de | |||
| acb1d957d3 | |||
| 4a492aeefc | |||
| eb143a41fc | |||
| 75db2f162b | |||
| 855d0e3ffc | |||
| 5ccd06cc68 | |||
| b2873378fc | |||
| 66a89d9e8e | |||
| 814deca19d | |||
| 3bb6754d9c | |||
| 7d11d67cd2 | |||
| c0bd10cfca | |||
| e003b15ffd | |||
| ac1c7d31c9 | |||
| 6fc9ffeb23 | |||
| 9bebed506b | |||
| bffeb55a7a | |||
| c66d13c9fd | |||
| 8529985a0e | |||
| a8a3973225 | |||
| 6710f90e1e | |||
| 929c5f3249 | |||
| f170ead7b9 | |||
| e63e366228 | |||
| 95e755e54e | |||
| c719406425 | |||
| 9627ef66cf | |||
| 15f977d98d | |||
| 5b5f043624 | |||
| 529a920b24 | |||
| 09eb6cf206 | |||
| af6fa6ea53 | |||
| 280b921755 | |||
| 6ebe0c51ce | |||
| 47bd24c1bd | |||
| 2b23678c0d | |||
| e8327545ad | |||
| 89a38af538 | |||
| b7f34ec47c | |||
| 967523bfc6 | |||
| 29d8a185f9 | |||
| 4495d4bf4e | |||
| 67737467e0 | |||
| 13845eea04 | |||
| 12779778d3 | |||
| d4178ad036 | |||
| 49ea84384d | |||
| a6d9849468 | |||
| 16100aa0fd | |||
| 387dd47374 | |||
| 6ecb69feae | |||
| feff985439 | |||
| 2e8fe34824 | |||
| f58005f406 | |||
| 75abc03a4f | |||
| 84381d142a | |||
| f67f52eba9 | |||
| 3747ffff64 | |||
| ed47efed17 | |||
| c0d72e89d7 | |||
| a4313cfe0f | |||
| c7bef03ee3 | |||
| ce5a9e0cff | |||
| 859b823e77 | |||
| 7d8cf5f7ca | |||
| 4adaed8da0 | |||
| 554fe08fcd | |||
| b8af75bf6e | |||
| 35f2f119db | |||
| f36096e0ac | |||
| 1665e4cd57 | |||
| 42f0267277 | |||
| 82f59d32b9 | |||
| 941347b007 | |||
| 739c89569f | |||
| 18607597e9 | |||
| 7bb808cba5 | |||
| 78cd396847 | |||
| bb342c01e2 | |||
| 8a5dc0edfe | |||
| 8540da484f | |||
| 20f789f8e0 | |||
| 3e89326c95 | |||
| a7ea4de25a | |||
| aabfbf062e | |||
| 7b9ed3ec8e | |||
| 6dad66d62d | |||
| 31018230ee | |||
| 54ddc1f59c | |||
| c6856bd1a1 | |||
| 8c18c7b8f1 | |||
| 10c5293f64 | |||
| c347b6999e | |||
| adc74741ce | |||
| cbfa147a12 | |||
| 5b8c953ae6 | |||
| 37a4dc096b | |||
| b3808645fb | |||
| 24aa804bf2 | |||
| 941ffb2bb7 | |||
| 59737d6f2b | |||
| c8ad93ee9b | |||
| 8cb0c037c2 | |||
| e30b69397b | |||
| d6e837fd61 | |||
| 5c97d202b9 | |||
| 0f6cfa75bb | |||
| 91bd6d1572 | |||
| dd05061829 | |||
| 8f6b99c550 | |||
| f54ee86591 | |||
| 42e0ec2663 | |||
| 0456a97b35 | |||
| 07c609cc3a | |||
| de5d26403f | |||
| 73c2d0efac | |||
| d3c1c440cc | |||
| 94195c636f | |||
| 9abf492362 | |||
| defc84c216 | |||
| 3c9ae39145 | |||
| 581f43f4c1 | |||
| 221d7e4829 | |||
| 706528f04b | |||
| f95a96dd1f | |||
| d85c16ce0f | |||
| 35afdf4be4 | |||
| eb5ed86019 | |||
| 0cfa6f56be | |||
| 5af88ead33 | |||
| 8ec63ee610 | |||
| c8247bf7a0 | |||
| 2f3270c7ff | |||
| 960d60f0bc |
@@ -393,6 +393,63 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
update-altstore:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [get-version, build-ios, create-release]
|
||||
if: ${{ needs.get-version.outputs.is_prerelease != 'true' }}
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout main branch
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: main
|
||||
|
||||
- name: Download iOS IPA
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: ios-ipa
|
||||
path: ./release
|
||||
|
||||
- name: Update apps.json
|
||||
run: |
|
||||
VERSION="${{ needs.get-version.outputs.version }}"
|
||||
VERSION_NUM="${VERSION#v}"
|
||||
DATE=$(date -u +%Y-%m-%d)
|
||||
IPA_FILE=$(find ./release -name "*ios*.ipa" | head -1)
|
||||
|
||||
if [ -z "$IPA_FILE" ]; then
|
||||
echo "WARNING: IPA file not found, skipping apps.json update"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
IPA_SIZE=$(stat -c%s "$IPA_FILE" 2>/dev/null || stat -f%z "$IPA_FILE")
|
||||
|
||||
if [ ! -f apps.json ]; then
|
||||
echo "WARNING: apps.json not found on main, skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
jq --arg ver "$VERSION_NUM" \
|
||||
--arg date "$DATE" \
|
||||
--arg url "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/${VERSION}/SpotiFLAC-${VERSION}-ios-unsigned.ipa" \
|
||||
--argjson size "$IPA_SIZE" \
|
||||
'.apps[0].version = $ver | .apps[0].versionDate = $date | .apps[0].downloadURL = $url | .apps[0].size = $size' \
|
||||
apps.json > apps.json.tmp && mv apps.json.tmp apps.json
|
||||
|
||||
echo "Updated apps.json:"
|
||||
cat apps.json
|
||||
|
||||
- name: Commit and push
|
||||
run: |
|
||||
VERSION="${{ needs.get-version.outputs.version }}"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add apps.json
|
||||
git diff --cached --quiet && echo "No changes to commit" || \
|
||||
(git commit -m "chore: update AltStore source to ${VERSION}" && git push)
|
||||
|
||||
notify-telegram:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [get-version, create-release]
|
||||
|
||||
@@ -77,3 +77,6 @@ flutter_*.log
|
||||
# Development tools
|
||||
tool/
|
||||
.claude/settings.local.json
|
||||
|
||||
# FVM Version Cache
|
||||
.fvm/
|
||||
|
||||
+2
-2
@@ -334,7 +334,7 @@ Thank you for your understanding and continued support. This decision was made t
|
||||
- Routing priority: YouTube service -> extension fallback -> built-in fallback -> direct service
|
||||
- New Android method channel handler: `"downloadByStrategy"` -> `Gobackend.downloadByStrategy(...)`
|
||||
- SpotFetch metadata fallback integration for Spotify-blocked regions
|
||||
- New backend client for `spotify.afkarxyz.fun/api`
|
||||
- New backend client for `sp.afkarxyz.qzz.io/api`
|
||||
- Automatic fallback in Spotify metadata fetch path when primary source fails
|
||||
- Lyrics extraction now supports MP3 (ID3v2) and Opus/OGG (Vorbis comments) in addition to FLAC
|
||||
- Includes heuristic detection of lyrics stored in Comment fields
|
||||
@@ -349,7 +349,7 @@ Thank you for your understanding and continued support. This decision was made t
|
||||
- Legacy Dart bridge methods (`downloadTrack`, `downloadWithFallback`, `downloadWithExtensions`, `downloadFromYouTube`) are now thin wrappers and marked `@Deprecated`
|
||||
- Qobuz downloader updated to latest Jumo API contract (`/get` endpoint, required headers)
|
||||
- Amazon download flow now returns `decryption_key` from Go and performs decryption in Flutter (local file + SAF paths)
|
||||
- Amazon now uses the new `amazon.afkarxyz.fun` API flow (ASIN-based track endpoint + legacy fallback) with encrypted stream support
|
||||
- Amazon now uses the new `amzn.afkarxyz.qzz.io` API flow (ASIN-based track endpoint + legacy fallback) with encrypted stream support
|
||||
- Amazon ASIN extraction rewritten with robust URL/query-param parsing and regex fallback
|
||||
- Amazon provider re-enabled in download service picker and download settings (alongside Tidal, Qobuz, and YouTube picker flow)
|
||||
- Track Metadata cover UI now refreshes from the embedded file after Edit Metadata/Re-enrich, so the displayed art matches actual file tags
|
||||
|
||||
+17
-3
@@ -86,17 +86,31 @@ Translation files are located in `lib/l10n/arb/`.
|
||||
git remote add upstream https://github.com/zarzet/SpotiFLAC-Mobile.git
|
||||
```
|
||||
|
||||
3. **Install dependencies**
|
||||
3. **Use FVM (Flutter Version: 3.38.1)**
|
||||
```bash
|
||||
fvm use
|
||||
```
|
||||
|
||||
4. **Install dependencies**
|
||||
```bash
|
||||
flutter pub get
|
||||
```
|
||||
|
||||
4. **Generate code** (for Riverpod, JSON serialization, etc.)
|
||||
5. **Generate code** (for Riverpod, JSON serialization, etc.)
|
||||
```bash
|
||||
dart run build_runner build --delete-conflicting-outputs
|
||||
```
|
||||
|
||||
5. **Run the app**
|
||||
6. **Set up Go environment (Go Version: 1.25.7)**
|
||||
```bash
|
||||
cd go_backend
|
||||
mkdir -p ../android/app/libs
|
||||
gomobile init
|
||||
gomobile bind -target=android -androidapi 24 -o ../android/app/libs/gobackend.aar .
|
||||
cd ..
|
||||
```
|
||||
|
||||
7. **Run the app**
|
||||
```bash
|
||||
flutter run
|
||||
```
|
||||
|
||||
@@ -14,6 +14,17 @@
|
||||
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||
[](https://www.virustotal.com/gui/file/cc11355330c76f97548b8d26452b91746db9d9c1edbcfc4c18250133484d1487)
|
||||
[](https://crowdin.com/project/spotiflac-mobile)
|
||||
|
||||
[](https://t.me/spotiflac)
|
||||
[](https://t.me/spotiflac_chat)
|
||||
|
||||
</div>
|
||||
|
||||
## Screenshots
|
||||
|
||||
<p align="center">
|
||||
@@ -23,68 +34,141 @@
|
||||
<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.
|
||||
Extensions let the community 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.
|
||||
|
||||
### Installing Extensions
|
||||
1. Go to **Store** tab in the app
|
||||
2. Browse and install extensions with one tap
|
||||
3. Or download a `.spotiflac-ext` file and install manually via **Settings > Extensions**
|
||||
4. Configure extension settings if needed
|
||||
5. Set provider priority in **Settings > Extensions > Provider Priority**
|
||||
|
||||
1. Open the **Store** tab in the app
|
||||
2. On first launch, enter an **Extension Repository URL** when prompted
|
||||
3. Browse and install extensions with one tap
|
||||
4. Or download a `.spotiflac-ext` file and install manually via **Settings > Extensions**
|
||||
5. Configure extension settings if needed
|
||||
6. Set provider priority under **Settings > Extensions > Provider Priority**
|
||||
|
||||
### Developing Extensions
|
||||
Want to create your own extension? Check out the [Extension Development Guide](https://zarzet.github.io/SpotiFLAC-Mobile/docs) for complete documentation.
|
||||
|
||||
## Other project
|
||||
> [!NOTE]
|
||||
> Want to build your own extension? The [Extension Development Guide](https://zarzet.github.io/SpotiFLAC-Mobile/docs) has everything you need.
|
||||
|
||||
---
|
||||
|
||||
## Related Projects
|
||||
|
||||
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
|
||||
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux
|
||||
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music available for Windows, macOS & Linux.
|
||||
|
||||
### [SpotiFLAC (Python Module)](https://github.com/ShuShuzinhuu/SpotiFLAC-Module-Version)
|
||||
Python library for SpotiFLAC integration, maintained by [@ShuShuzinhuu](https://github.com/ShuShuzinhuu).
|
||||
|
||||
---
|
||||
|
||||
## FAQ
|
||||
|
||||
**Q: Why is my download failing with "Song not found"?**
|
||||
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.
|
||||
<details>
|
||||
<summary><b>Why does the Store tab ask me to enter a URL?</b></summary>
|
||||
<br>
|
||||
|
||||
**Q: Why are some tracks downloading in lower 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.
|
||||
Starting from version 3.8.0, SpotiFLAC uses a decentralized extension repository system extensions are hosted on GitHub repositories rather than a built-in server, so anyone can create and host their own. Enter a repository URL in the Store tab to browse and install extensions.
|
||||
|
||||
**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.
|
||||
</details>
|
||||
|
||||
**Q: Why do I need to grant storage permission?**
|
||||
A: The app needs permission to save downloaded files to your device. On Android 13+, you may need to grant "All files access" in Settings > Apps > SpotiFLAC > Permissions.
|
||||
<details>
|
||||
<summary><b>Why is my download failing with "Song not found"?</b></summary>
|
||||
<br>
|
||||
|
||||
**Q: Is this app safe?**
|
||||
A: Yes, the app is open source and you can verify the code yourself. Each release is scanned with VirusTotal (see badge at top of README).
|
||||
The track may not be available on the streaming services. Try enabling more providers under **Settings > Download > Provider Priority**, or install additional extensions like Amazon Music from the Store.
|
||||
|
||||
**Q: Why is download not working in my country?**
|
||||
A: Some countries have restricted access to certain streaming service APIs. If downloads are failing, try using a VPN to connect through a different region.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Why are some tracks downloading in lower quality?</b></summary>
|
||||
<br>
|
||||
|
||||
### Want to support SpotiFLAC-Mobile?
|
||||
Quality depends on what's available from the streaming service and its extensions. Built-in providers:
|
||||
- **Tidal** up to 24-bit/192kHz
|
||||
- **Qobuz** up to 24-bit/192kHz
|
||||
- **Deezer** up to 16-bit/44.1kHz
|
||||
|
||||
_If this software is useful and brings you value, consider supporting the project. Your support helps keep development going._
|
||||
</details>
|
||||
|
||||
[](https://ko-fi.com/zarzet)
|
||||
<details>
|
||||
<summary><b>Can I download playlists?</b></summary>
|
||||
<br>
|
||||
|
||||
Yes! Just paste the playlist URL in the search bar. The app will fetch all tracks and queue them for download.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Why do I need to grant storage permission?</b></summary>
|
||||
<br>
|
||||
|
||||
The app needs permission to save downloaded files to your device. On Android 13+, you may need to grant **All files access** under **Settings > Apps > SpotiFLAC > Permissions**.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Is this app safe?</b></summary>
|
||||
<br>
|
||||
|
||||
Yes SpotiFLAC is open source and you can verify the code yourself. Each release is also scanned with VirusTotal (see badge above).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Why is downloading not working in my country?</b></summary>
|
||||
<br>
|
||||
|
||||
Some countries have restricted access to certain streaming service APIs. If downloads are failing, try using a VPN to connect through a different region.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Can I add SpotiFLAC to AltStore or SideStore?</b></summary>
|
||||
<br>
|
||||
|
||||
Yes! Add the official source to receive updates directly within the app. Copy this link:
|
||||
|
||||
```
|
||||
https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/refs/heads/main/apps.json
|
||||
```
|
||||
|
||||
In AltStore/SideStore, go to **Browse > Sources**, tap **+**, and paste the link.
|
||||
|
||||
</details>
|
||||
|
||||
> [!NOTE]
|
||||
> If SpotiFLAC is useful to you, consider supporting development:
|
||||
>
|
||||
> [](https://ko-fi.com/zarzet)
|
||||
|
||||
---
|
||||
|
||||
## Contributors
|
||||
|
||||
Thanks to everyone who has contributed to SpotiFLAC Mobile!
|
||||
|
||||
<a href="https://github.com/zarzet/SpotiFLAC-Mobile/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=zarzet/SpotiFLAC-Mobile" />
|
||||
</a>
|
||||
|
||||
We also appreciate everyone who helped with [translations on Crowdin](https://crowdin.com/project/spotiflac-mobile), reported bugs, suggested features, and spread the word.
|
||||
|
||||
Interested in contributing? Check out the [Contributing Guide](CONTRIBUTING.md) to get started!
|
||||
|
||||
---
|
||||
|
||||
## 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)
|
||||
|
||||
| | | | | |
|
||||
|---|---|---|---|---|
|
||||
| [hifi-api](https://github.com/binimum/hifi-api) | [music.binimum.org](https://music.binimum.org) | [qqdl.site](https://qqdl.site) | [squid.wtf](https://squid.wtf) | [spotisaver.net](https://spotisaver.net) |
|
||||
| [dabmusic.xyz](https://dabmusic.xyz) | [AfkarXYZ](https://github.com/afkarxyz) | [LRCLib](https://lrclib.net) | [Paxsenix](https://lyrics.paxsenix.org) | [Cobalt](https://cobalt.tools) |
|
||||
| [qwkuns.me](https://qwkuns.me) | [SpotubeDL](https://spotubedl.com) | [Song.link](https://song.link) | [IDHS](https://github.com/sjdonado/idonthavespotify) | |
|
||||
|
||||
> [!TIP]
|
||||
>
|
||||
> **Star Us**, You will receive all release notifications from GitHub without any delay ~
|
||||
> **Star the repo** to get notified about all new releases directly from GitHub.
|
||||
|
||||
@@ -137,14 +137,13 @@ class DownloadService : Service() {
|
||||
|
||||
private fun startForegroundService() {
|
||||
isRunning = true
|
||||
|
||||
// Acquire wake lock to prevent CPU sleep
|
||||
|
||||
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
wakeLock = powerManager.newWakeLock(
|
||||
PowerManager.PARTIAL_WAKE_LOCK,
|
||||
WAKELOCK_TAG
|
||||
).apply {
|
||||
acquire(60 * 60 * 1000L) // 1 hour max
|
||||
acquire(60 * 60 * 1000L)
|
||||
}
|
||||
|
||||
val notification = buildNotification(0, 0)
|
||||
|
||||
@@ -27,9 +27,11 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.json.JSONTokener
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import java.security.MessageDigest
|
||||
import java.util.Locale
|
||||
|
||||
class MainActivity: FlutterFragmentActivity() {
|
||||
@@ -111,6 +113,13 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildStableLibraryId(filePath: String): String {
|
||||
val digest = MessageDigest.getInstance("SHA-1")
|
||||
val bytes = digest.digest(filePath.toByteArray(Charsets.UTF_8))
|
||||
val hex = bytes.joinToString("") { "%02x".format(it) }
|
||||
return "lib_$hex"
|
||||
}
|
||||
|
||||
data class SafScanProgress(
|
||||
var totalFiles: Int = 0,
|
||||
var scannedFiles: Int = 0,
|
||||
@@ -121,39 +130,35 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
)
|
||||
|
||||
companion object {
|
||||
// Minimum API level we consider "safe" for Impeller (Android 10+)
|
||||
private const val SAFE_API_FOR_IMPELLER = 29
|
||||
|
||||
// Known problematic GPU patterns (lowercase)
|
||||
|
||||
private val PROBLEMATIC_GPU_PATTERNS = listOf(
|
||||
"adreno (tm) 3", // Adreno 300 series (305, 320, 330, etc.) - old Qualcomm
|
||||
"adreno (tm) 4", // Adreno 400 series - some have issues
|
||||
"mali-4", // Mali-400 series - old ARM GPUs
|
||||
"mali-t6", // Mali-T600 series
|
||||
"mali-t7", // Mali-T700 series (some)
|
||||
"powervr sgx", // PowerVR SGX series - old Imagination GPUs
|
||||
"powervr ge8320", // PowerVR GE8320 - known issues
|
||||
"gc1000", // Vivante GC1000
|
||||
"gc2000", // Vivante GC2000
|
||||
"adreno (tm) 3",
|
||||
"adreno (tm) 4",
|
||||
"mali-4",
|
||||
"mali-t6",
|
||||
"mali-t7",
|
||||
"powervr sgx",
|
||||
"powervr ge8320",
|
||||
"gc1000",
|
||||
"gc2000",
|
||||
)
|
||||
|
||||
// Known problematic chipsets/hardware (lowercase)
|
||||
|
||||
private val PROBLEMATIC_CHIPSETS = listOf(
|
||||
"mt6762", // MediaTek Helio P22 with PowerVR GE8320
|
||||
"mt6765", // MediaTek Helio P35 with PowerVR GE8320
|
||||
"mt8768", // MediaTek tablet chip
|
||||
"mp0873", // MediaTek variant
|
||||
"msm8974", // Snapdragon 800/801 with Adreno 330
|
||||
"msm8226", // Snapdragon 400 with Adreno 305
|
||||
"msm8926", // Snapdragon 400 with Adreno 305
|
||||
"apq8084", // Snapdragon 805 (some issues)
|
||||
"mt6762",
|
||||
"mt6765",
|
||||
"mt8768",
|
||||
"mp0873",
|
||||
"msm8974",
|
||||
"msm8226",
|
||||
"msm8926",
|
||||
"apq8084",
|
||||
)
|
||||
|
||||
// Known problematic device models (lowercase)
|
||||
|
||||
private val PROBLEMATIC_MODELS = listOf(
|
||||
"sm-t220", // Samsung Tab A7 Lite
|
||||
"sm-t225", // Samsung Tab A7 Lite LTE
|
||||
"hammerhead", // Nexus 5 (Adreno 330)
|
||||
"sm-t220",
|
||||
"sm-t225",
|
||||
"hammerhead",
|
||||
)
|
||||
/**
|
||||
* Check if device should use Skia instead of Impeller.
|
||||
@@ -165,7 +170,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
val model = Build.MODEL.lowercase(Locale.ROOT)
|
||||
val device = Build.DEVICE.lowercase(Locale.ROOT)
|
||||
|
||||
// 1. Check for explicitly problematic device models
|
||||
for (problematicModel in PROBLEMATIC_MODELS) {
|
||||
if (model.contains(problematicModel) || device.contains(problematicModel)) {
|
||||
android.util.Log.i("SpotiFLAC", "Matched problematic model: $problematicModel")
|
||||
@@ -173,7 +177,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check for problematic chipsets
|
||||
for (chipset in PROBLEMATIC_CHIPSETS) {
|
||||
if (hardware.contains(chipset) || board.contains(chipset)) {
|
||||
android.util.Log.i("SpotiFLAC", "Matched problematic chipset: $chipset")
|
||||
@@ -181,12 +184,9 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. For Android < 10 (API 29), be more aggressive about disabling Impeller
|
||||
if (Build.VERSION.SDK_INT < SAFE_API_FOR_IMPELLER) {
|
||||
// For older Android, check GPU renderer if available
|
||||
val gpuRenderer = getGpuRenderer().lowercase(Locale.ROOT)
|
||||
|
||||
// Check for known problematic GPUs
|
||||
for (pattern in PROBLEMATIC_GPU_PATTERNS) {
|
||||
if (gpuRenderer.contains(pattern)) {
|
||||
android.util.Log.i("SpotiFLAC", "Matched problematic GPU on old Android: $pattern")
|
||||
@@ -194,14 +194,12 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
// For very old Android (< 8.0), always use Skia as Vulkan support is spotty
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
android.util.Log.i("SpotiFLAC", "Android < 8.0, using Skia for safety")
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// 4. For Android 10+, still check for known problematic GPUs
|
||||
val gpuRenderer = getGpuRenderer().lowercase(Locale.ROOT)
|
||||
for (pattern in PROBLEMATIC_GPU_PATTERNS) {
|
||||
if (gpuRenderer.contains(pattern)) {
|
||||
@@ -219,8 +217,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
*/
|
||||
private fun getGpuRenderer(): String {
|
||||
return try {
|
||||
// This might not work before GL context is created,
|
||||
// but worth trying for additional detection
|
||||
android.opengl.GLES20.glGetString(android.opengl.GLES20.GL_RENDERER) ?: ""
|
||||
} catch (e: Exception) {
|
||||
""
|
||||
@@ -405,6 +401,38 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseJsonValue(value: Any?): Any? {
|
||||
return when (value) {
|
||||
null, JSONObject.NULL -> null
|
||||
is JSONObject -> {
|
||||
val map = LinkedHashMap<String, Any?>()
|
||||
val keys = value.keys()
|
||||
while (keys.hasNext()) {
|
||||
val key = keys.next()
|
||||
map[key] = parseJsonValue(value.opt(key))
|
||||
}
|
||||
map
|
||||
}
|
||||
is JSONArray -> {
|
||||
val list = ArrayList<Any?>()
|
||||
for (i in 0 until value.length()) {
|
||||
list.add(parseJsonValue(value.opt(i)))
|
||||
}
|
||||
list
|
||||
}
|
||||
is Number, is Boolean, is String -> value
|
||||
else -> value.toString()
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseJsonPayload(payload: String): Any {
|
||||
return try {
|
||||
parseJsonValue(JSONTokener(payload).nextValue()) ?: payload
|
||||
} catch (_: Exception) {
|
||||
payload
|
||||
}
|
||||
}
|
||||
|
||||
private fun startDownloadProgressStream(sink: EventChannel.EventSink) {
|
||||
stopDownloadProgressStream()
|
||||
downloadProgressEventSink = sink
|
||||
@@ -417,7 +445,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
if (payload != lastDownloadProgressPayload) {
|
||||
lastDownloadProgressPayload = payload
|
||||
sink.success(payload)
|
||||
sink.success(parseJsonPayload(payload))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w(
|
||||
@@ -449,7 +477,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
if (payload != lastLibraryScanProgressPayload) {
|
||||
lastLibraryScanProgressPayload = payload
|
||||
sink.success(payload)
|
||||
sink.success(parseJsonPayload(payload))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w(
|
||||
@@ -591,7 +619,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
* Resolve extension from a MediaStore URI by querying DISPLAY_NAME or MIME_TYPE.
|
||||
*/
|
||||
private fun resolveMediaStoreExt(uri: Uri, fallbackExt: String?): String {
|
||||
// Try DISPLAY_NAME first
|
||||
try {
|
||||
contentResolver.query(uri, arrayOf(android.provider.MediaStore.MediaColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
@@ -602,7 +629,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
|
||||
// Try MIME_TYPE
|
||||
try {
|
||||
val mime = contentResolver.getType(uri)
|
||||
val ext = extFromMimeType(mime)
|
||||
@@ -828,8 +854,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
val mimeType = mimeTypeForExt(outputExt)
|
||||
val fileName = buildSafFileName(req, outputExt)
|
||||
|
||||
// Check for existing file WITHOUT creating the directory first.
|
||||
// This prevents empty folders from being created for duplicate downloads.
|
||||
val existingDir = findDocumentDir(treeUri, relativeDir)
|
||||
if (existingDir != null) {
|
||||
val existing = existingDir.findFile(fileName)
|
||||
@@ -844,7 +868,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
// Only create the directory now that we know we need to download
|
||||
val targetDir = ensureDocumentDir(treeUri, relativeDir)
|
||||
?: return errorJson("Failed to access SAF directory")
|
||||
|
||||
@@ -867,7 +890,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
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://") &&
|
||||
@@ -916,15 +938,10 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
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
|
||||
|
||||
@@ -949,21 +966,17 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
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("\\")
|
||||
}
|
||||
}
|
||||
@@ -1048,7 +1061,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
|
||||
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>()
|
||||
val safChildLookupCache = mutableMapOf<String, Map<String, DocumentFile>>()
|
||||
@@ -1133,7 +1145,6 @@ 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) {
|
||||
@@ -1172,10 +1183,8 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
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)
|
||||
@@ -1189,7 +1198,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
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) {
|
||||
@@ -1232,14 +1240,12 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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
|
||||
@@ -1263,7 +1269,9 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
} else {
|
||||
try {
|
||||
val lastModified = try { doc.lastModified() } catch (_: Exception) { 0L }
|
||||
metadataObj.put("filePath", doc.uri.toString())
|
||||
val stableUri = doc.uri.toString()
|
||||
metadataObj.put("id", buildStableLibraryId(stableUri))
|
||||
metadataObj.put("filePath", stableUri)
|
||||
metadataObj.put("fileModTime", lastModified)
|
||||
results.put(metadataObj)
|
||||
} catch (_: Exception) {
|
||||
@@ -1316,7 +1324,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
return result.toString()
|
||||
}
|
||||
|
||||
// Parse existing files map: URI -> lastModified
|
||||
val existingFiles = mutableMapOf<String, Long>()
|
||||
try {
|
||||
val obj = JSONObject(existingFilesJson)
|
||||
@@ -1335,20 +1342,15 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
|
||||
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 audioFiles = mutableListOf<Triple<DocumentFile, String, Long>>()
|
||||
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>()
|
||||
val safChildLookupCache = mutableMapOf<String, Map<String, DocumentFile>>()
|
||||
var traversalErrors = 0
|
||||
|
||||
// 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]
|
||||
val existingCueVirtualPaths = mutableMapOf<String, MutableList<String>>()
|
||||
for (key in existingFiles.keys) {
|
||||
val hashIdx = key.indexOf("#track")
|
||||
if (hashIdx > 0) {
|
||||
@@ -1357,7 +1359,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
// Collect all files with lastModified
|
||||
val queue: ArrayDeque<Pair<DocumentFile, String>> = ArrayDeque()
|
||||
queue.add(root to "")
|
||||
|
||||
@@ -1413,8 +1414,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
queue.add(child to childPath)
|
||||
} else if (child.isFile) {
|
||||
// Mark file as present first so it cannot be mis-classified as removed
|
||||
// when provider-specific metadata calls (e.g., lastModified) fail.
|
||||
val uriStr = child.uri.toString()
|
||||
currentUris.add(uriStr)
|
||||
|
||||
@@ -1426,18 +1425,15 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
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")) {
|
||||
@@ -1448,7 +1444,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
existingModified ?: 0L
|
||||
}
|
||||
|
||||
// Check if file is new or modified
|
||||
if (existingModified == null || existingModified != lastModified) {
|
||||
audioFiles.add(Triple(child, path, lastModified))
|
||||
}
|
||||
@@ -1465,7 +1460,6 @@ 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 filesToProcess = audioFiles.size + cueFilesToScan.size
|
||||
@@ -1493,7 +1487,6 @@ 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) {
|
||||
@@ -1514,7 +1507,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
var tempCuePath: String? = null
|
||||
var tempAudioPath: String? = null
|
||||
try {
|
||||
// Copy CUE to temp
|
||||
tempCuePath = copyUriToTemp(cueDoc.uri, ".cue")
|
||||
if (tempCuePath == null) {
|
||||
errors++
|
||||
@@ -1523,10 +1515,8 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
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
|
||||
val audioDoc = resolveCueAudioSibling(
|
||||
parentDir = parentDir,
|
||||
cueName = cueName,
|
||||
@@ -1541,10 +1531,8 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
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)
|
||||
@@ -1558,7 +1546,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
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) {
|
||||
@@ -1566,7 +1553,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
tempAudioPath = renamedAudio.absolutePath
|
||||
}
|
||||
|
||||
// Call Go to produce library scan entries for each CUE track
|
||||
val cueResultsJson = Gobackend.scanCueSheetForLibrary(
|
||||
tempCuePath,
|
||||
tempDir,
|
||||
@@ -1578,7 +1564,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
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)
|
||||
@@ -1611,9 +1596,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -1638,7 +1620,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Regular audio file pass: skip files referenced by CUE sheets ---
|
||||
for ((doc, _, lastModified) in audioFiles) {
|
||||
if (safScanCancel) {
|
||||
updateSafScanProgress { it.isComplete = true }
|
||||
@@ -1651,7 +1632,6 @@ 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
|
||||
@@ -1680,7 +1660,9 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
} else {
|
||||
try {
|
||||
val safeLastModified = try { doc.lastModified() } catch (_: Exception) { lastModified }
|
||||
metadataObj.put("filePath", doc.uri.toString())
|
||||
val stableUri = doc.uri.toString()
|
||||
metadataObj.put("id", buildStableLibraryId(stableUri))
|
||||
metadataObj.put("filePath", stableUri)
|
||||
metadataObj.put("fileModTime", safeLastModified)
|
||||
metadataObj.put("lastModified", safeLastModified)
|
||||
results.put(metadataObj)
|
||||
@@ -1703,7 +1685,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
// Recalculate removedUris now that CUE virtual paths have been registered
|
||||
val finalRemovedUris = existingFiles.keys.filter { !currentUris.contains(it) }
|
||||
|
||||
updateSafScanProgress {
|
||||
@@ -1881,7 +1862,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
// Update the intent so receive_sharing_intent can access the new data
|
||||
setIntent(intent)
|
||||
}
|
||||
|
||||
@@ -1988,13 +1968,13 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getDownloadProgress()
|
||||
}
|
||||
result.success(response)
|
||||
result.success(parseJsonPayload(response))
|
||||
}
|
||||
"getAllDownloadProgress" -> {
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getAllDownloadProgress()
|
||||
}
|
||||
result.success(response)
|
||||
result.success(parseJsonPayload(response))
|
||||
}
|
||||
"initItemProgress" -> {
|
||||
val itemId = call.argument<String>("item_id") ?: ""
|
||||
@@ -2541,7 +2521,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
val tempPath = copyUriToTemp(uri)
|
||||
?: return@withContext """{"error":"Failed to copy SAF file to temp"}"""
|
||||
try {
|
||||
// Replace file_path with temp path for Go
|
||||
reqObj.put("file_path", tempPath)
|
||||
val raw = Gobackend.reEnrichFile(reqObj.toString())
|
||||
val obj = JSONObject(raw)
|
||||
@@ -2619,7 +2598,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
// Deezer API methods
|
||||
"searchDeezerAll" -> {
|
||||
val query = call.argument<String>("query") ?: ""
|
||||
val trackLimit = call.argument<Int>("track_limit") ?: 15
|
||||
@@ -2630,6 +2608,26 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"searchTidalAll" -> {
|
||||
val query = call.argument<String>("query") ?: ""
|
||||
val trackLimit = call.argument<Int>("track_limit") ?: 15
|
||||
val artistLimit = call.argument<Int>("artist_limit") ?: 2
|
||||
val filter = call.argument<String>("filter") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.searchTidalAll(query, trackLimit.toLong(), artistLimit.toLong(), filter)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"searchQobuzAll" -> {
|
||||
val query = call.argument<String>("query") ?: ""
|
||||
val trackLimit = call.argument<Int>("track_limit") ?: 15
|
||||
val artistLimit = call.argument<Int>("artist_limit") ?: 2
|
||||
val filter = call.argument<String>("filter") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.searchQobuzAll(query, trackLimit.toLong(), artistLimit.toLong(), filter)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"getDeezerRelatedArtists" -> {
|
||||
val artistId = call.argument<String>("artist_id") ?: ""
|
||||
val limit = call.argument<Int>("limit") ?: 12
|
||||
@@ -2749,7 +2747,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
// Log methods
|
||||
"getLogs" -> {
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getLogs()
|
||||
@@ -2782,7 +2779,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
// Extension System methods
|
||||
"initExtensionSystem" -> {
|
||||
val extensionsDir = call.argument<String>("extensions_dir") ?: ""
|
||||
val dataDir = call.argument<String>("data_dir") ?: ""
|
||||
@@ -2927,7 +2923,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
// Extension Auth API methods
|
||||
"getExtensionPendingAuth" -> {
|
||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
@@ -2977,7 +2972,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
// Extension FFmpeg API
|
||||
"getPendingFFmpegCommand" -> {
|
||||
val commandId = call.argument<String>("command_id") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
@@ -3005,7 +2999,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
// Extension Custom Search API
|
||||
"customSearchWithExtension" -> {
|
||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||
val query = call.argument<String>("query") ?: ""
|
||||
@@ -3021,7 +3014,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
// Extension URL Handler API
|
||||
"handleURLWithExtension" -> {
|
||||
val url = call.argument<String>("url") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
@@ -3066,7 +3058,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
// Extension Post-Processing API
|
||||
"runPostProcessing" -> {
|
||||
val filePath = call.argument<String>("file_path") ?: ""
|
||||
val metadataJson = call.argument<String>("metadata") ?: ""
|
||||
@@ -3110,7 +3101,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
// Extension Store
|
||||
"initExtensionStore" -> {
|
||||
val cacheDir = call.argument<String>("cache_dir") ?: ""
|
||||
withContext(Dispatchers.IO) {
|
||||
@@ -3172,7 +3162,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
// Extension Home Feed (Explore)
|
||||
"getExtensionHomeFeed" -> {
|
||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
@@ -3187,7 +3176,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
// Local Library Scanning
|
||||
"setLibraryCoverCacheDir" -> {
|
||||
val cacheDir = call.argument<String>("cache_dir") ?: ""
|
||||
withContext(Dispatchers.IO) {
|
||||
@@ -3264,7 +3252,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
Gobackend.getLibraryScanProgressJSON()
|
||||
}
|
||||
}
|
||||
result.success(response)
|
||||
result.success(parseJsonPayload(response))
|
||||
}
|
||||
"cancelLibraryScan" -> {
|
||||
withContext(Dispatchers.IO) {
|
||||
@@ -3292,7 +3280,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
// CUE Sheet Parsing
|
||||
"parseCueSheet" -> {
|
||||
val cuePath = call.argument<String>("cue_path") ?: ""
|
||||
val audioDir = call.argument<String>("audio_dir") ?: ""
|
||||
@@ -3304,17 +3291,14 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
?: 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 ?: ""
|
||||
@@ -3333,7 +3317,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
|
||||
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
|
||||
@@ -3348,15 +3331,11 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "SpotiFLAC Source",
|
||||
"identifier": "com.zarzet.spotiflac.source",
|
||||
"subtitle": "FLAC Downloader for iOS",
|
||||
"apps": [
|
||||
{
|
||||
"name": "SpotiFLAC",
|
||||
"bundleIdentifier": "com.zarzet.spotiflac",
|
||||
"developerName": "zarzet",
|
||||
"version": "3.9.0",
|
||||
"versionDate": "2026-03-25",
|
||||
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v3.9.0/SpotiFLAC-v3.9.0-ios-unsigned.ipa",
|
||||
"localizedDescription": "Mobile version of SpotiFLAC written in Flutter. Download Tracks in true FLAC from Tidal, Qobuz, & Amazon Music.",
|
||||
"iconURL": "https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/main/assets/images/logo.png",
|
||||
"size": 34477323
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -498,7 +498,13 @@ func extractUserTextFrame(data []byte) (string, string) {
|
||||
|
||||
func isLyricsDescription(description string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(description)) {
|
||||
case "lyrics", "lyric", "unsyncedlyrics", "unsynced lyrics", "lrc":
|
||||
case
|
||||
"lyrics",
|
||||
"lyric",
|
||||
"unsyncedlyrics",
|
||||
"unsynced lyrics",
|
||||
"uslt",
|
||||
"lrc":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -1594,7 +1600,19 @@ func extractAnyCoverArtWithHint(filePath, displayNameHint string) ([]byte, strin
|
||||
return extractOggCoverArt(filePath)
|
||||
|
||||
case ".m4a":
|
||||
return nil, "", fmt.Errorf("M4A cover extraction not yet supported")
|
||||
data, err := extractCoverFromM4A(filePath)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
mimeType := "image/jpeg"
|
||||
if len(data) >= 8 &&
|
||||
data[0] == 0x89 &&
|
||||
data[1] == 0x50 &&
|
||||
data[2] == 0x4E &&
|
||||
data[3] == 0x47 {
|
||||
mimeType = "image/png"
|
||||
}
|
||||
return data, mimeType, nil
|
||||
|
||||
default:
|
||||
return nil, "", fmt.Errorf("unsupported format: %s", ext)
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func ffmpegCommand(args ...string) *exec.Cmd {
|
||||
if ffmpegPath, err := exec.LookPath("ffmpeg"); err == nil {
|
||||
return exec.Command(ffmpegPath, args...)
|
||||
}
|
||||
return exec.Command("ffmpeg", args...)
|
||||
}
|
||||
|
||||
func runFFmpegTestCommand(t *testing.T, args ...string) {
|
||||
t.Helper()
|
||||
cmd := ffmpegCommand(args...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("ffmpeg failed: %v\n%s", err, string(output))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractLyricsReadsMp3AfterCoverEmbed(t *testing.T) {
|
||||
if _, err := exec.LookPath("ffmpeg"); err != nil {
|
||||
t.Skip("ffmpeg not available")
|
||||
}
|
||||
|
||||
tempDir := t.TempDir()
|
||||
sourceFlac := filepath.Join(tempDir, "source.flac")
|
||||
baseMp3 := filepath.Join(tempDir, "base.mp3")
|
||||
finalMp3 := filepath.Join(tempDir, "final.mp3")
|
||||
coverPath := filepath.Join(tempDir, "cover.jpg")
|
||||
lyrics := "[ti:Test Song]\n[ar:Test Artist]\n[00:00.00]Hello from embedded lyrics"
|
||||
|
||||
runFFmpegTestCommand(
|
||||
t,
|
||||
"-y",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"sine=frequency=440:duration=1",
|
||||
"-c:a",
|
||||
"flac",
|
||||
sourceFlac,
|
||||
)
|
||||
|
||||
runFFmpegTestCommand(
|
||||
t,
|
||||
"-y",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"color=c=red:s=32x32:d=1",
|
||||
"-frames:v",
|
||||
"1",
|
||||
coverPath,
|
||||
)
|
||||
|
||||
runFFmpegTestCommand(
|
||||
t,
|
||||
"-y",
|
||||
"-i",
|
||||
sourceFlac,
|
||||
"-b:a",
|
||||
"320k",
|
||||
"-metadata",
|
||||
"title=Test Song",
|
||||
"-metadata",
|
||||
"artist=Test Artist",
|
||||
"-metadata",
|
||||
"lyrics="+lyrics,
|
||||
baseMp3,
|
||||
)
|
||||
|
||||
runFFmpegTestCommand(
|
||||
t,
|
||||
"-y",
|
||||
"-i",
|
||||
baseMp3,
|
||||
"-i",
|
||||
coverPath,
|
||||
"-map",
|
||||
"0:a",
|
||||
"-map_metadata",
|
||||
"-1",
|
||||
"-map",
|
||||
"1:0",
|
||||
"-c:v:0",
|
||||
"copy",
|
||||
"-id3v2_version",
|
||||
"3",
|
||||
"-metadata",
|
||||
"title=Test Song",
|
||||
"-metadata",
|
||||
"artist=Test Artist",
|
||||
"-metadata",
|
||||
"lyrics="+lyrics,
|
||||
"-metadata:s:v",
|
||||
"title=Album cover",
|
||||
"-metadata:s:v",
|
||||
"comment=Cover (front)",
|
||||
"-c:a",
|
||||
"copy",
|
||||
finalMp3,
|
||||
)
|
||||
|
||||
meta, err := ReadID3Tags(finalMp3)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadID3Tags failed: %v", err)
|
||||
}
|
||||
if meta == nil {
|
||||
t.Fatalf("ReadID3Tags returned nil metadata")
|
||||
}
|
||||
|
||||
embeddedLyrics, err := ExtractLyrics(finalMp3)
|
||||
if err != nil {
|
||||
t.Fatalf("ExtractLyrics failed: %v (metadata=%+v)", err, meta)
|
||||
}
|
||||
if !strings.Contains(embeddedLyrics, "Hello from embedded lyrics") {
|
||||
t.Fatalf("embedded lyrics missing, got %q (metadata=%+v)", embeddedLyrics, meta)
|
||||
}
|
||||
if !strings.Contains(meta.Lyrics, "Hello from embedded lyrics") {
|
||||
t.Fatalf("ReadID3Tags lyrics missing, got %+v", meta)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(finalMp3); err != nil {
|
||||
t.Fatalf("expected final mp3 to exist: %v", err)
|
||||
}
|
||||
}
|
||||
+34
-4
@@ -17,6 +17,8 @@ const (
|
||||
// Deezer CDN supports these sizes: 56, 250, 500, 1000, 1400, 1800
|
||||
var deezerSizeRegex = regexp.MustCompile(`/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$`)
|
||||
|
||||
var tidalSizeRegex = regexp.MustCompile(`/\d+x\d+\.jpg$`)
|
||||
|
||||
func convertSmallToMedium(imageURL string) string {
|
||||
if strings.Contains(imageURL, spotifySize300) {
|
||||
return strings.Replace(imageURL, spotifySize300, spotifySize640, 1)
|
||||
@@ -40,7 +42,6 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
|
||||
maxURL := upgradeToMaxQuality(downloadURL)
|
||||
if maxURL != downloadURL {
|
||||
downloadURL = maxURL
|
||||
// Log already printed by upgradeToMaxQuality for Deezer
|
||||
if strings.Contains(coverURL, "scdn.co") || strings.Contains(coverURL, "spotifycdn") {
|
||||
GoLog("[Cover] Spotify: upgraded to max resolution (~2000x2000)")
|
||||
}
|
||||
@@ -86,16 +87,22 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
|
||||
}
|
||||
|
||||
func upgradeToMaxQuality(coverURL string) string {
|
||||
// Spotify CDN upgrade
|
||||
if strings.Contains(coverURL, spotifySize640) {
|
||||
return strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
|
||||
}
|
||||
|
||||
// Deezer CDN upgrade
|
||||
if strings.Contains(coverURL, "cdn-images.dzcdn.net") {
|
||||
return upgradeDeezerCover(coverURL)
|
||||
}
|
||||
|
||||
if strings.Contains(coverURL, "resources.tidal.com") {
|
||||
return upgradeTidalCover(coverURL)
|
||||
}
|
||||
|
||||
if strings.Contains(coverURL, "static.qobuz.com") {
|
||||
return upgradeQobuzCover(coverURL)
|
||||
}
|
||||
|
||||
return coverURL
|
||||
}
|
||||
|
||||
@@ -111,12 +118,35 @@ func upgradeDeezerCover(coverURL string) string {
|
||||
return upgraded
|
||||
}
|
||||
|
||||
func upgradeTidalCover(coverURL string) string {
|
||||
if !strings.Contains(coverURL, "resources.tidal.com") {
|
||||
return coverURL
|
||||
}
|
||||
|
||||
upgraded := tidalSizeRegex.ReplaceAllString(coverURL, "/origin.jpg")
|
||||
if upgraded != coverURL {
|
||||
GoLog("[Cover] Tidal: upgraded to origin resolution")
|
||||
}
|
||||
return upgraded
|
||||
}
|
||||
|
||||
func upgradeQobuzCover(coverURL string) string {
|
||||
if !strings.Contains(coverURL, "static.qobuz.com") {
|
||||
return coverURL
|
||||
}
|
||||
|
||||
upgraded := qobuzImageSizeRe.ReplaceAllString(coverURL, "_max.jpg")
|
||||
if upgraded != coverURL {
|
||||
GoLog("[Cover] Qobuz: upgraded to max resolution")
|
||||
}
|
||||
return upgraded
|
||||
}
|
||||
|
||||
func GetCoverFromSpotify(imageURL string, maxQuality bool) string {
|
||||
if imageURL == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Always upgrade small to medium first
|
||||
result := convertSmallToMedium(imageURL)
|
||||
|
||||
if maxQuality {
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
|
||||
// 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"`
|
||||
@@ -32,7 +31,6 @@ type CueTrack struct {
|
||||
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)
|
||||
}
|
||||
@@ -82,7 +80,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
|
||||
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)
|
||||
@@ -90,7 +87,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
|
||||
|
||||
upper := strings.ToUpper(line)
|
||||
|
||||
// REM commands (album-level metadata)
|
||||
if strings.HasPrefix(upper, "REM ") {
|
||||
matches := reRemCommand.FindStringSubmatch(line)
|
||||
if len(matches) == 3 {
|
||||
@@ -136,9 +132,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
|
||||
|
||||
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
|
||||
@@ -146,7 +139,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
|
||||
}
|
||||
|
||||
if strings.HasPrefix(upper, "TRACK ") {
|
||||
// Save previous track
|
||||
if currentTrack != nil {
|
||||
sheet.Tracks = append(sheet.Tracks, *currentTrack)
|
||||
}
|
||||
@@ -184,7 +176,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
// SONGWRITER (used as composer sometimes)
|
||||
if strings.HasPrefix(upper, "SONGWRITER ") {
|
||||
value := unquoteCue(line[len("SONGWRITER "):])
|
||||
if currentTrack != nil {
|
||||
@@ -196,7 +187,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Don't forget the last track
|
||||
if currentTrack != nil {
|
||||
sheet.Tracks = append(sheet.Tracks, *currentTrack)
|
||||
}
|
||||
|
||||
@@ -256,6 +256,7 @@ type deezerAlbumFull struct {
|
||||
NbTracks int `json:"nb_tracks"`
|
||||
RecordType string `json:"record_type"`
|
||||
Label string `json:"label"`
|
||||
Copyright string `json:"copyright"`
|
||||
Genres struct {
|
||||
Data []deezerGenre `json:"data"`
|
||||
} `json:"genres"`
|
||||
@@ -1084,8 +1085,9 @@ func (c *DeezerClient) getBestAlbumImage(album deezerAlbumFull) string {
|
||||
}
|
||||
|
||||
type AlbumExtendedMetadata struct {
|
||||
Genre string
|
||||
Label string
|
||||
Genre string
|
||||
Label string
|
||||
Copyright string
|
||||
}
|
||||
|
||||
func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID string) (*AlbumExtendedMetadata, error) {
|
||||
@@ -1116,8 +1118,9 @@ func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID str
|
||||
}
|
||||
|
||||
result := &AlbumExtendedMetadata{
|
||||
Genre: strings.Join(genres, ", "),
|
||||
Label: album.Label,
|
||||
Genre: strings.Join(genres, ", "),
|
||||
Label: album.Label,
|
||||
Copyright: album.Copyright,
|
||||
}
|
||||
|
||||
c.cacheMu.Lock()
|
||||
@@ -1129,7 +1132,7 @@ func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID str
|
||||
c.maybeCleanupCachesLocked(now)
|
||||
c.cacheMu.Unlock()
|
||||
|
||||
GoLog("[Deezer] Album metadata fetched - Genre: %s, Label: %s\n", result.Genre, result.Label)
|
||||
GoLog("[Deezer] Album metadata fetched - Genre: %s, Label: %s, Copyright: %s\n", result.Genre, result.Label, result.Copyright)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -1178,7 +1181,7 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa
|
||||
|
||||
for attempt := 0; attempt <= deezerMaxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
delay := deezerRetryDelay * time.Duration(1<<(attempt-1)) // Exponential backoff
|
||||
delay := deezerRetryDelay * time.Duration(1<<(attempt-1))
|
||||
GoLog("[Deezer] Retry %d/%d after %v...\n", attempt, deezerMaxRetries, delay)
|
||||
time.Sleep(delay)
|
||||
}
|
||||
@@ -1191,7 +1194,6 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa
|
||||
lastErr = err
|
||||
errStr := err.Error()
|
||||
|
||||
// Check if error is retryable
|
||||
isRetryable := strings.Contains(errStr, "timeout") ||
|
||||
strings.Contains(errStr, "connection reset") ||
|
||||
strings.Contains(errStr, "connection refused") ||
|
||||
|
||||
@@ -203,29 +203,48 @@ func resolveDeezerTrackURL(req DownloadRequest) (string, error) {
|
||||
}
|
||||
}
|
||||
if deezerID != "" {
|
||||
return fmt.Sprintf("https://www.deezer.com/track/%s", deezerID), nil
|
||||
trackURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerID)
|
||||
if err := verifyDeezerTrack(req, deezerID); err != nil {
|
||||
GoLog("[Deezer] Direct ID %s verification failed: %v\n", deezerID, err)
|
||||
// Don't reject direct IDs from request payload — they're presumably correct.
|
||||
}
|
||||
return trackURL, nil
|
||||
}
|
||||
|
||||
// Try resolving Deezer ID from Spotify ID via SongLink
|
||||
// Try SongLink
|
||||
spotifyID := strings.TrimSpace(req.SpotifyID)
|
||||
if spotifyID != "" && isLikelySpotifyTrackID(spotifyID) {
|
||||
songlink := NewSongLinkClient()
|
||||
availability, err := songlink.CheckTrackAvailability(spotifyID, "")
|
||||
if err == nil && availability.Deezer && availability.DeezerURL != "" {
|
||||
return availability.DeezerURL, nil
|
||||
resolvedID := extractDeezerIDFromURL(availability.DeezerURL)
|
||||
if resolvedID != "" {
|
||||
if verifyErr := verifyDeezerTrack(req, resolvedID); verifyErr != nil {
|
||||
GoLog("[Deezer] SongLink ID %s rejected: %v\n", resolvedID, verifyErr)
|
||||
// Fall through to ISRC search instead of using wrong track.
|
||||
} else {
|
||||
return availability.DeezerURL, nil
|
||||
}
|
||||
} else {
|
||||
return availability.DeezerURL, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try resolving from ISRC
|
||||
// Try ISRC
|
||||
isrc := strings.TrimSpace(req.ISRC)
|
||||
if isrc != "" {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout)
|
||||
defer cancel()
|
||||
track, err := GetDeezerClient().SearchByISRC(ctx, isrc)
|
||||
if err == nil && track != nil {
|
||||
deezerID = songLinkExtractDeezerTrackID(track)
|
||||
if deezerID != "" {
|
||||
return fmt.Sprintf("https://www.deezer.com/track/%s", deezerID), nil
|
||||
resolvedID := songLinkExtractDeezerTrackID(track)
|
||||
if resolvedID != "" {
|
||||
if verifyErr := verifyDeezerTrack(req, resolvedID); verifyErr != nil {
|
||||
GoLog("[Deezer] ISRC-resolved ID %s rejected: %v\n", resolvedID, verifyErr)
|
||||
return "", fmt.Errorf("deezer track resolved via ISRC does not match: %w", verifyErr)
|
||||
}
|
||||
return fmt.Sprintf("https://www.deezer.com/track/%s", resolvedID), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -233,6 +252,26 @@ func resolveDeezerTrackURL(req DownloadRequest) (string, error) {
|
||||
return "", fmt.Errorf("could not resolve Deezer track URL")
|
||||
}
|
||||
|
||||
func verifyDeezerTrack(req DownloadRequest, deezerID string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout)
|
||||
defer cancel()
|
||||
trackResp, err := GetDeezerClient().GetTrack(ctx, deezerID)
|
||||
if err != nil {
|
||||
return nil // Can't verify — don't block the download.
|
||||
}
|
||||
resolved := resolvedTrackInfo{
|
||||
Title: trackResp.Track.Name,
|
||||
ArtistName: trackResp.Track.Artists,
|
||||
Duration: trackResp.Track.DurationMS / 1000,
|
||||
}
|
||||
if !trackMatchesRequest(req, resolved, "Deezer") {
|
||||
return fmt.Errorf("expected '%s - %s', got '%s - %s'",
|
||||
req.ArtistName, req.TrackName, resolved.ArtistName, resolved.Title)
|
||||
}
|
||||
GoLog("[Deezer] Track %s verified: '%s - %s' ✓\n", deezerID, resolved.ArtistName, resolved.Title)
|
||||
return nil
|
||||
}
|
||||
|
||||
type deezerMusicDLRequest struct {
|
||||
Platform string `json:"platform"`
|
||||
URL string `json:"url"`
|
||||
@@ -280,7 +319,6 @@ func (c *DeezerClient) GetMusicDLDownloadURL(deezerTrackURL string) (string, err
|
||||
return "", fmt.Errorf("MusicDL error: %s", errMsg)
|
||||
}
|
||||
|
||||
// Try various response fields for download URL
|
||||
for _, key := range []string{"download_url", "url", "link"} {
|
||||
if urlVal, ok := raw[key].(string); ok && strings.TrimSpace(urlVal) != "" {
|
||||
return strings.TrimSpace(urlVal), nil
|
||||
|
||||
+231
-159
@@ -48,7 +48,6 @@ func CheckAvailability(spotifyID, isrc string) (string, error) {
|
||||
}
|
||||
|
||||
// SetSongLinkNetworkOptions is kept for backward compatibility.
|
||||
// It now applies global network compatibility options for all backend API requests.
|
||||
func SetSongLinkNetworkOptions(allowHTTP, insecureTLS bool) {
|
||||
SetNetworkCompatibilityOptions(allowHTTP, insecureTLS)
|
||||
}
|
||||
@@ -128,6 +127,7 @@ type DownloadResult struct {
|
||||
TrackNumber int
|
||||
DiscNumber int
|
||||
ISRC string
|
||||
CoverURL string
|
||||
Genre string
|
||||
Label string
|
||||
Copyright string
|
||||
@@ -135,6 +135,36 @@ type DownloadResult struct {
|
||||
DecryptionKey string
|
||||
}
|
||||
|
||||
func preferredReleaseMetadata(
|
||||
req DownloadRequest,
|
||||
album string,
|
||||
releaseDate string,
|
||||
trackNumber int,
|
||||
discNumber int,
|
||||
) (string, string, int, int) {
|
||||
preferredAlbum := strings.TrimSpace(req.AlbumName)
|
||||
if preferredAlbum == "" {
|
||||
preferredAlbum = album
|
||||
}
|
||||
|
||||
preferredReleaseDate := strings.TrimSpace(req.ReleaseDate)
|
||||
if preferredReleaseDate == "" {
|
||||
preferredReleaseDate = releaseDate
|
||||
}
|
||||
|
||||
preferredTrackNumber := req.TrackNumber
|
||||
if preferredTrackNumber == 0 {
|
||||
preferredTrackNumber = trackNumber
|
||||
}
|
||||
|
||||
preferredDiscNumber := req.DiscNumber
|
||||
if preferredDiscNumber == 0 {
|
||||
preferredDiscNumber = discNumber
|
||||
}
|
||||
|
||||
return preferredAlbum, preferredReleaseDate, preferredTrackNumber, preferredDiscNumber
|
||||
}
|
||||
|
||||
func buildDownloadSuccessResponse(
|
||||
req DownloadRequest,
|
||||
result DownloadResult,
|
||||
@@ -153,25 +183,16 @@ func buildDownloadSuccessResponse(
|
||||
artist = req.ArtistName
|
||||
}
|
||||
|
||||
album := result.Album
|
||||
if album == "" {
|
||||
album = req.AlbumName
|
||||
}
|
||||
|
||||
releaseDate := result.ReleaseDate
|
||||
if releaseDate == "" {
|
||||
releaseDate = req.ReleaseDate
|
||||
}
|
||||
|
||||
trackNumber := result.TrackNumber
|
||||
if trackNumber == 0 {
|
||||
trackNumber = req.TrackNumber
|
||||
}
|
||||
|
||||
discNumber := result.DiscNumber
|
||||
if discNumber == 0 {
|
||||
discNumber = req.DiscNumber
|
||||
}
|
||||
// Preserve requested release metadata when available so mixed-provider
|
||||
// fallback downloads from the same source album do not get split into
|
||||
// different albums just because Tidal/Qobuz report variant titles/dates.
|
||||
album, releaseDate, trackNumber, discNumber := preferredReleaseMetadata(
|
||||
req,
|
||||
result.Album,
|
||||
result.ReleaseDate,
|
||||
result.TrackNumber,
|
||||
result.DiscNumber,
|
||||
)
|
||||
|
||||
isrc := result.ISRC
|
||||
if isrc == "" {
|
||||
@@ -193,6 +214,11 @@ func buildDownloadSuccessResponse(
|
||||
copyright = req.Copyright
|
||||
}
|
||||
|
||||
coverURL := strings.TrimSpace(result.CoverURL)
|
||||
if coverURL == "" {
|
||||
coverURL = strings.TrimSpace(req.CoverURL)
|
||||
}
|
||||
|
||||
return DownloadResponse{
|
||||
Success: true,
|
||||
Message: message,
|
||||
@@ -209,7 +235,7 @@ func buildDownloadSuccessResponse(
|
||||
TrackNumber: trackNumber,
|
||||
DiscNumber: discNumber,
|
||||
ISRC: isrc,
|
||||
CoverURL: req.CoverURL,
|
||||
CoverURL: coverURL,
|
||||
Genre: genre,
|
||||
Label: label,
|
||||
Copyright: copyright,
|
||||
@@ -262,7 +288,7 @@ func enrichRequestExtendedMetadata(req *DownloadRequest) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.ISRC == "" || (req.Genre != "" && req.Label != "") {
|
||||
if req.ISRC == "" || (req.Genre != "" && req.Label != "" && req.Copyright != "") {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -284,8 +310,11 @@ func enrichRequestExtendedMetadata(req *DownloadRequest) {
|
||||
if req.Label == "" && extMeta.Label != "" {
|
||||
req.Label = extMeta.Label
|
||||
}
|
||||
if req.Genre != "" || req.Label != "" {
|
||||
GoLog("[DownloadWithFallback] Extended metadata ready: genre=%s, label=%s\n", req.Genre, req.Label)
|
||||
if req.Copyright == "" && extMeta.Copyright != "" {
|
||||
req.Copyright = extMeta.Copyright
|
||||
}
|
||||
if req.Genre != "" || req.Label != "" || req.Copyright != "" {
|
||||
GoLog("[DownloadWithFallback] Extended metadata ready: genre=%s, label=%s, copyright=%s\n", req.Genre, req.Label, req.Copyright)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -354,6 +383,7 @@ func DownloadTrack(requestJSON string) (string, error) {
|
||||
TrackNumber: qobuzResult.TrackNumber,
|
||||
DiscNumber: qobuzResult.DiscNumber,
|
||||
ISRC: qobuzResult.ISRC,
|
||||
CoverURL: qobuzResult.CoverURL,
|
||||
LyricsLRC: qobuzResult.LyricsLRC,
|
||||
}
|
||||
}
|
||||
@@ -376,24 +406,6 @@ func DownloadTrack(requestJSON string) (string, error) {
|
||||
}
|
||||
}
|
||||
err = deezerErr
|
||||
case "youtube":
|
||||
youtubeResult, youtubeErr := downloadFromYouTube(req)
|
||||
if youtubeErr == nil {
|
||||
result = DownloadResult{
|
||||
FilePath: youtubeResult.FilePath,
|
||||
BitDepth: 0,
|
||||
SampleRate: 0,
|
||||
Title: youtubeResult.Title,
|
||||
Artist: youtubeResult.Artist,
|
||||
Album: youtubeResult.Album,
|
||||
ReleaseDate: youtubeResult.ReleaseDate,
|
||||
TrackNumber: youtubeResult.TrackNumber,
|
||||
DiscNumber: youtubeResult.DiscNumber,
|
||||
ISRC: youtubeResult.ISRC,
|
||||
LyricsLRC: youtubeResult.LyricsLRC,
|
||||
}
|
||||
}
|
||||
err = youtubeErr
|
||||
default:
|
||||
return errorResponse("Unknown service: " + req.Service)
|
||||
}
|
||||
@@ -445,7 +457,7 @@ func DownloadByStrategy(requestJSON string) (string, error) {
|
||||
serviceNormalized := strings.ToLower(serviceRaw)
|
||||
|
||||
normalizedReq := req
|
||||
if serviceNormalized == "youtube" || isBuiltInProvider(serviceNormalized) {
|
||||
if isBuiltInProvider(serviceNormalized) {
|
||||
normalizedReq.Service = serviceNormalized
|
||||
}
|
||||
|
||||
@@ -455,10 +467,6 @@ func DownloadByStrategy(requestJSON string) (string, error) {
|
||||
}
|
||||
normalizedJSON := string(normalizedBytes)
|
||||
|
||||
if serviceNormalized == "youtube" {
|
||||
return DownloadFromYouTube(normalizedJSON)
|
||||
}
|
||||
|
||||
if req.UseExtensions {
|
||||
// Respect strict mode when auto fallback is disabled:
|
||||
// for built-in providers, route directly to selected service only.
|
||||
@@ -562,6 +570,7 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
||||
TrackNumber: qobuzResult.TrackNumber,
|
||||
DiscNumber: qobuzResult.DiscNumber,
|
||||
ISRC: qobuzResult.ISRC,
|
||||
CoverURL: qobuzResult.CoverURL,
|
||||
LyricsLRC: qobuzResult.LyricsLRC,
|
||||
}
|
||||
} else if !errors.Is(qobuzErr, ErrDownloadCancelled) {
|
||||
@@ -689,32 +698,80 @@ func ReadFileMetadata(filePath string) (string, error) {
|
||||
if isFlac {
|
||||
metadata, err := ReadMetadata(filePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read metadata: %w", err)
|
||||
}
|
||||
result["title"] = metadata.Title
|
||||
result["artist"] = metadata.Artist
|
||||
result["album"] = metadata.Album
|
||||
result["album_artist"] = metadata.AlbumArtist
|
||||
result["date"] = metadata.Date
|
||||
result["track_number"] = metadata.TrackNumber
|
||||
result["disc_number"] = metadata.DiscNumber
|
||||
result["isrc"] = metadata.ISRC
|
||||
result["lyrics"] = metadata.Lyrics
|
||||
result["genre"] = metadata.Genre
|
||||
result["label"] = metadata.Label
|
||||
result["copyright"] = metadata.Copyright
|
||||
result["composer"] = metadata.Composer
|
||||
result["comment"] = metadata.Comment
|
||||
// File may have wrong extension (e.g. opus saved as .flac).
|
||||
// Try Ogg/Opus parser as fallback before giving up.
|
||||
GoLog("[ReadFileMetadata] FLAC parse failed for %s, trying Ogg fallback: %v\n", filePath, err)
|
||||
oggMeta, oggErr := ReadOggVorbisComments(filePath)
|
||||
if oggErr == nil && oggMeta != nil {
|
||||
result["title"] = oggMeta.Title
|
||||
result["artist"] = oggMeta.Artist
|
||||
result["album"] = oggMeta.Album
|
||||
result["album_artist"] = oggMeta.AlbumArtist
|
||||
result["date"] = oggMeta.Date
|
||||
if oggMeta.Date == "" {
|
||||
result["date"] = oggMeta.Year
|
||||
}
|
||||
result["track_number"] = oggMeta.TrackNumber
|
||||
result["disc_number"] = oggMeta.DiscNumber
|
||||
result["isrc"] = oggMeta.ISRC
|
||||
result["lyrics"] = oggMeta.Lyrics
|
||||
result["genre"] = oggMeta.Genre
|
||||
result["composer"] = oggMeta.Composer
|
||||
result["comment"] = oggMeta.Comment
|
||||
quality, qualityErr := GetOggQuality(filePath)
|
||||
if qualityErr == nil {
|
||||
result["sample_rate"] = quality.SampleRate
|
||||
result["duration"] = quality.Duration
|
||||
}
|
||||
} else {
|
||||
return "", fmt.Errorf("failed to read metadata: %w", err)
|
||||
}
|
||||
} else {
|
||||
result["title"] = metadata.Title
|
||||
result["artist"] = metadata.Artist
|
||||
result["album"] = metadata.Album
|
||||
result["album_artist"] = metadata.AlbumArtist
|
||||
result["date"] = metadata.Date
|
||||
result["track_number"] = metadata.TrackNumber
|
||||
result["disc_number"] = metadata.DiscNumber
|
||||
result["isrc"] = metadata.ISRC
|
||||
result["lyrics"] = metadata.Lyrics
|
||||
result["genre"] = metadata.Genre
|
||||
result["label"] = metadata.Label
|
||||
result["copyright"] = metadata.Copyright
|
||||
result["composer"] = metadata.Composer
|
||||
result["comment"] = metadata.Comment
|
||||
|
||||
quality, qualityErr := GetAudioQuality(filePath)
|
||||
if qualityErr == nil {
|
||||
result["bit_depth"] = quality.BitDepth
|
||||
result["sample_rate"] = quality.SampleRate
|
||||
if quality.SampleRate > 0 && quality.TotalSamples > 0 {
|
||||
result["duration"] = int(quality.TotalSamples / int64(quality.SampleRate))
|
||||
quality, qualityErr := GetAudioQuality(filePath)
|
||||
if qualityErr == nil {
|
||||
result["bit_depth"] = quality.BitDepth
|
||||
result["sample_rate"] = quality.SampleRate
|
||||
if quality.SampleRate > 0 && quality.TotalSamples > 0 {
|
||||
result["duration"] = int(quality.TotalSamples / int64(quality.SampleRate))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if isM4A {
|
||||
meta, err := ReadM4ATags(filePath)
|
||||
if err == nil && meta != nil {
|
||||
result["title"] = meta.Title
|
||||
result["artist"] = meta.Artist
|
||||
result["album"] = meta.Album
|
||||
result["album_artist"] = meta.AlbumArtist
|
||||
result["date"] = meta.Date
|
||||
if meta.Date == "" {
|
||||
result["date"] = meta.Year
|
||||
}
|
||||
result["track_number"] = meta.TrackNumber
|
||||
result["disc_number"] = meta.DiscNumber
|
||||
result["isrc"] = meta.ISRC
|
||||
result["lyrics"] = meta.Lyrics
|
||||
result["genre"] = meta.Genre
|
||||
result["label"] = meta.Label
|
||||
result["copyright"] = meta.Copyright
|
||||
result["composer"] = meta.Composer
|
||||
result["comment"] = meta.Comment
|
||||
}
|
||||
quality, qualityErr := GetM4AQuality(filePath)
|
||||
if qualityErr == nil {
|
||||
result["bit_depth"] = quality.BitDepth
|
||||
@@ -858,7 +915,6 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// MP3/Opus: return metadata for Dart-side FFmpeg embedding
|
||||
resp := map[string]any{
|
||||
"success": true,
|
||||
"method": "ffmpeg",
|
||||
@@ -1103,6 +1159,36 @@ func SearchDeezerAll(query string, trackLimit, artistLimit int, filter string) (
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func SearchTidalAll(query string, trackLimit, artistLimit int, filter string) (string, error) {
|
||||
downloader := NewTidalDownloader()
|
||||
results, err := downloader.SearchAll(query, trackLimit, artistLimit, filter)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(results)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func SearchQobuzAll(query string, trackLimit, artistLimit int, filter string) (string, error) {
|
||||
downloader := NewQobuzDownloader()
|
||||
results, err := downloader.SearchAll(query, trackLimit, artistLimit, filter)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(results)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func GetDeezerRelatedArtists(artistID string, limit int) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
@@ -1314,10 +1400,7 @@ func GetDeezerExtendedMetadata(trackID string) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
result := map[string]string{
|
||||
"genre": metadata.Genre,
|
||||
"label": metadata.Label,
|
||||
}
|
||||
result := buildDeezerExtendedMetadataResult(metadata)
|
||||
|
||||
jsonBytes, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
@@ -1337,7 +1420,8 @@ func SearchDeezerByISRC(isrc string) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(track)
|
||||
result := buildDeezerISRCSearchResult(track)
|
||||
jsonBytes, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -1345,6 +1429,55 @@ func SearchDeezerByISRC(isrc string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func buildDeezerExtendedMetadataResult(metadata *AlbumExtendedMetadata) map[string]string {
|
||||
if metadata == nil {
|
||||
return map[string]string{
|
||||
"genre": "",
|
||||
"label": "",
|
||||
"copyright": "",
|
||||
}
|
||||
}
|
||||
|
||||
return map[string]string{
|
||||
"genre": metadata.Genre,
|
||||
"label": metadata.Label,
|
||||
"copyright": metadata.Copyright,
|
||||
}
|
||||
}
|
||||
|
||||
func buildDeezerISRCSearchResult(track *TrackMetadata) map[string]interface{} {
|
||||
if track == nil {
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"spotify_id": track.SpotifyID,
|
||||
"artists": track.Artists,
|
||||
"name": track.Name,
|
||||
"album_name": track.AlbumName,
|
||||
"album_artist": track.AlbumArtist,
|
||||
"duration_ms": track.DurationMS,
|
||||
"images": track.Images,
|
||||
"release_date": track.ReleaseDate,
|
||||
"track_number": track.TrackNumber,
|
||||
"total_tracks": track.TotalTracks,
|
||||
"disc_number": track.DiscNumber,
|
||||
"external_urls": track.ExternalURL,
|
||||
"isrc": track.ISRC,
|
||||
"album_id": track.AlbumID,
|
||||
"artist_id": track.ArtistID,
|
||||
"album_type": track.AlbumType,
|
||||
}
|
||||
|
||||
if deezerID := strings.TrimSpace(strings.TrimPrefix(track.SpotifyID, "deezer:")); deezerID != "" {
|
||||
result["id"] = deezerID
|
||||
result["track_id"] = deezerID
|
||||
result["success"] = true
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
@@ -1541,62 +1674,6 @@ func errorResponse(msg string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func DownloadFromYouTube(requestJSON string) (string, error) {
|
||||
var req DownloadRequest
|
||||
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
||||
return errorResponse("Invalid request: " + err.Error())
|
||||
}
|
||||
applySongLinkRegionFromRequest(&req)
|
||||
defer closeOwnedOutputFD(req.OutputFD)
|
||||
|
||||
req.TrackName = strings.TrimSpace(req.TrackName)
|
||||
req.ArtistName = strings.TrimSpace(req.ArtistName)
|
||||
req.AlbumName = strings.TrimSpace(req.AlbumName)
|
||||
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
|
||||
req.OutputDir = strings.TrimSpace(req.OutputDir)
|
||||
req.OutputPath = strings.TrimSpace(req.OutputPath)
|
||||
req.OutputExt = strings.TrimSpace(req.OutputExt)
|
||||
|
||||
if req.OutputPath == "" && req.OutputFD <= 0 && req.OutputDir != "" {
|
||||
AddAllowedDownloadDir(req.OutputDir)
|
||||
}
|
||||
|
||||
youtubeResult, err := downloadFromYouTube(req)
|
||||
if err != nil {
|
||||
return errorResponse(err.Error())
|
||||
}
|
||||
|
||||
resp := DownloadResponse{
|
||||
Success: true,
|
||||
Message: "Downloaded from YouTube",
|
||||
FilePath: youtubeResult.FilePath,
|
||||
Service: "youtube",
|
||||
Title: youtubeResult.Title,
|
||||
Artist: youtubeResult.Artist,
|
||||
Album: youtubeResult.Album,
|
||||
ReleaseDate: youtubeResult.ReleaseDate,
|
||||
TrackNumber: youtubeResult.TrackNumber,
|
||||
DiscNumber: youtubeResult.DiscNumber,
|
||||
ISRC: youtubeResult.ISRC,
|
||||
LyricsLRC: youtubeResult.LyricsLRC,
|
||||
CoverURL: req.CoverURL,
|
||||
Genre: req.Genre,
|
||||
Label: req.Label,
|
||||
Copyright: req.Copyright,
|
||||
}
|
||||
|
||||
jsonBytes, _ := json.Marshal(resp)
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func IsYouTubeURLExport(urlStr string) bool {
|
||||
return IsYouTubeURL(urlStr)
|
||||
}
|
||||
|
||||
func ExtractYouTubeVideoIDExport(urlStr string) (string, error) {
|
||||
return ExtractYouTubeVideoID(urlStr)
|
||||
}
|
||||
|
||||
func DownloadCoverToFile(coverURL string, outputPath string, maxQuality bool) error {
|
||||
if coverURL == "" {
|
||||
return fmt.Errorf("no cover URL provided")
|
||||
@@ -1623,6 +1700,8 @@ func ExtractCoverToFile(audioPath string, outputPath string) error {
|
||||
|
||||
if strings.HasSuffix(lower, ".flac") {
|
||||
coverData, err = ExtractCoverArt(audioPath)
|
||||
} else if strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".aac") {
|
||||
coverData, err = extractCoverFromM4A(audioPath)
|
||||
} else if strings.HasSuffix(lower, ".mp3") {
|
||||
coverData, _, err = extractMP3CoverArt(audioPath)
|
||||
} else if strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg") {
|
||||
@@ -1803,8 +1882,8 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
||||
GoLog("[ReEnrich] Metadata provider search failed: %v\n", searchErr)
|
||||
}
|
||||
|
||||
// Try to get extended metadata (genre, label) from Deezer if not already set
|
||||
if found && req.ISRC != "" && (req.Genre == "" || req.Label == "") {
|
||||
// Try to get extended metadata from Deezer if not already set
|
||||
if found && req.ISRC != "" && (req.Genre == "" || req.Label == "" || req.Copyright == "") {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
extMeta, err := deezerClient.GetExtendedMetadataByISRC(ctx, req.ISRC)
|
||||
cancel()
|
||||
@@ -1815,7 +1894,10 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
||||
if req.Label == "" && extMeta.Label != "" {
|
||||
req.Label = extMeta.Label
|
||||
}
|
||||
GoLog("[ReEnrich] Extended metadata: genre=%s, label=%s\n", req.Genre, req.Label)
|
||||
if req.Copyright == "" && extMeta.Copyright != "" {
|
||||
req.Copyright = extMeta.Copyright
|
||||
}
|
||||
GoLog("[ReEnrich] Extended metadata: genre=%s, label=%s, copyright=%s\n", req.Genre, req.Label, req.Copyright)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1824,7 +1906,6 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Log metadata summary before embedding
|
||||
GoLog("[ReEnrich] Metadata to embed: title=%s, artist=%s, album=%s, albumArtist=%s\n",
|
||||
req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist)
|
||||
GoLog("[ReEnrich] track=%d, disc=%d, date=%s, isrc=%s, genre=%s, label=%s\n",
|
||||
@@ -1884,8 +1965,15 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
||||
}
|
||||
}()
|
||||
|
||||
// Fetch lyrics
|
||||
// Preserve existing lyrics when online enrichment does not return a replacement.
|
||||
var lyricsLRC string
|
||||
existingLyrics, existingLyricsErr := ExtractLyrics(req.FilePath)
|
||||
if existingLyricsErr == nil && strings.TrimSpace(existingLyrics) != "" {
|
||||
lyricsLRC = existingLyrics
|
||||
GoLog("[ReEnrich] Preserving existing embedded/sidecar lyrics\n")
|
||||
}
|
||||
|
||||
// Fetch lyrics
|
||||
if req.EmbedLyrics {
|
||||
client := NewLyricsClient()
|
||||
durationSec := float64(req.DurationMs) / 1000.0
|
||||
@@ -1900,7 +1988,6 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Build enriched metadata response for Dart (includes online search results)
|
||||
enrichedMeta := map[string]interface{}{
|
||||
"track_name": req.TrackName,
|
||||
"artist_name": req.ArtistName,
|
||||
@@ -1966,7 +2053,6 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// MP3/Opus: return metadata map for Dart to use FFmpeg
|
||||
// Don't cleanup cover temp — Dart needs it for FFmpeg embed
|
||||
cleanupCover = false
|
||||
result := map[string]interface{}{
|
||||
@@ -2047,12 +2133,6 @@ func LoadExtensionFromPath(filePath string) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
settingsStore := GetExtensionSettingsStore()
|
||||
settings := settingsStore.GetAll(ext.ID)
|
||||
if len(settings) > 0 {
|
||||
manager.InitializeExtension(ext.ID, settings)
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"id": ext.ID,
|
||||
"name": ext.Manifest.Name,
|
||||
@@ -2086,12 +2166,6 @@ func UpgradeExtensionFromPath(filePath string) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
settingsStore := GetExtensionSettingsStore()
|
||||
settings := settingsStore.GetAll(ext.ID)
|
||||
if len(settings) > 0 {
|
||||
manager.InitializeExtension(ext.ID, settings)
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"id": ext.ID,
|
||||
"display_name": ext.Manifest.DisplayName,
|
||||
@@ -3086,11 +3160,7 @@ func GetStoreExtensionsJSON(forceRefresh bool) (string, error) {
|
||||
return "", fmt.Errorf("extension store not initialized")
|
||||
}
|
||||
|
||||
if forceRefresh {
|
||||
store.FetchRegistry(true)
|
||||
}
|
||||
|
||||
extensions, err := store.GetExtensionsWithStatus()
|
||||
extensions, err := store.getExtensionsWithStatus(forceRefresh)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -3184,12 +3254,14 @@ func callExtensionFunctionJSON(extensionID, functionName string, timeout time.Du
|
||||
if !ext.Enabled {
|
||||
return "", fmt.Errorf("extension '%s' is disabled", extensionID)
|
||||
}
|
||||
vm, err := ext.lockReadyVM()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer ext.VMMu.Unlock()
|
||||
|
||||
// Goja runtime is not thread-safe; guard direct extension.*() calls with VMMu
|
||||
// to avoid races with other provider calls (e.g. getAlbum/getPlaylist).
|
||||
ext.VMMu.Lock()
|
||||
defer ext.VMMu.Unlock()
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
(function() {
|
||||
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
|
||||
@@ -3199,7 +3271,7 @@ func callExtensionFunctionJSON(extensionID, functionName string, timeout time.Du
|
||||
})()
|
||||
`, functionName, functionName)
|
||||
|
||||
result, err := RunWithTimeoutAndRecover(ext.VM, script, timeout)
|
||||
result, err := RunWithTimeoutAndRecover(vm, script, timeout)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s failed: %w", functionName, err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package gobackend
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBuildDeezerExtendedMetadataResultHandlesNil(t *testing.T) {
|
||||
result := buildDeezerExtendedMetadataResult(nil)
|
||||
|
||||
if result["genre"] != "" {
|
||||
t.Fatalf("expected empty genre, got %q", result["genre"])
|
||||
}
|
||||
if result["label"] != "" {
|
||||
t.Fatalf("expected empty label, got %q", result["label"])
|
||||
}
|
||||
if result["copyright"] != "" {
|
||||
t.Fatalf("expected empty copyright, got %q", result["copyright"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDeezerExtendedMetadataResultIncludesCopyright(t *testing.T) {
|
||||
result := buildDeezerExtendedMetadataResult(&AlbumExtendedMetadata{
|
||||
Genre: "Rock",
|
||||
Label: "EMI",
|
||||
Copyright: "(C) Queen",
|
||||
})
|
||||
|
||||
if result["genre"] != "Rock" {
|
||||
t.Fatalf("unexpected genre: %q", result["genre"])
|
||||
}
|
||||
if result["label"] != "EMI" {
|
||||
t.Fatalf("unexpected label: %q", result["label"])
|
||||
}
|
||||
if result["copyright"] != "(C) Queen" {
|
||||
t.Fatalf("unexpected copyright: %q", result["copyright"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDeezerISRCSearchResultAddsCompatibilityIDs(t *testing.T) {
|
||||
result := buildDeezerISRCSearchResult(&TrackMetadata{
|
||||
SpotifyID: "deezer:3135556",
|
||||
Name: "Love Of My Life",
|
||||
Artists: "Queen",
|
||||
AlbumName: "A Night at the Opera",
|
||||
ISRC: "GBUM71029604",
|
||||
ReleaseDate: "1975-11-21",
|
||||
})
|
||||
|
||||
if result["spotify_id"] != "deezer:3135556" {
|
||||
t.Fatalf("unexpected spotify_id: %v", result["spotify_id"])
|
||||
}
|
||||
if result["id"] != "3135556" {
|
||||
t.Fatalf("unexpected id: %v", result["id"])
|
||||
}
|
||||
if result["track_id"] != "3135556" {
|
||||
t.Fatalf("unexpected track_id: %v", result["track_id"])
|
||||
}
|
||||
if result["success"] != true {
|
||||
t.Fatalf("expected success=true, got %v", result["success"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package gobackend
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBuildDownloadSuccessResponsePrefersRequestedAlbumMetadata(t *testing.T) {
|
||||
req := DownloadRequest{
|
||||
TrackName: "Bonus Track",
|
||||
ArtistName: "Artist",
|
||||
AlbumName: "Album (Deluxe)",
|
||||
AlbumArtist: "Artist",
|
||||
ReleaseDate: "2024-01-01",
|
||||
TrackNumber: 14,
|
||||
DiscNumber: 1,
|
||||
ISRC: "REQ123",
|
||||
CoverURL: "https://example.com/cover.jpg",
|
||||
Genre: "Pop",
|
||||
Label: "Label",
|
||||
Copyright: "Copyright",
|
||||
}
|
||||
|
||||
result := DownloadResult{
|
||||
Title: "Bonus Track",
|
||||
Artist: "Artist",
|
||||
Album: "Album",
|
||||
ReleaseDate: "2023-12-01",
|
||||
TrackNumber: 2,
|
||||
DiscNumber: 9,
|
||||
ISRC: "RES456",
|
||||
}
|
||||
|
||||
resp := buildDownloadSuccessResponse(
|
||||
req,
|
||||
result,
|
||||
"tidal",
|
||||
"ok",
|
||||
"/tmp/test.flac",
|
||||
false,
|
||||
)
|
||||
|
||||
if resp.Album != req.AlbumName {
|
||||
t.Fatalf("album = %q, want %q", resp.Album, req.AlbumName)
|
||||
}
|
||||
if resp.ReleaseDate != req.ReleaseDate {
|
||||
t.Fatalf("release date = %q, want %q", resp.ReleaseDate, req.ReleaseDate)
|
||||
}
|
||||
if resp.TrackNumber != req.TrackNumber {
|
||||
t.Fatalf("track number = %d, want %d", resp.TrackNumber, req.TrackNumber)
|
||||
}
|
||||
if resp.DiscNumber != req.DiscNumber {
|
||||
t.Fatalf("disc number = %d, want %d", resp.DiscNumber, req.DiscNumber)
|
||||
}
|
||||
if resp.Artist != result.Artist {
|
||||
t.Fatalf("artist = %q, want provider artist %q", resp.Artist, result.Artist)
|
||||
}
|
||||
if resp.ISRC != result.ISRC {
|
||||
t.Fatalf("isrc = %q, want provider isrc %q", resp.ISRC, result.ISRC)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreferredReleaseMetadataPrefersRequestValues(t *testing.T) {
|
||||
album, releaseDate, trackNumber, discNumber := preferredReleaseMetadata(
|
||||
DownloadRequest{
|
||||
AlbumName: "Album (Deluxe Edition)",
|
||||
ReleaseDate: "2024-01-01",
|
||||
TrackNumber: 13,
|
||||
DiscNumber: 2,
|
||||
},
|
||||
"Album",
|
||||
"2023-01-01",
|
||||
3,
|
||||
1,
|
||||
)
|
||||
|
||||
if album != "Album (Deluxe Edition)" {
|
||||
t.Fatalf("album = %q", album)
|
||||
}
|
||||
if releaseDate != "2024-01-01" {
|
||||
t.Fatalf("release date = %q", releaseDate)
|
||||
}
|
||||
if trackNumber != 13 {
|
||||
t.Fatalf("track number = %d", trackNumber)
|
||||
}
|
||||
if discNumber != 2 {
|
||||
t.Fatalf("disc number = %d", discNumber)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDownloadSuccessResponsePrefersProviderCoverURL(t *testing.T) {
|
||||
req := DownloadRequest{
|
||||
TrackName: "Track",
|
||||
ArtistName: "Artist",
|
||||
AlbumName: "Album",
|
||||
AlbumArtist: "Artist",
|
||||
}
|
||||
|
||||
result := DownloadResult{
|
||||
Title: "Track",
|
||||
Artist: "Artist",
|
||||
Album: "Album",
|
||||
CoverURL: "https://cdn.qobuz.test/cover.jpg",
|
||||
}
|
||||
|
||||
resp := buildDownloadSuccessResponse(
|
||||
req,
|
||||
result,
|
||||
"qobuz",
|
||||
"ok",
|
||||
"/tmp/test.flac",
|
||||
false,
|
||||
)
|
||||
|
||||
if resp.CoverURL != result.CoverURL {
|
||||
t.Fatalf("cover url = %q, want %q", resp.CoverURL, result.CoverURL)
|
||||
}
|
||||
}
|
||||
+241
-114
@@ -44,16 +44,76 @@ func compareVersions(v1, v2 string) int {
|
||||
}
|
||||
|
||||
type LoadedExtension struct {
|
||||
ID string `json:"id"`
|
||||
Manifest *ExtensionManifest `json:"manifest"`
|
||||
VM *goja.Runtime `json:"-"`
|
||||
VMMu sync.Mutex `json:"-"`
|
||||
runtime *ExtensionRuntime
|
||||
Enabled bool `json:"enabled"`
|
||||
Error string `json:"error,omitempty"`
|
||||
DataDir string `json:"data_dir"`
|
||||
SourceDir string `json:"source_dir"`
|
||||
IconPath string `json:"icon_path"`
|
||||
ID string `json:"id"`
|
||||
Manifest *ExtensionManifest `json:"manifest"`
|
||||
VM *goja.Runtime `json:"-"`
|
||||
VMMu sync.Mutex `json:"-"`
|
||||
runtime *ExtensionRuntime
|
||||
initialized bool
|
||||
Enabled bool `json:"enabled"`
|
||||
Error string `json:"error,omitempty"`
|
||||
DataDir string `json:"data_dir"`
|
||||
SourceDir string `json:"source_dir"`
|
||||
IconPath string `json:"icon_path"`
|
||||
}
|
||||
|
||||
func getExtensionInitSettings(extensionID string) map[string]interface{} {
|
||||
settings := GetExtensionSettingsStore().GetAll(extensionID)
|
||||
if len(settings) == 0 {
|
||||
return settings
|
||||
}
|
||||
|
||||
filtered := make(map[string]interface{}, len(settings))
|
||||
for key, value := range settings {
|
||||
if strings.HasPrefix(key, "_") {
|
||||
continue
|
||||
}
|
||||
filtered[key] = value
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func ensureRuntimeReadyLocked(ext *LoadedExtension, applyStoredSettings bool) error {
|
||||
if ext.VM == nil || ext.runtime == nil {
|
||||
if err := initializeVMLocked(ext); err != nil {
|
||||
ext.Error = err.Error()
|
||||
ext.Enabled = false
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if applyStoredSettings && !ext.initialized {
|
||||
settings := getExtensionInitSettings(ext.ID)
|
||||
if len(settings) > 0 {
|
||||
if err := initializeExtensionWithSettingsLocked(ext, settings); err != nil {
|
||||
teardownVMLocked(ext)
|
||||
ext.Error = err.Error()
|
||||
ext.Enabled = false
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
ext.initialized = true
|
||||
}
|
||||
}
|
||||
|
||||
ext.Error = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ext *LoadedExtension) ensureRuntimeReady() error {
|
||||
ext.VMMu.Lock()
|
||||
defer ext.VMMu.Unlock()
|
||||
|
||||
return ensureRuntimeReadyLocked(ext, true)
|
||||
}
|
||||
|
||||
func (ext *LoadedExtension) lockReadyVM() (*goja.Runtime, error) {
|
||||
ext.VMMu.Lock()
|
||||
if err := ensureRuntimeReadyLocked(ext, true); err != nil {
|
||||
ext.VMMu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
return ext.VM, nil
|
||||
}
|
||||
|
||||
type ExtensionManager struct {
|
||||
@@ -220,10 +280,10 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
||||
SourceDir: extDir,
|
||||
}
|
||||
|
||||
if err := m.initializeVM(ext); err != nil {
|
||||
if err := validateExtensionLoad(ext); err != nil {
|
||||
ext.Error = err.Error()
|
||||
ext.Enabled = false
|
||||
GoLog("[Extension] Failed to initialize VM for %s: %v\n", manifest.Name, err)
|
||||
GoLog("[Extension] Failed to validate extension %s: %v\n", manifest.Name, err)
|
||||
}
|
||||
|
||||
m.extensions[manifest.Name] = ext
|
||||
@@ -232,7 +292,10 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
||||
return ext, nil
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
|
||||
func initializeVMLocked(ext *LoadedExtension) error {
|
||||
ext.VM = nil
|
||||
ext.runtime = nil
|
||||
ext.initialized = false
|
||||
vm := goja.New()
|
||||
ext.VM = vm
|
||||
|
||||
@@ -279,6 +342,136 @@ func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
|
||||
ext.VMMu.Lock()
|
||||
defer ext.VMMu.Unlock()
|
||||
return initializeVMLocked(ext)
|
||||
}
|
||||
|
||||
func initializeExtensionWithSettingsLocked(
|
||||
ext *LoadedExtension,
|
||||
settings map[string]interface{},
|
||||
) error {
|
||||
if ext.VM == nil {
|
||||
return fmt.Errorf("Extension failed to load. Please reinstall the extension")
|
||||
}
|
||||
|
||||
settingsJSON, err := json.Marshal(settings)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to save settings")
|
||||
}
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
(function() {
|
||||
var settings = %s;
|
||||
if (typeof extension !== 'undefined' && typeof extension.initialize === 'function') {
|
||||
try {
|
||||
extension.initialize(settings);
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
return { success: false, error: e.toString() };
|
||||
}
|
||||
}
|
||||
return { success: true, message: 'no initialize function' };
|
||||
})()
|
||||
`, string(settingsJSON))
|
||||
|
||||
result, err := ext.VM.RunString(script)
|
||||
if err != nil {
|
||||
ext.Error = fmt.Sprintf("initialize failed: %v", err)
|
||||
ext.Enabled = false
|
||||
GoLog("[Extension] Initialize error for %s: %v\n", ext.ID, err)
|
||||
return err
|
||||
}
|
||||
|
||||
if result != nil && !goja.IsUndefined(result) {
|
||||
exported := result.Export()
|
||||
if resultMap, ok := exported.(map[string]interface{}); ok {
|
||||
if success, ok := resultMap["success"].(bool); ok && !success {
|
||||
errMsg := "unknown error"
|
||||
if e, ok := resultMap["error"].(string); ok {
|
||||
errMsg = e
|
||||
}
|
||||
ext.Error = errMsg
|
||||
ext.Enabled = false
|
||||
GoLog("[Extension] Initialize failed for %s: %s\n", ext.ID, errMsg)
|
||||
return fmt.Errorf("initialize failed: %s", errMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ext.initialized = true
|
||||
GoLog("[Extension] Initialized %s\n", ext.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runCleanupLocked(ext *LoadedExtension) error {
|
||||
if ext.VM != nil {
|
||||
script := `
|
||||
(function() {
|
||||
if (typeof extension !== 'undefined' && typeof extension.cleanup === 'function') {
|
||||
try {
|
||||
extension.cleanup();
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
return { success: false, error: e.toString() };
|
||||
}
|
||||
}
|
||||
return { success: true, message: 'no cleanup function' };
|
||||
})()
|
||||
`
|
||||
|
||||
result, err := ext.VM.RunString(script)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if result != nil && !goja.IsUndefined(result) {
|
||||
exported := result.Export()
|
||||
if resultMap, ok := exported.(map[string]interface{}); ok {
|
||||
if success, ok := resultMap["success"].(bool); ok && !success {
|
||||
errMsg := "unknown error"
|
||||
if e, ok := resultMap["error"].(string); ok {
|
||||
errMsg = e
|
||||
}
|
||||
return fmt.Errorf("cleanup failed: %s", errMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if result != nil && !goja.IsUndefined(result) && !goja.IsNull(result) {
|
||||
GoLog("[Extension] Cleanup called for %s\n", ext.ID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func teardownVMLocked(ext *LoadedExtension) {
|
||||
if err := runCleanupLocked(ext); err != nil {
|
||||
GoLog("[Extension] Error calling cleanup for %s: %v\n", ext.ID, err)
|
||||
}
|
||||
if ext.runtime != nil {
|
||||
if err := ext.runtime.flushStorageNow(); err != nil {
|
||||
GoLog("[Extension] Failed to flush storage for %s: %v\n", ext.ID, err)
|
||||
}
|
||||
ext.runtime.closeStorageFlusher()
|
||||
}
|
||||
ext.runtime = nil
|
||||
ext.VM = nil
|
||||
ext.initialized = false
|
||||
}
|
||||
|
||||
func validateExtensionLoad(ext *LoadedExtension) error {
|
||||
ext.VMMu.Lock()
|
||||
defer ext.VMMu.Unlock()
|
||||
|
||||
if err := initializeVMLocked(ext); err != nil {
|
||||
return err
|
||||
}
|
||||
teardownVMLocked(ext)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) UnloadExtension(extensionID string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
@@ -288,21 +481,9 @@ func (m *ExtensionManager) UnloadExtension(extensionID string) error {
|
||||
return fmt.Errorf("Extension not found")
|
||||
}
|
||||
|
||||
if ext.VM != nil {
|
||||
cleanup, err := ext.VM.RunString("typeof extension !== 'undefined' && typeof extension.cleanup === 'function' ? extension.cleanup() : null")
|
||||
if err != nil {
|
||||
GoLog("[Extension] Error calling cleanup for %s: %v\n", extensionID, err)
|
||||
} else if cleanup != nil && !goja.IsUndefined(cleanup) && !goja.IsNull(cleanup) {
|
||||
GoLog("[Extension] Cleanup called for %s\n", extensionID)
|
||||
}
|
||||
}
|
||||
if ext.runtime != nil {
|
||||
if err := ext.runtime.flushStorageNow(); err != nil {
|
||||
GoLog("[Extension] Failed to flush storage for %s: %v\n", extensionID, err)
|
||||
}
|
||||
ext.runtime.closeStorageFlusher()
|
||||
ext.runtime = nil
|
||||
}
|
||||
ext.VMMu.Lock()
|
||||
teardownVMLocked(ext)
|
||||
ext.VMMu.Unlock()
|
||||
|
||||
delete(m.extensions, extensionID)
|
||||
GoLog("[Extension] Unloaded extension: %s\n", extensionID)
|
||||
@@ -341,7 +522,21 @@ func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool)
|
||||
return fmt.Errorf("Extension not found")
|
||||
}
|
||||
|
||||
ext.Enabled = enabled
|
||||
if enabled {
|
||||
ext.Enabled = true
|
||||
if err := ext.ensureRuntimeReady(); err != nil {
|
||||
store := GetExtensionSettingsStore()
|
||||
ext.Enabled = false
|
||||
_ = store.Set(extensionID, "_enabled", false)
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
ext.Enabled = false
|
||||
ext.Error = ""
|
||||
ext.VMMu.Lock()
|
||||
teardownVMLocked(ext)
|
||||
ext.VMMu.Unlock()
|
||||
}
|
||||
GoLog("[Extension] %s %s\n", extensionID, map[bool]string{true: "enabled", false: "disabled"}[enabled])
|
||||
|
||||
store := GetExtensionSettingsStore()
|
||||
@@ -436,10 +631,10 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
|
||||
}
|
||||
}
|
||||
|
||||
if err := m.initializeVM(ext); err != nil {
|
||||
if err := validateExtensionLoad(ext); err != nil {
|
||||
ext.Error = err.Error()
|
||||
ext.Enabled = false
|
||||
GoLog("[Extension] Failed to initialize VM for %s: %v\n", manifest.Name, err)
|
||||
GoLog("[Extension] Failed to validate extension %s: %v\n", manifest.Name, err)
|
||||
}
|
||||
|
||||
m.extensions[manifest.Name] = ext
|
||||
@@ -590,10 +785,14 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
|
||||
SourceDir: extDir,
|
||||
}
|
||||
|
||||
if err := m.initializeVM(ext); err != nil {
|
||||
if wasEnabled {
|
||||
if err := ext.ensureRuntimeReady(); err != nil {
|
||||
GoLog("[Extension] Failed to initialize upgraded extension %s: %v\n", newManifest.Name, err)
|
||||
}
|
||||
} else if err := validateExtensionLoad(ext); err != nil {
|
||||
ext.Error = err.Error()
|
||||
ext.Enabled = false
|
||||
GoLog("[Extension] Failed to initialize VM for %s: %v\n", newManifest.Name, err)
|
||||
GoLog("[Extension] Failed to validate upgraded extension %s: %v\n", newManifest.Name, err)
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
@@ -790,56 +989,13 @@ func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[
|
||||
return fmt.Errorf("Extension not found")
|
||||
}
|
||||
|
||||
if ext.VM == nil {
|
||||
return fmt.Errorf("Extension failed to load. Please reinstall the extension")
|
||||
}
|
||||
ext.VMMu.Lock()
|
||||
defer ext.VMMu.Unlock()
|
||||
|
||||
settingsJSON, err := json.Marshal(settings)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to save settings")
|
||||
}
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
(function() {
|
||||
var settings = %s;
|
||||
if (typeof extension !== 'undefined' && typeof extension.initialize === 'function') {
|
||||
try {
|
||||
extension.initialize(settings);
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
return { success: false, error: e.toString() };
|
||||
}
|
||||
}
|
||||
return { success: true, message: 'no initialize function' };
|
||||
})()
|
||||
`, string(settingsJSON))
|
||||
|
||||
result, err := ext.VM.RunString(script)
|
||||
if err != nil {
|
||||
ext.Error = fmt.Sprintf("initialize failed: %v", err)
|
||||
ext.Enabled = false
|
||||
GoLog("[Extension] Initialize error for %s: %v\n", extensionID, err)
|
||||
if err := ensureRuntimeReadyLocked(ext, false); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if result != nil && !goja.IsUndefined(result) {
|
||||
exported := result.Export()
|
||||
if resultMap, ok := exported.(map[string]interface{}); ok {
|
||||
if success, ok := resultMap["success"].(bool); ok && !success {
|
||||
errMsg := "unknown error"
|
||||
if e, ok := resultMap["error"].(string); ok {
|
||||
errMsg = e
|
||||
}
|
||||
ext.Error = errMsg
|
||||
ext.Enabled = false
|
||||
GoLog("[Extension] Initialize failed for %s: %s\n", extensionID, errMsg)
|
||||
return fmt.Errorf("initialize failed: %s", errMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GoLog("[Extension] Initialized %s\n", extensionID)
|
||||
return nil
|
||||
return initializeExtensionWithSettingsLocked(ext, settings)
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) CleanupExtension(extensionID string) error {
|
||||
@@ -854,41 +1010,12 @@ func (m *ExtensionManager) CleanupExtension(extensionID string) error {
|
||||
if ext.VM == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
script := `
|
||||
(function() {
|
||||
if (typeof extension !== 'undefined' && typeof extension.cleanup === 'function') {
|
||||
try {
|
||||
extension.cleanup();
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
return { success: false, error: e.toString() };
|
||||
}
|
||||
}
|
||||
return { success: true, message: 'no cleanup function' };
|
||||
})()
|
||||
`
|
||||
|
||||
result, err := ext.VM.RunString(script)
|
||||
if err != nil {
|
||||
ext.VMMu.Lock()
|
||||
defer ext.VMMu.Unlock()
|
||||
if err := runCleanupLocked(ext); err != nil {
|
||||
GoLog("[Extension] Cleanup error for %s: %v\n", extensionID, err)
|
||||
return err
|
||||
}
|
||||
|
||||
if result != nil && !goja.IsUndefined(result) {
|
||||
exported := result.Export()
|
||||
if resultMap, ok := exported.(map[string]interface{}); ok {
|
||||
if success, ok := resultMap["success"].(bool); ok && !success {
|
||||
errMsg := "unknown error"
|
||||
if e, ok := resultMap["error"].(string); ok {
|
||||
errMsg = e
|
||||
}
|
||||
GoLog("[Extension] Cleanup failed for %s: %s\n", extensionID, errMsg)
|
||||
return fmt.Errorf("cleanup failed: %s", errMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GoLog("[Extension] Cleaned up %s\n", extensionID)
|
||||
return nil
|
||||
}
|
||||
@@ -917,8 +1044,8 @@ func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) (
|
||||
return nil, fmt.Errorf("extension not found: %s", extensionID)
|
||||
}
|
||||
|
||||
if ext.VM == nil {
|
||||
return nil, fmt.Errorf("extension VM not initialized")
|
||||
if err := ext.ensureRuntimeReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !ext.Enabled {
|
||||
|
||||
@@ -125,6 +125,15 @@ func NewExtensionProviderWrapper(ext *LoadedExtension) *ExtensionProviderWrapper
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ExtensionProviderWrapper) lockReadyVM() error {
|
||||
vm, err := p.extension.lockReadyVM()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.vm = vm
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSearchResult, error) {
|
||||
if !p.extension.Manifest.IsMetadataProvider() {
|
||||
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
|
||||
@@ -133,8 +142,9 @@ func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
p.extension.VMMu.Lock()
|
||||
if err := p.lockReadyVM(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
@@ -192,8 +202,9 @@ func (p *ExtensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata,
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
p.extension.VMMu.Lock()
|
||||
if err := p.lockReadyVM(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
@@ -240,8 +251,9 @@ func (p *ExtensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata,
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
p.extension.VMMu.Lock()
|
||||
if err := p.lockReadyVM(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
@@ -291,8 +303,9 @@ func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadat
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
p.extension.VMMu.Lock()
|
||||
if err := p.lockReadyVM(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
@@ -345,8 +358,10 @@ func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTra
|
||||
if !p.extension.Enabled {
|
||||
return track, nil
|
||||
}
|
||||
|
||||
p.extension.VMMu.Lock()
|
||||
if err := p.lockReadyVM(); err != nil {
|
||||
GoLog("[Extension] EnrichTrack init error for %s: %v\n", p.extension.ID, err)
|
||||
return track, nil
|
||||
}
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
trackJSON, err := json.Marshal(track)
|
||||
@@ -405,8 +420,9 @@ func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
p.extension.VMMu.Lock()
|
||||
if err := p.lockReadyVM(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
@@ -452,8 +468,9 @@ func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*Ext
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
p.extension.VMMu.Lock()
|
||||
if err := p.lockReadyVM(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
@@ -501,8 +518,13 @@ func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string,
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
p.extension.VMMu.Lock()
|
||||
if err := p.lockReadyVM(); err != nil {
|
||||
return &ExtDownloadResult{
|
||||
Success: false,
|
||||
ErrorMessage: err.Error(),
|
||||
ErrorType: "init_error",
|
||||
}, nil
|
||||
}
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
p.vm.Set("__onProgress", func(call goja.FunctionCall) goja.Value {
|
||||
@@ -1065,8 +1087,9 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
GoLog("[DownloadWithExtensionFallback] Metadata provider search failed (non-fatal): %v\n", searchErr)
|
||||
}
|
||||
|
||||
// Try Deezer extended metadata for genre/label if we have ISRC
|
||||
if req.ISRC != "" && (req.Genre == "" || req.Label == "") {
|
||||
// Try Deezer extended metadata if we have ISRC
|
||||
if req.ISRC != "" &&
|
||||
(req.Genre == "" || req.Label == "" || req.Copyright == "") {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
extMeta, err := GetDeezerClient().GetExtendedMetadataByISRC(ctx, req.ISRC)
|
||||
cancel()
|
||||
@@ -1077,7 +1100,10 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
if req.Label == "" && extMeta.Label != "" {
|
||||
req.Label = extMeta.Label
|
||||
}
|
||||
GoLog("[DownloadWithExtensionFallback] Extended metadata from Deezer: genre=%s, label=%s\n", req.Genre, req.Label)
|
||||
if req.Copyright == "" && extMeta.Copyright != "" {
|
||||
req.Copyright = extMeta.Copyright
|
||||
}
|
||||
GoLog("[DownloadWithExtensionFallback] Extended metadata from Deezer: genre=%s, label=%s, copyright=%s\n", req.Genre, req.Label, req.Copyright)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1249,7 +1275,8 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
GoLog("[DownloadWithExtensionFallback] Trying provider: %s\n", providerID)
|
||||
|
||||
if isBuiltInProvider(providerIDNormalized) {
|
||||
if (req.Genre == "" || req.Label == "") && req.ISRC != "" {
|
||||
if (req.Genre == "" || req.Label == "" || req.Copyright == "") &&
|
||||
req.ISRC != "" {
|
||||
GoLog("[DownloadWithExtensionFallback] Enriching extended metadata from Deezer for ISRC: %s\n", req.ISRC)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
deezerClient := GetDeezerClient()
|
||||
@@ -1264,6 +1291,10 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
req.Label = extMeta.Label
|
||||
GoLog("[DownloadWithExtensionFallback] Label from Deezer: %s\n", req.Label)
|
||||
}
|
||||
if req.Copyright == "" && extMeta.Copyright != "" {
|
||||
req.Copyright = extMeta.Copyright
|
||||
GoLog("[DownloadWithExtensionFallback] Copyright from Deezer: %s\n", req.Copyright)
|
||||
}
|
||||
} else if err != nil {
|
||||
GoLog("[DownloadWithExtensionFallback] Failed to get extended metadata from Deezer: %v\n", err)
|
||||
}
|
||||
@@ -1471,6 +1502,7 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
|
||||
TrackNumber: qobuzResult.TrackNumber,
|
||||
DiscNumber: qobuzResult.DiscNumber,
|
||||
ISRC: qobuzResult.ISRC,
|
||||
CoverURL: qobuzResult.CoverURL,
|
||||
}
|
||||
}
|
||||
err = qobuzErr
|
||||
@@ -1513,6 +1545,7 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
|
||||
TrackNumber: result.TrackNumber,
|
||||
DiscNumber: result.DiscNumber,
|
||||
ISRC: result.ISRC,
|
||||
CoverURL: result.CoverURL,
|
||||
Genre: req.Genre,
|
||||
Label: req.Label,
|
||||
Copyright: req.Copyright,
|
||||
@@ -1615,8 +1648,9 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
p.extension.VMMu.Lock()
|
||||
if err := p.lockReadyVM(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
if options == nil {
|
||||
@@ -1696,8 +1730,9 @@ func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
p.extension.VMMu.Lock()
|
||||
if err := p.lockReadyVM(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
@@ -1781,8 +1816,9 @@ func (p *ExtensionProviderWrapper) MatchTrack(sourceTrack map[string]interface{}
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
p.extension.VMMu.Lock()
|
||||
if err := p.lockReadyVM(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
sourceJSON, _ := json.Marshal(sourceTrack)
|
||||
@@ -1851,8 +1887,9 @@ func (p *ExtensionProviderWrapper) PostProcess(filePath string, metadata map[str
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
p.extension.VMMu.Lock()
|
||||
if err := p.lockReadyVM(); err != nil {
|
||||
return &PostProcessResult{Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
metadataJSON, _ := json.Marshal(metadata)
|
||||
@@ -1913,8 +1950,9 @@ func (p *ExtensionProviderWrapper) PostProcessV2(input PostProcessInput, metadat
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
p.extension.VMMu.Lock()
|
||||
if err := p.lockReadyVM(); err != nil {
|
||||
return &PostProcessResult{Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
metadataJSON, _ := json.Marshal(metadata)
|
||||
@@ -2171,8 +2209,9 @@ func (p *ExtensionProviderWrapper) FetchLyrics(trackName, artistName, albumName
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
p.extension.VMMu.Lock()
|
||||
if err := p.lockReadyVM(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
// Use global variables to avoid JS injection issues with special characters in track/artist names
|
||||
|
||||
@@ -81,13 +81,14 @@ func SetExtensionTokens(extensionID string, accessToken, refreshToken string, ex
|
||||
}
|
||||
|
||||
type ExtensionRuntime struct {
|
||||
extensionID string
|
||||
manifest *ExtensionManifest
|
||||
settings map[string]interface{}
|
||||
httpClient *http.Client
|
||||
cookieJar http.CookieJar
|
||||
dataDir string
|
||||
vm *goja.Runtime
|
||||
extensionID string
|
||||
manifest *ExtensionManifest
|
||||
settings map[string]interface{}
|
||||
httpClient *http.Client
|
||||
downloadClient *http.Client
|
||||
cookieJar http.CookieJar
|
||||
dataDir string
|
||||
vm *goja.Runtime
|
||||
|
||||
storageMu sync.RWMutex
|
||||
storageCache map[string]interface{}
|
||||
@@ -132,13 +133,20 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
|
||||
storageFlushDelay: defaultStorageFlushDelay,
|
||||
}
|
||||
|
||||
runtime.httpClient = newExtensionHTTPClient(ext, jar, 30*time.Second)
|
||||
runtime.downloadClient = newExtensionHTTPClient(ext, jar, DownloadTimeout)
|
||||
|
||||
return runtime
|
||||
}
|
||||
|
||||
func newExtensionHTTPClient(ext *LoadedExtension, jar http.CookieJar, timeout time.Duration) *http.Client {
|
||||
// Extension sandbox enforces HTTPS-only domains. Do not apply global
|
||||
// allow_http scheme downgrade here, because some extension APIs (e.g.
|
||||
// spotify-web) will redirect http -> https and can end up in 301 loops.
|
||||
// We still reuse sharedTransport so insecure TLS compatibility mode remains effective.
|
||||
client := &http.Client{
|
||||
Transport: sharedTransport,
|
||||
Timeout: 30 * time.Second,
|
||||
Timeout: timeout,
|
||||
Jar: jar,
|
||||
}
|
||||
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
@@ -165,9 +173,7 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
runtime.httpClient = client
|
||||
|
||||
return runtime
|
||||
return client
|
||||
}
|
||||
|
||||
type RedirectBlockedError struct {
|
||||
|
||||
@@ -174,7 +174,12 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
|
||||
}
|
||||
|
||||
resp, err := r.httpClient.Do(req)
|
||||
client := r.downloadClient
|
||||
if client == nil {
|
||||
client = r.httpClient
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
|
||||
@@ -97,8 +97,8 @@ type StoreExtensionResponse struct {
|
||||
HasUpdate bool `json:"has_update"`
|
||||
}
|
||||
|
||||
func (e *StoreExtension) ToResponse() StoreExtensionResponse {
|
||||
return StoreExtensionResponse{
|
||||
func (e *StoreExtension) ToResponse() *StoreExtensionResponse {
|
||||
return &StoreExtensionResponse{
|
||||
ID: e.ID,
|
||||
Name: e.Name,
|
||||
DisplayName: e.getDisplayName(),
|
||||
@@ -236,7 +236,6 @@ func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error
|
||||
s.cacheMu.Lock()
|
||||
defer s.cacheMu.Unlock()
|
||||
|
||||
// Check if a registry URL has been configured
|
||||
if s.registryURL == "" {
|
||||
return nil, fmt.Errorf("no registry URL configured. Please add a repository URL first")
|
||||
}
|
||||
@@ -289,8 +288,8 @@ func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error
|
||||
return ®istry, nil
|
||||
}
|
||||
|
||||
func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionResponse, error) {
|
||||
registry, err := s.FetchRegistry(false)
|
||||
func (s *ExtensionStore) getExtensionsWithStatus(forceRefresh bool) ([]*StoreExtensionResponse, error) {
|
||||
registry, err := s.FetchRegistry(forceRefresh)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -304,22 +303,29 @@ func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionResponse, er
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]StoreExtensionResponse, len(registry.Extensions))
|
||||
for i, ext := range registry.Extensions {
|
||||
resp := ext.ToResponse()
|
||||
LogDebug("ExtensionStore", "Building store response for %d registry extensions (%d installed)", len(registry.Extensions), len(installed))
|
||||
|
||||
result := make([]*StoreExtensionResponse, 0, len(registry.Extensions))
|
||||
for i := range registry.Extensions {
|
||||
ext := ®istry.Extensions[i]
|
||||
resp := ext.ToResponse()
|
||||
if installedVersion, ok := installed[ext.ID]; ok {
|
||||
resp.IsInstalled = true
|
||||
resp.InstalledVersion = installedVersion
|
||||
resp.HasUpdate = compareVersions(ext.Version, installedVersion) > 0
|
||||
}
|
||||
|
||||
result[i] = resp
|
||||
result = append(result, resp)
|
||||
}
|
||||
|
||||
LogDebug("ExtensionStore", "Built store response payload for %d extensions", len(result))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *ExtensionStore) GetExtensionsWithStatus() ([]*StoreExtensionResponse, error) {
|
||||
return s.getExtensionsWithStatus(false)
|
||||
}
|
||||
|
||||
func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string) error {
|
||||
registry, err := s.FetchRegistry(false)
|
||||
if err != nil {
|
||||
@@ -389,7 +395,6 @@ func ResolveRegistryURL(input string) (string, error) {
|
||||
return input, nil
|
||||
}
|
||||
|
||||
// Try to match https://github.com/<owner>/<repo>[/...]
|
||||
const ghPrefix = "https://github.com/"
|
||||
if !strings.HasPrefix(input, ghPrefix) {
|
||||
// Also accept http:// and upgrade silently.
|
||||
@@ -470,7 +475,7 @@ func (s *ExtensionStore) GetCategories() []string {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ExtensionStore) SearchExtensions(query string, category string) ([]StoreExtensionResponse, error) {
|
||||
func (s *ExtensionStore) SearchExtensions(query string, category string) ([]*StoreExtensionResponse, error) {
|
||||
extensions, err := s.GetExtensionsWithStatus()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -480,7 +485,7 @@ func (s *ExtensionStore) SearchExtensions(query string, category string) ([]Stor
|
||||
return extensions, nil
|
||||
}
|
||||
|
||||
var result []StoreExtensionResponse
|
||||
result := make([]*StoreExtensionResponse, 0, len(extensions))
|
||||
queryLower := toLower(query)
|
||||
|
||||
for _, ext := range extensions {
|
||||
@@ -493,7 +498,6 @@ func (s *ExtensionStore) SearchExtensions(query string, category string) ([]Stor
|
||||
!containsIgnoreCase(ext.DisplayName, queryLower) &&
|
||||
!containsIgnoreCase(ext.Description, queryLower) &&
|
||||
!containsIgnoreCase(ext.Author, queryLower) {
|
||||
// Check tags
|
||||
found := false
|
||||
for _, tag := range ext.Tags {
|
||||
if containsIgnoreCase(tag, queryLower) {
|
||||
|
||||
+1
-1
@@ -12,6 +12,7 @@ require (
|
||||
github.com/refraction-networking/utls v1.8.2
|
||||
golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864
|
||||
golang.org/x/net v0.50.0
|
||||
golang.org/x/text v0.34.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -24,6 +25,5 @@ require (
|
||||
golang.org/x/mod v0.33.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
golang.org/x/tools v0.42.0 // indirect
|
||||
)
|
||||
|
||||
@@ -6,8 +6,6 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
|
||||
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM=
|
||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
||||
github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5 h1:QckvTXtu55YMopmVeDrPQ/r+T6xjw8KMCmE3UgUldkw=
|
||||
github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
||||
github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE=
|
||||
@@ -30,36 +28,20 @@ github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEv
|
||||
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3 h1:NiJtT7g4ncNFVjVZMAYNBrPSNhIjFYPj8UKA8MEw2A4=
|
||||
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3/go.mod h1:wReH3Q1agKmmLapipWFnd4NSs8KPz3fK6mSEZjXLkrg=
|
||||
golang.org/x/mobile v0.0.0-20260209203831-923679eb55af h1:VqXrZNyqFISxo0rNDFZQlRDRIp7RXSJDeh/LbrK+W1k=
|
||||
golang.org/x/mobile v0.0.0-20260209203831-923679eb55af/go.mod h1:tbwefIr7RlQD1OpZ0KEZ9nux/uiihAOGdafgZfJkmII=
|
||||
golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864 h1:cTVynMSsMYgbUrtia2HB1jrhdUwQNtQti91vUCyjMp4=
|
||||
golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864/go.mod h1:4OGHIUSBiIqyFAQDaX1tpY0BVnO20DvNDeATBu8aeFQ=
|
||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
|
||||
@@ -300,14 +300,11 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for ISP blocking via HTTP status codes
|
||||
// Some ISPs return 403 or 451 when blocking content
|
||||
if resp.StatusCode == 403 || resp.StatusCode == 451 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
bodyStr := strings.ToLower(string(body))
|
||||
|
||||
// Check if response looks like ISP blocking page
|
||||
ispBlockingIndicators := []string{
|
||||
"blocked", "forbidden", "access denied", "not available in your",
|
||||
"restricted", "censored", "unavailable for legal", "blocked by",
|
||||
@@ -346,11 +343,12 @@ func calculateNextDelay(currentDelay time.Duration, config RetryConfig) time.Dur
|
||||
return min(nextDelay, config.MaxDelay)
|
||||
}
|
||||
|
||||
// Returns 60 seconds as default if header is missing or invalid
|
||||
// Returns 0 if the header is missing or invalid so callers can keep their
|
||||
// normal exponential backoff instead of stalling for an arbitrary minute.
|
||||
func getRetryAfterDuration(resp *http.Response) time.Duration {
|
||||
retryAfter := resp.Header.Get("Retry-After")
|
||||
if retryAfter == "" {
|
||||
return 60 * time.Second
|
||||
return 0
|
||||
}
|
||||
|
||||
if seconds, err := strconv.Atoi(retryAfter); err == nil {
|
||||
@@ -364,7 +362,7 @@ func getRetryAfterDuration(resp *http.Response) time.Duration {
|
||||
}
|
||||
}
|
||||
|
||||
return 60 * time.Second
|
||||
return 0
|
||||
}
|
||||
|
||||
func ReadResponseBody(resp *http.Response) ([]byte, error) {
|
||||
@@ -517,7 +515,6 @@ func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Returns true if ISP blocking was detected
|
||||
func CheckAndLogISPBlocking(err error, requestURL string, tag string) bool {
|
||||
ispErr := IsISPBlocking(err, requestURL)
|
||||
if ispErr != nil {
|
||||
@@ -552,7 +549,6 @@ func extractDomain(rawURL string) string {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// If ISP blocking is detected, returns a more descriptive error
|
||||
func WrapErrorWithISPCheck(err error, requestURL string, tag string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
|
||||
@@ -112,7 +112,6 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
|
||||
|
||||
resp, err := sharedClient.Do(req)
|
||||
if err == nil {
|
||||
// Check for Cloudflare challenge page (403 with specific markers)
|
||||
if resp.StatusCode == 403 || resp.StatusCode == 503 {
|
||||
body, readErr := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
@@ -154,7 +153,6 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// Check if error might be TLS-related (Cloudflare blocking)
|
||||
errStr := strings.ToLower(err.Error())
|
||||
tlsRelated := strings.Contains(errStr, "tls") ||
|
||||
strings.Contains(errStr, "handshake") ||
|
||||
|
||||
+20
-17
@@ -234,8 +234,6 @@ func ScanLibraryFolder(folderPath string) (string, error) {
|
||||
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
|
||||
@@ -293,7 +291,7 @@ func scanAudioFileWithKnownModTimeAndDisplayName(filePath, displayNameHint, scan
|
||||
libraryCoverCacheMu.RLock()
|
||||
coverCacheDir := libraryCoverCacheDir
|
||||
libraryCoverCacheMu.RUnlock()
|
||||
if coverCacheDir != "" && ext != ".m4a" {
|
||||
if coverCacheDir != "" {
|
||||
coverPath, err := SaveCoverToCacheWithHint(filePath, displayNameHint, coverCacheDir)
|
||||
if err == nil && coverPath != "" {
|
||||
result.CoverPath = coverPath
|
||||
@@ -373,13 +371,30 @@ func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResul
|
||||
}
|
||||
|
||||
func scanM4AFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||
metadata, err := ReadM4ATags(filePath)
|
||||
if err == nil && metadata != nil {
|
||||
result.TrackName = metadata.Title
|
||||
result.ArtistName = metadata.Artist
|
||||
result.AlbumName = metadata.Album
|
||||
result.AlbumArtist = metadata.AlbumArtist
|
||||
result.ISRC = metadata.ISRC
|
||||
result.TrackNumber = metadata.TrackNumber
|
||||
result.DiscNumber = metadata.DiscNumber
|
||||
result.ReleaseDate = metadata.Date
|
||||
if result.ReleaseDate == "" {
|
||||
result.ReleaseDate = metadata.Year
|
||||
}
|
||||
result.Genre = metadata.Genre
|
||||
}
|
||||
|
||||
quality, err := GetM4AQuality(filePath)
|
||||
if err == nil {
|
||||
result.BitDepth = quality.BitDepth
|
||||
result.SampleRate = quality.SampleRate
|
||||
}
|
||||
|
||||
return scanFromFilename(filePath, "", result)
|
||||
applyDefaultLibraryMetadata(filePath, "", result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||
@@ -540,9 +555,6 @@ func ReadAudioMetadataWithDisplayName(filePath, displayNameHint string) (string,
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// ScanLibraryFolderIncremental performs an incremental scan of the library folder
|
||||
// existingFilesJSON is a JSON object mapping filePath -> modTime (unix millis)
|
||||
// Only files that are new or have changed modification time will be scanned
|
||||
func loadExistingFilesSnapshot(snapshotPath string) (map[string]int64, error) {
|
||||
existingFiles := make(map[string]int64)
|
||||
if snapshotPath == "" {
|
||||
@@ -620,7 +632,6 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
|
||||
libraryScanProgress.TotalFiles = totalFiles
|
||||
libraryScanProgressMu.Unlock()
|
||||
|
||||
// Find files to scan (new or modified)
|
||||
var filesToScan []libraryAudioFileInfo
|
||||
skippedCount := 0
|
||||
existingCueTrackModTimes := make(map[string]int64)
|
||||
@@ -636,10 +647,8 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
|
||||
for _, f := range currentFiles {
|
||||
existingModTime, exists := existingFiles[f.path]
|
||||
if !exists {
|
||||
// For .cue files, also check if any virtual path entries exist
|
||||
if strings.ToLower(filepath.Ext(f.path)) == ".cue" {
|
||||
if cueTrackModTime, hasCueTracks := existingCueTrackModTimes[f.path]; hasCueTracks {
|
||||
// CUE file exists in DB via virtual paths; check if modTime changed
|
||||
if f.modTime == cueTrackModTime {
|
||||
skippedCount++
|
||||
} else {
|
||||
@@ -658,14 +667,11 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
|
||||
|
||||
var deletedPaths []string
|
||||
for existingPath := range existingFiles {
|
||||
// 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
|
||||
continue
|
||||
}
|
||||
// Base CUE file is gone, mark virtual path as deleted
|
||||
deletedPaths = append(deletedPaths, existingPath)
|
||||
} else if !currentPathSet[existingPath] {
|
||||
deletedPaths = append(deletedPaths, existingPath)
|
||||
@@ -696,7 +702,6 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
|
||||
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)
|
||||
parsedCueFiles := make(map[string]scannedCueFileInfo)
|
||||
for _, f := range filesToScan {
|
||||
@@ -731,7 +736,6 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(f.path))
|
||||
|
||||
// Handle .cue files: produce multiple track results
|
||||
if ext == ".cue" {
|
||||
var cueResults []LibraryScanResult
|
||||
cueInfo, ok := parsedCueFiles[f.path]
|
||||
@@ -756,7 +760,6 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip audio files referenced by .cue sheets
|
||||
if cueReferencedAudioFilesInc[f.path] {
|
||||
continue
|
||||
}
|
||||
|
||||
+107
-70
@@ -3,6 +3,7 @@ package gobackend
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -82,7 +83,6 @@ func SetLyricsProviderOrder(providers []string) {
|
||||
return
|
||||
}
|
||||
|
||||
// Validate provider names
|
||||
validNames := map[string]bool{
|
||||
LyricsProviderSpotifyAPI: true,
|
||||
LyricsProviderLRCLIB: true,
|
||||
@@ -104,7 +104,6 @@ func SetLyricsProviderOrder(providers []string) {
|
||||
GoLog("[Lyrics] Provider order set to: %v\n", valid)
|
||||
}
|
||||
|
||||
// GetLyricsProviderOrder returns the current lyrics provider order.
|
||||
func GetLyricsProviderOrder() []string {
|
||||
lyricsProvidersMu.RLock()
|
||||
defer lyricsProvidersMu.RUnlock()
|
||||
@@ -118,15 +117,14 @@ func GetLyricsProviderOrder() []string {
|
||||
return result
|
||||
}
|
||||
|
||||
// GetAvailableLyricsProviders returns metadata about all available providers.
|
||||
func GetAvailableLyricsProviders() []map[string]interface{} {
|
||||
return []map[string]interface{}{
|
||||
{"id": LyricsProviderSpotifyAPI, "name": "Spotify Lyrics API", "has_proxy_dependency": true, "description": "Spotify-sourced synced lyrics via community API"},
|
||||
{"id": LyricsProviderSpotifyAPI, "name": "Spotify Lyrics API", "has_proxy_dependency": true, "description": "Spotify-sourced lyrics via Paxsenix"},
|
||||
{"id": LyricsProviderLRCLIB, "name": "LRCLIB", "has_proxy_dependency": false, "description": "Open-source synced lyrics database"},
|
||||
{"id": LyricsProviderNetease, "name": "Netease", "has_proxy_dependency": false, "description": "NetEase Cloud Music (good for Asian songs)"},
|
||||
{"id": LyricsProviderMusixmatch, "name": "Musixmatch", "has_proxy_dependency": true, "description": "Largest lyrics database (multi-language)"},
|
||||
{"id": LyricsProviderAppleMusic, "name": "Apple Music", "has_proxy_dependency": true, "description": "Word-by-word synced lyrics"},
|
||||
{"id": LyricsProviderQQMusic, "name": "QQ Music", "has_proxy_dependency": true, "description": "QQ Music lyrics (good for Chinese songs)"},
|
||||
{"id": LyricsProviderNetease, "name": "Netease", "has_proxy_dependency": true, "description": "NetEase Cloud Music lyrics via Paxsenix"},
|
||||
{"id": LyricsProviderMusixmatch, "name": "Musixmatch", "has_proxy_dependency": true, "description": "Musixmatch lyrics via Paxsenix"},
|
||||
{"id": LyricsProviderAppleMusic, "name": "Apple Music", "has_proxy_dependency": true, "description": "Apple Music synced lyrics via Paxsenix"},
|
||||
{"id": LyricsProviderQQMusic, "name": "QQ Music", "has_proxy_dependency": true, "description": "QQ Music lyrics via Paxsenix"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,7 +137,6 @@ func normalizeLyricsFetchOptions(opts LyricsFetchOptions) LyricsFetchOptions {
|
||||
return opts
|
||||
}
|
||||
|
||||
// SetLyricsFetchOptions sets provider-specific lyric fetch behavior.
|
||||
func SetLyricsFetchOptions(opts LyricsFetchOptions) {
|
||||
normalized := normalizeLyricsFetchOptions(opts)
|
||||
|
||||
@@ -155,7 +152,6 @@ func SetLyricsFetchOptions(opts LyricsFetchOptions) {
|
||||
)
|
||||
}
|
||||
|
||||
// GetLyricsFetchOptions returns current provider-specific lyric fetch behavior.
|
||||
func GetLyricsFetchOptions() LyricsFetchOptions {
|
||||
lyricsFetchOptionsMu.RLock()
|
||||
defer lyricsFetchOptionsMu.RUnlock()
|
||||
@@ -431,6 +427,99 @@ func parseSpotifyRetryAfter(retryAfter string, now time.Time) time.Time {
|
||||
return now.Add(10 * time.Minute)
|
||||
}
|
||||
|
||||
func buildSpotifyLyricsResponse(lines []LyricsLine, syncType, plainLyrics string) (*LyricsResponse, error) {
|
||||
if len(lines) == 0 {
|
||||
return nil, fmt.Errorf("Spotify Lyrics API returned empty lines")
|
||||
}
|
||||
if syncType == "" {
|
||||
if len(lines) > 0 && lines[0].StartTimeMs > 0 {
|
||||
syncType = "LINE_SYNCED"
|
||||
} else {
|
||||
syncType = "UNSYNCED"
|
||||
}
|
||||
}
|
||||
return &LyricsResponse{
|
||||
Lines: lines,
|
||||
SyncType: syncType,
|
||||
Instrumental: false,
|
||||
PlainLyrics: plainLyrics,
|
||||
Provider: "Spotify Lyrics API",
|
||||
Source: "Spotify Lyrics API",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func plainLyricsFromTimedLines(lines []LyricsLine) string {
|
||||
parts := make([]string, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
words := strings.TrimSpace(line.Words)
|
||||
if words == "" {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, words)
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
func parseSpotifyLyricsResponseBody(body []byte) (*LyricsResponse, error) {
|
||||
var lrcPayload string
|
||||
if err := json.Unmarshal(body, &lrcPayload); err == nil {
|
||||
trimmed := strings.TrimSpace(lrcPayload)
|
||||
if trimmed == "" {
|
||||
return nil, fmt.Errorf("Spotify Lyrics API returned empty payload")
|
||||
}
|
||||
|
||||
lines := parseSyncedLyrics(trimmed)
|
||||
if len(lines) > 0 {
|
||||
return buildSpotifyLyricsResponse(lines, "LINE_SYNCED", plainLyricsFromTimedLines(lines))
|
||||
}
|
||||
|
||||
plainLines := plainTextLyricsLines(trimmed)
|
||||
return buildSpotifyLyricsResponse(plainLines, "UNSYNCED", trimmed)
|
||||
}
|
||||
|
||||
var apiResp SpotifyLyricsAPIResponse
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse Spotify Lyrics API response: %w", err)
|
||||
}
|
||||
|
||||
if apiResp.Error {
|
||||
msg := strings.TrimSpace(apiResp.Message)
|
||||
if msg == "" {
|
||||
msg = "Spotify Lyrics API returned error"
|
||||
}
|
||||
return nil, fmt.Errorf("%s", msg)
|
||||
}
|
||||
|
||||
lines := make([]LyricsLine, 0, len(apiResp.Lines))
|
||||
for _, line := range apiResp.Lines {
|
||||
words := strings.TrimSpace(line.Words)
|
||||
if words == "" {
|
||||
continue
|
||||
}
|
||||
startMs := parseSpotifyLyricsTimeTagToMs(line.TimeTag)
|
||||
lines = append(lines, LyricsLine{
|
||||
StartTimeMs: startMs,
|
||||
Words: words,
|
||||
EndTimeMs: 0,
|
||||
})
|
||||
}
|
||||
|
||||
for i := 0; i < len(lines)-1; i++ {
|
||||
nextStart := lines[i+1].StartTimeMs
|
||||
if nextStart > lines[i].StartTimeMs {
|
||||
lines[i].EndTimeMs = nextStart
|
||||
}
|
||||
}
|
||||
if len(lines) > 0 {
|
||||
last := len(lines) - 1
|
||||
if lines[last].EndTimeMs == 0 {
|
||||
lines[last].EndTimeMs = lines[last].StartTimeMs + 5000
|
||||
}
|
||||
}
|
||||
|
||||
return buildSpotifyLyricsResponse(lines, apiResp.SyncType, plainLyricsFromTimedLines(lines))
|
||||
}
|
||||
|
||||
func (c *LyricsClient) FetchLyricsFromSpotifyAPI(spotifyID string) (*LyricsResponse, error) {
|
||||
now := time.Now()
|
||||
if limitedUntil := getSpotifyLyricsRateLimitUntil(); limitedUntil.After(now) {
|
||||
@@ -449,7 +538,7 @@ func (c *LyricsClient) FetchLyricsFromSpotifyAPI(spotifyID string) (*LyricsRespo
|
||||
spotifyID = parsed.ID
|
||||
}
|
||||
|
||||
apiURL := fmt.Sprintf("https://spotify-lyrics-api-pi.vercel.app/?trackid=%s&format=lrc", url.QueryEscape(spotifyID))
|
||||
apiURL := fmt.Sprintf("https://lyrics.paxsenix.org/spotify/lyrics?id=%s", url.QueryEscape(spotifyID))
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
@@ -462,13 +551,18 @@ func (c *LyricsClient) FetchLyricsFromSpotifyAPI(spotifyID string) (*LyricsRespo
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read Spotify Lyrics API response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
retryUntil := parseSpotifyRetryAfter(resp.Header.Get("Retry-After"), now)
|
||||
setSpotifyLyricsRateLimitUntil(retryUntil)
|
||||
}
|
||||
var payload map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err == nil {
|
||||
if err := json.Unmarshal(bodyBytes, &payload); err == nil {
|
||||
if msg, ok := payload["message"].(string); ok && strings.TrimSpace(msg) != "" {
|
||||
return nil, fmt.Errorf("Spotify Lyrics API returned status %d: %s", resp.StatusCode, strings.TrimSpace(msg))
|
||||
}
|
||||
@@ -479,63 +573,7 @@ func (c *LyricsClient) FetchLyricsFromSpotifyAPI(spotifyID string) (*LyricsRespo
|
||||
return nil, fmt.Errorf("Spotify Lyrics API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var apiResp SpotifyLyricsAPIResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse Spotify Lyrics API response: %w", err)
|
||||
}
|
||||
|
||||
if apiResp.Error {
|
||||
msg := strings.TrimSpace(apiResp.Message)
|
||||
if msg == "" {
|
||||
msg = "Spotify Lyrics API returned error"
|
||||
}
|
||||
return nil, fmt.Errorf("%s", msg)
|
||||
}
|
||||
|
||||
result := &LyricsResponse{
|
||||
Lines: make([]LyricsLine, 0, len(apiResp.Lines)),
|
||||
SyncType: apiResp.SyncType,
|
||||
Instrumental: false,
|
||||
PlainLyrics: "",
|
||||
Provider: "Spotify Lyrics API",
|
||||
Source: "Spotify Lyrics API",
|
||||
}
|
||||
|
||||
for _, line := range apiResp.Lines {
|
||||
words := strings.TrimSpace(line.Words)
|
||||
if words == "" {
|
||||
continue
|
||||
}
|
||||
startMs := parseSpotifyLyricsTimeTagToMs(line.TimeTag)
|
||||
result.Lines = append(result.Lines, LyricsLine{
|
||||
StartTimeMs: startMs,
|
||||
Words: words,
|
||||
EndTimeMs: 0,
|
||||
})
|
||||
}
|
||||
|
||||
if len(result.Lines) > 1 {
|
||||
for i := 0; i < len(result.Lines)-1; i++ {
|
||||
nextStart := result.Lines[i+1].StartTimeMs
|
||||
if nextStart > result.Lines[i].StartTimeMs {
|
||||
result.Lines[i].EndTimeMs = nextStart
|
||||
}
|
||||
}
|
||||
last := len(result.Lines) - 1
|
||||
if result.Lines[last].EndTimeMs == 0 {
|
||||
result.Lines[last].EndTimeMs = result.Lines[last].StartTimeMs + 5000
|
||||
}
|
||||
}
|
||||
|
||||
if len(result.Lines) == 0 {
|
||||
return nil, fmt.Errorf("Spotify Lyrics API returned empty lines")
|
||||
}
|
||||
|
||||
if result.SyncType == "" {
|
||||
result.SyncType = "LINE_SYNCED"
|
||||
}
|
||||
|
||||
return result, nil
|
||||
return parseSpotifyLyricsResponseBody(bodyBytes)
|
||||
}
|
||||
|
||||
func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec float64) *LRCLibResponse {
|
||||
@@ -624,7 +662,6 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
|
||||
|
||||
GoLog("[Lyrics] Searching for: %s - %s (providers: %v)\n", artistName, trackName, providerOrder)
|
||||
|
||||
// Cascade through all configured built-in providers
|
||||
for _, providerName := range providerOrder {
|
||||
GoLog("[Lyrics] Trying provider: %s\n", providerName)
|
||||
|
||||
|
||||
+65
-126
@@ -4,121 +4,25 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AppleMusicClient fetches lyrics from Apple Music.
|
||||
// Uses a scraped JWT token for search and a proxy for lyrics.
|
||||
// Uses Paxsenix endpoints for search and lyrics.
|
||||
type AppleMusicClient struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// Apple Music token manager — singleton with mutex for thread safety
|
||||
type appleTokenManager struct {
|
||||
mu sync.Mutex
|
||||
token string
|
||||
}
|
||||
|
||||
var globalAppleTokenManager = &appleTokenManager{}
|
||||
|
||||
func (m *appleTokenManager) getToken(client *http.Client) (string, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if m.token != "" {
|
||||
return m.token, nil
|
||||
}
|
||||
|
||||
// Step 1: Fetch the Apple Music beta page
|
||||
req, err := http.NewRequest("GET", "https://beta.music.apple.com", nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to fetch Apple Music page: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read Apple Music page: %w", err)
|
||||
}
|
||||
|
||||
// Step 2: Find the index JS file URL
|
||||
indexJsRegex := regexp.MustCompile(`/assets/index~[^/]+\.js`)
|
||||
match := indexJsRegex.Find(body)
|
||||
if match == nil {
|
||||
return "", fmt.Errorf("could not find index JS script URL on Apple Music page")
|
||||
}
|
||||
|
||||
indexJsURL := "https://beta.music.apple.com" + string(match)
|
||||
|
||||
// Step 3: Fetch the JS file
|
||||
jsReq, err := http.NewRequest("GET", indexJsURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create JS request: %w", err)
|
||||
}
|
||||
jsReq.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
jsResp, err := client.Do(jsReq)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to fetch Apple Music JS: %w", err)
|
||||
}
|
||||
defer jsResp.Body.Close()
|
||||
|
||||
jsBody, err := io.ReadAll(jsResp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read Apple Music JS: %w", err)
|
||||
}
|
||||
|
||||
// Step 4: Extract JWT token (starts with eyJh)
|
||||
tokenRegex := regexp.MustCompile(`eyJh[^"]*`)
|
||||
tokenMatch := tokenRegex.Find(jsBody)
|
||||
if tokenMatch == nil {
|
||||
return "", fmt.Errorf("could not find JWT token in Apple Music JS")
|
||||
}
|
||||
|
||||
m.token = string(tokenMatch)
|
||||
GoLog("[AppleMusic] Token obtained successfully (length: %d)\n", len(m.token))
|
||||
return m.token, nil
|
||||
}
|
||||
|
||||
func (m *appleTokenManager) clearToken() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.token = ""
|
||||
}
|
||||
|
||||
type appleMusicSearchResponse struct {
|
||||
Results struct {
|
||||
Songs *struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
} `json:"data"`
|
||||
} `json:"songs"`
|
||||
} `json:"results"`
|
||||
Resources *struct {
|
||||
Songs map[string]struct {
|
||||
Attributes struct {
|
||||
Name string `json:"name"`
|
||||
ArtistName string `json:"artistName"`
|
||||
AlbumName string `json:"albumName"`
|
||||
URL string `json:"url"`
|
||||
Artwork struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"artwork"`
|
||||
} `json:"attributes"`
|
||||
} `json:"songs"`
|
||||
} `json:"resources"`
|
||||
type appleMusicSearchResult struct {
|
||||
ID string `json:"id"`
|
||||
SongName string `json:"songName"`
|
||||
ArtistName string `json:"artistName"`
|
||||
AlbumName string `json:"albumName"`
|
||||
Duration int `json:"duration"`
|
||||
}
|
||||
|
||||
// PaxResponse represents the lyrics proxy response for word-by-word / line lyrics
|
||||
@@ -149,32 +53,71 @@ func NewAppleMusicClient() *AppleMusicClient {
|
||||
}
|
||||
}
|
||||
|
||||
func selectBestAppleMusicSearchResult(results []appleMusicSearchResult, trackName, artistName string, durationSec float64) *appleMusicSearchResult {
|
||||
if len(results) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
normalizedTrack := strings.ToLower(strings.TrimSpace(simplifyTrackName(trackName)))
|
||||
normalizedArtist := strings.ToLower(strings.TrimSpace(normalizeArtistName(artistName)))
|
||||
if normalizedArtist == "" {
|
||||
normalizedArtist = strings.ToLower(strings.TrimSpace(artistName))
|
||||
}
|
||||
|
||||
bestIndex := 0
|
||||
bestScore := -1
|
||||
for i := range results {
|
||||
result := &results[i]
|
||||
score := 0
|
||||
|
||||
candidateTrack := strings.ToLower(strings.TrimSpace(simplifyTrackName(result.SongName)))
|
||||
candidateArtist := strings.ToLower(strings.TrimSpace(normalizeArtistName(result.ArtistName)))
|
||||
|
||||
switch {
|
||||
case candidateTrack == normalizedTrack:
|
||||
score += 50
|
||||
case strings.Contains(candidateTrack, normalizedTrack) || strings.Contains(normalizedTrack, candidateTrack):
|
||||
score += 25
|
||||
}
|
||||
|
||||
switch {
|
||||
case candidateArtist == normalizedArtist:
|
||||
score += 60
|
||||
case strings.Contains(candidateArtist, normalizedArtist) || strings.Contains(normalizedArtist, candidateArtist):
|
||||
score += 30
|
||||
}
|
||||
|
||||
if durationSec > 0 && result.Duration > 0 {
|
||||
diff := math.Abs(float64(result.Duration)/1000.0 - durationSec)
|
||||
if diff <= durationToleranceSec {
|
||||
score += 20
|
||||
}
|
||||
}
|
||||
|
||||
if score > bestScore {
|
||||
bestScore = score
|
||||
bestIndex = i
|
||||
}
|
||||
}
|
||||
|
||||
return &results[bestIndex]
|
||||
}
|
||||
|
||||
// SearchSong searches for a song on Apple Music and returns its ID.
|
||||
func (c *AppleMusicClient) SearchSong(trackName, artistName string) (string, error) {
|
||||
func (c *AppleMusicClient) SearchSong(trackName, artistName string, durationSec float64) (string, error) {
|
||||
query := trackName + " " + artistName
|
||||
if strings.TrimSpace(query) == "" {
|
||||
return "", fmt.Errorf("empty search query")
|
||||
}
|
||||
|
||||
token, err := globalAppleTokenManager.getToken(c.httpClient)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("apple music token error: %w", err)
|
||||
}
|
||||
|
||||
encodedQuery := url.QueryEscape(query)
|
||||
searchURL := fmt.Sprintf(
|
||||
"https://amp-api.music.apple.com/v1/catalog/us/search?term=%s&types=songs&limit=5&l=en-US&platform=web&format[resources]=map&include[songs]=artists&extend=artistUrl",
|
||||
encodedQuery,
|
||||
)
|
||||
searchURL := fmt.Sprintf("https://lyrics.paxsenix.org/apple-music/search?q=%s", encodedQuery)
|
||||
|
||||
req, err := http.NewRequest("GET", searchURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Origin", "https://music.apple.com")
|
||||
req.Header.Set("Referer", "https://music.apple.com/")
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
@@ -184,25 +127,21 @@ func (c *AppleMusicClient) SearchSong(trackName, artistName string) (string, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 401 {
|
||||
globalAppleTokenManager.clearToken()
|
||||
return "", fmt.Errorf("apple music token expired")
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("apple music search returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var searchResp appleMusicSearchResponse
|
||||
var searchResp []appleMusicSearchResult
|
||||
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
|
||||
return "", fmt.Errorf("failed to decode apple music response: %w", err)
|
||||
}
|
||||
|
||||
if searchResp.Results.Songs == nil || len(searchResp.Results.Songs.Data) == 0 {
|
||||
best := selectBestAppleMusicSearchResult(searchResp, trackName, artistName, durationSec)
|
||||
if best == nil || strings.TrimSpace(best.ID) == "" {
|
||||
return "", fmt.Errorf("no songs found on apple music")
|
||||
}
|
||||
|
||||
return searchResp.Results.Songs.Data[0].ID, nil
|
||||
return strings.TrimSpace(best.ID), nil
|
||||
}
|
||||
|
||||
// FetchLyricsByID fetches lyrics from the paxsenix proxy using Apple Music song ID.
|
||||
@@ -320,7 +259,7 @@ func (c *AppleMusicClient) FetchLyrics(
|
||||
durationSec float64,
|
||||
multiPersonWordByWord bool,
|
||||
) (*LyricsResponse, error) {
|
||||
songID, err := c.SearchSong(trackName, artistName)
|
||||
songID, err := c.SearchSong(trackName, artistName, durationSec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package gobackend
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
@@ -45,100 +47,105 @@ type musixmatchLyricsResponse struct {
|
||||
func NewMusixmatchClient() *MusixmatchClient {
|
||||
return &MusixmatchClient{
|
||||
httpClient: NewMetadataHTTPClient(15 * time.Second),
|
||||
baseURL: "http://158.180.60.95",
|
||||
baseURL: "https://lyrics.paxsenix.org/musixmatch/lyrics",
|
||||
}
|
||||
}
|
||||
|
||||
// searchAndGetLyrics searches for a song and retrieves its lyrics in one call.
|
||||
// The Musixmatch proxy returns both search result and lyrics in a single response.
|
||||
func (c *MusixmatchClient) searchAndGetLyrics(trackName, artistName string) (*musixmatchSearchResponse, error) {
|
||||
func (c *MusixmatchClient) fetchLyricsPayload(trackName, artistName string, durationSec float64, lyricsType, language string) (string, error) {
|
||||
if strings.TrimSpace(trackName) == "" || strings.TrimSpace(artistName) == "" {
|
||||
return nil, fmt.Errorf("empty track or artist name")
|
||||
return "", fmt.Errorf("empty track or artist name")
|
||||
}
|
||||
|
||||
encodedArtist := url.QueryEscape(artistName)
|
||||
encodedTrack := url.QueryEscape(trackName)
|
||||
|
||||
fullURL := fmt.Sprintf("%s/v2/full?artist=%s&track=%s", c.baseURL, encodedArtist, encodedTrack)
|
||||
params := url.Values{}
|
||||
params.Set("t", trackName)
|
||||
params.Set("a", artistName)
|
||||
params.Set("type", lyricsType)
|
||||
params.Set("format", "lrc")
|
||||
if durationSec > 0 {
|
||||
params.Set("d", fmt.Sprintf("%d", int(math.Round(durationSec))))
|
||||
}
|
||||
if strings.TrimSpace(language) != "" {
|
||||
params.Set("l", strings.ToLower(strings.TrimSpace(language)))
|
||||
}
|
||||
fullURL := c.baseURL + "?" + params.Encode()
|
||||
|
||||
req, err := http.NewRequest("GET", fullURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("musixmatch search failed: %w", err)
|
||||
return "", fmt.Errorf("musixmatch request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read musixmatch response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("musixmatch proxy returned HTTP %d", resp.StatusCode)
|
||||
trimmed := strings.TrimSpace(string(body))
|
||||
if errMsg, isErrorPayload := detectLyricsErrorPayload(trimmed); isErrorPayload {
|
||||
return "", fmt.Errorf("musixmatch proxy returned HTTP %d: %s", resp.StatusCode, errMsg)
|
||||
}
|
||||
return "", fmt.Errorf("musixmatch proxy returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result musixmatchSearchResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode musixmatch response: %w", err)
|
||||
var lrcPayload string
|
||||
if err := json.Unmarshal(body, &lrcPayload); err == nil {
|
||||
lrcPayload = strings.TrimSpace(lrcPayload)
|
||||
if lrcPayload == "" {
|
||||
return "", fmt.Errorf("empty musixmatch lyrics payload")
|
||||
}
|
||||
return lrcPayload, nil
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
trimmed := strings.TrimSpace(string(body))
|
||||
if errMsg, isErrorPayload := detectLyricsErrorPayload(trimmed); isErrorPayload {
|
||||
return "", fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
if trimmed != "" && !strings.HasPrefix(trimmed, "{") {
|
||||
return trimmed, nil
|
||||
}
|
||||
return "", fmt.Errorf("failed to decode musixmatch response")
|
||||
}
|
||||
|
||||
// FetchLyricsInLanguage retrieves lyrics from Musixmatch for a specific language code.
|
||||
func (c *MusixmatchClient) FetchLyricsInLanguage(songID int64, language string) (*LyricsResponse, error) {
|
||||
func (c *MusixmatchClient) FetchLyricsInLanguage(trackName, artistName string, durationSec float64, language string) (*LyricsResponse, error) {
|
||||
lang := strings.ToLower(strings.TrimSpace(language))
|
||||
if songID <= 0 || lang == "" {
|
||||
return nil, fmt.Errorf("invalid song id or language")
|
||||
if lang == "" {
|
||||
return nil, fmt.Errorf("invalid language")
|
||||
}
|
||||
|
||||
fullURL := fmt.Sprintf("%s/v2/full?id=%d&lang=%s", c.baseURL, songID, url.QueryEscape(lang))
|
||||
|
||||
req, err := http.NewRequest("GET", fullURL, nil)
|
||||
lrcText, err := c.fetchLyricsPayload(trackName, artistName, durationSec, "translate", lang)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("musixmatch language fetch failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("musixmatch language endpoint returned HTTP %d", resp.StatusCode)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result musixmatchSearchResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode musixmatch language response: %w", err)
|
||||
lines := parseSyncedLyrics(lrcText)
|
||||
if len(lines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: lines,
|
||||
SyncType: "LINE_SYNCED",
|
||||
PlainLyrics: plainLyricsFromTimedLines(lines),
|
||||
Provider: "Musixmatch",
|
||||
Source: fmt.Sprintf("Musixmatch (%s)", lang),
|
||||
}, nil
|
||||
}
|
||||
|
||||
if result.SyncedLyrics != nil && strings.TrimSpace(result.SyncedLyrics.Lyrics) != "" {
|
||||
lines := parseSyncedLyrics(result.SyncedLyrics.Lyrics)
|
||||
if len(lines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: lines,
|
||||
SyncType: "LINE_SYNCED",
|
||||
Provider: "Musixmatch",
|
||||
Source: fmt.Sprintf("Musixmatch (%s)", lang),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
if result.UnsyncedLyrics != nil && strings.TrimSpace(result.UnsyncedLyrics.Lyrics) != "" {
|
||||
lines := plainTextLyricsLines(result.UnsyncedLyrics.Lyrics)
|
||||
|
||||
if len(lines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: lines,
|
||||
SyncType: "UNSYNCED",
|
||||
PlainLyrics: result.UnsyncedLyrics.Lyrics,
|
||||
Provider: "Musixmatch",
|
||||
Source: fmt.Sprintf("Musixmatch (%s)", lang),
|
||||
}, nil
|
||||
}
|
||||
plainLines := plainTextLyricsLines(lrcText)
|
||||
if len(plainLines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: plainLines,
|
||||
SyncType: "UNSYNCED",
|
||||
PlainLyrics: lrcText,
|
||||
Provider: "Musixmatch",
|
||||
Source: fmt.Sprintf("Musixmatch (%s)", lang),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no lyrics found on musixmatch for language %s", lang)
|
||||
@@ -146,43 +153,39 @@ func (c *MusixmatchClient) FetchLyricsInLanguage(songID int64, language string)
|
||||
|
||||
// FetchLyrics searches Musixmatch and returns parsed LyricsResponse.
|
||||
func (c *MusixmatchClient) FetchLyrics(trackName, artistName string, durationSec float64, preferredLanguage string) (*LyricsResponse, error) {
|
||||
result, err := c.searchAndGetLyrics(trackName, artistName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if preferred := strings.ToLower(strings.TrimSpace(preferredLanguage)); preferred != "" && result.ID > 0 {
|
||||
localized, localizedErr := c.FetchLyricsInLanguage(result.ID, preferred)
|
||||
if preferred := strings.ToLower(strings.TrimSpace(preferredLanguage)); preferred != "" {
|
||||
localized, localizedErr := c.FetchLyricsInLanguage(trackName, artistName, durationSec, preferred)
|
||||
if localizedErr == nil {
|
||||
return localized, nil
|
||||
}
|
||||
GoLog("[Musixmatch] Language override '%s' failed: %v\n", preferred, localizedErr)
|
||||
}
|
||||
|
||||
if result.SyncedLyrics != nil && strings.TrimSpace(result.SyncedLyrics.Lyrics) != "" {
|
||||
lines := parseSyncedLyrics(result.SyncedLyrics.Lyrics)
|
||||
if len(lines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: lines,
|
||||
SyncType: "LINE_SYNCED",
|
||||
Provider: "Musixmatch",
|
||||
Source: "Musixmatch",
|
||||
}, nil
|
||||
}
|
||||
lrcText, err := c.fetchLyricsPayload(trackName, artistName, durationSec, "word", "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if result.UnsyncedLyrics != nil && strings.TrimSpace(result.UnsyncedLyrics.Lyrics) != "" {
|
||||
lines := plainTextLyricsLines(result.UnsyncedLyrics.Lyrics)
|
||||
lines := parseSyncedLyrics(lrcText)
|
||||
if len(lines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: lines,
|
||||
SyncType: "LINE_SYNCED",
|
||||
PlainLyrics: plainLyricsFromTimedLines(lines),
|
||||
Provider: "Musixmatch",
|
||||
Source: "Musixmatch",
|
||||
}, nil
|
||||
}
|
||||
|
||||
if len(lines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: lines,
|
||||
SyncType: "UNSYNCED",
|
||||
PlainLyrics: result.UnsyncedLyrics.Lyrics,
|
||||
Provider: "Musixmatch",
|
||||
Source: "Musixmatch",
|
||||
}, nil
|
||||
}
|
||||
plainLines := plainTextLyricsLines(lrcText)
|
||||
if len(plainLines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: plainLines,
|
||||
SyncType: "UNSYNCED",
|
||||
PlainLyrics: lrcText,
|
||||
Provider: "Musixmatch",
|
||||
Source: "Musixmatch",
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no lyrics found on musixmatch")
|
||||
|
||||
@@ -9,8 +9,7 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// NeteaseClient fetches lyrics from NetEase Cloud Music (music.163.com).
|
||||
// This is a direct public API — no proxy dependency.
|
||||
// NeteaseClient fetches lyrics through Paxsenix's NetEase endpoints.
|
||||
type NeteaseClient struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
@@ -59,12 +58,9 @@ func (c *NeteaseClient) SearchSong(trackName, artistName string) (int64, error)
|
||||
return 0, fmt.Errorf("empty search query")
|
||||
}
|
||||
|
||||
searchURL := "http://music.163.com/api/search/pc"
|
||||
searchURL := "https://lyrics.paxsenix.org/netease/search"
|
||||
params := url.Values{}
|
||||
params.Set("s", query)
|
||||
params.Set("type", "1")
|
||||
params.Set("limit", "1")
|
||||
params.Set("offset", "0")
|
||||
params.Set("q", query)
|
||||
|
||||
fullURL := searchURL + "?" + params.Encode()
|
||||
|
||||
@@ -102,12 +98,9 @@ func (c *NeteaseClient) SearchSong(trackName, artistName string) (int64, error)
|
||||
|
||||
// FetchLyricsByID fetches synced lyrics for a given Netease song ID.
|
||||
func (c *NeteaseClient) FetchLyricsByID(songID int64, includeTranslation, includeRomanization bool) (string, error) {
|
||||
lyricsURL := "http://music.163.com/api/song/lyric"
|
||||
lyricsURL := "https://lyrics.paxsenix.org/netease/lyrics"
|
||||
params := url.Values{}
|
||||
params.Set("id", fmt.Sprintf("%d", songID))
|
||||
params.Set("lv", "1")
|
||||
params.Set("tv", "1")
|
||||
params.Set("rv", "1")
|
||||
|
||||
fullURL := lyricsURL + "?" + params.Encode()
|
||||
|
||||
|
||||
@@ -1,45 +1,31 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// QQMusicClient fetches lyrics from QQ Music.
|
||||
// Search uses public QQ Music API, lyrics use the paxsenix proxy.
|
||||
// Uses Paxsenix metadata lookup for lyrics.
|
||||
type QQMusicClient struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
type qqMusicSearchResponse struct {
|
||||
Data struct {
|
||||
Song struct {
|
||||
List []struct {
|
||||
Title string `json:"title"`
|
||||
Singer []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"singer"`
|
||||
Album struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"album"`
|
||||
ID int64 `json:"id"`
|
||||
} `json:"list"`
|
||||
} `json:"song"`
|
||||
} `json:"data"`
|
||||
type qqLyricsMetadataRequest struct {
|
||||
Artist []string `json:"artist"`
|
||||
Album string `json:"album,omitempty"`
|
||||
SongID int64 `json:"songid,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Duration int64 `json:"duration,omitempty"`
|
||||
}
|
||||
|
||||
// QQ Music lyrics request payload for paxsenix proxy
|
||||
type qqLyricsPayload struct {
|
||||
Artist []string `json:"artist"`
|
||||
Album string `json:"album"`
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
type qqLyricsMetadataResponse struct {
|
||||
Lyrics []paxLyrics `json:"lyrics"`
|
||||
}
|
||||
|
||||
func NewQQMusicClient() *QQMusicClient {
|
||||
@@ -48,79 +34,29 @@ func NewQQMusicClient() *QQMusicClient {
|
||||
}
|
||||
}
|
||||
|
||||
// searchSong searches QQ Music and returns the song info needed for lyrics fetch.
|
||||
func (c *QQMusicClient) searchSong(trackName, artistName string) (*qqLyricsPayload, error) {
|
||||
query := trackName + " " + artistName
|
||||
if strings.TrimSpace(query) == "" {
|
||||
return nil, fmt.Errorf("empty search query")
|
||||
// fetchLyricsByMetadata asks Paxsenix to resolve and return QQ lyrics using track metadata.
|
||||
func (c *QQMusicClient) fetchLyricsByMetadata(trackName, artistName string, durationSec float64) (string, error) {
|
||||
payload := qqLyricsMetadataRequest{
|
||||
Artist: []string{artistName},
|
||||
Title: trackName,
|
||||
}
|
||||
if durationSec > 0 {
|
||||
payload.Duration = int64(math.Round(durationSec))
|
||||
}
|
||||
|
||||
searchURL := "https://c.y.qq.com/soso/fcgi-bin/client_search_cp"
|
||||
params := url.Values{}
|
||||
params.Set("format", "json")
|
||||
params.Set("inCharset", "utf8")
|
||||
params.Set("outCharset", "utf8")
|
||||
params.Set("platform", "yqq.json")
|
||||
params.Set("new_json", "1")
|
||||
params.Set("w", query)
|
||||
|
||||
fullURL := searchURL + "?" + params.Encode()
|
||||
|
||||
req, err := http.NewRequest("GET", fullURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("qqmusic search failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("qqmusic search returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var searchResp qqMusicSearchResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode qqmusic response: %w", err)
|
||||
}
|
||||
|
||||
if len(searchResp.Data.Song.List) == 0 {
|
||||
return nil, fmt.Errorf("no songs found on qqmusic")
|
||||
}
|
||||
|
||||
song := searchResp.Data.Song.List[0]
|
||||
|
||||
var artists []string
|
||||
for _, singer := range song.Singer {
|
||||
artists = append(artists, singer.Name)
|
||||
}
|
||||
|
||||
return &qqLyricsPayload{
|
||||
Artist: artists,
|
||||
Album: song.Album.Name,
|
||||
ID: song.ID,
|
||||
Title: song.Title,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// fetchLyricsByPayload fetches lyrics from the paxsenix proxy using QQ Music song info.
|
||||
func (c *QQMusicClient) fetchLyricsByPayload(payload *qqLyricsPayload) (string, error) {
|
||||
lyricsURL := "https://paxsenix.alwaysdata.net/getQQLyrics.php"
|
||||
lyricsURL := "https://lyrics.paxsenix.org/qq/lyrics-metadata"
|
||||
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal payload: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", lyricsURL, bytes.NewReader(payloadBytes))
|
||||
req, err := http.NewRequest("POST", lyricsURL, strings.NewReader(string(payloadBytes)))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
@@ -146,6 +82,17 @@ func (c *QQMusicClient) fetchLyricsByPayload(payload *qqLyricsPayload) (string,
|
||||
return bodyStr, nil
|
||||
}
|
||||
|
||||
func formatQQLyricsMetadataToLRC(rawJSON string, multiPersonWordByWord bool) (string, error) {
|
||||
var response qqLyricsMetadataResponse
|
||||
if err := json.Unmarshal([]byte(rawJSON), &response); err != nil {
|
||||
return "", fmt.Errorf("failed to parse qq metadata lyrics response")
|
||||
}
|
||||
if len(response.Lyrics) == 0 {
|
||||
return "", fmt.Errorf("qq metadata lyrics response was empty")
|
||||
}
|
||||
return formatPaxContent("Syllable", response.Lyrics, multiPersonWordByWord), nil
|
||||
}
|
||||
|
||||
// FetchLyrics searches QQ Music and returns parsed LyricsResponse.
|
||||
func (c *QQMusicClient) FetchLyrics(
|
||||
trackName,
|
||||
@@ -153,12 +100,7 @@ func (c *QQMusicClient) FetchLyrics(
|
||||
durationSec float64,
|
||||
multiPersonWordByWord bool,
|
||||
) (*LyricsResponse, error) {
|
||||
payload, err := c.searchSong(trackName, artistName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rawLyrics, err := c.fetchLyricsByPayload(payload)
|
||||
rawLyrics, err := c.fetchLyricsByMetadata(trackName, artistName, durationSec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -166,11 +108,13 @@ func (c *QQMusicClient) FetchLyrics(
|
||||
return nil, fmt.Errorf("qqmusic proxy returned non-lyric payload: %s", errMsg)
|
||||
}
|
||||
|
||||
// Try to parse as pax format (word-by-word or line)
|
||||
lrcText, err := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord)
|
||||
lrcText, err := formatQQLyricsMetadataToLRC(rawLyrics, multiPersonWordByWord)
|
||||
if err != nil {
|
||||
// If pax parsing fails, try to use as direct LRC text
|
||||
lrcText = rawLyrics
|
||||
if fallback, fallbackErr := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord); fallbackErr == nil {
|
||||
lrcText = fallback
|
||||
} else {
|
||||
lrcText = rawLyrics
|
||||
}
|
||||
}
|
||||
|
||||
lines := parseSyncedLyrics(lrcText)
|
||||
|
||||
+320
-6
@@ -552,6 +552,14 @@ func ExtractLyrics(filePath string) (string, error) {
|
||||
return extractLyricsFromSidecarLRC(filePath)
|
||||
}
|
||||
|
||||
if strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".aac") {
|
||||
lyrics, err := extractLyricsFromM4A(filePath)
|
||||
if err == nil && strings.TrimSpace(lyrics) != "" {
|
||||
return lyrics, nil
|
||||
}
|
||||
return extractLyricsFromSidecarLRC(filePath)
|
||||
}
|
||||
|
||||
if strings.HasSuffix(lower, ".mp3") {
|
||||
meta, err := ReadID3Tags(filePath)
|
||||
if err == nil && meta != nil {
|
||||
@@ -581,6 +589,299 @@ func ExtractLyrics(filePath string) (string, error) {
|
||||
return extractLyricsFromSidecarLRC(filePath)
|
||||
}
|
||||
|
||||
func ReadM4ATags(filePath string) (*AudioMetadata, error) {
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
fi, err := f.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ilst, err := findM4AIlstAtom(f, fi.Size())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
metadata := &AudioMetadata{}
|
||||
start := ilst.offset + ilst.headerSize
|
||||
end := ilst.offset + ilst.size
|
||||
for pos := start; pos+8 <= end; {
|
||||
header, err := readAtomHeaderAt(f, pos, fi.Size())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if header.size == 0 {
|
||||
header.size = end - pos
|
||||
}
|
||||
if header.size < header.headerSize {
|
||||
return nil, fmt.Errorf("invalid atom size for %s", header.typ)
|
||||
}
|
||||
|
||||
switch header.typ {
|
||||
case "\xa9nam":
|
||||
metadata.Title, _ = readM4ATextValue(f, header, fi.Size())
|
||||
case "\xa9ART":
|
||||
metadata.Artist, _ = readM4ATextValue(f, header, fi.Size())
|
||||
case "\xa9alb":
|
||||
metadata.Album, _ = readM4ATextValue(f, header, fi.Size())
|
||||
case "aART":
|
||||
metadata.AlbumArtist, _ = readM4ATextValue(f, header, fi.Size())
|
||||
case "\xa9day":
|
||||
metadata.Date, _ = readM4ATextValue(f, header, fi.Size())
|
||||
metadata.Year = metadata.Date
|
||||
case "\xa9gen":
|
||||
metadata.Genre, _ = readM4ATextValue(f, header, fi.Size())
|
||||
case "\xa9wrt":
|
||||
metadata.Composer, _ = readM4ATextValue(f, header, fi.Size())
|
||||
case "\xa9cmt":
|
||||
metadata.Comment, _ = readM4ATextValue(f, header, fi.Size())
|
||||
case "cprt":
|
||||
metadata.Copyright, _ = readM4ATextValue(f, header, fi.Size())
|
||||
case "\xa9lyr":
|
||||
metadata.Lyrics, _ = readM4ATextValue(f, header, fi.Size())
|
||||
case "trkn":
|
||||
metadata.TrackNumber, _ = readM4AIndexValue(f, header, fi.Size())
|
||||
case "disk":
|
||||
metadata.DiscNumber, _ = readM4AIndexValue(f, header, fi.Size())
|
||||
case "----":
|
||||
name, value, freeformErr := readM4AFreeformValue(f, header, fi.Size())
|
||||
if freeformErr == nil {
|
||||
switch strings.ToUpper(strings.TrimSpace(name)) {
|
||||
case "ISRC":
|
||||
metadata.ISRC = value
|
||||
case "LABEL", "ORGANIZATION":
|
||||
metadata.Label = value
|
||||
case "COMMENT":
|
||||
if metadata.Comment == "" {
|
||||
metadata.Comment = value
|
||||
}
|
||||
case "COMPOSER":
|
||||
if metadata.Composer == "" {
|
||||
metadata.Composer = value
|
||||
}
|
||||
case "COPYRIGHT":
|
||||
if metadata.Copyright == "" {
|
||||
metadata.Copyright = value
|
||||
}
|
||||
case "LYRICS", "UNSYNCEDLYRICS":
|
||||
if metadata.Lyrics == "" {
|
||||
metadata.Lyrics = value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pos += header.size
|
||||
}
|
||||
|
||||
if metadata.Title == "" &&
|
||||
metadata.Artist == "" &&
|
||||
metadata.Album == "" &&
|
||||
metadata.AlbumArtist == "" &&
|
||||
metadata.Lyrics == "" &&
|
||||
metadata.TrackNumber == 0 &&
|
||||
metadata.DiscNumber == 0 {
|
||||
return nil, fmt.Errorf("no M4A tags found")
|
||||
}
|
||||
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
func extractLyricsFromM4A(filePath string) (string, error) {
|
||||
metadata, err := ReadM4ATags(filePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if metadata == nil || strings.TrimSpace(metadata.Lyrics) == "" {
|
||||
return "", fmt.Errorf("no lyrics found in file")
|
||||
}
|
||||
return metadata.Lyrics, nil
|
||||
}
|
||||
|
||||
func extractCoverFromM4A(filePath string) ([]byte, error) {
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
fi, err := f.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fileSize := fi.Size()
|
||||
|
||||
ilst, err := findM4AIlstAtom(f, fileSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
bodyStart := ilst.offset + ilst.headerSize
|
||||
bodySize := ilst.size - ilst.headerSize
|
||||
|
||||
covr, found, err := findAtomInRange(f, bodyStart, bodySize, "covr", fileSize)
|
||||
if err != nil || !found {
|
||||
return nil, fmt.Errorf("cover atom not found")
|
||||
}
|
||||
|
||||
dataStart := covr.offset + covr.headerSize
|
||||
dataSize := covr.size - covr.headerSize
|
||||
|
||||
dataAtom, found, err := findAtomInRange(f, dataStart, dataSize, "data", fileSize)
|
||||
if err != nil || !found {
|
||||
return nil, fmt.Errorf("data atom not found in cover")
|
||||
}
|
||||
|
||||
// data atom: header + 4 bytes type indicator + 4 bytes locale
|
||||
imgStart := dataAtom.offset + dataAtom.headerSize + 8
|
||||
imgLen := dataAtom.size - dataAtom.headerSize - 8
|
||||
if imgLen <= 0 {
|
||||
return nil, fmt.Errorf("empty cover data")
|
||||
}
|
||||
|
||||
buf := make([]byte, imgLen)
|
||||
if _, err := f.ReadAt(buf, imgStart); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
// findM4AIlstAtom locates the ilst atom that holds all iTunes-style tags.
|
||||
// It tries two common layouts:
|
||||
// 1. moov > udta > meta > ilst (iTunes, FFmpeg default)
|
||||
// 2. moov > meta > ilst (some encoders omit the udta wrapper)
|
||||
func findM4AIlstAtom(f *os.File, fileSize int64) (atomHeader, error) {
|
||||
moov, found, err := findAtomInRange(f, 0, fileSize, "moov", fileSize)
|
||||
if err != nil || !found {
|
||||
return atomHeader{}, fmt.Errorf("moov not found")
|
||||
}
|
||||
|
||||
moovBodyStart := moov.offset + moov.headerSize
|
||||
moovBodySize := moov.size - moov.headerSize
|
||||
|
||||
// Path 1: moov > udta > meta > ilst
|
||||
if udta, ok, _ := findAtomInRange(f, moovBodyStart, moovBodySize, "udta", fileSize); ok {
|
||||
udtaBodyStart := udta.offset + udta.headerSize
|
||||
udtaBodySize := udta.size - udta.headerSize
|
||||
if meta, ok2, _ := findAtomInRange(f, udtaBodyStart, udtaBodySize, "meta", fileSize); ok2 {
|
||||
metaBodyStart := meta.offset + meta.headerSize + 4
|
||||
metaBodySize := meta.size - meta.headerSize - 4
|
||||
if ilst, ok3, _ := findAtomInRange(f, metaBodyStart, metaBodySize, "ilst", fileSize); ok3 {
|
||||
return ilst, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Path 2: moov > meta > ilst (no udta wrapper)
|
||||
if meta, ok, _ := findAtomInRange(f, moovBodyStart, moovBodySize, "meta", fileSize); ok {
|
||||
metaBodyStart := meta.offset + meta.headerSize + 4
|
||||
metaBodySize := meta.size - meta.headerSize - 4
|
||||
if ilst, ok2, _ := findAtomInRange(f, metaBodyStart, metaBodySize, "ilst", fileSize); ok2 {
|
||||
return ilst, nil
|
||||
}
|
||||
}
|
||||
|
||||
return atomHeader{}, fmt.Errorf("ilst not found (tried moov>udta>meta>ilst and moov>meta>ilst)")
|
||||
}
|
||||
|
||||
func readM4ADataAtomPayload(f *os.File, dataAtom atomHeader) ([]byte, error) {
|
||||
payloadStart := dataAtom.offset + dataAtom.headerSize + 8
|
||||
payloadLen := dataAtom.size - dataAtom.headerSize - 8
|
||||
if payloadLen <= 0 {
|
||||
return nil, fmt.Errorf("empty data atom in %s", dataAtom.typ)
|
||||
}
|
||||
|
||||
buf := make([]byte, payloadLen)
|
||||
if _, err := f.ReadAt(buf, payloadStart); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
func readM4ADataPayload(f *os.File, parent atomHeader, fileSize int64) ([]byte, error) {
|
||||
dataStart := parent.offset + parent.headerSize
|
||||
dataSize := parent.size - parent.headerSize
|
||||
|
||||
dataAtom, found, err := findAtomInRange(f, dataStart, dataSize, "data", fileSize)
|
||||
if err != nil || !found {
|
||||
return nil, fmt.Errorf("data atom not found in %s", parent.typ)
|
||||
}
|
||||
return readM4ADataAtomPayload(f, dataAtom)
|
||||
}
|
||||
|
||||
func readM4ATextValue(f *os.File, parent atomHeader, fileSize int64) (string, error) {
|
||||
payload, err := readM4ADataPayload(f, parent, fileSize)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(strings.TrimRight(string(payload), "\x00")), nil
|
||||
}
|
||||
|
||||
func readM4AIndexValue(f *os.File, parent atomHeader, fileSize int64) (int, error) {
|
||||
payload, err := readM4ADataPayload(f, parent, fileSize)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(payload) < 4 {
|
||||
return 0, fmt.Errorf("index payload too short in %s", parent.typ)
|
||||
}
|
||||
return int(binary.BigEndian.Uint16(payload[2:4])), nil
|
||||
}
|
||||
|
||||
func readM4AFreeformValue(f *os.File, parent atomHeader, fileSize int64) (string, string, error) {
|
||||
start := parent.offset + parent.headerSize
|
||||
end := parent.offset + parent.size
|
||||
|
||||
var nameValue string
|
||||
var dataValue string
|
||||
for pos := start; pos+8 <= end; {
|
||||
header, err := readAtomHeaderAt(f, pos, fileSize)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if header.size == 0 {
|
||||
header.size = end - pos
|
||||
}
|
||||
if header.size < header.headerSize {
|
||||
return "", "", fmt.Errorf("invalid atom size for %s", header.typ)
|
||||
}
|
||||
|
||||
switch header.typ {
|
||||
case "mean":
|
||||
// Domain qualifier (e.g. "com.apple.iTunes") — not needed, skip.
|
||||
case "name":
|
||||
// The "name" atom payload is: 4-byte version/flags, then raw UTF-8 text.
|
||||
// It does NOT contain a nested "data" atom, so read the payload directly.
|
||||
payloadStart := header.offset + header.headerSize + 4
|
||||
payloadLen := header.size - header.headerSize - 4
|
||||
if payloadLen > 0 {
|
||||
buf := make([]byte, payloadLen)
|
||||
if _, readErr := f.ReadAt(buf, payloadStart); readErr == nil {
|
||||
nameValue = strings.TrimSpace(strings.TrimRight(string(buf), "\x00"))
|
||||
}
|
||||
}
|
||||
case "data":
|
||||
payload, payloadErr := readM4ADataAtomPayload(f, header)
|
||||
if payloadErr == nil {
|
||||
dataValue = strings.TrimSpace(strings.TrimRight(string(payload), "\x00"))
|
||||
}
|
||||
}
|
||||
|
||||
pos += header.size
|
||||
}
|
||||
|
||||
if nameValue == "" || dataValue == "" {
|
||||
return "", "", fmt.Errorf("freeform M4A tag incomplete")
|
||||
}
|
||||
|
||||
return nameValue, dataValue, nil
|
||||
}
|
||||
|
||||
func extractLyricsFromSidecarLRC(filePath string) (string, error) {
|
||||
ext := filepath.Ext(filePath)
|
||||
base := strings.TrimSuffix(filePath, ext)
|
||||
@@ -743,15 +1044,28 @@ func GetM4AQuality(filePath string) (AudioQuality, error) {
|
||||
return AudioQuality{}, err
|
||||
}
|
||||
|
||||
buf := make([]byte, 24)
|
||||
buf := make([]byte, 32)
|
||||
if _, err := f.ReadAt(buf, sampleOffset); err != nil {
|
||||
return AudioQuality{}, fmt.Errorf("failed to read audio sample entry: %w", err)
|
||||
}
|
||||
|
||||
sampleRate := int(buf[22])<<8 | int(buf[23])
|
||||
bitDepth := 16
|
||||
if atomType == "alac" {
|
||||
bitDepth = 24
|
||||
// AudioSampleEntry layout from the box type field:
|
||||
// [0:4] type ("mp4a"/"alac")
|
||||
// [4:10] SampleEntry.reserved
|
||||
// [10:12] data_reference_index
|
||||
// [12:20] reserved[8]
|
||||
// [20:22] channelcount
|
||||
// [22:24] samplesize (bit depth)
|
||||
// [24:26] pre_defined
|
||||
// [26:28] reserved
|
||||
// [28:32] samplerate (16.16 fixed-point)
|
||||
sampleRate := int(buf[28])<<8 | int(buf[29])
|
||||
bitDepth := int(buf[22])<<8 | int(buf[23])
|
||||
if bitDepth <= 0 {
|
||||
bitDepth = 16
|
||||
if atomType == "alac" {
|
||||
bitDepth = 24
|
||||
}
|
||||
}
|
||||
|
||||
return AudioQuality{BitDepth: bitDepth, SampleRate: sampleRate}, nil
|
||||
@@ -874,7 +1188,7 @@ func findAudioSampleEntry(f *os.File, start, end, fileSize int64) (int64, string
|
||||
|
||||
if bestIdx >= 0 {
|
||||
absolute := readPos - int64(len(tail)) + int64(bestIdx)
|
||||
if absolute+24 > fileSize {
|
||||
if absolute+32 > fileSize {
|
||||
return 0, "", fmt.Errorf("audio info not found in M4A file")
|
||||
}
|
||||
return absolute, bestType, nil
|
||||
|
||||
+199
-24
@@ -49,11 +49,13 @@ const (
|
||||
qobuzArtistGetBaseURL = "https://www.qobuz.com/api.json/0.2/artist/get?artist_id="
|
||||
qobuzPlaylistGetBaseURL = "https://www.qobuz.com/api.json/0.2/playlist/get?playlist_id="
|
||||
qobuzStoreSearchBaseURL = "https://www.qobuz.com/us-en/search/tracks/"
|
||||
qobuzTrackOpenBaseURL = "https://open.qobuz.com/track/"
|
||||
qobuzTrackPlayBaseURL = "https://play.qobuz.com/track/"
|
||||
qobuzStoreBaseURL = "https://www.qobuz.com/us-en"
|
||||
qobuzDownloadAPIURL = "https://www.musicdl.me/api/qobuz/download"
|
||||
qobuzDownloadAPIURL = "https://dl.musicdl.me/qobuz/download"
|
||||
qobuzDabMusicAPIURL = "https://dabmusic.xyz/api/stream?trackId="
|
||||
qobuzDeebAPIURL = "https://dab.yeet.su/api/stream?trackId="
|
||||
qobuzAfkarAPIURL = "https://qbz.afkarxyz.qzz.io/api/track/"
|
||||
qobuzSquidAPIURL = "https://qobuz.squid.wtf/api/download-music?country=US&track_id="
|
||||
qobuzDebugKeyXORMask = byte(0x5A)
|
||||
)
|
||||
@@ -260,26 +262,35 @@ func qobuzTrackDisplayTitle(track *QobuzTrack) string {
|
||||
return fmt.Sprintf("%s (%s)", title, version)
|
||||
}
|
||||
|
||||
var qobuzImageSizeRe = regexp.MustCompile(`_\d+\.jpg$`)
|
||||
|
||||
func qobuzUpscaleImageURL(url string) string {
|
||||
if url == "" {
|
||||
return ""
|
||||
}
|
||||
return qobuzImageSizeRe.ReplaceAllString(url, "_max.jpg")
|
||||
}
|
||||
|
||||
func qobuzTrackAlbumImage(track *QobuzTrack) string {
|
||||
if track == nil {
|
||||
return ""
|
||||
}
|
||||
return qobuzFirstNonEmpty(
|
||||
return qobuzUpscaleImageURL(qobuzFirstNonEmpty(
|
||||
track.Album.Image.Large,
|
||||
track.Album.Image.Small,
|
||||
track.Album.Image.Thumbnail,
|
||||
)
|
||||
))
|
||||
}
|
||||
|
||||
func qobuzAlbumImage(album *qobuzAlbumDetails) string {
|
||||
if album == nil {
|
||||
return ""
|
||||
}
|
||||
return qobuzFirstNonEmpty(
|
||||
return qobuzUpscaleImageURL(qobuzFirstNonEmpty(
|
||||
album.Image.Large,
|
||||
album.Image.Small,
|
||||
album.Image.Thumbnail,
|
||||
)
|
||||
))
|
||||
}
|
||||
|
||||
func qobuzTrackArtistID(track *QobuzTrack) string {
|
||||
@@ -477,8 +488,8 @@ func parseQobuzURL(input string) (string, string, error) {
|
||||
}
|
||||
|
||||
func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
|
||||
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
|
||||
normFound := strings.ToLower(strings.TrimSpace(foundArtist))
|
||||
normExpected := normalizeLooseArtistName(expectedArtist)
|
||||
normFound := normalizeLooseArtistName(foundArtist)
|
||||
|
||||
if normExpected == normFound {
|
||||
return true
|
||||
@@ -934,7 +945,17 @@ func (q *QobuzDownloader) GetAlbumMetadata(resourceID string) (*AlbumResponsePay
|
||||
|
||||
tracks := make([]AlbumTrackMetadata, 0, len(album.Tracks.Items))
|
||||
for i := range album.Tracks.Items {
|
||||
tracks = append(tracks, qobuzTrackToAlbumTrackMetadata(&album.Tracks.Items[i]))
|
||||
track := &album.Tracks.Items[i]
|
||||
track.Album.ID = album.ID
|
||||
track.Album.Title = album.Title
|
||||
track.Album.ReleaseDate = album.ReleaseDateOriginal
|
||||
track.Album.Image = qobuzImageSet{
|
||||
Thumbnail: album.Image.Thumbnail,
|
||||
Small: album.Image.Small,
|
||||
Large: album.Image.Large,
|
||||
}
|
||||
track.Album.TracksCount = album.TracksCount
|
||||
tracks = append(tracks, qobuzTrackToAlbumTrackMetadata(track))
|
||||
}
|
||||
|
||||
return &AlbumResponsePayload{
|
||||
@@ -1019,6 +1040,10 @@ func (q *QobuzDownloader) GetArtistMetadata(resourceID string) (*ArtistResponseP
|
||||
func (q *QobuzDownloader) GetAvailableAPIs() []string {
|
||||
return []string{
|
||||
qobuzDownloadAPIURL,
|
||||
qobuzDabMusicAPIURL,
|
||||
qobuzDeebAPIURL,
|
||||
qobuzAfkarAPIURL,
|
||||
qobuzSquidAPIURL,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1039,6 +1064,8 @@ func (q *QobuzDownloader) GetAvailableProviders() []qobuzAPIProvider {
|
||||
{Name: "dabmusic", URL: qobuzDabMusicAPIURL, Kind: qobuzAPIKindStandard},
|
||||
// "deeb" is mapped from the legacy reference fallback endpoint.
|
||||
{Name: "deeb", URL: qobuzDeebAPIURL, Kind: qobuzAPIKindStandard},
|
||||
// "qbz" comes from the desktop reference app and uses /api/track/{id}?quality=...
|
||||
{Name: "qbz", URL: qobuzAfkarAPIURL, Kind: qobuzAPIKindStandard},
|
||||
{Name: "squid", URL: qobuzSquidAPIURL, Kind: qobuzAPIKindStandard},
|
||||
}
|
||||
}
|
||||
@@ -1299,6 +1326,134 @@ func (q *QobuzDownloader) SearchTracks(query string, limit int) ([]ExtTrackMetad
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// SearchAll searches Qobuz for tracks, artists, and albums matching the query.
|
||||
// Returns results in the same SearchAllResult format as Deezer's SearchAll.
|
||||
func (q *QobuzDownloader) SearchAll(query string, trackLimit, artistLimit int, filter string) (*SearchAllResult, error) {
|
||||
GoLog("[Qobuz] SearchAll: query=%q, trackLimit=%d, artistLimit=%d, filter=%q\n", query, trackLimit, artistLimit, filter)
|
||||
|
||||
cleanQuery := strings.TrimSpace(query)
|
||||
if cleanQuery == "" {
|
||||
return nil, fmt.Errorf("empty qobuz search query")
|
||||
}
|
||||
|
||||
albumLimit := 5
|
||||
|
||||
if filter != "" {
|
||||
switch filter {
|
||||
case "track":
|
||||
trackLimit = 50
|
||||
artistLimit = 0
|
||||
albumLimit = 0
|
||||
case "artist":
|
||||
trackLimit = 0
|
||||
artistLimit = 20
|
||||
albumLimit = 0
|
||||
case "album":
|
||||
trackLimit = 0
|
||||
artistLimit = 0
|
||||
albumLimit = 20
|
||||
}
|
||||
}
|
||||
|
||||
result := &SearchAllResult{
|
||||
Tracks: make([]TrackMetadata, 0, trackLimit),
|
||||
Artists: make([]SearchArtistResult, 0, artistLimit),
|
||||
Albums: make([]SearchAlbumResult, 0, albumLimit),
|
||||
Playlists: make([]SearchPlaylistResult, 0),
|
||||
}
|
||||
|
||||
if trackLimit > 0 {
|
||||
tracks, err := q.searchQobuzTracksWithFallback(cleanQuery, trackLimit)
|
||||
if err != nil {
|
||||
GoLog("[Qobuz] Track search failed: %v\n", err)
|
||||
return nil, fmt.Errorf("qobuz track search failed: %w", err)
|
||||
}
|
||||
GoLog("[Qobuz] Got %d tracks from API\n", len(tracks))
|
||||
for i := range tracks {
|
||||
result.Tracks = append(result.Tracks, qobuzTrackToTrackMetadata(&tracks[i]))
|
||||
}
|
||||
}
|
||||
|
||||
if artistLimit > 0 {
|
||||
searchURL := fmt.Sprintf("https://www.qobuz.com/api.json/0.2/artist/search?query=%s&limit=%d&app_id=%s",
|
||||
url.QueryEscape(cleanQuery), artistLimit, q.appID)
|
||||
req, err := http.NewRequest("GET", searchURL, nil)
|
||||
if err == nil {
|
||||
resp, reqErr := DoRequestWithUserAgent(q.client, req)
|
||||
if reqErr == nil {
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == 200 {
|
||||
var artistResp struct {
|
||||
Artists struct {
|
||||
Items []struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Image qobuzImageSet `json:"image"`
|
||||
} `json:"items"`
|
||||
} `json:"artists"`
|
||||
}
|
||||
if decErr := json.NewDecoder(resp.Body).Decode(&artistResp); decErr == nil {
|
||||
GoLog("[Qobuz] Got %d artists from API\n", len(artistResp.Artists.Items))
|
||||
for _, artist := range artistResp.Artists.Items {
|
||||
imageURL := qobuzFirstNonEmpty(artist.Image.Large, artist.Image.Small, artist.Image.Thumbnail)
|
||||
result.Artists = append(result.Artists, SearchArtistResult{
|
||||
ID: qobuzPrefixedNumericID(artist.ID),
|
||||
Name: strings.TrimSpace(artist.Name),
|
||||
Images: imageURL,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
GoLog("[Qobuz] Artist search decode failed: %v\n", decErr)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
GoLog("[Qobuz] Artist search request failed: %v\n", reqErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if albumLimit > 0 {
|
||||
searchURL := fmt.Sprintf("https://www.qobuz.com/api.json/0.2/album/search?query=%s&limit=%d&app_id=%s",
|
||||
url.QueryEscape(cleanQuery), albumLimit, q.appID)
|
||||
req, err := http.NewRequest("GET", searchURL, nil)
|
||||
if err == nil {
|
||||
resp, reqErr := DoRequestWithUserAgent(q.client, req)
|
||||
if reqErr == nil {
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == 200 {
|
||||
var albumResp struct {
|
||||
Albums struct {
|
||||
Items []qobuzAlbumDetails `json:"items"`
|
||||
} `json:"albums"`
|
||||
}
|
||||
if decErr := json.NewDecoder(resp.Body).Decode(&albumResp); decErr == nil {
|
||||
GoLog("[Qobuz] Got %d albums from API\n", len(albumResp.Albums.Items))
|
||||
for i := range albumResp.Albums.Items {
|
||||
album := &albumResp.Albums.Items[i]
|
||||
result.Albums = append(result.Albums, SearchAlbumResult{
|
||||
ID: qobuzPrefixedID(album.ID),
|
||||
Name: strings.TrimSpace(album.Title),
|
||||
Artists: qobuzArtistsDisplayName(album.Artists, album.Artist.Name),
|
||||
Images: qobuzAlbumImage(album),
|
||||
ReleaseDate: qobuzNormalizeReleaseDate(album.ReleaseDateOriginal),
|
||||
TotalTracks: album.TracksCount,
|
||||
AlbumType: qobuzNormalizeAlbumType(album.ReleaseType, album.ProductType, album.TracksCount),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
GoLog("[Qobuz] Album search decode failed: %v\n", decErr)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
GoLog("[Qobuz] Album search request failed: %v\n", reqErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GoLog("[Qobuz] SearchAll complete: %d tracks, %d artists, %d albums\n", len(result.Tracks), len(result.Artists), len(result.Albums))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) {
|
||||
queries := []string{}
|
||||
|
||||
@@ -1624,19 +1779,23 @@ func fetchQobuzURLWithRetry(provider qobuzAPIProvider, trackID int64, quality st
|
||||
return fetchQobuzURLSingleAttempt(provider, trackID, quality, timeout, "")
|
||||
}
|
||||
|
||||
func buildQobuzMusicDLPayload(trackID int64, quality string) ([]byte, error) {
|
||||
requestQuality := mapQobuzQualityCodeToAPI(quality)
|
||||
payload := map[string]any{
|
||||
"quality": requestQuality,
|
||||
"upload_to_r2": false,
|
||||
"url": fmt.Sprintf("%s%d", qobuzTrackOpenBaseURL, trackID),
|
||||
}
|
||||
return json.Marshal(payload)
|
||||
}
|
||||
|
||||
func fetchQobuzURLSingleAttempt(provider qobuzAPIProvider, trackID int64, quality string, timeout time.Duration, country string) (qobuzDownloadInfo, error) {
|
||||
var lastErr error
|
||||
retryDelay := qobuzRetryDelay
|
||||
var payloadBytes []byte
|
||||
if provider.Kind == qobuzAPIKindMusicDL {
|
||||
requestQuality := mapQobuzQualityCodeToAPI(quality)
|
||||
payload := map[string]any{
|
||||
"quality": requestQuality,
|
||||
"upload_to_r2": false,
|
||||
"url": fmt.Sprintf("%s%d", qobuzTrackPlayBaseURL, trackID),
|
||||
}
|
||||
var err error
|
||||
payloadBytes, err = json.Marshal(payload)
|
||||
payloadBytes, err = buildQobuzMusicDLPayload(trackID, quality)
|
||||
if err != nil {
|
||||
return qobuzDownloadInfo{}, fmt.Errorf("failed to encode qobuz request: %w", err)
|
||||
}
|
||||
@@ -1681,7 +1840,6 @@ func fetchQobuzURLSingleAttempt(provider qobuzAPIProvider, trackID int64, qualit
|
||||
}
|
||||
if provider.Kind == qobuzAPIKindMusicDL {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Debug-Key", getQobuzDebugKey())
|
||||
}
|
||||
|
||||
resp, err := DoRequestWithUserAgent(client, req)
|
||||
@@ -1928,6 +2086,7 @@ type QobuzDownloadResult struct {
|
||||
TrackNumber int
|
||||
DiscNumber int
|
||||
ISRC string
|
||||
CoverURL string
|
||||
LyricsLRC string
|
||||
}
|
||||
|
||||
@@ -1989,8 +2148,8 @@ func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloade
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 3: Try to get QobuzID from SongLink if we have SpotifyID
|
||||
if track == nil && req.SpotifyID != "" && req.QobuzID == "" {
|
||||
// Strategy 3: Try to get QobuzID from SongLink if we have SpotifyID but no ISRC
|
||||
if track == nil && req.SpotifyID != "" && req.QobuzID == "" && req.ISRC == "" {
|
||||
GoLog("[%s] Trying to get Qobuz ID from SongLink for Spotify ID: %s\n", logPrefix, req.SpotifyID)
|
||||
songLinkClient := NewSongLinkClient()
|
||||
availability, slErr := songLinkCheckTrackAvailabilityFunc(songLinkClient, req.SpotifyID, req.ISRC)
|
||||
@@ -2121,7 +2280,10 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
parallelDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(parallelDone)
|
||||
coverURL := req.CoverURL
|
||||
coverURL := strings.TrimSpace(req.CoverURL)
|
||||
if coverURL == "" {
|
||||
coverURL = strings.TrimSpace(qobuzTrackAlbumImage(track))
|
||||
}
|
||||
embedLyrics := req.EmbedLyrics
|
||||
if !req.EmbedMetadata {
|
||||
coverURL = ""
|
||||
@@ -2156,6 +2318,10 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
if req.AlbumName != "" {
|
||||
albumName = req.AlbumName
|
||||
}
|
||||
releaseDate := track.Album.ReleaseDate
|
||||
if req.ReleaseDate != "" {
|
||||
releaseDate = req.ReleaseDate
|
||||
}
|
||||
|
||||
actualTrackNumber := req.TrackNumber
|
||||
if actualTrackNumber == 0 {
|
||||
@@ -2167,7 +2333,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
Artist: track.Performer.Name,
|
||||
Album: albumName,
|
||||
AlbumArtist: req.AlbumArtist,
|
||||
Date: track.Album.ReleaseDate,
|
||||
Date: releaseDate,
|
||||
TrackNumber: actualTrackNumber,
|
||||
TotalTracks: req.TotalTracks,
|
||||
DiscNumber: req.DiscNumber,
|
||||
@@ -2231,17 +2397,26 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
lyricsLRC = parallelResult.LyricsLRC
|
||||
}
|
||||
|
||||
resultAlbum, resultReleaseDate, resultTrackNumber, resultDiscNumber := preferredReleaseMetadata(
|
||||
req,
|
||||
track.Album.Title,
|
||||
track.Album.ReleaseDate,
|
||||
actualTrackNumber,
|
||||
req.DiscNumber,
|
||||
)
|
||||
|
||||
return QobuzDownloadResult{
|
||||
FilePath: outputPath,
|
||||
BitDepth: actualBitDepth,
|
||||
SampleRate: actualSampleRate,
|
||||
Title: track.Title,
|
||||
Artist: track.Performer.Name,
|
||||
Album: track.Album.Title,
|
||||
ReleaseDate: track.Album.ReleaseDate,
|
||||
TrackNumber: actualTrackNumber,
|
||||
DiscNumber: req.DiscNumber,
|
||||
Album: resultAlbum,
|
||||
ReleaseDate: resultReleaseDate,
|
||||
TrackNumber: resultTrackNumber,
|
||||
DiscNumber: resultDiscNumber,
|
||||
ISRC: track.ISRC,
|
||||
CoverURL: strings.TrimSpace(qobuzTrackAlbumImage(track)),
|
||||
LyricsLRC: lyricsLRC,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package gobackend
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseQobuzURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
@@ -195,6 +198,28 @@ func TestGetQobuzDebugKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildQobuzMusicDLPayloadUsesOpenTrackURL(t *testing.T) {
|
||||
payloadBytes, err := buildQobuzMusicDLPayload(374610875, "7")
|
||||
if err != nil {
|
||||
t.Fatalf("buildQobuzMusicDLPayload returned error: %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(payloadBytes, &payload); err != nil {
|
||||
t.Fatalf("payload is not valid JSON: %v", err)
|
||||
}
|
||||
|
||||
if got := payload["url"]; got != "https://open.qobuz.com/track/374610875" {
|
||||
t.Fatalf("payload url = %v, want open.qobuz.com track URL", got)
|
||||
}
|
||||
if got := payload["quality"]; got != "hi-res" {
|
||||
t.Fatalf("payload quality = %v, want hi-res", got)
|
||||
}
|
||||
if got := payload["upload_to_r2"]; got != false {
|
||||
t.Fatalf("payload upload_to_r2 = %v, want false", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractQobuzAlbumIDsFromArtistHTML(t *testing.T) {
|
||||
body := []byte(`
|
||||
<button data-itemtype="album" data-itemId="0886446451985"></button>
|
||||
@@ -213,14 +238,16 @@ func TestExtractQobuzAlbumIDsFromArtistHTML(t *testing.T) {
|
||||
|
||||
func TestQobuzAvailableProviders(t *testing.T) {
|
||||
providers := NewQobuzDownloader().GetAvailableProviders()
|
||||
if len(providers) != 3 {
|
||||
t.Fatalf("expected 3 Qobuz providers, got %d", len(providers))
|
||||
if len(providers) != 5 {
|
||||
t.Fatalf("expected 5 Qobuz providers, got %d", len(providers))
|
||||
}
|
||||
|
||||
want := map[string]string{
|
||||
"musicdl": qobuzAPIKindMusicDL,
|
||||
"dabmusic": qobuzAPIKindStandard,
|
||||
"deeb": qobuzAPIKindStandard,
|
||||
"qbz": qobuzAPIKindStandard,
|
||||
"squid": qobuzAPIKindStandard,
|
||||
}
|
||||
|
||||
for _, provider := range providers {
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSanitizeSensitiveLogText(t *testing.T) {
|
||||
input := "access_token=abc123 Authorization:Bearer xyz456 https://api.example.com/cb?refresh_token=zzz"
|
||||
redacted := sanitizeSensitiveLogText(input)
|
||||
|
||||
if strings.Contains(redacted, "abc123") || strings.Contains(redacted, "xyz456") || strings.Contains(redacted, "zzz") {
|
||||
t.Fatalf("expected sensitive values to be redacted, got: %s", redacted)
|
||||
}
|
||||
if !strings.Contains(redacted, "[REDACTED]") {
|
||||
t.Fatalf("expected redaction marker in output, got: %s", redacted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateExtensionAuthURL(t *testing.T) {
|
||||
if err := validateExtensionAuthURL("https://accounts.example.com/oauth/authorize"); err != nil {
|
||||
t.Fatalf("expected valid auth URL, got error: %v", err)
|
||||
}
|
||||
|
||||
blocked := []string{
|
||||
"http://accounts.example.com/oauth/authorize",
|
||||
"https://user:pass@accounts.example.com/oauth/authorize",
|
||||
"https://localhost/oauth/authorize",
|
||||
}
|
||||
|
||||
for _, rawURL := range blocked {
|
||||
if err := validateExtensionAuthURL(rawURL); err == nil {
|
||||
t.Fatalf("expected URL to be blocked: %s", rawURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDomainRejectsEmbeddedCredentials(t *testing.T) {
|
||||
ext := &LoadedExtension{
|
||||
ID: "test-ext",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "test-ext",
|
||||
Permissions: ExtensionPermissions{
|
||||
Network: []string{"api.example.com"},
|
||||
},
|
||||
},
|
||||
DataDir: t.TempDir(),
|
||||
}
|
||||
|
||||
runtime := NewExtensionRuntime(ext)
|
||||
if err := runtime.validateDomain("https://user:pass@api.example.com/resource"); err == nil {
|
||||
t.Fatal("expected embedded URL credentials to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildStoreExtensionDestPath(t *testing.T) {
|
||||
baseDir := t.TempDir()
|
||||
|
||||
destPath, err := buildStoreExtensionDestPath(baseDir, "../evil/name")
|
||||
if err != nil {
|
||||
t.Fatalf("expected sanitized path to be generated, got error: %v", err)
|
||||
}
|
||||
|
||||
if !isPathWithinBase(baseDir, destPath) {
|
||||
t.Fatalf("expected destination path to remain under base dir: %s", destPath)
|
||||
}
|
||||
|
||||
baseName := filepath.Base(destPath)
|
||||
if strings.Contains(baseName, "/") || strings.Contains(baseName, `\`) {
|
||||
t.Fatalf("expected filename to be sanitized, got: %s", baseName)
|
||||
}
|
||||
if !strings.HasSuffix(baseName, ".spotiflac-ext") {
|
||||
t.Fatalf("expected .spotiflac-ext suffix, got: %s", baseName)
|
||||
}
|
||||
|
||||
if _, err := buildStoreExtensionDestPath(baseDir, " "); err == nil {
|
||||
t.Fatal("expected empty extension id to be rejected")
|
||||
}
|
||||
}
|
||||
+144
-43
@@ -1,6 +1,7 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -14,6 +15,10 @@ type SongLinkClient struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
type songLinkPlatformLink struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type TrackAvailability struct {
|
||||
SpotifyID string `json:"spotify_id"`
|
||||
Tidal bool `json:"tidal"`
|
||||
@@ -43,6 +48,7 @@ var (
|
||||
songLinkCheckAvailabilityFromDeezer = func(s *SongLinkClient, deezerTrackID string) (*TrackAvailability, error) {
|
||||
return s.CheckAvailabilityFromDeezer(deezerTrackID)
|
||||
}
|
||||
songLinkRetryConfig = DefaultRetryConfig
|
||||
)
|
||||
|
||||
func NewSongLinkClient() *SongLinkClient {
|
||||
@@ -130,7 +136,14 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) checkTrackAvailabilityFromSpotify(spotifyTrackID string) (*TrackAvailability, error) {
|
||||
songLinkRateLimiter.WaitForSlot()
|
||||
availability, pageErr := s.checkTrackAvailabilityFromSpotifyPage(spotifyTrackID)
|
||||
if pageErr == nil {
|
||||
return availability, nil
|
||||
}
|
||||
|
||||
if !songLinkRateLimiter.TryAcquire() {
|
||||
return nil, fmt.Errorf("song.link page lookup failed: %w (SongLink local rate limit exceeded)", pageErr)
|
||||
}
|
||||
|
||||
spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID)
|
||||
apiURL := buildSongLinkURLFromTarget(spotifyURL, "")
|
||||
@@ -140,10 +153,10 @@ func (s *SongLinkClient) checkTrackAvailabilityFromSpotify(spotifyTrackID string
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
retryConfig := DefaultRetryConfig()
|
||||
retryConfig := songLinkRetryConfig()
|
||||
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check availability: %w", err)
|
||||
return nil, fmt.Errorf("song.link page lookup failed: %w; SongLink API lookup failed: %w", pageErr, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
@@ -154,10 +167,10 @@ func (s *SongLinkClient) checkTrackAvailabilityFromSpotify(spotifyTrackID string
|
||||
return nil, fmt.Errorf("track not found on any streaming platform")
|
||||
}
|
||||
if resp.StatusCode == 429 {
|
||||
return nil, fmt.Errorf("SongLink rate limit exceeded")
|
||||
return nil, fmt.Errorf("song.link page lookup failed: %w; SongLink API rate limit exceeded", pageErr)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("SongLink API returned status %d", resp.StatusCode)
|
||||
return nil, fmt.Errorf("song.link page lookup failed: %w; SongLink API returned status %d", pageErr, resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := ReadResponseBody(resp)
|
||||
@@ -166,59 +179,102 @@ func (s *SongLinkClient) checkTrackAvailabilityFromSpotify(spotifyTrackID string
|
||||
}
|
||||
|
||||
var songLinkResp struct {
|
||||
LinksByPlatform map[string]struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"linksByPlatform"`
|
||||
LinksByPlatform map[string]songLinkPlatformLink `json:"linksByPlatform"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
availability := &TrackAvailability{
|
||||
SpotifyID: spotifyTrackID,
|
||||
LogWarn("SongLink", "Spotify %s resolved via SongLink API after song.link page failure: %v", spotifyTrackID, pageErr)
|
||||
return buildTrackAvailabilityFromSongLinkLinks(spotifyTrackID, songLinkResp.LinksByPlatform), nil
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) checkTrackAvailabilityFromSpotifyPage(spotifyTrackID string) (*TrackAvailability, error) {
|
||||
pageURL := fmt.Sprintf("https://song.link/s/%s", spotifyTrackID)
|
||||
req, err := http.NewRequest("GET", pageURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create song.link page request: %w", err)
|
||||
}
|
||||
|
||||
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
||||
availability.Tidal = true
|
||||
availability.TidalURL = tidalLink.URL
|
||||
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
|
||||
req.Header.Set("Accept", "text/html,application/xhtml+xml")
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch song.link page: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 404 {
|
||||
return nil, fmt.Errorf("track not found on song.link page")
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("song.link page returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||
availability.Amazon = true
|
||||
availability.AmazonURL = amazonLink.URL
|
||||
body, err := ReadResponseBody(resp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read song.link page: %w", err)
|
||||
}
|
||||
|
||||
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||
availability.Deezer = true
|
||||
availability.DeezerURL = deezerLink.URL
|
||||
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
||||
nextDataJSON, err := extractSongLinkNextDataJSON(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" {
|
||||
availability.Qobuz = true
|
||||
availability.QobuzURL = qobuzLink.URL
|
||||
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
|
||||
var pageData struct {
|
||||
Props struct {
|
||||
PageProps struct {
|
||||
PageData struct {
|
||||
Sections []struct {
|
||||
Links []struct {
|
||||
Platform string `json:"platform"`
|
||||
URL string `json:"url"`
|
||||
Show bool `json:"show"`
|
||||
} `json:"links"`
|
||||
} `json:"sections"`
|
||||
} `json:"pageData"`
|
||||
} `json:"pageProps"`
|
||||
} `json:"props"`
|
||||
}
|
||||
if err := json.Unmarshal(nextDataJSON, &pageData); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode song.link page data: %w", err)
|
||||
}
|
||||
|
||||
// Prefer youtubeMusic URLs — they bypass Cobalt login requirements
|
||||
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
|
||||
availability.YouTube = true
|
||||
availability.YouTubeURL = ytMusicLink.URL
|
||||
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
|
||||
}
|
||||
|
||||
// Fallback to regular youtube if youtubeMusic not available
|
||||
if !availability.YouTube {
|
||||
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
|
||||
availability.YouTube = true
|
||||
availability.YouTubeURL = youtubeLink.URL
|
||||
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
|
||||
linksByPlatform := make(map[string]songLinkPlatformLink)
|
||||
for _, section := range pageData.Props.PageProps.PageData.Sections {
|
||||
for _, link := range section.Links {
|
||||
if !link.Show || strings.TrimSpace(link.URL) == "" {
|
||||
continue
|
||||
}
|
||||
linksByPlatform[link.Platform] = songLinkPlatformLink{URL: link.URL}
|
||||
}
|
||||
}
|
||||
|
||||
return availability, nil
|
||||
if len(linksByPlatform) == 0 {
|
||||
return nil, fmt.Errorf("song.link page contained no usable platform links")
|
||||
}
|
||||
|
||||
return buildTrackAvailabilityFromSongLinkLinks(spotifyTrackID, linksByPlatform), nil
|
||||
}
|
||||
|
||||
func extractSongLinkNextDataJSON(body []byte) ([]byte, error) {
|
||||
const startMarker = `<script id="__NEXT_DATA__" type="application/json">`
|
||||
const endMarker = `</script>`
|
||||
|
||||
start := bytes.Index(body, []byte(startMarker))
|
||||
if start < 0 {
|
||||
return nil, fmt.Errorf("song.link page missing __NEXT_DATA__")
|
||||
}
|
||||
start += len(startMarker)
|
||||
|
||||
end := bytes.Index(body[start:], []byte(endMarker))
|
||||
if end < 0 {
|
||||
return nil, fmt.Errorf("song.link page has unterminated __NEXT_DATA__")
|
||||
}
|
||||
|
||||
return body[start : start+end], nil
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) checkTrackAvailabilityFromISRC(isrc string) (*TrackAvailability, error) {
|
||||
@@ -459,7 +515,7 @@ func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAv
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
retryConfig := DefaultRetryConfig()
|
||||
retryConfig := songLinkRetryConfig()
|
||||
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check album availability: %w", err)
|
||||
@@ -542,7 +598,7 @@ func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID strin
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
retryConfig := DefaultRetryConfig()
|
||||
retryConfig := songLinkRetryConfig()
|
||||
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check availability: %w", err)
|
||||
@@ -647,7 +703,7 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
retryConfig := DefaultRetryConfig()
|
||||
retryConfig := songLinkRetryConfig()
|
||||
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check availability: %w", err)
|
||||
@@ -728,6 +784,51 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
|
||||
return availability, nil
|
||||
}
|
||||
|
||||
func buildTrackAvailabilityFromSongLinkLinks(spotifyTrackID string, links map[string]songLinkPlatformLink) *TrackAvailability {
|
||||
availability := &TrackAvailability{
|
||||
SpotifyID: spotifyTrackID,
|
||||
}
|
||||
|
||||
if availability.SpotifyID == "" {
|
||||
if spotifyLink, ok := links["spotify"]; ok && spotifyLink.URL != "" {
|
||||
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
|
||||
}
|
||||
}
|
||||
if tidalLink, ok := links["tidal"]; ok && tidalLink.URL != "" {
|
||||
availability.Tidal = true
|
||||
availability.TidalURL = tidalLink.URL
|
||||
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
|
||||
}
|
||||
if amazonLink, ok := links["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||
availability.Amazon = true
|
||||
availability.AmazonURL = amazonLink.URL
|
||||
}
|
||||
if qobuzLink, ok := links["qobuz"]; ok && qobuzLink.URL != "" {
|
||||
availability.Qobuz = true
|
||||
availability.QobuzURL = qobuzLink.URL
|
||||
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
|
||||
}
|
||||
if deezerLink, ok := links["deezer"]; ok && deezerLink.URL != "" {
|
||||
availability.Deezer = true
|
||||
availability.DeezerURL = deezerLink.URL
|
||||
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
||||
}
|
||||
if ytMusicLink, ok := links["youtubeMusic"]; ok && ytMusicLink.URL != "" {
|
||||
availability.YouTube = true
|
||||
availability.YouTubeURL = ytMusicLink.URL
|
||||
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
|
||||
}
|
||||
if !availability.YouTube {
|
||||
if youtubeLink, ok := links["youtube"]; ok && youtubeLink.URL != "" {
|
||||
availability.YouTube = true
|
||||
availability.YouTubeURL = youtubeLink.URL
|
||||
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
|
||||
}
|
||||
}
|
||||
|
||||
return availability
|
||||
}
|
||||
|
||||
func extractSpotifyIDFromURL(spotifyURL string) string {
|
||||
parts := strings.Split(spotifyURL, "/track/")
|
||||
if len(parts) > 1 {
|
||||
@@ -802,7 +903,7 @@ func (s *SongLinkClient) CheckAvailabilityFromURL(inputURL string) (*TrackAvaila
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
retryConfig := DefaultRetryConfig()
|
||||
retryConfig := songLinkRetryConfig()
|
||||
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check availability: %w", err)
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return fn(req)
|
||||
}
|
||||
|
||||
func TestGetRetryAfterDurationMissingHeaderReturnsZero(t *testing.T) {
|
||||
resp := &http.Response{
|
||||
Header: make(http.Header),
|
||||
}
|
||||
|
||||
if got := getRetryAfterDuration(resp); got != 0 {
|
||||
t.Fatalf("getRetryAfterDuration() = %v, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckTrackAvailabilityFromSpotifyPrefersSongLinkPage(t *testing.T) {
|
||||
client := &SongLinkClient{
|
||||
client: &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case req.URL.Host == "api.song.link":
|
||||
t.Fatalf("api.song.link should not be called when song.link page succeeds")
|
||||
return nil, nil
|
||||
case req.URL.Host == "song.link" && req.URL.Path == "/s/testspotifyid":
|
||||
body := `<!DOCTYPE html><html><body><script id="__NEXT_DATA__" type="application/json">{"props":{"pageProps":{"pageData":{"sections":[{"displayName":"Listen","links":[{"platform":"spotify","url":"https://open.spotify.com/track/testspotifyid","show":true},{"platform":"deezer","url":"https://www.deezer.com/track/908604612","show":true},{"platform":"amazonMusic","url":"https://music.amazon.com/albums/B086Q2QNLH?trackAsin=B086Q41M9C","show":true},{"platform":"tidal","url":"https://listen.tidal.com/track/134858527","show":true},{"platform":"qobuz","url":"https://open.qobuz.com/track/195125822","show":true},{"platform":"youtubeMusic","url":"https://music.youtube.com/watch?v=testvideoid1","show":true}]}]}}}}</script></body></html>`
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Request: req,
|
||||
}, nil
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s", req.URL.String())
|
||||
return nil, nil
|
||||
}
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
availability, err := client.CheckTrackAvailability("testspotifyid", "")
|
||||
if err != nil {
|
||||
t.Fatalf("CheckTrackAvailability() error = %v", err)
|
||||
}
|
||||
|
||||
if availability.SpotifyID != "testspotifyid" {
|
||||
t.Fatalf("SpotifyID = %q, want %q", availability.SpotifyID, "testspotifyid")
|
||||
}
|
||||
if !availability.Deezer || availability.DeezerID != "908604612" {
|
||||
t.Fatalf("Deezer availability = %+v, want DeezerID 908604612", availability)
|
||||
}
|
||||
if !availability.Amazon || !availability.Tidal || !availability.Qobuz || !availability.YouTube {
|
||||
t.Fatalf("availability flags = %+v, want Amazon/Tidal/Qobuz/YouTube true", availability)
|
||||
}
|
||||
if availability.YouTubeID != "testvideoid1" {
|
||||
t.Fatalf("YouTubeID = %q, want %q", availability.YouTubeID, "testvideoid1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckTrackAvailabilityFromSpotifyFallsBackToAPIWhenPageFails(t *testing.T) {
|
||||
origRetryConfig := songLinkRetryConfig
|
||||
songLinkRetryConfig = func() RetryConfig {
|
||||
return RetryConfig{
|
||||
MaxRetries: 0,
|
||||
InitialDelay: 0,
|
||||
MaxDelay: 0,
|
||||
BackoffFactor: 1,
|
||||
}
|
||||
}
|
||||
defer func() {
|
||||
songLinkRetryConfig = origRetryConfig
|
||||
}()
|
||||
|
||||
client := &SongLinkClient{
|
||||
client: &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case req.URL.Host == "song.link" && req.URL.Path == "/s/testspotifyid":
|
||||
return &http.Response{
|
||||
StatusCode: 500,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader("page failure")),
|
||||
Request: req,
|
||||
}, nil
|
||||
case req.URL.Host == "api.song.link":
|
||||
body := `{"linksByPlatform":{"spotify":{"url":"https://open.spotify.com/track/testspotifyid"},"deezer":{"url":"https://www.deezer.com/track/908604612"},"amazonMusic":{"url":"https://music.amazon.com/albums/B086Q2QNLH?trackAsin=B086Q41M9C"},"tidal":{"url":"https://listen.tidal.com/track/134858527"},"qobuz":{"url":"https://open.qobuz.com/track/195125822"},"youtubeMusic":{"url":"https://music.youtube.com/watch?v=testvideoid1"}}}`
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Request: req,
|
||||
}, nil
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s", req.URL.String())
|
||||
return nil, nil
|
||||
}
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
availability, err := client.CheckTrackAvailability("testspotifyid", "")
|
||||
if err != nil {
|
||||
t.Fatalf("CheckTrackAvailability() error = %v", err)
|
||||
}
|
||||
|
||||
if availability.SpotifyID != "testspotifyid" {
|
||||
t.Fatalf("SpotifyID = %q, want %q", availability.SpotifyID, "testspotifyid")
|
||||
}
|
||||
if !availability.Deezer || availability.DeezerID != "908604612" {
|
||||
t.Fatalf("Deezer availability = %+v, want DeezerID 908604612", availability)
|
||||
}
|
||||
if !availability.Amazon || !availability.Tidal || !availability.Qobuz || !availability.YouTube {
|
||||
t.Fatalf("availability flags = %+v, want Amazon/Tidal/Qobuz/YouTube true", availability)
|
||||
}
|
||||
if availability.YouTubeID != "testvideoid1" {
|
||||
t.Fatalf("YouTubeID = %q, want %q", availability.YouTubeID, "testvideoid1")
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const DefaultSpotFetchAPIBaseURL = "https://spotify.afkarxyz.fun/api"
|
||||
const DefaultSpotFetchAPIBaseURL = "https://sp.afkarxyz.qzz.io/api"
|
||||
|
||||
// GetSpotifyDataWithAPI fetches Spotify metadata through SpotFetch-compatible API.
|
||||
// This is used as a fallback when direct Spotify API access is blocked/limited.
|
||||
|
||||
+288
-19
@@ -26,8 +26,14 @@ type TidalDownloader struct {
|
||||
}
|
||||
|
||||
var (
|
||||
globalTidalDownloader *TidalDownloader
|
||||
tidalDownloaderOnce sync.Once
|
||||
globalTidalDownloader *TidalDownloader
|
||||
tidalDownloaderOnce sync.Once
|
||||
tidalGetTrackSearchPageFunc = func(t *TidalDownloader, query string, limit int) (*tidalPublicTrackSearchResponse, error) {
|
||||
return t.getTrackSearchPage(query, limit)
|
||||
}
|
||||
tidalGetPublicTrackFunc = func(t *TidalDownloader, resourceID string) (*TidalTrack, error) {
|
||||
return t.getPublicTrack(resourceID)
|
||||
}
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -758,15 +764,101 @@ func (t *TidalDownloader) GetTrackInfoByID(trackID int64) (*TidalTrack, error) {
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) {
|
||||
return nil, fmt.Errorf("tidal ISRC search API disabled: no client credentials mode")
|
||||
normalizedISRC := strings.ToUpper(strings.TrimSpace(isrc))
|
||||
if normalizedISRC == "" {
|
||||
return nil, fmt.Errorf("empty tidal ISRC")
|
||||
}
|
||||
|
||||
page, err := tidalGetTrackSearchPageFunc(t, normalizedISRC, 20)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for i := range page.Items {
|
||||
if strings.EqualFold(strings.TrimSpace(page.Items[i].ISRC), normalizedISRC) {
|
||||
return &page.Items[i], nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no exact tidal ISRC match found for %s", normalizedISRC)
|
||||
}
|
||||
|
||||
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")
|
||||
queryParts := make([]string, 0, 3)
|
||||
if trimmed := strings.TrimSpace(trackName); trimmed != "" {
|
||||
queryParts = append(queryParts, trimmed)
|
||||
}
|
||||
if trimmed := strings.TrimSpace(artistName); trimmed != "" {
|
||||
queryParts = append(queryParts, trimmed)
|
||||
}
|
||||
if len(queryParts) == 0 {
|
||||
return nil, fmt.Errorf("tidal metadata search requires track or artist name")
|
||||
}
|
||||
|
||||
queries := []string{strings.Join(queryParts, " ")}
|
||||
if trimmedAlbum := strings.TrimSpace(albumName); trimmedAlbum != "" {
|
||||
queries = append(queries, strings.Join(append(queryParts, trimmedAlbum), " "))
|
||||
}
|
||||
|
||||
req := DownloadRequest{
|
||||
TrackName: strings.TrimSpace(trackName),
|
||||
ArtistName: strings.TrimSpace(artistName),
|
||||
AlbumName: strings.TrimSpace(albumName),
|
||||
ISRC: strings.ToUpper(strings.TrimSpace(spotifyISRC)),
|
||||
DurationMS: expectedDuration * 1000,
|
||||
}
|
||||
|
||||
seenQueries := make(map[string]struct{}, len(queries))
|
||||
for _, query := range queries {
|
||||
if _, seen := seenQueries[query]; seen {
|
||||
continue
|
||||
}
|
||||
seenQueries[query] = struct{}{}
|
||||
|
||||
page, err := tidalGetTrackSearchPageFunc(t, query, 20)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var candidates []*TidalTrack
|
||||
for i := range page.Items {
|
||||
track := &page.Items[i]
|
||||
if req.ISRC != "" && !strings.EqualFold(strings.TrimSpace(track.ISRC), req.ISRC) {
|
||||
continue
|
||||
}
|
||||
resolved := resolvedTrackInfo{
|
||||
Title: strings.TrimSpace(track.Title),
|
||||
ArtistName: tidalTrackArtistsDisplay(track),
|
||||
Duration: track.Duration,
|
||||
}
|
||||
if trackMatchesRequest(req, resolved, "Tidal search") {
|
||||
candidates = append(candidates, track)
|
||||
}
|
||||
}
|
||||
|
||||
if len(candidates) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if req.AlbumName != "" {
|
||||
for _, candidate := range candidates {
|
||||
if titlesMatch(req.AlbumName, candidate.Album.Title) {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return candidates[0], nil
|
||||
}
|
||||
|
||||
if req.ISRC != "" {
|
||||
return nil, fmt.Errorf("no tidal metadata match found for exact ISRC %s", req.ISRC)
|
||||
}
|
||||
return nil, fmt.Errorf("no tidal metadata match found")
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) SearchTrackByMetadata(trackName, artistName string) (*TidalTrack, error) {
|
||||
return nil, fmt.Errorf("tidal metadata search API disabled: no client credentials mode")
|
||||
return t.SearchTrackByMetadataWithISRC(trackName, artistName, "", "", 0)
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) SearchTracks(query string, limit int) ([]ExtTrackMetadata, error) {
|
||||
@@ -782,6 +874,121 @@ func (t *TidalDownloader) SearchTracks(query string, limit int) ([]ExtTrackMetad
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// SearchAll searches Tidal for tracks, artists, and albums matching the query.
|
||||
// Returns results in the same SearchAllResult format as Deezer's SearchAll.
|
||||
func (t *TidalDownloader) SearchAll(query string, trackLimit, artistLimit int, filter string) (*SearchAllResult, error) {
|
||||
GoLog("[Tidal] SearchAll: query=%q, trackLimit=%d, artistLimit=%d, filter=%q\n", query, trackLimit, artistLimit, filter)
|
||||
|
||||
cleanQuery := strings.TrimSpace(query)
|
||||
if cleanQuery == "" {
|
||||
return nil, fmt.Errorf("empty tidal search query")
|
||||
}
|
||||
|
||||
albumLimit := 5
|
||||
|
||||
if filter != "" {
|
||||
switch filter {
|
||||
case "track":
|
||||
trackLimit = 50
|
||||
artistLimit = 0
|
||||
albumLimit = 0
|
||||
case "artist":
|
||||
trackLimit = 0
|
||||
artistLimit = 20
|
||||
albumLimit = 0
|
||||
case "album":
|
||||
trackLimit = 0
|
||||
artistLimit = 0
|
||||
albumLimit = 20
|
||||
}
|
||||
}
|
||||
|
||||
result := &SearchAllResult{
|
||||
Tracks: make([]TrackMetadata, 0, trackLimit),
|
||||
Artists: make([]SearchArtistResult, 0, artistLimit),
|
||||
Albums: make([]SearchAlbumResult, 0, albumLimit),
|
||||
Playlists: make([]SearchPlaylistResult, 0),
|
||||
}
|
||||
|
||||
if trackLimit > 0 {
|
||||
page, err := t.getTrackSearchPage(cleanQuery, trackLimit)
|
||||
if err != nil {
|
||||
GoLog("[Tidal] Track search failed: %v\n", err)
|
||||
return nil, fmt.Errorf("tidal track search failed: %w", err)
|
||||
}
|
||||
GoLog("[Tidal] Got %d tracks from API\n", len(page.Items))
|
||||
for i := range page.Items {
|
||||
result.Tracks = append(result.Tracks, tidalTrackToTrackMetadata(&page.Items[i]))
|
||||
}
|
||||
}
|
||||
|
||||
if artistLimit > 0 {
|
||||
requestURL := tidalBuildMetadataURL("search/artists", url.Values{
|
||||
"query": {cleanQuery},
|
||||
"limit": {strconv.Itoa(artistLimit)},
|
||||
"offset": {"0"},
|
||||
})
|
||||
var artistResp struct {
|
||||
Items []struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Picture string `json:"picture"`
|
||||
Popularity int `json:"popularity"`
|
||||
URL string `json:"url"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if err := t.getTidalMetadataJSON(requestURL, &artistResp); err == nil {
|
||||
GoLog("[Tidal] Got %d artists from API\n", len(artistResp.Items))
|
||||
for _, artist := range artistResp.Items {
|
||||
result.Artists = append(result.Artists, SearchArtistResult{
|
||||
ID: tidalPrefixedNumericID(artist.ID),
|
||||
Name: strings.TrimSpace(artist.Name),
|
||||
Images: tidalImageURL(artist.Picture, "750x750"),
|
||||
Followers: 0,
|
||||
Popularity: artist.Popularity,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
GoLog("[Tidal] Artist search failed: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
if albumLimit > 0 {
|
||||
requestURL := tidalBuildMetadataURL("search/albums", url.Values{
|
||||
"query": {cleanQuery},
|
||||
"limit": {strconv.Itoa(albumLimit)},
|
||||
"offset": {"0"},
|
||||
})
|
||||
var albumResp struct {
|
||||
Items []tidalPublicAlbum `json:"items"`
|
||||
}
|
||||
if err := t.getTidalMetadataJSON(requestURL, &albumResp); err == nil {
|
||||
GoLog("[Tidal] Got %d albums from API\n", len(albumResp.Items))
|
||||
for i := range albumResp.Items {
|
||||
album := &albumResp.Items[i]
|
||||
albumType := strings.ToLower(strings.TrimSpace(album.Type))
|
||||
if albumType == "" {
|
||||
albumType = "album"
|
||||
}
|
||||
result.Albums = append(result.Albums, SearchAlbumResult{
|
||||
ID: tidalPrefixedNumericID(album.ID),
|
||||
Name: strings.TrimSpace(album.Title),
|
||||
Artists: tidalAlbumArtistsDisplay(album),
|
||||
Images: tidalImageURL(album.Cover, "1280x1280"),
|
||||
ReleaseDate: strings.TrimSpace(album.ReleaseDate),
|
||||
TotalTracks: album.NumberOfTracks,
|
||||
AlbumType: albumType,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
GoLog("[Tidal] Album search failed: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
GoLog("[Tidal] SearchAll complete: %d tracks, %d artists, %d albums\n", len(result.Tracks), len(result.Artists), len(result.Albums))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) GetTrackMetadata(resourceID string) (*TrackResponse, error) {
|
||||
track, err := t.getPublicTrack(resourceID)
|
||||
if err != nil {
|
||||
@@ -808,13 +1015,11 @@ func (t *TidalDownloader) GetAlbumMetadata(resourceID string) (*AlbumResponsePay
|
||||
tracks := make([]AlbumTrackMetadata, 0, len(itemsModule.PagedList.Items))
|
||||
for _, item := range itemsModule.PagedList.Items {
|
||||
track := item.Item
|
||||
if track.Album.ID == 0 {
|
||||
track.Album.ID = headerModule.Album.ID
|
||||
track.Album.Title = headerModule.Album.Title
|
||||
track.Album.Cover = headerModule.Album.Cover
|
||||
track.Album.ReleaseDate = headerModule.Album.ReleaseDate
|
||||
track.Album.URL = headerModule.Album.URL
|
||||
}
|
||||
track.Album.ID = headerModule.Album.ID
|
||||
track.Album.Title = headerModule.Album.Title
|
||||
track.Album.Cover = headerModule.Album.Cover
|
||||
track.Album.ReleaseDate = headerModule.Album.ReleaseDate
|
||||
track.Album.URL = headerModule.Album.URL
|
||||
tracks = append(tracks, tidalTrackToAlbumTrackMetadata(&track))
|
||||
}
|
||||
|
||||
@@ -1537,8 +1742,8 @@ type TidalDownloadResult struct {
|
||||
}
|
||||
|
||||
func artistsMatch(spotifyArtist, tidalArtist string) bool {
|
||||
normSpotify := strings.ToLower(strings.TrimSpace(spotifyArtist))
|
||||
normTidal := strings.ToLower(strings.TrimSpace(tidalArtist))
|
||||
normSpotify := normalizeLooseArtistName(spotifyArtist)
|
||||
normTidal := normalizeLooseArtistName(tidalArtist)
|
||||
|
||||
if normSpotify == normTidal {
|
||||
return true
|
||||
@@ -1847,6 +2052,36 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade
|
||||
}
|
||||
}
|
||||
|
||||
if !gotTidalID && req.ISRC != "" {
|
||||
GoLog("[%s] Trying direct Tidal ISRC search: %s\n", logPrefix, req.ISRC)
|
||||
directTrack, directErr := downloader.SearchTrackByISRC(req.ISRC)
|
||||
if directErr == nil && directTrack != nil && directTrack.ID > 0 {
|
||||
trackID = directTrack.ID
|
||||
gotTidalID = true
|
||||
GoLog("[%s] Got Tidal ID %d from direct ISRC search\n", logPrefix, trackID)
|
||||
} else if directErr != nil {
|
||||
GoLog("[%s] Direct Tidal ISRC search failed: %v\n", logPrefix, directErr)
|
||||
}
|
||||
}
|
||||
|
||||
if !gotTidalID && req.ISRC != "" && req.TrackName != "" && req.ArtistName != "" {
|
||||
GoLog("[%s] Trying Tidal public metadata search with ISRC\n", logPrefix)
|
||||
searchTrack, searchErr := downloader.SearchTrackByMetadataWithISRC(
|
||||
req.TrackName,
|
||||
req.ArtistName,
|
||||
req.AlbumName,
|
||||
req.ISRC,
|
||||
expectedDurationSec,
|
||||
)
|
||||
if searchErr == nil && searchTrack != nil && searchTrack.ID > 0 {
|
||||
trackID = searchTrack.ID
|
||||
gotTidalID = true
|
||||
GoLog("[%s] Got Tidal ID %d from public metadata search\n", logPrefix, trackID)
|
||||
} else if searchErr != nil {
|
||||
GoLog("[%s] Tidal public metadata search failed: %v\n", logPrefix, searchErr)
|
||||
}
|
||||
}
|
||||
|
||||
if !gotTidalID && (req.SpotifyID != "" || req.DeezerID != "") {
|
||||
GoLog("[%s] Trying SongLink for Tidal ID...\n", logPrefix)
|
||||
|
||||
@@ -1911,6 +2146,32 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade
|
||||
return nil, fmt.Errorf("failed to find tidal track id from request/cache/songlink")
|
||||
}
|
||||
|
||||
// Verify the resolved track matches the request.
|
||||
actualTrack, fetchErr := tidalGetPublicTrackFunc(downloader, strconv.FormatInt(trackID, 10))
|
||||
if fetchErr != nil {
|
||||
GoLog("[%s] Warning: could not fetch Tidal track %d for verification: %v\n", logPrefix, trackID, fetchErr)
|
||||
// Continue without verification — better than failing entirely.
|
||||
} else {
|
||||
providerArtist := actualTrack.Artist.Name
|
||||
if providerArtist == "" && len(actualTrack.Artists) > 0 {
|
||||
providerArtist = actualTrack.Artists[0].Name
|
||||
}
|
||||
resolved := resolvedTrackInfo{
|
||||
Title: actualTrack.Title,
|
||||
ArtistName: providerArtist,
|
||||
Duration: actualTrack.Duration,
|
||||
}
|
||||
if !trackMatchesRequest(req, resolved, logPrefix) {
|
||||
// Invalidate the cached ID so future requests don't reuse it.
|
||||
if req.ISRC != "" {
|
||||
GetTrackIDCache().SetTidal(req.ISRC, 0)
|
||||
}
|
||||
return nil, fmt.Errorf("tidal track %d does not match request: expected '%s - %s', got '%s - %s'",
|
||||
trackID, req.ArtistName, req.TrackName, resolved.ArtistName, resolved.Title)
|
||||
}
|
||||
GoLog("[%s] Track %d verified: '%s - %s' ✓\n", logPrefix, trackID, resolved.ArtistName, resolved.Title)
|
||||
}
|
||||
|
||||
track := &TidalTrack{
|
||||
ID: trackID,
|
||||
Title: strings.TrimSpace(req.TrackName),
|
||||
@@ -2180,25 +2441,33 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
|
||||
bitDepth := downloadInfo.BitDepth
|
||||
sampleRate := downloadInfo.SampleRate
|
||||
lyricsLRC := ""
|
||||
if quality == "HIGH" {
|
||||
bitDepth = 0
|
||||
sampleRate = 44100
|
||||
}
|
||||
lyricsLRC := ""
|
||||
if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
lyricsLRC = parallelResult.LyricsLRC
|
||||
}
|
||||
|
||||
resultAlbum, resultReleaseDate, resultTrackNumber, resultDiscNumber := preferredReleaseMetadata(
|
||||
req,
|
||||
track.Album.Title,
|
||||
track.Album.ReleaseDate,
|
||||
actualTrackNumber,
|
||||
actualDiscNumber,
|
||||
)
|
||||
|
||||
return TidalDownloadResult{
|
||||
FilePath: actualOutputPath,
|
||||
BitDepth: bitDepth,
|
||||
SampleRate: sampleRate,
|
||||
Title: track.Title,
|
||||
Artist: track.Artist.Name,
|
||||
Album: track.Album.Title,
|
||||
ReleaseDate: track.Album.ReleaseDate,
|
||||
TrackNumber: actualTrackNumber,
|
||||
DiscNumber: actualDiscNumber,
|
||||
Album: resultAlbum,
|
||||
ReleaseDate: resultReleaseDate,
|
||||
TrackNumber: resultTrackNumber,
|
||||
DiscNumber: resultDiscNumber,
|
||||
ISRC: track.ISRC,
|
||||
LyricsLRC: lyricsLRC,
|
||||
}, nil
|
||||
|
||||
@@ -3,6 +3,8 @@ package gobackend
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
// normalizeLooseTitle collapses separators/punctuation so titles like
|
||||
@@ -22,11 +24,39 @@ func normalizeLooseTitle(title string) string {
|
||||
b.WriteRune(r)
|
||||
case unicode.IsSpace(r):
|
||||
b.WriteByte(' ')
|
||||
// Treat common separators as spaces.
|
||||
case r == '/', r == '\\', r == '_', r == '-', r == '|', r == '.', r == '&', r == '+':
|
||||
b.WriteByte(' ')
|
||||
default:
|
||||
// Drop other punctuation/symbols (including emoji) for loose matching.
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(strings.Fields(b.String()), " ")
|
||||
}
|
||||
|
||||
// normalizeLooseArtistName folds diacritics and common separators so artist
|
||||
// verification is resilient to variants like "Özkent" vs "Ozkent".
|
||||
func normalizeLooseArtistName(name string) string {
|
||||
trimmed := strings.TrimSpace(strings.ToLower(name))
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
decomposed := norm.NFD.String(trimmed)
|
||||
|
||||
var b strings.Builder
|
||||
b.Grow(len(decomposed))
|
||||
|
||||
for _, r := range decomposed {
|
||||
switch {
|
||||
case unicode.Is(unicode.Mn, r), unicode.Is(unicode.Mc, r), unicode.Is(unicode.Me, r):
|
||||
continue
|
||||
case unicode.IsLetter(r), unicode.IsNumber(r):
|
||||
b.WriteRune(r)
|
||||
case unicode.IsSpace(r):
|
||||
b.WriteByte(' ')
|
||||
case r == '/', r == '\\', r == '_', r == '-', r == '|', r == '.', r == '&', r == '+':
|
||||
b.WriteByte(' ')
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,3 +98,43 @@ func normalizeSymbolOnlyTitle(title string) string {
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// resolvedTrackInfo holds the metadata fetched from a provider for verification.
|
||||
type resolvedTrackInfo struct {
|
||||
Title string
|
||||
ArtistName string
|
||||
Duration int
|
||||
}
|
||||
|
||||
// trackMatchesRequest checks whether a resolved track from a provider matches
|
||||
// the original download request. Returns true if the track is a plausible match.
|
||||
func trackMatchesRequest(req DownloadRequest, resolved resolvedTrackInfo, logPrefix string) bool {
|
||||
if req.ArtistName != "" && resolved.ArtistName != "" &&
|
||||
!artistsMatch(req.ArtistName, resolved.ArtistName) {
|
||||
GoLog("[%s] Verification failed: artist mismatch — expected '%s', got '%s'\n",
|
||||
logPrefix, req.ArtistName, resolved.ArtistName)
|
||||
return false
|
||||
}
|
||||
|
||||
if req.TrackName != "" && resolved.Title != "" &&
|
||||
!titlesMatch(req.TrackName, resolved.Title) {
|
||||
GoLog("[%s] Verification failed: title mismatch — expected '%s', got '%s'\n",
|
||||
logPrefix, req.TrackName, resolved.Title)
|
||||
return false
|
||||
}
|
||||
|
||||
expectedDurationSec := req.DurationMS / 1000
|
||||
if expectedDurationSec > 0 && resolved.Duration > 0 {
|
||||
diff := expectedDurationSec - resolved.Duration
|
||||
if diff < 0 {
|
||||
diff = -diff
|
||||
}
|
||||
if diff > 10 {
|
||||
GoLog("[%s] Verification failed: duration mismatch — expected %ds, got %ds\n",
|
||||
logPrefix, expectedDurationSec, resolved.Duration)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1,747 +0,0 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type YouTubeDownloader struct {
|
||||
client *http.Client
|
||||
apiURL string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
const spotubeBaseURL = "https://spotubedl.com"
|
||||
|
||||
var (
|
||||
globalYouTubeDownloader *YouTubeDownloader
|
||||
youtubeDownloaderOnce sync.Once
|
||||
)
|
||||
|
||||
type YouTubeQuality string
|
||||
|
||||
const (
|
||||
YouTubeQualityOpus256 YouTubeQuality = "opus_256"
|
||||
YouTubeQualityOpus128 YouTubeQuality = "opus_128"
|
||||
YouTubeQualityMP3128 YouTubeQuality = "mp3_128"
|
||||
YouTubeQualityMP3256 YouTubeQuality = "mp3_256"
|
||||
YouTubeQualityMP3320 YouTubeQuality = "mp3_320"
|
||||
)
|
||||
|
||||
var (
|
||||
youtubeOpusSupportedBitrates = []int{128, 256}
|
||||
youtubeMp3SupportedBitrates = []int{128, 256, 320}
|
||||
)
|
||||
|
||||
type CobaltRequest struct {
|
||||
URL string `json:"url"`
|
||||
AudioBitrate string `json:"audioBitrate,omitempty"`
|
||||
AudioFormat string `json:"audioFormat,omitempty"`
|
||||
DownloadMode string `json:"downloadMode,omitempty"`
|
||||
FilenameStyle string `json:"filenameStyle,omitempty"`
|
||||
DisableMetadata bool `json:"disableMetadata,omitempty"`
|
||||
}
|
||||
|
||||
type CobaltResponse struct {
|
||||
Status string `json:"status"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Filename string `json:"filename,omitempty"`
|
||||
Error *struct {
|
||||
Code string `json:"code"`
|
||||
Context *struct {
|
||||
Service string `json:"service,omitempty"`
|
||||
Limit int `json:"limit,omitempty"`
|
||||
} `json:"context,omitempty"`
|
||||
} `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type YouTubeDownloadResult struct {
|
||||
FilePath string
|
||||
Title string
|
||||
Artist string
|
||||
Album string
|
||||
ReleaseDate string
|
||||
TrackNumber int
|
||||
DiscNumber int
|
||||
ISRC string
|
||||
Format string // "opus" or "mp3"
|
||||
Bitrate int
|
||||
LyricsLRC string
|
||||
CoverData []byte
|
||||
}
|
||||
|
||||
func NewYouTubeDownloader() *YouTubeDownloader {
|
||||
youtubeDownloaderOnce.Do(func() {
|
||||
globalYouTubeDownloader = &YouTubeDownloader{
|
||||
client: NewHTTPClientWithTimeout(DownloadTimeout),
|
||||
apiURL: "https://api.qwkuns.me",
|
||||
}
|
||||
})
|
||||
return globalYouTubeDownloader
|
||||
}
|
||||
|
||||
func extractBitrateFromQuality(raw string, defaultBitrate int) int {
|
||||
parts := strings.FieldsFunc(raw, func(r rune) bool {
|
||||
return (r < '0' || r > '9')
|
||||
})
|
||||
for i := len(parts) - 1; i >= 0; i-- {
|
||||
part := parts[i]
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
if parsed, err := strconv.Atoi(part); err == nil {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return defaultBitrate
|
||||
}
|
||||
|
||||
func nearestSupportedBitrate(value int, supported []int) int {
|
||||
nearest := supported[0]
|
||||
nearestDistance := absInt(value - nearest)
|
||||
|
||||
for _, option := range supported[1:] {
|
||||
distance := absInt(value - option)
|
||||
// On tie prefer higher quality.
|
||||
if distance < nearestDistance || (distance == nearestDistance && option > nearest) {
|
||||
nearest = option
|
||||
nearestDistance = distance
|
||||
}
|
||||
}
|
||||
|
||||
return nearest
|
||||
}
|
||||
|
||||
func absInt(value int) int {
|
||||
if value < 0 {
|
||||
return -value
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func parseYouTubeQualityInput(raw string) (format string, bitrate int, normalized YouTubeQuality) {
|
||||
normalizedRaw := strings.ToLower(strings.TrimSpace(raw))
|
||||
|
||||
if strings.HasPrefix(normalizedRaw, "opus") {
|
||||
parsed := extractBitrateFromQuality(normalizedRaw, 256)
|
||||
finalBitrate := nearestSupportedBitrate(parsed, youtubeOpusSupportedBitrates)
|
||||
return "opus", finalBitrate, YouTubeQuality(fmt.Sprintf("opus_%d", finalBitrate))
|
||||
}
|
||||
|
||||
if strings.HasPrefix(normalizedRaw, "mp3") {
|
||||
parsed := extractBitrateFromQuality(normalizedRaw, 320)
|
||||
finalBitrate := nearestSupportedBitrate(parsed, youtubeMp3SupportedBitrates)
|
||||
return "mp3", finalBitrate, YouTubeQuality(fmt.Sprintf("mp3_%d", finalBitrate))
|
||||
}
|
||||
|
||||
// Backward compatibility for legacy symbolic values.
|
||||
switch normalizedRaw {
|
||||
case "opus_256", "opus256", "opus":
|
||||
return "opus", 256, YouTubeQualityOpus256
|
||||
case "opus_128", "opus128":
|
||||
return "opus", 128, YouTubeQualityOpus128
|
||||
case "mp3_320", "mp3320", "mp3", "":
|
||||
return "mp3", 320, YouTubeQualityMP3320
|
||||
case "mp3_256", "mp3256":
|
||||
return "mp3", 256, YouTubeQualityMP3256
|
||||
case "mp3_128", "mp3128":
|
||||
return "mp3", 128, YouTubeQualityMP3128
|
||||
default:
|
||||
return "mp3", 320, YouTubeQualityMP3320
|
||||
}
|
||||
}
|
||||
|
||||
func (y *YouTubeDownloader) SearchYouTube(trackName, artistName string) (string, error) {
|
||||
query := fmt.Sprintf("%s %s", artistName, trackName)
|
||||
searchQuery := url.QueryEscape(query)
|
||||
|
||||
GoLog("[YouTube] Search query: %s\n", query)
|
||||
|
||||
youtubeMusicURL := fmt.Sprintf("https://music.youtube.com/search?q=%s", searchQuery)
|
||||
|
||||
return youtubeMusicURL, nil
|
||||
}
|
||||
|
||||
func (y *YouTubeDownloader) GetDownloadURL(youtubeURL string, quality YouTubeQuality) (*CobaltResponse, error) {
|
||||
y.mu.Lock()
|
||||
defer y.mu.Unlock()
|
||||
|
||||
audioFormat, bitrate, _ := parseYouTubeQualityInput(string(quality))
|
||||
audioBitrate := strconv.Itoa(bitrate)
|
||||
|
||||
// Try SpotubeDL first (primary)
|
||||
var spotubeErr error
|
||||
videoID, extractErr := ExtractYouTubeVideoID(youtubeURL)
|
||||
if extractErr == nil {
|
||||
GoLog("[YouTube] Requesting from SpotubeDL: videoID=%s (format: %s, bitrate: %s)\n",
|
||||
videoID, audioFormat, audioBitrate)
|
||||
|
||||
resp, err := y.requestSpotubeDL(videoID, audioFormat, audioBitrate)
|
||||
if err == nil {
|
||||
return resp, nil
|
||||
}
|
||||
spotubeErr = err
|
||||
GoLog("[YouTube] SpotubeDL failed: %v, trying Cobalt fallback...\n", err)
|
||||
} else {
|
||||
GoLog("[YouTube] Could not extract video ID: %v, skipping SpotubeDL\n", extractErr)
|
||||
}
|
||||
|
||||
// Fallback: direct Cobalt API (api.qwkuns.me)
|
||||
cobaltURL := toYouTubeMusicURL(youtubeURL)
|
||||
GoLog("[YouTube] Requesting from Cobalt API: %s (format: %s, bitrate: %s)\n",
|
||||
cobaltURL, audioFormat, audioBitrate)
|
||||
|
||||
resp, err := y.requestCobaltDirect(cobaltURL, audioFormat, audioBitrate)
|
||||
if err != nil {
|
||||
if spotubeErr != nil {
|
||||
return nil, fmt.Errorf("all download methods failed: spotubedl: %v, cobalt: %v", spotubeErr, err)
|
||||
}
|
||||
return nil, fmt.Errorf("all download methods failed: spotubedl: extractErr=%v, cobalt: %v", extractErr, err)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (y *YouTubeDownloader) requestCobaltDirect(videoURL, audioFormat, audioBitrate string) (*CobaltResponse, error) {
|
||||
reqBody := CobaltRequest{
|
||||
URL: videoURL,
|
||||
AudioFormat: audioFormat,
|
||||
AudioBitrate: audioBitrate,
|
||||
DownloadMode: "audio",
|
||||
FilenameStyle: "basic",
|
||||
DisableMetadata: true,
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", y.apiURL, strings.NewReader(string(jsonData)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := DoRequestWithUserAgent(y.client, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cobalt API request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
GoLog("[YouTube] Cobalt API response status: %d\n", resp.StatusCode)
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("cobalt API returned status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var cobaltResp CobaltResponse
|
||||
if err := json.Unmarshal(body, &cobaltResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
if cobaltResp.Status == "error" && cobaltResp.Error != nil {
|
||||
return nil, fmt.Errorf("cobalt error: %s", cobaltResp.Error.Code)
|
||||
}
|
||||
|
||||
if cobaltResp.Status != "tunnel" && cobaltResp.Status != "redirect" {
|
||||
return nil, fmt.Errorf("unexpected cobalt status: %s", cobaltResp.Status)
|
||||
}
|
||||
|
||||
if cobaltResp.URL == "" {
|
||||
return nil, fmt.Errorf("no download URL in response")
|
||||
}
|
||||
|
||||
GoLog("[YouTube] Got download URL from Cobalt (status: %s)\n", cobaltResp.Status)
|
||||
return &cobaltResp, nil
|
||||
}
|
||||
|
||||
// requestSpotubeDL uses SpotubeDL as a Cobalt proxy (they handle auth to yt-dl.click instances).
|
||||
// Engines v3/v2 are MP3-oriented outputs, so we only use them for MP3 requests.
|
||||
func (y *YouTubeDownloader) requestSpotubeDL(videoID, audioFormat, audioBitrate string) (*CobaltResponse, error) {
|
||||
engines := []string{"v1"}
|
||||
if strings.EqualFold(audioFormat, "mp3") {
|
||||
engines = append(engines, "v3", "v2")
|
||||
}
|
||||
var lastErr error
|
||||
|
||||
for _, engine := range engines {
|
||||
resp, err := y.requestSpotubeDLEngine(videoID, audioFormat, audioBitrate, engine)
|
||||
if err == nil {
|
||||
return resp, nil
|
||||
}
|
||||
lastErr = err
|
||||
GoLog("[YouTube] SpotubeDL (%s) failed: %v\n", engine, err)
|
||||
}
|
||||
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("no SpotubeDL engine available")
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func (y *YouTubeDownloader) requestSpotubeDLEngine(videoID, audioFormat, audioBitrate, engine string) (*CobaltResponse, error) {
|
||||
apiURL := fmt.Sprintf("%s/api/download/%s?engine=%s&format=%s&quality=%s",
|
||||
spotubeBaseURL, videoID, url.QueryEscape(engine), url.QueryEscape(audioFormat), url.QueryEscape(audioBitrate))
|
||||
|
||||
GoLog("[YouTube] Requesting from SpotubeDL (%s): %s\n", engine, apiURL)
|
||||
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := DoRequestWithUserAgent(y.client, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("spotubedl request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
GoLog("[YouTube] SpotubeDL (%s) response status: %d\n", engine, resp.StatusCode)
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("spotubedl(%s) returned status %d: %s", engine, resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
URL string `json:"url"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error"`
|
||||
Message string `json:"message"`
|
||||
Filename string `json:"filename"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse spotubedl response: %w", err)
|
||||
}
|
||||
|
||||
downloadURL := strings.TrimSpace(result.URL)
|
||||
if downloadURL == "" {
|
||||
if result.Error != "" {
|
||||
return nil, fmt.Errorf("spotubedl(%s) error: %s", engine, result.Error)
|
||||
}
|
||||
if result.Message != "" {
|
||||
return nil, fmt.Errorf("spotubedl(%s) message: %s", engine, result.Message)
|
||||
}
|
||||
return nil, fmt.Errorf("no download URL from spotubedl(%s)", engine)
|
||||
}
|
||||
|
||||
if strings.HasPrefix(downloadURL, "/") {
|
||||
downloadURL = spotubeBaseURL + downloadURL
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(downloadURL, "http://") && !strings.HasPrefix(downloadURL, "https://") {
|
||||
return nil, fmt.Errorf("invalid download URL from spotubedl(%s): %s", engine, downloadURL)
|
||||
}
|
||||
|
||||
filename := strings.TrimSpace(result.Filename)
|
||||
if filename == "" {
|
||||
if parsedURL, parseErr := url.Parse(downloadURL); parseErr == nil {
|
||||
if queryFilename := strings.TrimSpace(parsedURL.Query().Get("filename")); queryFilename != "" {
|
||||
if decodedFilename, decodeErr := url.QueryUnescape(queryFilename); decodeErr == nil {
|
||||
filename = decodedFilename
|
||||
} else {
|
||||
filename = queryFilename
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GoLog("[YouTube] Got download URL from SpotubeDL (%s)\n", engine)
|
||||
return &CobaltResponse{
|
||||
Status: "tunnel",
|
||||
URL: downloadURL,
|
||||
Filename: filename,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (y *YouTubeDownloader) 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)
|
||||
}
|
||||
|
||||
resp, err := DoRequestWithUserAgent(y.client, req)
|
||||
if err != nil {
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
return fmt.Errorf("download request failed: %w", 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 fmt.Errorf("failed to create output file: %w", err)
|
||||
}
|
||||
|
||||
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
||||
|
||||
var written int64
|
||||
if itemID != "" {
|
||||
progressWriter := NewItemProgressWriter(bufWriter, itemID)
|
||||
written, err = io.Copy(progressWriter, 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("[YouTube] Download completed: %d bytes written\n", written)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func BuildYouTubeSearchURL(trackName, artistName string) string {
|
||||
query := fmt.Sprintf("%s %s official audio", artistName, trackName)
|
||||
return fmt.Sprintf("https://music.youtube.com/search?q=%s", url.QueryEscape(query))
|
||||
}
|
||||
|
||||
func BuildYouTubeWatchURL(videoID string) string {
|
||||
return fmt.Sprintf("https://music.youtube.com/watch?v=%s", videoID)
|
||||
}
|
||||
|
||||
func isYouTubeVideoID(s string) bool {
|
||||
if len(s) != 11 {
|
||||
return false
|
||||
}
|
||||
for _, c := range s {
|
||||
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func IsYouTubeURL(urlStr string) bool {
|
||||
lower := strings.ToLower(urlStr)
|
||||
return strings.Contains(lower, "youtube.com") ||
|
||||
strings.Contains(lower, "youtu.be") ||
|
||||
strings.Contains(lower, "music.youtube.com")
|
||||
}
|
||||
|
||||
// toYouTubeMusicURL converts any YouTube URL to music.youtube.com format.
|
||||
// YouTube Music URLs bypass the login requirement that affects regular YouTube videos on Cobalt.
|
||||
func toYouTubeMusicURL(rawURL string) string {
|
||||
videoID, err := ExtractYouTubeVideoID(rawURL)
|
||||
if err != nil {
|
||||
return rawURL
|
||||
}
|
||||
return fmt.Sprintf("https://music.youtube.com/watch?v=%s", videoID)
|
||||
}
|
||||
|
||||
func ExtractYouTubeVideoID(urlStr string) (string, error) {
|
||||
if strings.Contains(urlStr, "youtu.be/") {
|
||||
parts := strings.Split(urlStr, "youtu.be/")
|
||||
if len(parts) >= 2 {
|
||||
videoID := strings.Split(parts[1], "?")[0]
|
||||
videoID = strings.Split(videoID, "&")[0]
|
||||
return strings.TrimSpace(videoID), nil
|
||||
}
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
|
||||
if v := parsed.Query().Get("v"); v != "" {
|
||||
return v, nil
|
||||
}
|
||||
|
||||
if strings.Contains(parsed.Path, "/embed/") {
|
||||
parts := strings.Split(parsed.Path, "/embed/")
|
||||
if len(parts) >= 2 {
|
||||
return strings.Split(parts[1], "/")[0], nil
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(parsed.Path, "/v/") {
|
||||
parts := strings.Split(parsed.Path, "/v/")
|
||||
if len(parts) >= 2 {
|
||||
return strings.Split(parts[1], "/")[0], nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("could not extract video ID from URL")
|
||||
}
|
||||
|
||||
// searchYouTubeMusicViaExtension uses the YT Music extension's customSearch
|
||||
// to find a track by artist + title. It filters for tracks only (not videos,
|
||||
// albums, or playlists) and returns the YouTube Music watch URL for the first
|
||||
// matching track, or "" if nothing was found.
|
||||
func searchYouTubeMusicViaExtension(artistName, trackName string) string {
|
||||
extManager := GetExtensionManager()
|
||||
searchProviders := extManager.GetSearchProviders()
|
||||
|
||||
// Find the ytmusic-spotiflac extension
|
||||
var ytProvider *ExtensionProviderWrapper
|
||||
for _, p := range searchProviders {
|
||||
if p.extension.ID == "ytmusic-spotiflac" {
|
||||
ytProvider = p
|
||||
break
|
||||
}
|
||||
}
|
||||
if ytProvider == nil {
|
||||
GoLog("[YouTube] YT Music extension not found or not enabled, skipping fallback\n")
|
||||
return ""
|
||||
}
|
||||
|
||||
query := strings.TrimSpace(artistName + " " + trackName)
|
||||
if query == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
GoLog("[YouTube] Searching YT Music extension for: %s\n", query)
|
||||
results, err := ytProvider.CustomSearch(query, map[string]interface{}{
|
||||
"filter": "tracks",
|
||||
})
|
||||
if err != nil {
|
||||
GoLog("[YouTube] YT Music extension search failed: %v\n", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
// Find the first track result (item_type == "track" with a valid video ID)
|
||||
for _, track := range results {
|
||||
if track.ItemType != "" && track.ItemType != "track" {
|
||||
continue
|
||||
}
|
||||
videoID := strings.TrimSpace(track.ID)
|
||||
if videoID == "" {
|
||||
continue
|
||||
}
|
||||
if isYouTubeVideoID(videoID) {
|
||||
return BuildYouTubeWatchURL(videoID)
|
||||
}
|
||||
}
|
||||
|
||||
GoLog("[YouTube] YT Music extension returned no matching tracks for: %s\n", query)
|
||||
return ""
|
||||
}
|
||||
|
||||
func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) {
|
||||
downloader := NewYouTubeDownloader()
|
||||
|
||||
format, bitrate, quality := parseYouTubeQualityInput(req.Quality)
|
||||
|
||||
// URL lookup priority: YouTube video ID > YT Music extension > SongLink (Spotify/Deezer/ISRC)
|
||||
var youtubeURL string
|
||||
var lookupErr error
|
||||
|
||||
// SpotifyID might actually be a YouTube video ID (from YT Music extension)
|
||||
if req.SpotifyID != "" && isYouTubeVideoID(req.SpotifyID) {
|
||||
youtubeURL = BuildYouTubeWatchURL(req.SpotifyID)
|
||||
GoLog("[YouTube] SpotifyID appears to be YouTube video ID, using directly: %s\n", youtubeURL)
|
||||
}
|
||||
|
||||
// Try YT Music extension search first (if installed) - more accurate, tracks only
|
||||
if youtubeURL == "" && (req.TrackName != "" || req.ArtistName != "") {
|
||||
youtubeURL = searchYouTubeMusicViaExtension(req.ArtistName, req.TrackName)
|
||||
if youtubeURL != "" {
|
||||
GoLog("[YouTube] Found YouTube URL via YT Music extension: %s\n", youtubeURL)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Try Spotify ID via SongLink
|
||||
if youtubeURL == "" && req.SpotifyID != "" && !isYouTubeVideoID(req.SpotifyID) {
|
||||
GoLog("[YouTube] Looking up YouTube URL via SongLink for Spotify ID: %s\n", req.SpotifyID)
|
||||
songlink := NewSongLinkClient()
|
||||
youtubeURL, lookupErr = songlink.GetYouTubeURLFromSpotify(req.SpotifyID)
|
||||
if lookupErr != nil {
|
||||
GoLog("[YouTube] SongLink Spotify lookup failed: %v\n", lookupErr)
|
||||
} else {
|
||||
GoLog("[YouTube] Found YouTube URL via SongLink (Spotify): %s\n", youtubeURL)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Try Deezer ID via SongLink
|
||||
if youtubeURL == "" && req.DeezerID != "" {
|
||||
GoLog("[YouTube] Looking up YouTube URL via SongLink for Deezer ID: %s\n", req.DeezerID)
|
||||
songlink := NewSongLinkClient()
|
||||
youtubeURL, lookupErr = songlink.GetYouTubeURLFromDeezer(req.DeezerID)
|
||||
if lookupErr != nil {
|
||||
GoLog("[YouTube] SongLink Deezer lookup failed: %v\n", lookupErr)
|
||||
} else {
|
||||
GoLog("[YouTube] Found YouTube URL via SongLink (Deezer): %s\n", youtubeURL)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Try ISRC via SongLink
|
||||
if youtubeURL == "" && req.ISRC != "" {
|
||||
GoLog("[YouTube] Looking up YouTube URL via SongLink for ISRC: %s\n", req.ISRC)
|
||||
songlink := NewSongLinkClient()
|
||||
availability, isrcErr := songlink.CheckTrackAvailability("", req.ISRC)
|
||||
if isrcErr == nil && availability.YouTube && availability.YouTubeURL != "" {
|
||||
youtubeURL = availability.YouTubeURL
|
||||
GoLog("[YouTube] Found YouTube URL via SongLink (ISRC): %s\n", youtubeURL)
|
||||
} else if isrcErr != nil {
|
||||
GoLog("[YouTube] SongLink ISRC lookup failed: %v\n", isrcErr)
|
||||
}
|
||||
}
|
||||
|
||||
// Cobalt requires direct video URLs, not search URLs
|
||||
if youtubeURL == "" {
|
||||
return YouTubeDownloadResult{}, fmt.Errorf("could not find YouTube URL for track: %s - %s (no Spotify/Deezer ID available or track not on YouTube)", req.ArtistName, req.TrackName)
|
||||
}
|
||||
|
||||
GoLog("[YouTube] Requesting download from Cobalt for: %s\n", youtubeURL)
|
||||
|
||||
cobaltResp, err := downloader.GetDownloadURL(youtubeURL, quality)
|
||||
if err != nil {
|
||||
return YouTubeDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
|
||||
}
|
||||
|
||||
ext := ".mp3"
|
||||
if format == "opus" {
|
||||
ext = ".opus"
|
||||
}
|
||||
|
||||
// Some SpotubeDL engines may return a different output container than requested.
|
||||
// Respect the provider-reported filename to avoid saving MP3 bytes with .opus extension.
|
||||
if cobaltResp != nil && cobaltResp.Filename != "" {
|
||||
lowerName := strings.ToLower(strings.TrimSpace(cobaltResp.Filename))
|
||||
switch {
|
||||
case strings.HasSuffix(lowerName, ".mp3"):
|
||||
ext = ".mp3"
|
||||
format = "mp3"
|
||||
case strings.HasSuffix(lowerName, ".opus"), strings.HasSuffix(lowerName, ".ogg"):
|
||||
ext = ".opus"
|
||||
format = "opus"
|
||||
}
|
||||
}
|
||||
|
||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]any{
|
||||
"title": req.TrackName,
|
||||
"artist": req.ArtistName,
|
||||
"album": req.AlbumName,
|
||||
"track": req.TrackNumber,
|
||||
"year": extractYear(req.ReleaseDate),
|
||||
"date": req.ReleaseDate,
|
||||
"disc": req.DiscNumber,
|
||||
})
|
||||
filename = sanitizeFilename(filename) + ext
|
||||
|
||||
var outputPath string
|
||||
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
|
||||
if isSafOutput {
|
||||
outputPath = strings.TrimSpace(req.OutputPath)
|
||||
if outputPath == "" && isFDOutput(req.OutputFD) {
|
||||
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
|
||||
}
|
||||
} else {
|
||||
outputPath = req.OutputDir + "/" + filename
|
||||
}
|
||||
|
||||
GoLog("[YouTube] Downloading to: %s\n", outputPath)
|
||||
|
||||
var parallelResult *ParallelDownloadResult
|
||||
if req.EmbedLyrics || req.CoverURL != "" {
|
||||
GoLog("[YouTube] Starting parallel fetch for cover and lyrics...\n")
|
||||
parallelResult = FetchCoverAndLyricsParallel(
|
||||
req.CoverURL,
|
||||
req.EmbedMaxQualityCover,
|
||||
req.SpotifyID,
|
||||
req.TrackName,
|
||||
req.ArtistName,
|
||||
req.EmbedLyrics,
|
||||
int64(req.DurationMS),
|
||||
)
|
||||
}
|
||||
|
||||
if err := downloader.DownloadFile(cobaltResp.URL, outputPath, req.OutputFD, req.ItemID); err != nil {
|
||||
return YouTubeDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
||||
}
|
||||
|
||||
lyricsLRC := ""
|
||||
var coverData []byte
|
||||
if parallelResult != nil {
|
||||
if parallelResult.LyricsLRC != "" {
|
||||
lyricsLRC = parallelResult.LyricsLRC
|
||||
GoLog("[YouTube] Got lyrics from lrclib (%d lines)\n", len(parallelResult.LyricsData.Lines))
|
||||
}
|
||||
if parallelResult.CoverData != nil {
|
||||
coverData = parallelResult.CoverData
|
||||
GoLog("[YouTube] Got cover art (%d bytes)\n", len(coverData))
|
||||
}
|
||||
}
|
||||
|
||||
return YouTubeDownloadResult{
|
||||
FilePath: outputPath,
|
||||
Title: req.TrackName,
|
||||
Artist: req.ArtistName,
|
||||
Album: req.AlbumName,
|
||||
ReleaseDate: req.ReleaseDate,
|
||||
TrackNumber: req.TrackNumber,
|
||||
DiscNumber: req.DiscNumber,
|
||||
ISRC: req.ISRC,
|
||||
Format: format,
|
||||
Bitrate: bitrate,
|
||||
LyricsLRC: lyricsLRC,
|
||||
CoverData: coverData,
|
||||
}, nil
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
package gobackend
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseYouTubeQualityInput_OpusNormalizesToSupportedBitrates(t *testing.T) {
|
||||
format, bitrate, normalized := parseYouTubeQualityInput("opus_160")
|
||||
if format != "opus" {
|
||||
t.Fatalf("expected opus format, got %s", format)
|
||||
}
|
||||
if bitrate != 128 {
|
||||
t.Fatalf("expected 128 bitrate, got %d", bitrate)
|
||||
}
|
||||
if normalized != YouTubeQualityOpus128 {
|
||||
t.Fatalf("expected %s normalized, got %s", YouTubeQualityOpus128, normalized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseYouTubeQualityInput_Mp3NormalizesToSupportedBitrates(t *testing.T) {
|
||||
format, bitrate, normalized := parseYouTubeQualityInput("mp3_192")
|
||||
if format != "mp3" {
|
||||
t.Fatalf("expected mp3 format, got %s", format)
|
||||
}
|
||||
if bitrate != 256 {
|
||||
t.Fatalf("expected 256 bitrate, got %d", bitrate)
|
||||
}
|
||||
if normalized != YouTubeQualityMP3256 {
|
||||
t.Fatalf("expected %s normalized, got %s", YouTubeQualityMP3256, normalized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseYouTubeQualityInput_PicksNearestSupportedBitrate(t *testing.T) {
|
||||
_, opusBitrate, _ := parseYouTubeQualityInput("opus_999")
|
||||
if opusBitrate != 256 {
|
||||
t.Fatalf("expected opus normalization to 256, got %d", opusBitrate)
|
||||
}
|
||||
|
||||
_, mp3Bitrate, _ := parseYouTubeQualityInput("mp3_1")
|
||||
if mp3Bitrate != 128 {
|
||||
t.Fatalf("expected mp3 normalization to 128, got %d", mp3Bitrate)
|
||||
}
|
||||
}
|
||||
@@ -367,6 +367,26 @@ import Gobackend // Import Go framework
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "searchTidalAll":
|
||||
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 filter = args["filter"] as? String ?? ""
|
||||
let response = GobackendSearchTidalAll(query, Int(trackLimit), Int(artistLimit), filter, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "searchQobuzAll":
|
||||
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 filter = args["filter"] as? String ?? ""
|
||||
let response = GobackendSearchQobuzAll(query, Int(trackLimit), Int(artistLimit), filter, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "getDeezerRelatedArtists":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let artistId = args["artist_id"] as! String
|
||||
|
||||
@@ -3,24 +3,24 @@ import 'package:flutter/foundation.dart';
|
||||
/// App version and info constants
|
||||
/// Update version here only - all other files will reference this
|
||||
class AppInfo {
|
||||
static const String version = '3.8.0';
|
||||
static const String buildNumber = '106';
|
||||
static const String version = '4.1.0';
|
||||
static const String buildNumber = '117';
|
||||
static const String fullVersion = '$version+$buildNumber';
|
||||
|
||||
/// Shows "Internal" in debug builds, actual version in release.
|
||||
static String get displayVersion => kDebugMode ? 'Internal' : version;
|
||||
|
||||
|
||||
static const String appName = 'SpotiFLAC';
|
||||
|
||||
static const String appName = 'SpotiFLAC Mobile';
|
||||
static const String copyright = '© 2026 SpotiFLAC';
|
||||
|
||||
|
||||
static const String mobileAuthor = 'zarzet';
|
||||
static const String originalAuthor = 'afkarxyz';
|
||||
|
||||
|
||||
static const String githubRepo = 'zarzet/SpotiFLAC-Mobile';
|
||||
static const String githubUrl = 'https://github.com/$githubRepo';
|
||||
static const String originalGithubUrl = 'https://github.com/afkarxyz/SpotiFLAC';
|
||||
|
||||
static const String originalGithubUrl =
|
||||
'https://github.com/afkarxyz/SpotiFLAC';
|
||||
|
||||
static const String kofiUrl = 'https://ko-fi.com/zarzet';
|
||||
static const String githubSponsorsUrl = 'https://github.com/sponsors/zarzet/';
|
||||
}
|
||||
|
||||
+570
-87
@@ -1066,6 +1066,12 @@ abstract class AppLocalizations {
|
||||
/// **'Import'**
|
||||
String get dialogImport;
|
||||
|
||||
/// Confirm button in Download All dialog
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Download'**
|
||||
String get dialogDownload;
|
||||
|
||||
/// Dialog button - discard changes
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -1426,6 +1432,66 @@ abstract class AppLocalizations {
|
||||
/// **'Playlists'**
|
||||
String get searchPlaylists;
|
||||
|
||||
/// Bottom sheet title for search sort options
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Sort Results'**
|
||||
String get searchSortTitle;
|
||||
|
||||
/// Sort option - default API order
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Default'**
|
||||
String get searchSortDefault;
|
||||
|
||||
/// Sort option - title ascending
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Title (A-Z)'**
|
||||
String get searchSortTitleAZ;
|
||||
|
||||
/// Sort option - title descending
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Title (Z-A)'**
|
||||
String get searchSortTitleZA;
|
||||
|
||||
/// Sort option - artist ascending
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Artist (A-Z)'**
|
||||
String get searchSortArtistAZ;
|
||||
|
||||
/// Sort option - artist descending
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Artist (Z-A)'**
|
||||
String get searchSortArtistZA;
|
||||
|
||||
/// Sort option - shortest duration first
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Duration (Shortest)'**
|
||||
String get searchSortDurationShort;
|
||||
|
||||
/// Sort option - longest duration first
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Duration (Longest)'**
|
||||
String get searchSortDurationLong;
|
||||
|
||||
/// Sort option - oldest release first
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Release Date (Oldest)'**
|
||||
String get searchSortDateOldest;
|
||||
|
||||
/// Sort option - newest release first
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Release Date (Newest)'**
|
||||
String get searchSortDateNewest;
|
||||
|
||||
/// Tooltip - play button
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -2317,7 +2383,7 @@ abstract class AppLocalizations {
|
||||
/// Default search provider option
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Default (Deezer/Spotify)'**
|
||||
/// **'Default (Deezer)'**
|
||||
String get extensionDefaultProvider;
|
||||
|
||||
/// Subtitle for default provider
|
||||
@@ -2590,30 +2656,72 @@ abstract class AppLocalizations {
|
||||
/// **'24-bit / up to 192kHz'**
|
||||
String get qualityHiResFlacMaxSubtitle;
|
||||
|
||||
/// Quality option label for Tidal lossy 320kbps
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Lossy 320kbps'**
|
||||
String get downloadLossy320;
|
||||
|
||||
/// Setting title to pick output format for Tidal lossy downloads
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Lossy Format'**
|
||||
String get downloadLossyFormat;
|
||||
|
||||
/// Title of the Tidal lossy format picker bottom sheet
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Lossy 320kbps Format'**
|
||||
String get downloadLossy320Format;
|
||||
|
||||
/// Description in the Tidal lossy format picker
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.'**
|
||||
String get downloadLossy320FormatDesc;
|
||||
|
||||
/// Tidal lossy format option - MP3 320kbps
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'MP3 320kbps'**
|
||||
String get downloadLossyMp3;
|
||||
|
||||
/// Subtitle for MP3 320kbps Tidal lossy option
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Best compatibility, ~10MB per track'**
|
||||
String get downloadLossyMp3Subtitle;
|
||||
|
||||
/// Tidal lossy format option - Opus 256kbps
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Opus 256kbps'**
|
||||
String get downloadLossyOpus256;
|
||||
|
||||
/// Subtitle for Opus 256kbps Tidal lossy option
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Best quality Opus, ~8MB per track'**
|
||||
String get downloadLossyOpus256Subtitle;
|
||||
|
||||
/// Tidal lossy format option - Opus 128kbps
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Opus 128kbps'**
|
||||
String get downloadLossyOpus128;
|
||||
|
||||
/// Subtitle for Opus 128kbps Tidal lossy option
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Smallest size, ~4MB per track'**
|
||||
String get downloadLossyOpus128Subtitle;
|
||||
|
||||
/// Note about quality availability
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Actual quality depends on track availability from the service'**
|
||||
String get qualityNote;
|
||||
|
||||
/// Note for YouTube service explaining lossy-only quality
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'YouTube provides lossy audio only. Not part of lossless fallback.'**
|
||||
String get youtubeQualityNote;
|
||||
|
||||
/// Title for YouTube Opus bitrate setting
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'YouTube Opus Bitrate'**
|
||||
String get youtubeOpusBitrateTitle;
|
||||
|
||||
/// Title for YouTube MP3 bitrate setting
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'YouTube MP3 Bitrate'**
|
||||
String get youtubeMp3BitrateTitle;
|
||||
|
||||
/// Setting - show quality picker
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -2794,6 +2902,18 @@ abstract class AppLocalizations {
|
||||
/// **'Artist/Album/ and Artist/Singles/'**
|
||||
String get albumFolderArtistAlbumSinglesSubtitle;
|
||||
|
||||
/// Album folder option with singles directly in artist folder
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Artist / Album (Singles flat)'**
|
||||
String get albumFolderArtistAlbumFlat;
|
||||
|
||||
/// Folder structure example for flat singles
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Artist/Album/ and Artist/song.flac'**
|
||||
String get albumFolderArtistAlbumFlatSubtitle;
|
||||
|
||||
/// Button - delete selected tracks
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -3100,6 +3220,42 @@ abstract class AppLocalizations {
|
||||
/// **'Show when searching for existing tracks'**
|
||||
String get libraryShowDuplicateIndicatorSubtitle;
|
||||
|
||||
/// Setting for automatic library scanning
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Auto Scan'**
|
||||
String get libraryAutoScan;
|
||||
|
||||
/// Subtitle for auto scan setting
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Automatically scan your library for new files'**
|
||||
String get libraryAutoScanSubtitle;
|
||||
|
||||
/// Auto scan disabled
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Off'**
|
||||
String get libraryAutoScanOff;
|
||||
|
||||
/// Auto scan when app opens
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Every app open'**
|
||||
String get libraryAutoScanOnOpen;
|
||||
|
||||
/// Auto scan once per day
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Daily'**
|
||||
String get libraryAutoScanDaily;
|
||||
|
||||
/// Auto scan once per week
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Weekly'**
|
||||
String get libraryAutoScanWeekly;
|
||||
|
||||
/// Section header for library actions
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -3832,6 +3988,36 @@ abstract class AppLocalizations {
|
||||
/// **'FFmpeg metadata embed failed'**
|
||||
String get trackReEnrichFfmpegFailed;
|
||||
|
||||
/// Action/button label for queueing FLAC redownloads for local tracks
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Queue FLAC'**
|
||||
String get queueFlacAction;
|
||||
|
||||
/// Confirmation dialog body before queueing FLAC redownloads for local tracks
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n{count} selected'**
|
||||
String queueFlacConfirmMessage(int count);
|
||||
|
||||
/// Snackbar while resolving remote matches for local FLAC redownloads
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Finding FLAC matches... ({current}/{total})'**
|
||||
String queueFlacFindingProgress(int current, int total);
|
||||
|
||||
/// Snackbar when no safe FLAC redownload matches were found
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No reliable online matches found for the selection'**
|
||||
String get queueFlacNoReliableMatches;
|
||||
|
||||
/// Snackbar when some selected local tracks were queued for FLAC redownload and some were skipped
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Added {addedCount} tracks to queue, skipped {skippedCount}'**
|
||||
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount);
|
||||
|
||||
/// Snackbar when save operation fails
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -3847,7 +4033,7 @@ abstract class AppLocalizations {
|
||||
/// Subtitle for convert format menu item
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Convert to MP3 or Opus'**
|
||||
/// **'Convert to MP3, Opus, ALAC, or FLAC'**
|
||||
String get trackConvertFormatSubtitle;
|
||||
|
||||
/// Title of convert bottom sheet
|
||||
@@ -3884,6 +4070,21 @@ abstract class AppLocalizations {
|
||||
String bitrate,
|
||||
);
|
||||
|
||||
/// Confirmation dialog message for lossless-to-lossless conversion
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Convert from {sourceFormat} to {targetFormat}? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.'**
|
||||
String trackConvertConfirmMessageLossless(
|
||||
String sourceFormat,
|
||||
String targetFormat,
|
||||
);
|
||||
|
||||
/// Hint shown when converting between lossless formats
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Lossless conversion — no quality loss'**
|
||||
String get trackConvertLosslessHint;
|
||||
|
||||
/// Snackbar while converting
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -4254,6 +4455,12 @@ abstract class AppLocalizations {
|
||||
String bitrate,
|
||||
);
|
||||
|
||||
/// Confirmation dialog message for lossless batch conversion
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Convert {count} {count, plural, =1{track} other{tracks}} to {format}? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.'**
|
||||
String selectionBatchConvertConfirmMessageLossless(int count, String format);
|
||||
|
||||
/// Snackbar during batch conversion progress
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -4434,7 +4641,7 @@ abstract class AppLocalizations {
|
||||
/// **'Added {count} tracks to Loved'**
|
||||
String snackbarAddedTracksToLoved(int count);
|
||||
|
||||
/// Title of the Download All confirmation dialog
|
||||
/// Dialog title for bulk download confirmation
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Download All'**
|
||||
@@ -4446,12 +4653,6 @@ abstract class AppLocalizations {
|
||||
/// **'Download {count} tracks?'**
|
||||
String dialogDownloadAllMessage(int count);
|
||||
|
||||
/// Confirm button in Download All dialog
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Download'**
|
||||
String get dialogDownload;
|
||||
|
||||
/// Checkbox label in import dialog to skip already-downloaded songs
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -4572,6 +4773,30 @@ abstract class AppLocalizations {
|
||||
/// **'Artist Name Filters'**
|
||||
String get downloadArtistNameFilters;
|
||||
|
||||
/// Setting title for adding a playlist folder prefix before the normal organization structure
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Create playlist source folder'**
|
||||
String get downloadCreatePlaylistSourceFolder;
|
||||
|
||||
/// Subtitle when playlist source folder prefix is enabled
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Playlist downloads use Playlist/ plus your normal folder structure.'**
|
||||
String get downloadCreatePlaylistSourceFolderEnabled;
|
||||
|
||||
/// Subtitle when playlist source folder prefix is disabled
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Playlist downloads use the normal folder structure only.'**
|
||||
String get downloadCreatePlaylistSourceFolderDisabled;
|
||||
|
||||
/// Subtitle when playlist folder prefix setting is redundant because folder organization is already by playlist
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'By Playlist already places downloads inside a playlist folder.'**
|
||||
String get downloadCreatePlaylistSourceFolderRedundant;
|
||||
|
||||
/// Setting title for SongLink country region
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -4602,18 +4827,6 @@ abstract class AppLocalizations {
|
||||
/// **'Select a built-in service to enable'**
|
||||
String get downloadSelectServiceToEnable;
|
||||
|
||||
/// Quality option label for Tidal lossy 320kbps
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Lossy 320kbps'**
|
||||
String get downloadLossy320;
|
||||
|
||||
/// Setting title to pick output format for Tidal lossy downloads
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Lossy Format'**
|
||||
String get downloadLossyFormat;
|
||||
|
||||
/// Info hint when non-Tidal/Qobuz service is selected
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -4740,54 +4953,6 @@ abstract class AppLocalizations {
|
||||
/// **'Auto'**
|
||||
String get downloadMusixmatchAuto;
|
||||
|
||||
/// Title of the Tidal lossy format picker bottom sheet
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Lossy 320kbps Format'**
|
||||
String get downloadLossy320Format;
|
||||
|
||||
/// Description in the Tidal lossy format picker
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.'**
|
||||
String get downloadLossy320FormatDesc;
|
||||
|
||||
/// Tidal lossy format option - MP3 320kbps
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'MP3 320kbps'**
|
||||
String get downloadLossyMp3;
|
||||
|
||||
/// Subtitle for MP3 320kbps option
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Best compatibility, ~10MB per track'**
|
||||
String get downloadLossyMp3Subtitle;
|
||||
|
||||
/// Tidal lossy format option - Opus 256kbps
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Opus 256kbps'**
|
||||
String get downloadLossyOpus256;
|
||||
|
||||
/// Subtitle for Opus 256kbps option
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Best quality Opus, ~8MB per track'**
|
||||
String get downloadLossyOpus256Subtitle;
|
||||
|
||||
/// Tidal lossy format option - Opus 128kbps
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Opus 128kbps'**
|
||||
String get downloadLossyOpus128;
|
||||
|
||||
/// Subtitle for Opus 128kbps option
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Smallest size, ~4MB per track'**
|
||||
String get downloadLossyOpus128Subtitle;
|
||||
|
||||
/// Subtitle for 'Any' network mode option
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -4817,6 +4982,324 @@ abstract class AppLocalizations {
|
||||
/// In en, this message translates to:
|
||||
/// **'Refresh'**
|
||||
String get cacheRefresh;
|
||||
|
||||
/// Dialog message for bulk playlist download confirmation
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Download {trackCount} {trackCount, plural, =1{track} other{tracks}} from {playlistCount} {playlistCount, plural, =1{playlist} other{playlists}}?'**
|
||||
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount);
|
||||
|
||||
/// Button label for bulk downloading selected playlists
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Download {count} {count, plural, =1{playlist} other{playlists}}'**
|
||||
String bulkDownloadPlaylistsButton(int count);
|
||||
|
||||
/// Button label when no playlists are selected for download
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Select playlists to download'**
|
||||
String get bulkDownloadSelectPlaylists;
|
||||
|
||||
/// Snackbar when selected playlists contain no tracks
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Selected playlists have no tracks'**
|
||||
String get snackbarSelectedPlaylistsEmpty;
|
||||
|
||||
/// Playlist count display
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{1 playlist} other{{count} playlists}}'**
|
||||
String playlistsCount(int count);
|
||||
|
||||
/// Section title for selective online metadata auto-fill in the edit metadata sheet
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Auto-fill from online'**
|
||||
String get editMetadataAutoFill;
|
||||
|
||||
/// Description for the auto-fill section
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Select fields to fill automatically from online metadata'**
|
||||
String get editMetadataAutoFillDesc;
|
||||
|
||||
/// Button label to fetch online metadata and fill selected fields
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Fetch & Fill'**
|
||||
String get editMetadataAutoFillFetch;
|
||||
|
||||
/// Snackbar shown while searching for online metadata
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Searching online...'**
|
||||
String get editMetadataAutoFillSearching;
|
||||
|
||||
/// Snackbar when online metadata search returns no results
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No matching metadata found online'**
|
||||
String get editMetadataAutoFillNoResults;
|
||||
|
||||
/// Snackbar confirming how many fields were auto-filled
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Filled {count} {count, plural, =1{field} other{fields}} from online metadata'**
|
||||
String editMetadataAutoFillDone(int count);
|
||||
|
||||
/// Snackbar when user taps Fetch without selecting any fields
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Select at least one field to auto-fill'**
|
||||
String get editMetadataAutoFillNoneSelected;
|
||||
|
||||
/// Chip label for title field in auto-fill selector
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Title'**
|
||||
String get editMetadataFieldTitle;
|
||||
|
||||
/// Chip label for artist field in auto-fill selector
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Artist'**
|
||||
String get editMetadataFieldArtist;
|
||||
|
||||
/// Chip label for album field in auto-fill selector
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Album'**
|
||||
String get editMetadataFieldAlbum;
|
||||
|
||||
/// Chip label for album artist field in auto-fill selector
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Album Artist'**
|
||||
String get editMetadataFieldAlbumArtist;
|
||||
|
||||
/// Chip label for date field in auto-fill selector
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Date'**
|
||||
String get editMetadataFieldDate;
|
||||
|
||||
/// Chip label for track number field in auto-fill selector
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Track #'**
|
||||
String get editMetadataFieldTrackNum;
|
||||
|
||||
/// Chip label for disc number field in auto-fill selector
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Disc #'**
|
||||
String get editMetadataFieldDiscNum;
|
||||
|
||||
/// Chip label for genre field in auto-fill selector
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Genre'**
|
||||
String get editMetadataFieldGenre;
|
||||
|
||||
/// Chip label for ISRC field in auto-fill selector
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'ISRC'**
|
||||
String get editMetadataFieldIsrc;
|
||||
|
||||
/// Chip label for label field in auto-fill selector
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Label'**
|
||||
String get editMetadataFieldLabel;
|
||||
|
||||
/// Chip label for copyright field in auto-fill selector
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Copyright'**
|
||||
String get editMetadataFieldCopyright;
|
||||
|
||||
/// Chip label for cover art field in auto-fill selector
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Cover Art'**
|
||||
String get editMetadataFieldCover;
|
||||
|
||||
/// Button to select all fields for auto-fill
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'All'**
|
||||
String get editMetadataSelectAll;
|
||||
|
||||
/// Button to select only fields that are currently empty
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Empty only'**
|
||||
String get editMetadataSelectEmpty;
|
||||
|
||||
/// Header for active downloads section with count
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Downloading ({count})'**
|
||||
String queueDownloadingCount(int count);
|
||||
|
||||
/// Header label for downloaded items section in library
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Downloaded'**
|
||||
String get queueDownloadedHeader;
|
||||
|
||||
/// Shown while filter results are being computed
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Filtering...'**
|
||||
String get queueFilteringIndicator;
|
||||
|
||||
/// Track count label with plural support
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{1 track} other{{count} tracks}}'**
|
||||
String queueTrackCount(int count);
|
||||
|
||||
/// Album count label with plural support
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{1 album} other{{count} albums}}'**
|
||||
String queueAlbumCount(int count);
|
||||
|
||||
/// Empty state title when no album downloads exist
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No album downloads'**
|
||||
String get queueEmptyAlbums;
|
||||
|
||||
/// Empty state subtitle for album downloads
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Download multiple tracks from an album to see them here'**
|
||||
String get queueEmptyAlbumsSubtitle;
|
||||
|
||||
/// Empty state title when no single track downloads exist
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No single downloads'**
|
||||
String get queueEmptySingles;
|
||||
|
||||
/// Empty state subtitle for single track downloads
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Single track downloads will appear here'**
|
||||
String get queueEmptySinglesSubtitle;
|
||||
|
||||
/// Empty state title when download history is empty
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No download history'**
|
||||
String get queueEmptyHistory;
|
||||
|
||||
/// Empty state subtitle for download history
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Downloaded tracks will appear here'**
|
||||
String get queueEmptyHistorySubtitle;
|
||||
|
||||
/// Shown when all playlists are selected in selection mode
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'All playlists selected'**
|
||||
String get selectionAllPlaylistsSelected;
|
||||
|
||||
/// Hint shown in playlist selection mode
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Tap playlists to select'**
|
||||
String get selectionTapPlaylistsToSelect;
|
||||
|
||||
/// Hint shown when no playlists are selected for deletion
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Select playlists to delete'**
|
||||
String get selectionSelectPlaylistsToDelete;
|
||||
|
||||
/// Title for audio analysis section
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Audio Quality Analysis'**
|
||||
String get audioAnalysisTitle;
|
||||
|
||||
/// Description for audio analysis tap-to-analyze prompt
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Verify lossless quality with spectrum analysis'**
|
||||
String get audioAnalysisDescription;
|
||||
|
||||
/// Loading text while analyzing audio
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Analyzing audio...'**
|
||||
String get audioAnalysisAnalyzing;
|
||||
|
||||
/// Sample rate metric label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Sample Rate'**
|
||||
String get audioAnalysisSampleRate;
|
||||
|
||||
/// Bit depth metric label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Bit Depth'**
|
||||
String get audioAnalysisBitDepth;
|
||||
|
||||
/// Channels metric label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Channels'**
|
||||
String get audioAnalysisChannels;
|
||||
|
||||
/// Duration metric label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Duration'**
|
||||
String get audioAnalysisDuration;
|
||||
|
||||
/// Nyquist frequency metric label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Nyquist'**
|
||||
String get audioAnalysisNyquist;
|
||||
|
||||
/// File size metric label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Size'**
|
||||
String get audioAnalysisFileSize;
|
||||
|
||||
/// Dynamic range metric label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Dynamic Range'**
|
||||
String get audioAnalysisDynamicRange;
|
||||
|
||||
/// Peak amplitude metric label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Peak'**
|
||||
String get audioAnalysisPeak;
|
||||
|
||||
/// RMS level metric label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'RMS'**
|
||||
String get audioAnalysisRms;
|
||||
|
||||
/// Total samples metric label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Samples'**
|
||||
String get audioAnalysisSamples;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
||||
@@ -365,7 +365,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get aboutAppDescription =>
|
||||
'Lade Spotify-Titel in verlustfreier Qualität von Tidal, Qobuz und Amazon Music herunter.';
|
||||
'Lade Spotify-Titel in verlustfreier Qualität von Tidal und Qobuz herunter.';
|
||||
|
||||
@override
|
||||
String get artistAlbums => 'Alben';
|
||||
@@ -441,7 +441,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get setupDownloadLocationIosMessage =>
|
||||
'Auf iOS werden Downloads im Dokumentenverzeichnis der App gespeichert. Du kannst sie über die Datei-App aufrufen.';
|
||||
'Auf iOS werden Downloads im Dokumentenordner der App gespeichert. Du kannst sie über die Datei-App aufrufen.';
|
||||
|
||||
@override
|
||||
String get setupAppDocumentsFolder => 'App-Dokumentenordner';
|
||||
@@ -536,6 +536,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get dialogImport => 'Importieren';
|
||||
|
||||
@override
|
||||
String get dialogDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get dialogDiscard => 'Verwerfen';
|
||||
|
||||
@@ -702,15 +705,15 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get errorNoTracksFound => 'Keine Titel gefunden';
|
||||
|
||||
@override
|
||||
String get errorUrlNotRecognized => 'Link not recognized';
|
||||
String get errorUrlNotRecognized => 'Link wurde nicht erkannt';
|
||||
|
||||
@override
|
||||
String get errorUrlNotRecognizedMessage =>
|
||||
'This link is not supported. Make sure the URL is correct and a compatible extension is installed.';
|
||||
'Dieser Link ist inkompatibel. Prüfe die URL und stelle sicher, dass eine kompatible Erweiterung installiert ist.';
|
||||
|
||||
@override
|
||||
String get errorUrlFetchFailed =>
|
||||
'Failed to load content from this link. Please try again.';
|
||||
'Laden fehlgeschlagen. Bitte erneut versuchen.';
|
||||
|
||||
@override
|
||||
String errorMissingExtensionSource(String item) {
|
||||
@@ -747,7 +750,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get selectionAllSelected => 'Alle Titel sind ausgewählt';
|
||||
|
||||
@override
|
||||
String get selectionSelectToDelete => 'Titel zum Löschen auswählen';
|
||||
String get selectionSelectToDelete => 'Titel zum Löschen wählen';
|
||||
|
||||
@override
|
||||
String progressFetchingMetadata(int current, int total) {
|
||||
@@ -764,11 +767,41 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get searchArtists => 'Künstler';
|
||||
|
||||
@override
|
||||
String get searchAlbums => 'Albums';
|
||||
String get searchAlbums => 'Alben';
|
||||
|
||||
@override
|
||||
String get searchPlaylists => 'Playlisten';
|
||||
|
||||
@override
|
||||
String get searchSortTitle => 'Sort Results';
|
||||
|
||||
@override
|
||||
String get searchSortDefault => 'Default';
|
||||
|
||||
@override
|
||||
String get searchSortTitleAZ => 'Title (A-Z)';
|
||||
|
||||
@override
|
||||
String get searchSortTitleZA => 'Title (Z-A)';
|
||||
|
||||
@override
|
||||
String get searchSortArtistAZ => 'Artist (A-Z)';
|
||||
|
||||
@override
|
||||
String get searchSortArtistZA => 'Artist (Z-A)';
|
||||
|
||||
@override
|
||||
String get searchSortDurationShort => 'Duration (Shortest)';
|
||||
|
||||
@override
|
||||
String get searchSortDurationLong => 'Duration (Longest)';
|
||||
|
||||
@override
|
||||
String get searchSortDateOldest => 'Release Date (Oldest)';
|
||||
|
||||
@override
|
||||
String get searchSortDateNewest => 'Release Date (Newest)';
|
||||
|
||||
@override
|
||||
String get tooltipPlay => 'Abspielen';
|
||||
|
||||
@@ -786,11 +819,11 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get folderOrganizationNone => 'Keine Organisation';
|
||||
|
||||
@override
|
||||
String get folderOrganizationByPlaylist => 'By Playlist';
|
||||
String get folderOrganizationByPlaylist => 'Nach Playlist';
|
||||
|
||||
@override
|
||||
String get folderOrganizationByPlaylistSubtitle =>
|
||||
'Separate folder for each playlist';
|
||||
'Ordner für jede Playlist trennen';
|
||||
|
||||
@override
|
||||
String get folderOrganizationByArtist => 'Nach Künstler';
|
||||
@@ -807,7 +840,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get folderOrganizationNoneSubtitle =>
|
||||
'Alle Dateien im Download-Verzeichnis';
|
||||
'Alle Dateien im Download-Ordner';
|
||||
|
||||
@override
|
||||
String get folderOrganizationByArtistSubtitle =>
|
||||
@@ -1410,37 +1443,60 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get qualityHiResFlacMaxSubtitle => '24-Bit / bis 192kHz';
|
||||
|
||||
@override
|
||||
String get downloadLossy320 => 'Lossy 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyFormat => 'Lossy Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320FormatDesc =>
|
||||
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256Subtitle =>
|
||||
'Best quality Opus, ~8MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128 => 'Opus 128kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
|
||||
|
||||
@override
|
||||
String get qualityNote =>
|
||||
'Die eigentliche Qualität hängt von der Verfügbarkeit des Dienstes ab';
|
||||
|
||||
@override
|
||||
String get youtubeQualityNote =>
|
||||
'YouTube bietet nur verlustbehaftete Audioqualität. Deswegen ist es kein Teil des verlustfreien Fallbacks.';
|
||||
|
||||
@override
|
||||
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
|
||||
|
||||
@override
|
||||
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
|
||||
|
||||
@override
|
||||
String get downloadAskBeforeDownload => 'Qualität vor Download fragen';
|
||||
|
||||
@override
|
||||
String get downloadDirectory => 'Downloadverzeichnis';
|
||||
String get downloadDirectory => 'Download-Ordner';
|
||||
|
||||
@override
|
||||
String get downloadSeparateSinglesFolder => 'Singles Ordner trennen';
|
||||
|
||||
@override
|
||||
String get downloadAlbumFolderStructure => 'Album Folder Structure';
|
||||
String get downloadAlbumFolderStructure => 'Album-Ordnerstruktur';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
||||
String get downloadUseAlbumArtistForFolders =>
|
||||
'Album-Künstler für Ordner verwenden';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
|
||||
String get downloadUsePrimaryArtistOnly => 'Primärer Künstler nur für Ordner';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyEnabled =>
|
||||
@@ -1448,7 +1504,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyDisabled =>
|
||||
'Full artist string used for folder name';
|
||||
'Vollständiger Künstler für Ordnername';
|
||||
|
||||
@override
|
||||
String get downloadSelectQuality => 'Qualität wählen';
|
||||
@@ -1470,7 +1526,8 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
'Bist du dir sicher, dass du alle Downloads löschen möchten?';
|
||||
|
||||
@override
|
||||
String get settingsAutoExportFailed => 'Auto-export failed downloads';
|
||||
String get settingsAutoExportFailed =>
|
||||
'Auto-Export fehlgeschlagener Downloads';
|
||||
|
||||
@override
|
||||
String get settingsAutoExportFailedSubtitle =>
|
||||
@@ -1493,14 +1550,14 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get albumFolderArtistAlbum => 'Künstler/Album';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumSubtitle => 'Albums/Artist Name/Album Name/';
|
||||
String get albumFolderArtistAlbumSubtitle => 'Alben/Künster Name/Album Name/';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistYearAlbum => 'Artist / [Year] Album';
|
||||
String get albumFolderArtistYearAlbum => 'Künstler / [Year] Album';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistYearAlbumSubtitle =>
|
||||
'Albums/Künster Name/[2005] Album Name/';
|
||||
'Alben/Künster Name/[2005] Album Name/';
|
||||
|
||||
@override
|
||||
String get albumFolderAlbumOnly => 'Nur Alben';
|
||||
@@ -1512,14 +1569,21 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get albumFolderYearAlbum => '[Year] Album';
|
||||
|
||||
@override
|
||||
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
|
||||
String get albumFolderYearAlbumSubtitle => 'Alben/[2005] Album Name/';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
|
||||
String get albumFolderArtistAlbumSingles => 'Künstler / Album + Singles';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||
'Artist/Album/ and Artist/Singles/';
|
||||
'Künstler/Album/ und Künstler/Singles/';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumFlatSubtitle =>
|
||||
'Artist/Album/ and Artist/song.flac';
|
||||
|
||||
@override
|
||||
String get downloadedAlbumDeleteSelected => 'Ausgewählte löschen';
|
||||
@@ -1558,7 +1622,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
}
|
||||
|
||||
@override
|
||||
String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
|
||||
String get downloadedAlbumSelectToDelete => 'Titel zum Löschen wählen';
|
||||
|
||||
@override
|
||||
String downloadedAlbumDiscHeader(int discNumber) {
|
||||
@@ -1604,7 +1668,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
|
||||
return '$count Titel von $albumCount Albums';
|
||||
return '$count Titel aus $albumCount Alben';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -1620,14 +1684,14 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get discographySelectAlbumsSubtitle =>
|
||||
'Choose specific albums or singles';
|
||||
'Wähle bestimmte Alben oder Singles';
|
||||
|
||||
@override
|
||||
String get discographyFetchingTracks => 'Lade Titel...';
|
||||
|
||||
@override
|
||||
String discographyFetchingAlbum(int current, int total) {
|
||||
return 'Fetching $current of $total...';
|
||||
return 'Lade $current von $total...';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -1640,7 +1704,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String discographyAddedToQueue(int count) {
|
||||
return 'Added $count tracks to queue';
|
||||
return '$count Titel zur Warteschlange hinzugefügt';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -1652,7 +1716,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get discographyNoAlbums => 'Es sind keine Alben verfügbar';
|
||||
|
||||
@override
|
||||
String get discographyFailedToFetch => 'Failed to fetch some albums';
|
||||
String get discographyFailedToFetch => 'Fehler beim Abrufen einiger Alben';
|
||||
|
||||
@override
|
||||
String get sectionStorageAccess => 'Speicherzugriff';
|
||||
@@ -1661,14 +1725,14 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get allFilesAccess => 'Zugriff auf alle Dateien';
|
||||
|
||||
@override
|
||||
String get allFilesAccessEnabledSubtitle => 'Can write to any folder';
|
||||
String get allFilesAccessEnabledSubtitle => 'Darf in jeden Ordner schreiben';
|
||||
|
||||
@override
|
||||
String get allFilesAccessDisabledSubtitle => 'Limited to media folders only';
|
||||
String get allFilesAccessDisabledSubtitle => 'Nur auf Medienordner begrenzt';
|
||||
|
||||
@override
|
||||
String get allFilesAccessDescription =>
|
||||
'Aktiviere die Option, wenn beim Speichern in benutzerdefinierten Ordnern Schreibfehler auftreten. Weil Android 13+ standardmäßig den Zugriff auf bestimmte Verzeichnisse einschränkt.';
|
||||
'Option bei Schreibfehlern bitte aktivieren (erforderlich ab Android 13).';
|
||||
|
||||
@override
|
||||
String get allFilesAccessDeniedMessage =>
|
||||
@@ -1682,13 +1746,15 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get settingsLocalLibrary => 'Lokale Bibliothek';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
||||
String get settingsLocalLibrarySubtitle =>
|
||||
'Musik scannen & Duplikate erkennen';
|
||||
|
||||
@override
|
||||
String get settingsCache => 'Speicher & Cache';
|
||||
|
||||
@override
|
||||
String get settingsCacheSubtitle => 'View size and clear cached data';
|
||||
String get settingsCacheSubtitle =>
|
||||
'Größe anzeigen und Daten im Cache leeren';
|
||||
|
||||
@override
|
||||
String get libraryTitle => 'Lokale Bibliothek';
|
||||
@@ -1701,7 +1767,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrarySubtitle =>
|
||||
'Scan and track your existing music';
|
||||
'Scan und verfolge deine bestehende Musik';
|
||||
|
||||
@override
|
||||
String get libraryFolder => 'Bibliotheksordner';
|
||||
@@ -1710,12 +1776,31 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get libraryFolderHint => 'Tippe um Ordner auszuwählen';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
|
||||
String get libraryShowDuplicateIndicator => 'Duplikat Indikator anzeigen';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Bei der Suche nach vorhandenen Titeln anzeigen';
|
||||
|
||||
@override
|
||||
String get libraryAutoScan => 'Auto Scan';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanSubtitle =>
|
||||
'Automatically scan your library for new files';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanOff => 'Off';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanOnOpen => 'Every app open';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanDaily => 'Daily';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanWeekly => 'Weekly';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Aktionen';
|
||||
|
||||
@@ -1892,7 +1977,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip2 =>
|
||||
'Hole dir FLAC Audio von Tidal, Qobuz oder Amazon Musik';
|
||||
'Hole dir FLAC Audio von Tidal, Qobuz oder Deezer';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip3 =>
|
||||
@@ -1959,7 +2044,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip1 =>
|
||||
'Downloadverzeichnis und Ordnerorganisation ändern';
|
||||
'Download-Ordner und Ordner-Organisation ändern';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip2 =>
|
||||
@@ -2017,14 +2102,14 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get cacheSectionMaintenance => 'Wartung';
|
||||
|
||||
@override
|
||||
String get cacheAppDirectory => 'App-Cache Verzeichnis';
|
||||
String get cacheAppDirectory => 'App-Cache Ordner';
|
||||
|
||||
@override
|
||||
String get cacheAppDirectoryDesc =>
|
||||
'HTTP-Antworten, WebView Daten und andere temporäre App-Daten.';
|
||||
|
||||
@override
|
||||
String get cacheTempDirectory => 'Temporäres Verzeichnis';
|
||||
String get cacheTempDirectory => 'Temporärer Ordner';
|
||||
|
||||
@override
|
||||
String get cacheTempDirectoryDesc =>
|
||||
@@ -2145,11 +2230,11 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String trackCoverSaved(String fileName) {
|
||||
return 'Cover art saved to $fileName';
|
||||
return 'Cover in $fileName gespeichert';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackCoverNoSource => 'No cover art source available';
|
||||
String get trackCoverNoSource => 'Keine Cover Quelle vorhanden';
|
||||
|
||||
@override
|
||||
String trackLyricsSaved(String fileName) {
|
||||
@@ -2169,6 +2254,28 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get trackReEnrichFfmpegFailed =>
|
||||
'FFmpeg Metadaten-Einbettung fehlgeschlagen';
|
||||
|
||||
@override
|
||||
String get queueFlacAction => 'Queue FLAC';
|
||||
|
||||
@override
|
||||
String queueFlacConfirmMessage(int count) {
|
||||
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
|
||||
}
|
||||
|
||||
@override
|
||||
String queueFlacFindingProgress(int current, int total) {
|
||||
return 'Finding FLAC matches... ($current/$total)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueFlacNoReliableMatches =>
|
||||
'No reliable online matches found for the selection';
|
||||
|
||||
@override
|
||||
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
|
||||
return 'Added $addedCount tracks to queue, skipped $skippedCount';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackSaveFailed(String error) {
|
||||
return 'Fehler: $error';
|
||||
@@ -2201,6 +2308,18 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
return 'Konvertieren von $sourceFormat in $targetFormat bei $bitrate?\n\nDie Originaldatei wird nach der Konvertierung gelöscht.';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackConvertConfirmMessageLossless(
|
||||
String sourceFormat,
|
||||
String targetFormat,
|
||||
) {
|
||||
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertLosslessHint =>
|
||||
'Lossless conversion — no quality loss';
|
||||
|
||||
@override
|
||||
String get trackConvertConverting => 'Konvertiere Audio...';
|
||||
|
||||
@@ -2213,10 +2332,10 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get trackConvertFailed => 'Konvertierung fehlgeschlagen';
|
||||
|
||||
@override
|
||||
String get cueSplitTitle => 'Split CUE Sheet';
|
||||
String get cueSplitTitle => 'CUE-Sheet aufteilen';
|
||||
|
||||
@override
|
||||
String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
|
||||
String get cueSplitSubtitle => 'CUE+FLAC in einzelne Titel aufteilen';
|
||||
|
||||
@override
|
||||
String cueSplitAlbum(String album) {
|
||||
@@ -2225,40 +2344,41 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String cueSplitArtist(String artist) {
|
||||
return 'Artist: $artist';
|
||||
return 'Künstler: $artist';
|
||||
}
|
||||
|
||||
@override
|
||||
String cueSplitTrackCount(int count) {
|
||||
return '$count tracks';
|
||||
return '$count Titel';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cueSplitConfirmTitle => 'Split CUE Album';
|
||||
String get cueSplitConfirmTitle => 'CUE-Album aufteilen';
|
||||
|
||||
@override
|
||||
String cueSplitConfirmMessage(String album, int count) {
|
||||
return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
|
||||
return 'Soll „$album“ in $count einzelne FLAC-Dateien aufgeteilt werden?\n\nDie Dateien werden im selben Ordner gespeichert.';
|
||||
}
|
||||
|
||||
@override
|
||||
String cueSplitSplitting(int current, int total) {
|
||||
return 'Splitting CUE sheet... ($current/$total)';
|
||||
return 'CUE-Sheet wird geteilt... ($current/$total)';
|
||||
}
|
||||
|
||||
@override
|
||||
String cueSplitSuccess(int count) {
|
||||
return 'Split into $count tracks successfully';
|
||||
return '$count Titel erfolgreich aufgeteilt';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cueSplitFailed => 'CUE split failed';
|
||||
String get cueSplitFailed => 'CUE-Aufteilung fehlgeschlagen';
|
||||
|
||||
@override
|
||||
String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
|
||||
String get cueSplitNoAudioFile =>
|
||||
'Audiodatei für dieses CUE-Sheet nicht gefunden';
|
||||
|
||||
@override
|
||||
String get cueSplitButton => 'Split into Tracks';
|
||||
String get cueSplitButton => 'In Titel aufteilen';
|
||||
|
||||
@override
|
||||
String get actionCreate => 'Erstellen';
|
||||
@@ -2455,6 +2575,17 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
return 'Konvertiere $count $format $_temp0 zu $bitrate?\n\nOriginaldateien werden nach der Konvertierung gelöscht.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertProgress(int current, int total) {
|
||||
return 'Konvertiere $current von $total...';
|
||||
@@ -2472,11 +2603,11 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
|
||||
'Künstlerordner verwenden den Album-Interpreten, wenn verfügbar';
|
||||
'Interpret-Ordner verwenden Album-Interpret, sofern vorhanden';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||
'Artist folders use Track Artist only';
|
||||
'Künstler-Ordner nur für Titel-Künstler';
|
||||
|
||||
@override
|
||||
String get lyricsProvidersTitle => 'Lyrics Providers';
|
||||
@@ -2580,9 +2711,6 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
return 'Download $count tracks?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get dialogDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
|
||||
|
||||
@@ -2648,6 +2776,22 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get downloadArtistNameFilters => 'Artist Name Filters';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolder =>
|
||||
'Create playlist source folder';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderEnabled =>
|
||||
'Playlist downloads use Playlist/ plus your normal folder structure.';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderDisabled =>
|
||||
'Playlist downloads use the normal folder structure only.';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderRedundant =>
|
||||
'By Playlist already places downloads inside a playlist folder.';
|
||||
|
||||
@override
|
||||
String get downloadSongLinkRegion => 'SongLink Region';
|
||||
|
||||
@@ -2666,12 +2810,6 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get downloadSelectServiceToEnable =>
|
||||
'Select a built-in service to enable';
|
||||
|
||||
@override
|
||||
String get downloadLossy320 => 'Lossy 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyFormat => 'Lossy Format';
|
||||
|
||||
@override
|
||||
String get downloadSelectTidalQobuz =>
|
||||
'Select Tidal or Qobuz above to configure quality';
|
||||
@@ -2748,32 +2886,6 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get downloadMusixmatchAuto => 'Auto';
|
||||
|
||||
@override
|
||||
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320FormatDesc =>
|
||||
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256Subtitle =>
|
||||
'Best quality Opus, ~8MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128 => 'Opus 128kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
|
||||
|
||||
@override
|
||||
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
||||
|
||||
@@ -2790,4 +2902,226 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get cacheRefresh => 'Refresh';
|
||||
|
||||
@override
|
||||
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
trackCount,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
String _temp1 = intl.Intl.pluralLogic(
|
||||
playlistCount,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
|
||||
}
|
||||
|
||||
@override
|
||||
String bulkDownloadPlaylistsButton(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Download $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
|
||||
|
||||
@override
|
||||
String get snackbarSelectedPlaylistsEmpty =>
|
||||
'Selected playlists have no tracks';
|
||||
|
||||
@override
|
||||
String playlistsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count playlists',
|
||||
one: '1 playlist',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFill => 'Auto-fill from online';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillDesc =>
|
||||
'Select fields to fill automatically from online metadata';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillFetch => 'Fetch & Fill';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillSearching => 'Searching online...';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillNoResults =>
|
||||
'No matching metadata found online';
|
||||
|
||||
@override
|
||||
String editMetadataAutoFillDone(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'fields',
|
||||
one: 'field',
|
||||
);
|
||||
return 'Filled $count $_temp0 from online metadata';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillNoneSelected =>
|
||||
'Select at least one field to auto-fill';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTitle => 'Title';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldArtist => 'Artist';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbum => 'Album';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbumArtist => 'Album Artist';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDate => 'Date';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTrackNum => 'Track #';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDiscNum => 'Disc #';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldGenre => 'Genre';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldIsrc => 'ISRC';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldLabel => 'Label';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldCopyright => 'Copyright';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldCover => 'Cover Art';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectAll => 'All';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectEmpty => 'Empty only';
|
||||
|
||||
@override
|
||||
String queueDownloadingCount(int count) {
|
||||
return 'Downloading ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueDownloadedHeader => 'Downloaded';
|
||||
|
||||
@override
|
||||
String get queueFilteringIndicator => 'Filtering...';
|
||||
|
||||
@override
|
||||
String queueTrackCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count tracks',
|
||||
one: '1 track',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String queueAlbumCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count albums',
|
||||
one: '1 album',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueEmptyAlbums => 'No album downloads';
|
||||
|
||||
@override
|
||||
String get queueEmptyAlbumsSubtitle =>
|
||||
'Download multiple tracks from an album to see them here';
|
||||
|
||||
@override
|
||||
String get queueEmptySingles => 'No single downloads';
|
||||
|
||||
@override
|
||||
String get queueEmptySinglesSubtitle =>
|
||||
'Single track downloads will appear here';
|
||||
|
||||
@override
|
||||
String get queueEmptyHistory => 'No download history';
|
||||
|
||||
@override
|
||||
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
|
||||
|
||||
@override
|
||||
String get selectionAllPlaylistsSelected => 'All playlists selected';
|
||||
|
||||
@override
|
||||
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
|
||||
|
||||
@override
|
||||
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
|
||||
|
||||
@override
|
||||
String get audioAnalysisTitle => 'Audio Quality Analysis';
|
||||
|
||||
@override
|
||||
String get audioAnalysisDescription =>
|
||||
'Verify lossless quality with spectrum analysis';
|
||||
|
||||
@override
|
||||
String get audioAnalysisAnalyzing => 'Analyzing audio...';
|
||||
|
||||
@override
|
||||
String get audioAnalysisSampleRate => 'Sample Rate';
|
||||
|
||||
@override
|
||||
String get audioAnalysisBitDepth => 'Bit Depth';
|
||||
|
||||
@override
|
||||
String get audioAnalysisChannels => 'Channels';
|
||||
|
||||
@override
|
||||
String get audioAnalysisDuration => 'Duration';
|
||||
|
||||
@override
|
||||
String get audioAnalysisNyquist => 'Nyquist';
|
||||
|
||||
@override
|
||||
String get audioAnalysisFileSize => 'Size';
|
||||
|
||||
@override
|
||||
String get audioAnalysisDynamicRange => 'Dynamic Range';
|
||||
|
||||
@override
|
||||
String get audioAnalysisPeak => 'Peak';
|
||||
|
||||
@override
|
||||
String get audioAnalysisRms => 'RMS';
|
||||
|
||||
@override
|
||||
String get audioAnalysisSamples => 'Samples';
|
||||
}
|
||||
|
||||
@@ -525,6 +525,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get dialogImport => 'Import';
|
||||
|
||||
@override
|
||||
String get dialogDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get dialogDiscard => 'Discard';
|
||||
|
||||
@@ -756,6 +759,36 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get searchPlaylists => 'Playlists';
|
||||
|
||||
@override
|
||||
String get searchSortTitle => 'Sort Results';
|
||||
|
||||
@override
|
||||
String get searchSortDefault => 'Default';
|
||||
|
||||
@override
|
||||
String get searchSortTitleAZ => 'Title (A-Z)';
|
||||
|
||||
@override
|
||||
String get searchSortTitleZA => 'Title (Z-A)';
|
||||
|
||||
@override
|
||||
String get searchSortArtistAZ => 'Artist (A-Z)';
|
||||
|
||||
@override
|
||||
String get searchSortArtistZA => 'Artist (Z-A)';
|
||||
|
||||
@override
|
||||
String get searchSortDurationShort => 'Duration (Shortest)';
|
||||
|
||||
@override
|
||||
String get searchSortDurationLong => 'Duration (Longest)';
|
||||
|
||||
@override
|
||||
String get searchSortDateOldest => 'Release Date (Oldest)';
|
||||
|
||||
@override
|
||||
String get searchSortDateNewest => 'Release Date (Newest)';
|
||||
|
||||
@override
|
||||
String get tooltipPlay => 'Play';
|
||||
|
||||
@@ -1237,7 +1270,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get storeEmptyNoResults => 'No extensions found';
|
||||
|
||||
@override
|
||||
String get extensionDefaultProvider => 'Default (Deezer/Spotify)';
|
||||
String get extensionDefaultProvider => 'Default (Deezer)';
|
||||
|
||||
@override
|
||||
String get extensionDefaultProviderSubtitle => 'Use built-in search';
|
||||
@@ -1386,20 +1419,42 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
||||
|
||||
@override
|
||||
String get downloadLossy320 => 'Lossy 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyFormat => 'Lossy Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320FormatDesc =>
|
||||
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256Subtitle =>
|
||||
'Best quality Opus, ~8MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128 => 'Opus 128kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
|
||||
|
||||
@override
|
||||
String get qualityNote =>
|
||||
'Actual quality depends on track availability from the service';
|
||||
|
||||
@override
|
||||
String get youtubeQualityNote =>
|
||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
||||
|
||||
@override
|
||||
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
|
||||
|
||||
@override
|
||||
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
|
||||
|
||||
@override
|
||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||
|
||||
@@ -1497,6 +1552,13 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||
'Artist/Album/ and Artist/Singles/';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumFlatSubtitle =>
|
||||
'Artist/Album/ and Artist/song.flac';
|
||||
|
||||
@override
|
||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||
|
||||
@@ -1692,6 +1754,25 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Show when searching for existing tracks';
|
||||
|
||||
@override
|
||||
String get libraryAutoScan => 'Auto Scan';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanSubtitle =>
|
||||
'Automatically scan your library for new files';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanOff => 'Off';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanOnOpen => 'Every app open';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanDaily => 'Daily';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanWeekly => 'Weekly';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Actions';
|
||||
|
||||
@@ -2142,6 +2223,28 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||
|
||||
@override
|
||||
String get queueFlacAction => 'Queue FLAC';
|
||||
|
||||
@override
|
||||
String queueFlacConfirmMessage(int count) {
|
||||
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
|
||||
}
|
||||
|
||||
@override
|
||||
String queueFlacFindingProgress(int current, int total) {
|
||||
return 'Finding FLAC matches... ($current/$total)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueFlacNoReliableMatches =>
|
||||
'No reliable online matches found for the selection';
|
||||
|
||||
@override
|
||||
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
|
||||
return 'Added $addedCount tracks to queue, skipped $skippedCount';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackSaveFailed(String error) {
|
||||
return 'Failed: $error';
|
||||
@@ -2151,7 +2254,8 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get trackConvertFormat => 'Convert Format';
|
||||
|
||||
@override
|
||||
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||
String get trackConvertFormatSubtitle =>
|
||||
'Convert to MP3, Opus, ALAC, or FLAC';
|
||||
|
||||
@override
|
||||
String get trackConvertTitle => 'Convert Audio';
|
||||
@@ -2174,6 +2278,18 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackConvertConfirmMessageLossless(
|
||||
String sourceFormat,
|
||||
String targetFormat,
|
||||
) {
|
||||
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertLosslessHint =>
|
||||
'Lossless conversion — no quality loss';
|
||||
|
||||
@override
|
||||
String get trackConvertConverting => 'Converting audio...';
|
||||
|
||||
@@ -2427,6 +2543,17 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertProgress(int current, int total) {
|
||||
return 'Converting $current of $total...';
|
||||
@@ -2552,9 +2679,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
return 'Download $count tracks?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get dialogDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
|
||||
|
||||
@@ -2620,6 +2744,22 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get downloadArtistNameFilters => 'Artist Name Filters';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolder =>
|
||||
'Create playlist source folder';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderEnabled =>
|
||||
'Playlist downloads use Playlist/ plus your normal folder structure.';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderDisabled =>
|
||||
'Playlist downloads use the normal folder structure only.';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderRedundant =>
|
||||
'By Playlist already places downloads inside a playlist folder.';
|
||||
|
||||
@override
|
||||
String get downloadSongLinkRegion => 'SongLink Region';
|
||||
|
||||
@@ -2638,12 +2778,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get downloadSelectServiceToEnable =>
|
||||
'Select a built-in service to enable';
|
||||
|
||||
@override
|
||||
String get downloadLossy320 => 'Lossy 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyFormat => 'Lossy Format';
|
||||
|
||||
@override
|
||||
String get downloadSelectTidalQobuz =>
|
||||
'Select Tidal or Qobuz above to configure quality';
|
||||
@@ -2720,32 +2854,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get downloadMusixmatchAuto => 'Auto';
|
||||
|
||||
@override
|
||||
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320FormatDesc =>
|
||||
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256Subtitle =>
|
||||
'Best quality Opus, ~8MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128 => 'Opus 128kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
|
||||
|
||||
@override
|
||||
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
||||
|
||||
@@ -2762,4 +2870,226 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get cacheRefresh => 'Refresh';
|
||||
|
||||
@override
|
||||
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
trackCount,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
String _temp1 = intl.Intl.pluralLogic(
|
||||
playlistCount,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
|
||||
}
|
||||
|
||||
@override
|
||||
String bulkDownloadPlaylistsButton(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Download $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
|
||||
|
||||
@override
|
||||
String get snackbarSelectedPlaylistsEmpty =>
|
||||
'Selected playlists have no tracks';
|
||||
|
||||
@override
|
||||
String playlistsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count playlists',
|
||||
one: '1 playlist',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFill => 'Auto-fill from online';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillDesc =>
|
||||
'Select fields to fill automatically from online metadata';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillFetch => 'Fetch & Fill';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillSearching => 'Searching online...';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillNoResults =>
|
||||
'No matching metadata found online';
|
||||
|
||||
@override
|
||||
String editMetadataAutoFillDone(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'fields',
|
||||
one: 'field',
|
||||
);
|
||||
return 'Filled $count $_temp0 from online metadata';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillNoneSelected =>
|
||||
'Select at least one field to auto-fill';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTitle => 'Title';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldArtist => 'Artist';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbum => 'Album';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbumArtist => 'Album Artist';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDate => 'Date';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTrackNum => 'Track #';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDiscNum => 'Disc #';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldGenre => 'Genre';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldIsrc => 'ISRC';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldLabel => 'Label';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldCopyright => 'Copyright';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldCover => 'Cover Art';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectAll => 'All';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectEmpty => 'Empty only';
|
||||
|
||||
@override
|
||||
String queueDownloadingCount(int count) {
|
||||
return 'Downloading ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueDownloadedHeader => 'Downloaded';
|
||||
|
||||
@override
|
||||
String get queueFilteringIndicator => 'Filtering...';
|
||||
|
||||
@override
|
||||
String queueTrackCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count tracks',
|
||||
one: '1 track',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String queueAlbumCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count albums',
|
||||
one: '1 album',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueEmptyAlbums => 'No album downloads';
|
||||
|
||||
@override
|
||||
String get queueEmptyAlbumsSubtitle =>
|
||||
'Download multiple tracks from an album to see them here';
|
||||
|
||||
@override
|
||||
String get queueEmptySingles => 'No single downloads';
|
||||
|
||||
@override
|
||||
String get queueEmptySinglesSubtitle =>
|
||||
'Single track downloads will appear here';
|
||||
|
||||
@override
|
||||
String get queueEmptyHistory => 'No download history';
|
||||
|
||||
@override
|
||||
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
|
||||
|
||||
@override
|
||||
String get selectionAllPlaylistsSelected => 'All playlists selected';
|
||||
|
||||
@override
|
||||
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
|
||||
|
||||
@override
|
||||
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
|
||||
|
||||
@override
|
||||
String get audioAnalysisTitle => 'Audio Quality Analysis';
|
||||
|
||||
@override
|
||||
String get audioAnalysisDescription =>
|
||||
'Verify lossless quality with spectrum analysis';
|
||||
|
||||
@override
|
||||
String get audioAnalysisAnalyzing => 'Analyzing audio...';
|
||||
|
||||
@override
|
||||
String get audioAnalysisSampleRate => 'Sample Rate';
|
||||
|
||||
@override
|
||||
String get audioAnalysisBitDepth => 'Bit Depth';
|
||||
|
||||
@override
|
||||
String get audioAnalysisChannels => 'Channels';
|
||||
|
||||
@override
|
||||
String get audioAnalysisDuration => 'Duration';
|
||||
|
||||
@override
|
||||
String get audioAnalysisNyquist => 'Nyquist';
|
||||
|
||||
@override
|
||||
String get audioAnalysisFileSize => 'Size';
|
||||
|
||||
@override
|
||||
String get audioAnalysisDynamicRange => 'Dynamic Range';
|
||||
|
||||
@override
|
||||
String get audioAnalysisPeak => 'Peak';
|
||||
|
||||
@override
|
||||
String get audioAnalysisRms => 'RMS';
|
||||
|
||||
@override
|
||||
String get audioAnalysisSamples => 'Samples';
|
||||
}
|
||||
|
||||
+3074
-2460
File diff suppressed because it is too large
Load Diff
@@ -358,7 +358,7 @@ class AppLocalizationsFr 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';
|
||||
@@ -527,6 +527,9 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get dialogImport => 'Import';
|
||||
|
||||
@override
|
||||
String get dialogDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get dialogDiscard => 'Discard';
|
||||
|
||||
@@ -758,6 +761,36 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get searchPlaylists => 'Playlists';
|
||||
|
||||
@override
|
||||
String get searchSortTitle => 'Sort Results';
|
||||
|
||||
@override
|
||||
String get searchSortDefault => 'Default';
|
||||
|
||||
@override
|
||||
String get searchSortTitleAZ => 'Title (A-Z)';
|
||||
|
||||
@override
|
||||
String get searchSortTitleZA => 'Title (Z-A)';
|
||||
|
||||
@override
|
||||
String get searchSortArtistAZ => 'Artist (A-Z)';
|
||||
|
||||
@override
|
||||
String get searchSortArtistZA => 'Artist (Z-A)';
|
||||
|
||||
@override
|
||||
String get searchSortDurationShort => 'Duration (Shortest)';
|
||||
|
||||
@override
|
||||
String get searchSortDurationLong => 'Duration (Longest)';
|
||||
|
||||
@override
|
||||
String get searchSortDateOldest => 'Release Date (Oldest)';
|
||||
|
||||
@override
|
||||
String get searchSortDateNewest => 'Release Date (Newest)';
|
||||
|
||||
@override
|
||||
String get tooltipPlay => 'Play';
|
||||
|
||||
@@ -1388,20 +1421,42 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
||||
|
||||
@override
|
||||
String get downloadLossy320 => 'Lossy 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyFormat => 'Lossy Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320FormatDesc =>
|
||||
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256Subtitle =>
|
||||
'Best quality Opus, ~8MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128 => 'Opus 128kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
|
||||
|
||||
@override
|
||||
String get qualityNote =>
|
||||
'Actual quality depends on track availability from the service';
|
||||
|
||||
@override
|
||||
String get youtubeQualityNote =>
|
||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
||||
|
||||
@override
|
||||
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
|
||||
|
||||
@override
|
||||
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
|
||||
|
||||
@override
|
||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||
|
||||
@@ -1499,6 +1554,13 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||
'Artist/Album/ and Artist/Singles/';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumFlatSubtitle =>
|
||||
'Artist/Album/ and Artist/song.flac';
|
||||
|
||||
@override
|
||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||
|
||||
@@ -1694,6 +1756,25 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Show when searching for existing tracks';
|
||||
|
||||
@override
|
||||
String get libraryAutoScan => 'Auto Scan';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanSubtitle =>
|
||||
'Automatically scan your library for new files';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanOff => 'Off';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanOnOpen => 'Every app open';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanDaily => 'Daily';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanWeekly => 'Weekly';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Actions';
|
||||
|
||||
@@ -1870,7 +1951,7 @@ class AppLocalizationsFr 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 =>
|
||||
@@ -2144,6 +2225,28 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||
|
||||
@override
|
||||
String get queueFlacAction => 'Queue FLAC';
|
||||
|
||||
@override
|
||||
String queueFlacConfirmMessage(int count) {
|
||||
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
|
||||
}
|
||||
|
||||
@override
|
||||
String queueFlacFindingProgress(int current, int total) {
|
||||
return 'Finding FLAC matches... ($current/$total)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueFlacNoReliableMatches =>
|
||||
'No reliable online matches found for the selection';
|
||||
|
||||
@override
|
||||
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
|
||||
return 'Added $addedCount tracks to queue, skipped $skippedCount';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackSaveFailed(String error) {
|
||||
return 'Failed: $error';
|
||||
@@ -2176,6 +2279,18 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackConvertConfirmMessageLossless(
|
||||
String sourceFormat,
|
||||
String targetFormat,
|
||||
) {
|
||||
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertLosslessHint =>
|
||||
'Lossless conversion — no quality loss';
|
||||
|
||||
@override
|
||||
String get trackConvertConverting => 'Converting audio...';
|
||||
|
||||
@@ -2429,6 +2544,17 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertProgress(int current, int total) {
|
||||
return 'Converting $current of $total...';
|
||||
@@ -2554,9 +2680,6 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
return 'Download $count tracks?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get dialogDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
|
||||
|
||||
@@ -2622,6 +2745,22 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get downloadArtistNameFilters => 'Artist Name Filters';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolder =>
|
||||
'Create playlist source folder';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderEnabled =>
|
||||
'Playlist downloads use Playlist/ plus your normal folder structure.';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderDisabled =>
|
||||
'Playlist downloads use the normal folder structure only.';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderRedundant =>
|
||||
'By Playlist already places downloads inside a playlist folder.';
|
||||
|
||||
@override
|
||||
String get downloadSongLinkRegion => 'SongLink Region';
|
||||
|
||||
@@ -2640,12 +2779,6 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
String get downloadSelectServiceToEnable =>
|
||||
'Select a built-in service to enable';
|
||||
|
||||
@override
|
||||
String get downloadLossy320 => 'Lossy 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyFormat => 'Lossy Format';
|
||||
|
||||
@override
|
||||
String get downloadSelectTidalQobuz =>
|
||||
'Select Tidal or Qobuz above to configure quality';
|
||||
@@ -2722,32 +2855,6 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get downloadMusixmatchAuto => 'Auto';
|
||||
|
||||
@override
|
||||
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320FormatDesc =>
|
||||
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256Subtitle =>
|
||||
'Best quality Opus, ~8MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128 => 'Opus 128kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
|
||||
|
||||
@override
|
||||
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
||||
|
||||
@@ -2764,4 +2871,226 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get cacheRefresh => 'Refresh';
|
||||
|
||||
@override
|
||||
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
trackCount,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
String _temp1 = intl.Intl.pluralLogic(
|
||||
playlistCount,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
|
||||
}
|
||||
|
||||
@override
|
||||
String bulkDownloadPlaylistsButton(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Download $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
|
||||
|
||||
@override
|
||||
String get snackbarSelectedPlaylistsEmpty =>
|
||||
'Selected playlists have no tracks';
|
||||
|
||||
@override
|
||||
String playlistsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count playlists',
|
||||
one: '1 playlist',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFill => 'Auto-fill from online';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillDesc =>
|
||||
'Select fields to fill automatically from online metadata';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillFetch => 'Fetch & Fill';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillSearching => 'Searching online...';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillNoResults =>
|
||||
'No matching metadata found online';
|
||||
|
||||
@override
|
||||
String editMetadataAutoFillDone(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'fields',
|
||||
one: 'field',
|
||||
);
|
||||
return 'Filled $count $_temp0 from online metadata';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillNoneSelected =>
|
||||
'Select at least one field to auto-fill';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTitle => 'Title';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldArtist => 'Artist';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbum => 'Album';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbumArtist => 'Album Artist';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDate => 'Date';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTrackNum => 'Track #';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDiscNum => 'Disc #';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldGenre => 'Genre';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldIsrc => 'ISRC';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldLabel => 'Label';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldCopyright => 'Copyright';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldCover => 'Cover Art';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectAll => 'All';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectEmpty => 'Empty only';
|
||||
|
||||
@override
|
||||
String queueDownloadingCount(int count) {
|
||||
return 'Downloading ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueDownloadedHeader => 'Downloaded';
|
||||
|
||||
@override
|
||||
String get queueFilteringIndicator => 'Filtering...';
|
||||
|
||||
@override
|
||||
String queueTrackCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count tracks',
|
||||
one: '1 track',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String queueAlbumCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count albums',
|
||||
one: '1 album',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueEmptyAlbums => 'No album downloads';
|
||||
|
||||
@override
|
||||
String get queueEmptyAlbumsSubtitle =>
|
||||
'Download multiple tracks from an album to see them here';
|
||||
|
||||
@override
|
||||
String get queueEmptySingles => 'No single downloads';
|
||||
|
||||
@override
|
||||
String get queueEmptySinglesSubtitle =>
|
||||
'Single track downloads will appear here';
|
||||
|
||||
@override
|
||||
String get queueEmptyHistory => 'No download history';
|
||||
|
||||
@override
|
||||
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
|
||||
|
||||
@override
|
||||
String get selectionAllPlaylistsSelected => 'All playlists selected';
|
||||
|
||||
@override
|
||||
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
|
||||
|
||||
@override
|
||||
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
|
||||
|
||||
@override
|
||||
String get audioAnalysisTitle => 'Audio Quality Analysis';
|
||||
|
||||
@override
|
||||
String get audioAnalysisDescription =>
|
||||
'Verify lossless quality with spectrum analysis';
|
||||
|
||||
@override
|
||||
String get audioAnalysisAnalyzing => 'Analyzing audio...';
|
||||
|
||||
@override
|
||||
String get audioAnalysisSampleRate => 'Sample Rate';
|
||||
|
||||
@override
|
||||
String get audioAnalysisBitDepth => 'Bit Depth';
|
||||
|
||||
@override
|
||||
String get audioAnalysisChannels => 'Channels';
|
||||
|
||||
@override
|
||||
String get audioAnalysisDuration => 'Duration';
|
||||
|
||||
@override
|
||||
String get audioAnalysisNyquist => 'Nyquist';
|
||||
|
||||
@override
|
||||
String get audioAnalysisFileSize => 'Size';
|
||||
|
||||
@override
|
||||
String get audioAnalysisDynamicRange => 'Dynamic Range';
|
||||
|
||||
@override
|
||||
String get audioAnalysisPeak => 'Peak';
|
||||
|
||||
@override
|
||||
String get audioAnalysisRms => 'RMS';
|
||||
|
||||
@override
|
||||
String get audioAnalysisSamples => 'Samples';
|
||||
}
|
||||
|
||||
@@ -356,7 +356,7 @@ class AppLocalizationsHi 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';
|
||||
@@ -525,6 +525,9 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get dialogImport => 'Import';
|
||||
|
||||
@override
|
||||
String get dialogDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get dialogDiscard => 'Discard';
|
||||
|
||||
@@ -756,6 +759,36 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get searchPlaylists => 'Playlists';
|
||||
|
||||
@override
|
||||
String get searchSortTitle => 'Sort Results';
|
||||
|
||||
@override
|
||||
String get searchSortDefault => 'Default';
|
||||
|
||||
@override
|
||||
String get searchSortTitleAZ => 'Title (A-Z)';
|
||||
|
||||
@override
|
||||
String get searchSortTitleZA => 'Title (Z-A)';
|
||||
|
||||
@override
|
||||
String get searchSortArtistAZ => 'Artist (A-Z)';
|
||||
|
||||
@override
|
||||
String get searchSortArtistZA => 'Artist (Z-A)';
|
||||
|
||||
@override
|
||||
String get searchSortDurationShort => 'Duration (Shortest)';
|
||||
|
||||
@override
|
||||
String get searchSortDurationLong => 'Duration (Longest)';
|
||||
|
||||
@override
|
||||
String get searchSortDateOldest => 'Release Date (Oldest)';
|
||||
|
||||
@override
|
||||
String get searchSortDateNewest => 'Release Date (Newest)';
|
||||
|
||||
@override
|
||||
String get tooltipPlay => 'Play';
|
||||
|
||||
@@ -1386,20 +1419,42 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
||||
|
||||
@override
|
||||
String get downloadLossy320 => 'Lossy 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyFormat => 'Lossy Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320FormatDesc =>
|
||||
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256Subtitle =>
|
||||
'Best quality Opus, ~8MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128 => 'Opus 128kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
|
||||
|
||||
@override
|
||||
String get qualityNote =>
|
||||
'Actual quality depends on track availability from the service';
|
||||
|
||||
@override
|
||||
String get youtubeQualityNote =>
|
||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
||||
|
||||
@override
|
||||
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
|
||||
|
||||
@override
|
||||
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
|
||||
|
||||
@override
|
||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||
|
||||
@@ -1497,6 +1552,13 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||
'Artist/Album/ and Artist/Singles/';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumFlatSubtitle =>
|
||||
'Artist/Album/ and Artist/song.flac';
|
||||
|
||||
@override
|
||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||
|
||||
@@ -1692,6 +1754,25 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Show when searching for existing tracks';
|
||||
|
||||
@override
|
||||
String get libraryAutoScan => 'Auto Scan';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanSubtitle =>
|
||||
'Automatically scan your library for new files';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanOff => 'Off';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanOnOpen => 'Every app open';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanDaily => 'Daily';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanWeekly => 'Weekly';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Actions';
|
||||
|
||||
@@ -1868,7 +1949,7 @@ class AppLocalizationsHi 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 =>
|
||||
@@ -2142,6 +2223,28 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||
|
||||
@override
|
||||
String get queueFlacAction => 'Queue FLAC';
|
||||
|
||||
@override
|
||||
String queueFlacConfirmMessage(int count) {
|
||||
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
|
||||
}
|
||||
|
||||
@override
|
||||
String queueFlacFindingProgress(int current, int total) {
|
||||
return 'Finding FLAC matches... ($current/$total)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueFlacNoReliableMatches =>
|
||||
'No reliable online matches found for the selection';
|
||||
|
||||
@override
|
||||
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
|
||||
return 'Added $addedCount tracks to queue, skipped $skippedCount';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackSaveFailed(String error) {
|
||||
return 'Failed: $error';
|
||||
@@ -2174,6 +2277,18 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackConvertConfirmMessageLossless(
|
||||
String sourceFormat,
|
||||
String targetFormat,
|
||||
) {
|
||||
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertLosslessHint =>
|
||||
'Lossless conversion — no quality loss';
|
||||
|
||||
@override
|
||||
String get trackConvertConverting => 'Converting audio...';
|
||||
|
||||
@@ -2427,6 +2542,17 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertProgress(int current, int total) {
|
||||
return 'Converting $current of $total...';
|
||||
@@ -2552,9 +2678,6 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
return 'Download $count tracks?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get dialogDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
|
||||
|
||||
@@ -2620,6 +2743,22 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get downloadArtistNameFilters => 'Artist Name Filters';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolder =>
|
||||
'Create playlist source folder';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderEnabled =>
|
||||
'Playlist downloads use Playlist/ plus your normal folder structure.';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderDisabled =>
|
||||
'Playlist downloads use the normal folder structure only.';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderRedundant =>
|
||||
'By Playlist already places downloads inside a playlist folder.';
|
||||
|
||||
@override
|
||||
String get downloadSongLinkRegion => 'SongLink Region';
|
||||
|
||||
@@ -2638,12 +2777,6 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
String get downloadSelectServiceToEnable =>
|
||||
'Select a built-in service to enable';
|
||||
|
||||
@override
|
||||
String get downloadLossy320 => 'Lossy 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyFormat => 'Lossy Format';
|
||||
|
||||
@override
|
||||
String get downloadSelectTidalQobuz =>
|
||||
'Select Tidal or Qobuz above to configure quality';
|
||||
@@ -2720,32 +2853,6 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get downloadMusixmatchAuto => 'Auto';
|
||||
|
||||
@override
|
||||
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320FormatDesc =>
|
||||
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256Subtitle =>
|
||||
'Best quality Opus, ~8MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128 => 'Opus 128kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
|
||||
|
||||
@override
|
||||
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
||||
|
||||
@@ -2762,4 +2869,226 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get cacheRefresh => 'Refresh';
|
||||
|
||||
@override
|
||||
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
trackCount,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
String _temp1 = intl.Intl.pluralLogic(
|
||||
playlistCount,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
|
||||
}
|
||||
|
||||
@override
|
||||
String bulkDownloadPlaylistsButton(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Download $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
|
||||
|
||||
@override
|
||||
String get snackbarSelectedPlaylistsEmpty =>
|
||||
'Selected playlists have no tracks';
|
||||
|
||||
@override
|
||||
String playlistsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count playlists',
|
||||
one: '1 playlist',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFill => 'Auto-fill from online';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillDesc =>
|
||||
'Select fields to fill automatically from online metadata';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillFetch => 'Fetch & Fill';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillSearching => 'Searching online...';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillNoResults =>
|
||||
'No matching metadata found online';
|
||||
|
||||
@override
|
||||
String editMetadataAutoFillDone(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'fields',
|
||||
one: 'field',
|
||||
);
|
||||
return 'Filled $count $_temp0 from online metadata';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillNoneSelected =>
|
||||
'Select at least one field to auto-fill';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTitle => 'Title';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldArtist => 'Artist';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbum => 'Album';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbumArtist => 'Album Artist';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDate => 'Date';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTrackNum => 'Track #';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDiscNum => 'Disc #';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldGenre => 'Genre';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldIsrc => 'ISRC';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldLabel => 'Label';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldCopyright => 'Copyright';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldCover => 'Cover Art';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectAll => 'All';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectEmpty => 'Empty only';
|
||||
|
||||
@override
|
||||
String queueDownloadingCount(int count) {
|
||||
return 'Downloading ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueDownloadedHeader => 'Downloaded';
|
||||
|
||||
@override
|
||||
String get queueFilteringIndicator => 'Filtering...';
|
||||
|
||||
@override
|
||||
String queueTrackCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count tracks',
|
||||
one: '1 track',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String queueAlbumCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count albums',
|
||||
one: '1 album',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueEmptyAlbums => 'No album downloads';
|
||||
|
||||
@override
|
||||
String get queueEmptyAlbumsSubtitle =>
|
||||
'Download multiple tracks from an album to see them here';
|
||||
|
||||
@override
|
||||
String get queueEmptySingles => 'No single downloads';
|
||||
|
||||
@override
|
||||
String get queueEmptySinglesSubtitle =>
|
||||
'Single track downloads will appear here';
|
||||
|
||||
@override
|
||||
String get queueEmptyHistory => 'No download history';
|
||||
|
||||
@override
|
||||
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
|
||||
|
||||
@override
|
||||
String get selectionAllPlaylistsSelected => 'All playlists selected';
|
||||
|
||||
@override
|
||||
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
|
||||
|
||||
@override
|
||||
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
|
||||
|
||||
@override
|
||||
String get audioAnalysisTitle => 'Audio Quality Analysis';
|
||||
|
||||
@override
|
||||
String get audioAnalysisDescription =>
|
||||
'Verify lossless quality with spectrum analysis';
|
||||
|
||||
@override
|
||||
String get audioAnalysisAnalyzing => 'Analyzing audio...';
|
||||
|
||||
@override
|
||||
String get audioAnalysisSampleRate => 'Sample Rate';
|
||||
|
||||
@override
|
||||
String get audioAnalysisBitDepth => 'Bit Depth';
|
||||
|
||||
@override
|
||||
String get audioAnalysisChannels => 'Channels';
|
||||
|
||||
@override
|
||||
String get audioAnalysisDuration => 'Duration';
|
||||
|
||||
@override
|
||||
String get audioAnalysisNyquist => 'Nyquist';
|
||||
|
||||
@override
|
||||
String get audioAnalysisFileSize => 'Size';
|
||||
|
||||
@override
|
||||
String get audioAnalysisDynamicRange => 'Dynamic Range';
|
||||
|
||||
@override
|
||||
String get audioAnalysisPeak => 'Peak';
|
||||
|
||||
@override
|
||||
String get audioAnalysisRms => 'RMS';
|
||||
|
||||
@override
|
||||
String get audioAnalysisSamples => 'Samples';
|
||||
}
|
||||
|
||||
@@ -359,7 +359,7 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get aboutAppDescription =>
|
||||
'Unduh lagu Spotify dalam kualitas lossless dari Tidal, Qobuz, dan Amazon Music.';
|
||||
'Unduh lagu-lagu Spotify dalam kualitas lossless dari Tidal dan Qobuz.';
|
||||
|
||||
@override
|
||||
String get artistAlbums => 'Album';
|
||||
@@ -528,6 +528,9 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get dialogImport => 'Impor';
|
||||
|
||||
@override
|
||||
String get dialogDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get dialogDiscard => 'Buang';
|
||||
|
||||
@@ -759,6 +762,36 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get searchPlaylists => 'Playlist';
|
||||
|
||||
@override
|
||||
String get searchSortTitle => 'Sort Results';
|
||||
|
||||
@override
|
||||
String get searchSortDefault => 'Default';
|
||||
|
||||
@override
|
||||
String get searchSortTitleAZ => 'Title (A-Z)';
|
||||
|
||||
@override
|
||||
String get searchSortTitleZA => 'Title (Z-A)';
|
||||
|
||||
@override
|
||||
String get searchSortArtistAZ => 'Artist (A-Z)';
|
||||
|
||||
@override
|
||||
String get searchSortArtistZA => 'Artist (Z-A)';
|
||||
|
||||
@override
|
||||
String get searchSortDurationShort => 'Duration (Shortest)';
|
||||
|
||||
@override
|
||||
String get searchSortDurationLong => 'Duration (Longest)';
|
||||
|
||||
@override
|
||||
String get searchSortDateOldest => 'Release Date (Oldest)';
|
||||
|
||||
@override
|
||||
String get searchSortDateNewest => 'Release Date (Newest)';
|
||||
|
||||
@override
|
||||
String get tooltipPlay => 'Putar';
|
||||
|
||||
@@ -766,21 +799,21 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get filenameFormat => 'Format Nama File';
|
||||
|
||||
@override
|
||||
String get filenameShowAdvancedTags => 'Show advanced tags';
|
||||
String get filenameShowAdvancedTags => 'Tampilkan tag lanjutan';
|
||||
|
||||
@override
|
||||
String get filenameShowAdvancedTagsDescription =>
|
||||
'Enable formatted tags for track padding and date patterns';
|
||||
'Aktifkan tag yang diformat untuk padding trek dan pola tanggal';
|
||||
|
||||
@override
|
||||
String get folderOrganizationNone => 'Tidak ada';
|
||||
|
||||
@override
|
||||
String get folderOrganizationByPlaylist => 'By Playlist';
|
||||
String get folderOrganizationByPlaylist => 'Berdasarkan Daftar Putar';
|
||||
|
||||
@override
|
||||
String get folderOrganizationByPlaylistSubtitle =>
|
||||
'Separate folder for each playlist';
|
||||
'Setiap daftar putar memerlukan folder terpisah';
|
||||
|
||||
@override
|
||||
String get folderOrganizationByArtist => 'Berdasarkan Artis';
|
||||
@@ -936,13 +969,13 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
'Masukkan Client ID dan Secret Anda untuk menggunakan kuota aplikasi Spotify Anda sendiri.';
|
||||
|
||||
@override
|
||||
String get credentialsClientId => 'Client ID';
|
||||
String get credentialsClientId => 'ID Klien';
|
||||
|
||||
@override
|
||||
String get credentialsClientIdHint => 'Tempel Client ID';
|
||||
|
||||
@override
|
||||
String get credentialsClientSecret => 'Client Secret';
|
||||
String get credentialsClientSecret => 'Rahasia Klien';
|
||||
|
||||
@override
|
||||
String get credentialsClientSecretHint => 'Tempel Client Secret';
|
||||
@@ -951,7 +984,7 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get channelStable => 'Stabil';
|
||||
|
||||
@override
|
||||
String get channelPreview => 'Preview';
|
||||
String get channelPreview => 'Pratinjau';
|
||||
|
||||
@override
|
||||
String get sectionSearchSource => 'Sumber Pencarian';
|
||||
@@ -981,33 +1014,34 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get sectionFileSettings => 'Pengaturan File';
|
||||
|
||||
@override
|
||||
String get sectionLyrics => 'Lyrics';
|
||||
String get sectionLyrics => 'Lirik';
|
||||
|
||||
@override
|
||||
String get lyricsMode => 'Lyrics Mode';
|
||||
String get lyricsMode => 'Mode Lirik';
|
||||
|
||||
@override
|
||||
String get lyricsModeDescription =>
|
||||
'Choose how lyrics are saved with your downloads';
|
||||
'Pilih cara lirik disimpan bersama unduhan Anda';
|
||||
|
||||
@override
|
||||
String get lyricsModeEmbed => 'Embed in file';
|
||||
String get lyricsModeEmbed => 'Sematkan dalam file';
|
||||
|
||||
@override
|
||||
String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata';
|
||||
String get lyricsModeEmbedSubtitle =>
|
||||
'Lirik tersimpan di dalam metadata FLAC';
|
||||
|
||||
@override
|
||||
String get lyricsModeExternal => 'External .lrc file';
|
||||
String get lyricsModeExternal => 'File .lrc eksternal';
|
||||
|
||||
@override
|
||||
String get lyricsModeExternalSubtitle =>
|
||||
'Separate .lrc file for players like Samsung Music';
|
||||
'File .lrc terpisah untuk pemutar musik seperti Samsung Music';
|
||||
|
||||
@override
|
||||
String get lyricsModeBoth => 'Both';
|
||||
String get lyricsModeBoth => 'Keduanya';
|
||||
|
||||
@override
|
||||
String get lyricsModeBothSubtitle => 'Embed and save .lrc file';
|
||||
String get lyricsModeBothSubtitle => 'Sematkan dan simpan file .lrc';
|
||||
|
||||
@override
|
||||
String get sectionColor => 'Warna';
|
||||
@@ -1119,10 +1153,10 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get trackGenre => 'Genre';
|
||||
|
||||
@override
|
||||
String get trackLabel => 'Label';
|
||||
String get trackLabel => 'Lebel';
|
||||
|
||||
@override
|
||||
String get trackCopyright => 'Copyright';
|
||||
String get trackCopyright => 'Hak cipta';
|
||||
|
||||
@override
|
||||
String get trackDownloaded => 'Diunduh';
|
||||
@@ -1140,13 +1174,13 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get trackLyricsLoadFailed => 'Gagal memuat lirik';
|
||||
|
||||
@override
|
||||
String get trackEmbedLyrics => 'Embed Lyrics';
|
||||
String get trackEmbedLyrics => 'Sematkan Lirik';
|
||||
|
||||
@override
|
||||
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
|
||||
String get trackLyricsEmbedded => 'Lirik berhasil disematkan';
|
||||
|
||||
@override
|
||||
String get trackInstrumental => 'Instrumental track';
|
||||
String get trackInstrumental => 'Lagu instrumental';
|
||||
|
||||
@override
|
||||
String get trackCopiedToClipboard => 'Disalin ke clipboard';
|
||||
@@ -1242,7 +1276,7 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get storeEmptyNoResults => 'No extensions found';
|
||||
|
||||
@override
|
||||
String get extensionDefaultProvider => 'Default (Deezer/Spotify)';
|
||||
String get extensionDefaultProvider => 'Bawaan (Deezer/Spotify)';
|
||||
|
||||
@override
|
||||
String get extensionDefaultProviderSubtitle => 'Gunakan pencarian bawaan';
|
||||
@@ -1254,7 +1288,7 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get extensionId => 'ID';
|
||||
|
||||
@override
|
||||
String get extensionError => 'Error';
|
||||
String get extensionError => 'Terjadi kesalahan';
|
||||
|
||||
@override
|
||||
String get extensionCapabilities => 'Kemampuan';
|
||||
@@ -1393,20 +1427,42 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get qualityHiResFlacMaxSubtitle => '24-bit / hingga 192kHz';
|
||||
|
||||
@override
|
||||
String get downloadLossy320 => 'Lossy 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyFormat => 'Lossy Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320FormatDesc =>
|
||||
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256Subtitle =>
|
||||
'Best quality Opus, ~8MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128 => 'Opus 128kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
|
||||
|
||||
@override
|
||||
String get qualityNote =>
|
||||
'Kualitas sebenarnya tergantung ketersediaan lagu dari layanan';
|
||||
|
||||
@override
|
||||
String get youtubeQualityNote =>
|
||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
||||
|
||||
@override
|
||||
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
|
||||
|
||||
@override
|
||||
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
|
||||
|
||||
@override
|
||||
String get downloadAskBeforeDownload => 'Tanya Sebelum Unduh';
|
||||
|
||||
@@ -1420,18 +1476,19 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get downloadAlbumFolderStructure => 'Struktur Folder Album';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
||||
String get downloadUseAlbumArtistForFolders =>
|
||||
'Gunakan Artis Album untuk folder';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
|
||||
String get downloadUsePrimaryArtistOnly => 'Hanya artis utama untuk folder';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyEnabled =>
|
||||
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
|
||||
'Artis unggulan dihapus dari nama folder (misalnya Justin Bieber, Quavo → Justin Bieber)';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyDisabled =>
|
||||
'Full artist string used for folder name';
|
||||
'Nama lengkap artis digunakan untuk nama folder';
|
||||
|
||||
@override
|
||||
String get downloadSelectQuality => 'Pilih Kualitas';
|
||||
@@ -1453,24 +1510,24 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
'Apakah Anda yakin ingin menghapus semua unduhan?';
|
||||
|
||||
@override
|
||||
String get settingsAutoExportFailed => 'Auto-export failed downloads';
|
||||
String get settingsAutoExportFailed => 'Unduhan yang gagal diekspor otomatis';
|
||||
|
||||
@override
|
||||
String get settingsAutoExportFailedSubtitle =>
|
||||
'Save failed downloads to TXT file automatically';
|
||||
'Simpan unduhan yang gagal ke file TXT secara otomatis';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetwork => 'Download Network';
|
||||
String get settingsDownloadNetwork => 'Jaringan Unduhan';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkAny => 'WiFi + Mobile Data';
|
||||
String get settingsDownloadNetworkAny => 'WiFi + Data Seluler';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkWifiOnly => 'WiFi Only';
|
||||
String get settingsDownloadNetworkWifiOnly => 'Hanya WiFi';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkSubtitle =>
|
||||
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
|
||||
'Pilih jaringan mana yang akan digunakan untuk mengunduh. Jika diatur ke Hanya WiFi, unduhan akan berhenti sementara dan menggunakan data seluler.';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbum => 'Artis / Album';
|
||||
@@ -1498,11 +1555,18 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Nama Album/';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
|
||||
String get albumFolderArtistAlbumSingles => 'Artis / Album + Singel';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||
'Artist/Album/ and Artist/Singles/';
|
||||
'Artis/Album/ dan Artis/Single/';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumFlatSubtitle =>
|
||||
'Artist/Album/ and Artist/song.flac';
|
||||
|
||||
@override
|
||||
String get downloadedAlbumDeleteSelected => 'Hapus yang Dipilih';
|
||||
@@ -1558,21 +1622,21 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get recentTypeSong => 'Lagu';
|
||||
|
||||
@override
|
||||
String get recentTypePlaylist => 'Playlist';
|
||||
String get recentTypePlaylist => 'Daftar putar';
|
||||
|
||||
@override
|
||||
String get recentEmpty => 'No recent items yet';
|
||||
String get recentEmpty => 'Belum ada item terbaru';
|
||||
|
||||
@override
|
||||
String get recentShowAllDownloads => 'Show All Downloads';
|
||||
String get recentShowAllDownloads => 'Tampilkan Semua Unduhan';
|
||||
|
||||
@override
|
||||
String recentPlaylistInfo(String name) {
|
||||
return 'Playlist: $name';
|
||||
return 'Daftar Putar: $name';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyDownload => 'Download Discography';
|
||||
String get discographyDownload => 'Unduh Diskografi';
|
||||
|
||||
@override
|
||||
String get discographyDownloadAll => 'Unduh Semua';
|
||||
@@ -1699,6 +1763,25 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Show when searching for existing tracks';
|
||||
|
||||
@override
|
||||
String get libraryAutoScan => 'Auto Scan';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanSubtitle =>
|
||||
'Automatically scan your library for new files';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanOff => 'Off';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanOnOpen => 'Every app open';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanDaily => 'Daily';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanWeekly => 'Weekly';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Actions';
|
||||
|
||||
@@ -1863,44 +1946,44 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
}
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
|
||||
String get tutorialWelcomeTitle => 'Selamat Datang di SpotiFLAC!';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeDesc =>
|
||||
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
|
||||
'Mari kita pelajari cara mengunduh musik favorit Anda dalam kualitas lossless. Tutorial singkat ini akan menunjukkan dasar-dasarnya.';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip1 =>
|
||||
'Download music from Spotify, Deezer, or paste any supported URL';
|
||||
'Unduh musik dari Spotify, Deezer, atau tempel URL yang didukung';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip2 =>
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
|
||||
'Dapatkan audio berkualitas FLAC dari Tidal, Qobuz, atau Deezer';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip3 =>
|
||||
'Automatic metadata, cover art, and lyrics embedding';
|
||||
'Penyematan metadata, sampul album, dan lirik secara otomatis';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTitle => 'Finding Music';
|
||||
String get tutorialSearchTitle => 'Menemukan Musik';
|
||||
|
||||
@override
|
||||
String get tutorialSearchDesc =>
|
||||
'There are two easy ways to find music you want to download.';
|
||||
'Ada dua cara mudah untuk menemukan musik yang ingin Anda unduh.';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTitle => 'Downloading Music';
|
||||
String get tutorialDownloadTitle => 'Mengunduh Musik';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadDesc =>
|
||||
'Downloading music is simple and fast. Here\'s how it works.';
|
||||
'Mengunduh musik itu mudah dan cepat. Begini cara kerjanya.';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTitle => 'Your Library';
|
||||
String get tutorialLibraryTitle => 'Perpustakaan Anda';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryDesc =>
|
||||
'All your downloaded music is organized in the Library tab.';
|
||||
'Semua musik yang Anda unduh tersusun rapi di tab Perpustakaan.';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip1 =>
|
||||
@@ -2149,6 +2232,28 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||
|
||||
@override
|
||||
String get queueFlacAction => 'Antrekan FLAC';
|
||||
|
||||
@override
|
||||
String queueFlacConfirmMessage(int count) {
|
||||
return 'Cari kecocokan online untuk track yang dipilih lalu antrekan download FLAC.\n\nFile yang sudah ada tidak akan diubah atau dihapus.\n\nHanya kecocokan dengan keyakinan tinggi yang akan diantrikan otomatis.\n\n$count dipilih';
|
||||
}
|
||||
|
||||
@override
|
||||
String queueFlacFindingProgress(int current, int total) {
|
||||
return 'Mencari kecocokan FLAC... ($current/$total)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueFlacNoReliableMatches =>
|
||||
'Tidak ada kecocokan online yang cukup meyakinkan untuk pilihan ini';
|
||||
|
||||
@override
|
||||
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
|
||||
return 'Menambahkan $addedCount track ke antrean, melewati $skippedCount';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackSaveFailed(String error) {
|
||||
return 'Failed: $error';
|
||||
@@ -2158,7 +2263,8 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get trackConvertFormat => 'Convert Format';
|
||||
|
||||
@override
|
||||
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||
String get trackConvertFormatSubtitle =>
|
||||
'Konversi ke MP3, Opus, ALAC, atau FLAC';
|
||||
|
||||
@override
|
||||
String get trackConvertTitle => 'Convert Audio';
|
||||
@@ -2181,6 +2287,18 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackConvertConfirmMessageLossless(
|
||||
String sourceFormat,
|
||||
String targetFormat,
|
||||
) {
|
||||
return 'Konversi dari $sourceFormat ke $targetFormat? (Lossless — tanpa kehilangan kualitas)\n\nFile asli akan dihapus setelah konversi.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertLosslessHint =>
|
||||
'Konversi lossless — tanpa kehilangan kualitas';
|
||||
|
||||
@override
|
||||
String get trackConvertConverting => 'Converting audio...';
|
||||
|
||||
@@ -2434,6 +2552,17 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertProgress(int current, int total) {
|
||||
return 'Converting $current of $total...';
|
||||
@@ -2559,9 +2688,6 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
return 'Download $count tracks?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get dialogDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
|
||||
|
||||
@@ -2627,6 +2753,22 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get downloadArtistNameFilters => 'Artist Name Filters';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolder =>
|
||||
'Buat folder sumber playlist';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderEnabled =>
|
||||
'Unduhan dari playlist memakai Playlist/ lalu struktur folder normal Anda.';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderDisabled =>
|
||||
'Unduhan dari playlist hanya memakai struktur folder normal.';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderRedundant =>
|
||||
'Mode Berdasarkan Playlist sudah menaruh unduhan ke dalam folder playlist.';
|
||||
|
||||
@override
|
||||
String get downloadSongLinkRegion => 'SongLink Region';
|
||||
|
||||
@@ -2645,12 +2787,6 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get downloadSelectServiceToEnable =>
|
||||
'Select a built-in service to enable';
|
||||
|
||||
@override
|
||||
String get downloadLossy320 => 'Lossy 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyFormat => 'Lossy Format';
|
||||
|
||||
@override
|
||||
String get downloadSelectTidalQobuz =>
|
||||
'Select Tidal or Qobuz above to configure quality';
|
||||
@@ -2727,32 +2863,6 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get downloadMusixmatchAuto => 'Auto';
|
||||
|
||||
@override
|
||||
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320FormatDesc =>
|
||||
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256Subtitle =>
|
||||
'Best quality Opus, ~8MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128 => 'Opus 128kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
|
||||
|
||||
@override
|
||||
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
||||
|
||||
@@ -2769,4 +2879,226 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get cacheRefresh => 'Refresh';
|
||||
|
||||
@override
|
||||
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
trackCount,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
String _temp1 = intl.Intl.pluralLogic(
|
||||
playlistCount,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
|
||||
}
|
||||
|
||||
@override
|
||||
String bulkDownloadPlaylistsButton(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Download $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
|
||||
|
||||
@override
|
||||
String get snackbarSelectedPlaylistsEmpty =>
|
||||
'Selected playlists have no tracks';
|
||||
|
||||
@override
|
||||
String playlistsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count playlists',
|
||||
one: '1 playlist',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFill => 'Auto-fill from online';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillDesc =>
|
||||
'Select fields to fill automatically from online metadata';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillFetch => 'Fetch & Fill';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillSearching => 'Searching online...';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillNoResults =>
|
||||
'No matching metadata found online';
|
||||
|
||||
@override
|
||||
String editMetadataAutoFillDone(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'fields',
|
||||
one: 'field',
|
||||
);
|
||||
return 'Filled $count $_temp0 from online metadata';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillNoneSelected =>
|
||||
'Select at least one field to auto-fill';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTitle => 'Title';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldArtist => 'Artist';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbum => 'Album';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbumArtist => 'Album Artist';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDate => 'Date';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTrackNum => 'Track #';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDiscNum => 'Disc #';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldGenre => 'Genre';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldIsrc => 'ISRC';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldLabel => 'Label';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldCopyright => 'Copyright';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldCover => 'Cover Art';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectAll => 'All';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectEmpty => 'Empty only';
|
||||
|
||||
@override
|
||||
String queueDownloadingCount(int count) {
|
||||
return 'Downloading ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueDownloadedHeader => 'Downloaded';
|
||||
|
||||
@override
|
||||
String get queueFilteringIndicator => 'Filtering...';
|
||||
|
||||
@override
|
||||
String queueTrackCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count tracks',
|
||||
one: '1 track',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String queueAlbumCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count albums',
|
||||
one: '1 album',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueEmptyAlbums => 'No album downloads';
|
||||
|
||||
@override
|
||||
String get queueEmptyAlbumsSubtitle =>
|
||||
'Download multiple tracks from an album to see them here';
|
||||
|
||||
@override
|
||||
String get queueEmptySingles => 'No single downloads';
|
||||
|
||||
@override
|
||||
String get queueEmptySinglesSubtitle =>
|
||||
'Single track downloads will appear here';
|
||||
|
||||
@override
|
||||
String get queueEmptyHistory => 'No download history';
|
||||
|
||||
@override
|
||||
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
|
||||
|
||||
@override
|
||||
String get selectionAllPlaylistsSelected => 'All playlists selected';
|
||||
|
||||
@override
|
||||
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
|
||||
|
||||
@override
|
||||
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
|
||||
|
||||
@override
|
||||
String get audioAnalysisTitle => 'Audio Quality Analysis';
|
||||
|
||||
@override
|
||||
String get audioAnalysisDescription =>
|
||||
'Verify lossless quality with spectrum analysis';
|
||||
|
||||
@override
|
||||
String get audioAnalysisAnalyzing => 'Analyzing audio...';
|
||||
|
||||
@override
|
||||
String get audioAnalysisSampleRate => 'Sample Rate';
|
||||
|
||||
@override
|
||||
String get audioAnalysisBitDepth => 'Bit Depth';
|
||||
|
||||
@override
|
||||
String get audioAnalysisChannels => 'Channels';
|
||||
|
||||
@override
|
||||
String get audioAnalysisDuration => 'Duration';
|
||||
|
||||
@override
|
||||
String get audioAnalysisNyquist => 'Nyquist';
|
||||
|
||||
@override
|
||||
String get audioAnalysisFileSize => 'Size';
|
||||
|
||||
@override
|
||||
String get audioAnalysisDynamicRange => 'Dynamic Range';
|
||||
|
||||
@override
|
||||
String get audioAnalysisPeak => 'Peak';
|
||||
|
||||
@override
|
||||
String get audioAnalysisRms => 'RMS';
|
||||
|
||||
@override
|
||||
String get audioAnalysisSamples => 'Samples';
|
||||
}
|
||||
|
||||
@@ -352,7 +352,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get aboutAppDescription =>
|
||||
'Tidal、Qobuz、Amazon Music から Spotify のトラックをロスレス品質でダウンロードします。';
|
||||
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
|
||||
|
||||
@override
|
||||
String get artistAlbums => 'アルバム';
|
||||
@@ -521,6 +521,9 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get dialogImport => 'インポート';
|
||||
|
||||
@override
|
||||
String get dialogDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get dialogDiscard => '破棄';
|
||||
|
||||
@@ -751,6 +754,36 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get searchPlaylists => 'プレイリスト';
|
||||
|
||||
@override
|
||||
String get searchSortTitle => 'Sort Results';
|
||||
|
||||
@override
|
||||
String get searchSortDefault => 'Default';
|
||||
|
||||
@override
|
||||
String get searchSortTitleAZ => 'Title (A-Z)';
|
||||
|
||||
@override
|
||||
String get searchSortTitleZA => 'Title (Z-A)';
|
||||
|
||||
@override
|
||||
String get searchSortArtistAZ => 'Artist (A-Z)';
|
||||
|
||||
@override
|
||||
String get searchSortArtistZA => 'Artist (Z-A)';
|
||||
|
||||
@override
|
||||
String get searchSortDurationShort => 'Duration (Shortest)';
|
||||
|
||||
@override
|
||||
String get searchSortDurationLong => 'Duration (Longest)';
|
||||
|
||||
@override
|
||||
String get searchSortDateOldest => 'Release Date (Oldest)';
|
||||
|
||||
@override
|
||||
String get searchSortDateNewest => 'Release Date (Newest)';
|
||||
|
||||
@override
|
||||
String get tooltipPlay => '再生';
|
||||
|
||||
@@ -758,7 +791,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get filenameFormat => 'ファイル名の形式';
|
||||
|
||||
@override
|
||||
String get filenameShowAdvancedTags => 'Show advanced tags';
|
||||
String get filenameShowAdvancedTags => '高度なタグを表示';
|
||||
|
||||
@override
|
||||
String get filenameShowAdvancedTagsDescription =>
|
||||
@@ -1135,7 +1168,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
|
||||
|
||||
@override
|
||||
String get trackInstrumental => 'Instrumental track';
|
||||
String get trackInstrumental => 'インストゥルメンタルのトラック';
|
||||
|
||||
@override
|
||||
String get trackCopiedToClipboard => 'クリップボードにコピーしました';
|
||||
@@ -1376,19 +1409,41 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get qualityHiResFlacMaxSubtitle => '24-bit / 最大 192kHz';
|
||||
|
||||
@override
|
||||
String get downloadLossy320 => 'Lossy 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyFormat => 'Lossy Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320FormatDesc =>
|
||||
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256Subtitle =>
|
||||
'Best quality Opus, ~8MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128 => 'Opus 128kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
|
||||
|
||||
@override
|
||||
String get qualityNote => '実際の品質はサービスからのトラックの可用性に依存します';
|
||||
|
||||
@override
|
||||
String get youtubeQualityNote =>
|
||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
||||
|
||||
@override
|
||||
String get youtubeOpusBitrateTitle => 'YouTube Opus のビットレート';
|
||||
|
||||
@override
|
||||
String get youtubeMp3BitrateTitle => 'YouTube MP3 のビットレート';
|
||||
|
||||
@override
|
||||
String get downloadAskBeforeDownload => 'ダウンロード前に確認する';
|
||||
|
||||
@@ -1484,6 +1539,13 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||
'Artist/Album/ and Artist/Singles/';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumFlatSubtitle =>
|
||||
'Artist/Album/ and Artist/song.flac';
|
||||
|
||||
@override
|
||||
String get downloadedAlbumDeleteSelected => '選択済みを削除';
|
||||
|
||||
@@ -1679,6 +1741,25 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Show when searching for existing tracks';
|
||||
|
||||
@override
|
||||
String get libraryAutoScan => 'Auto Scan';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanSubtitle =>
|
||||
'Automatically scan your library for new files';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanOff => 'Off';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanOnOpen => 'Every app open';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanDaily => 'Daily';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanWeekly => 'Weekly';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'アクション';
|
||||
|
||||
@@ -1855,7 +1936,7 @@ class AppLocalizationsJa 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 =>
|
||||
@@ -2129,6 +2210,28 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||
|
||||
@override
|
||||
String get queueFlacAction => 'Queue FLAC';
|
||||
|
||||
@override
|
||||
String queueFlacConfirmMessage(int count) {
|
||||
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
|
||||
}
|
||||
|
||||
@override
|
||||
String queueFlacFindingProgress(int current, int total) {
|
||||
return 'Finding FLAC matches... ($current/$total)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueFlacNoReliableMatches =>
|
||||
'No reliable online matches found for the selection';
|
||||
|
||||
@override
|
||||
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
|
||||
return 'Added $addedCount tracks to queue, skipped $skippedCount';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackSaveFailed(String error) {
|
||||
return '失敗: $error';
|
||||
@@ -2161,6 +2264,18 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackConvertConfirmMessageLossless(
|
||||
String sourceFormat,
|
||||
String targetFormat,
|
||||
) {
|
||||
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertLosslessHint =>
|
||||
'Lossless conversion — no quality loss';
|
||||
|
||||
@override
|
||||
String get trackConvertConverting => 'オーディオを変換中...';
|
||||
|
||||
@@ -2173,7 +2288,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get trackConvertFailed => '変換に失敗しました';
|
||||
|
||||
@override
|
||||
String get cueSplitTitle => 'Split CUE Sheet';
|
||||
String get cueSplitTitle => '分割 CUE シート';
|
||||
|
||||
@override
|
||||
String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
|
||||
@@ -2323,7 +2438,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get collectionRemoveFromPlaylist => 'Remove from playlist';
|
||||
|
||||
@override
|
||||
String get collectionRemoveFromFolder => 'Remove from folder';
|
||||
String get collectionRemoveFromFolder => 'フォルダから削除';
|
||||
|
||||
@override
|
||||
String collectionRemoved(String trackName) {
|
||||
@@ -2357,26 +2472,26 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get trackOptionRemoveFromLoved => 'Remove from Loved';
|
||||
|
||||
@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: '個のトラック',
|
||||
one: '個のトラック',
|
||||
);
|
||||
return 'Share $count $_temp0';
|
||||
return '$count $_temp0を共有';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -2397,7 +2512,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get selectionConvertNoConvertible => 'No convertible tracks selected';
|
||||
|
||||
@override
|
||||
String get selectionBatchConvertConfirmTitle => 'Batch Convert';
|
||||
String get selectionBatchConvertConfirmTitle => '一括変換';
|
||||
|
||||
@override
|
||||
String selectionBatchConvertConfirmMessage(
|
||||
@@ -2414,6 +2529,17 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertProgress(int current, int total) {
|
||||
return 'Converting $current of $total...';
|
||||
@@ -2539,9 +2665,6 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
return 'Download $count tracks?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get dialogDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
|
||||
|
||||
@@ -2607,6 +2730,22 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get downloadArtistNameFilters => 'Artist Name Filters';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolder =>
|
||||
'Create playlist source folder';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderEnabled =>
|
||||
'Playlist downloads use Playlist/ plus your normal folder structure.';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderDisabled =>
|
||||
'Playlist downloads use the normal folder structure only.';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderRedundant =>
|
||||
'By Playlist already places downloads inside a playlist folder.';
|
||||
|
||||
@override
|
||||
String get downloadSongLinkRegion => 'SongLink Region';
|
||||
|
||||
@@ -2625,12 +2764,6 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get downloadSelectServiceToEnable =>
|
||||
'Select a built-in service to enable';
|
||||
|
||||
@override
|
||||
String get downloadLossy320 => 'Lossy 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyFormat => 'Lossy Format';
|
||||
|
||||
@override
|
||||
String get downloadSelectTidalQobuz =>
|
||||
'Select Tidal or Qobuz above to configure quality';
|
||||
@@ -2707,32 +2840,6 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get downloadMusixmatchAuto => 'Auto';
|
||||
|
||||
@override
|
||||
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320FormatDesc =>
|
||||
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256Subtitle =>
|
||||
'Best quality Opus, ~8MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128 => 'Opus 128kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
|
||||
|
||||
@override
|
||||
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
||||
|
||||
@@ -2749,4 +2856,226 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get cacheRefresh => 'Refresh';
|
||||
|
||||
@override
|
||||
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
trackCount,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
String _temp1 = intl.Intl.pluralLogic(
|
||||
playlistCount,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
|
||||
}
|
||||
|
||||
@override
|
||||
String bulkDownloadPlaylistsButton(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Download $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
|
||||
|
||||
@override
|
||||
String get snackbarSelectedPlaylistsEmpty =>
|
||||
'Selected playlists have no tracks';
|
||||
|
||||
@override
|
||||
String playlistsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count playlists',
|
||||
one: '1 playlist',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFill => 'Auto-fill from online';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillDesc =>
|
||||
'Select fields to fill automatically from online metadata';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillFetch => 'Fetch & Fill';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillSearching => 'Searching online...';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillNoResults =>
|
||||
'No matching metadata found online';
|
||||
|
||||
@override
|
||||
String editMetadataAutoFillDone(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'fields',
|
||||
one: 'field',
|
||||
);
|
||||
return 'Filled $count $_temp0 from online metadata';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillNoneSelected =>
|
||||
'Select at least one field to auto-fill';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTitle => 'Title';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldArtist => 'Artist';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbum => 'Album';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbumArtist => 'Album Artist';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDate => 'Date';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTrackNum => 'Track #';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDiscNum => 'Disc #';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldGenre => 'Genre';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldIsrc => 'ISRC';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldLabel => 'Label';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldCopyright => 'Copyright';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldCover => 'Cover Art';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectAll => 'All';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectEmpty => 'Empty only';
|
||||
|
||||
@override
|
||||
String queueDownloadingCount(int count) {
|
||||
return 'Downloading ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueDownloadedHeader => 'Downloaded';
|
||||
|
||||
@override
|
||||
String get queueFilteringIndicator => 'Filtering...';
|
||||
|
||||
@override
|
||||
String queueTrackCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count tracks',
|
||||
one: '1 track',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String queueAlbumCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count albums',
|
||||
one: '1 album',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueEmptyAlbums => 'No album downloads';
|
||||
|
||||
@override
|
||||
String get queueEmptyAlbumsSubtitle =>
|
||||
'Download multiple tracks from an album to see them here';
|
||||
|
||||
@override
|
||||
String get queueEmptySingles => 'No single downloads';
|
||||
|
||||
@override
|
||||
String get queueEmptySinglesSubtitle =>
|
||||
'Single track downloads will appear here';
|
||||
|
||||
@override
|
||||
String get queueEmptyHistory => 'No download history';
|
||||
|
||||
@override
|
||||
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
|
||||
|
||||
@override
|
||||
String get selectionAllPlaylistsSelected => 'All playlists selected';
|
||||
|
||||
@override
|
||||
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
|
||||
|
||||
@override
|
||||
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
|
||||
|
||||
@override
|
||||
String get audioAnalysisTitle => 'Audio Quality Analysis';
|
||||
|
||||
@override
|
||||
String get audioAnalysisDescription =>
|
||||
'Verify lossless quality with spectrum analysis';
|
||||
|
||||
@override
|
||||
String get audioAnalysisAnalyzing => 'Analyzing audio...';
|
||||
|
||||
@override
|
||||
String get audioAnalysisSampleRate => 'Sample Rate';
|
||||
|
||||
@override
|
||||
String get audioAnalysisBitDepth => 'Bit Depth';
|
||||
|
||||
@override
|
||||
String get audioAnalysisChannels => 'Channels';
|
||||
|
||||
@override
|
||||
String get audioAnalysisDuration => 'Duration';
|
||||
|
||||
@override
|
||||
String get audioAnalysisNyquist => 'Nyquist';
|
||||
|
||||
@override
|
||||
String get audioAnalysisFileSize => 'Size';
|
||||
|
||||
@override
|
||||
String get audioAnalysisDynamicRange => 'Dynamic Range';
|
||||
|
||||
@override
|
||||
String get audioAnalysisPeak => 'Peak';
|
||||
|
||||
@override
|
||||
String get audioAnalysisRms => 'RMS';
|
||||
|
||||
@override
|
||||
String get audioAnalysisSamples => 'Samples';
|
||||
}
|
||||
|
||||
@@ -344,7 +344,7 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get aboutAppDescription =>
|
||||
'Tidal, Qobuz, Amazon Music에서 Spotify 트랙을 무손실 음질로 다운로드하세요.';
|
||||
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
|
||||
|
||||
@override
|
||||
String get artistAlbums => '앨범';
|
||||
@@ -510,6 +510,9 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get dialogImport => '불러오기';
|
||||
|
||||
@override
|
||||
String get dialogDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get dialogDiscard => '취소';
|
||||
|
||||
@@ -738,6 +741,36 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get searchPlaylists => '재생목록들';
|
||||
|
||||
@override
|
||||
String get searchSortTitle => 'Sort Results';
|
||||
|
||||
@override
|
||||
String get searchSortDefault => 'Default';
|
||||
|
||||
@override
|
||||
String get searchSortTitleAZ => 'Title (A-Z)';
|
||||
|
||||
@override
|
||||
String get searchSortTitleZA => 'Title (Z-A)';
|
||||
|
||||
@override
|
||||
String get searchSortArtistAZ => 'Artist (A-Z)';
|
||||
|
||||
@override
|
||||
String get searchSortArtistZA => 'Artist (Z-A)';
|
||||
|
||||
@override
|
||||
String get searchSortDurationShort => 'Duration (Shortest)';
|
||||
|
||||
@override
|
||||
String get searchSortDurationLong => 'Duration (Longest)';
|
||||
|
||||
@override
|
||||
String get searchSortDateOldest => 'Release Date (Oldest)';
|
||||
|
||||
@override
|
||||
String get searchSortDateNewest => 'Release Date (Newest)';
|
||||
|
||||
@override
|
||||
String get tooltipPlay => '재생';
|
||||
|
||||
@@ -1366,20 +1399,42 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
||||
|
||||
@override
|
||||
String get downloadLossy320 => 'Lossy 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyFormat => 'Lossy Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320FormatDesc =>
|
||||
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256Subtitle =>
|
||||
'Best quality Opus, ~8MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128 => 'Opus 128kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
|
||||
|
||||
@override
|
||||
String get qualityNote =>
|
||||
'Actual quality depends on track availability from the service';
|
||||
|
||||
@override
|
||||
String get youtubeQualityNote =>
|
||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
||||
|
||||
@override
|
||||
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
|
||||
|
||||
@override
|
||||
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
|
||||
|
||||
@override
|
||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||
|
||||
@@ -1477,6 +1532,13 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||
'Artist/Album/ and Artist/Singles/';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumFlatSubtitle =>
|
||||
'Artist/Album/ and Artist/song.flac';
|
||||
|
||||
@override
|
||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||
|
||||
@@ -1672,6 +1734,25 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Show when searching for existing tracks';
|
||||
|
||||
@override
|
||||
String get libraryAutoScan => 'Auto Scan';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanSubtitle =>
|
||||
'Automatically scan your library for new files';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanOff => 'Off';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanOnOpen => 'Every app open';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanDaily => 'Daily';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanWeekly => 'Weekly';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Actions';
|
||||
|
||||
@@ -1848,7 +1929,7 @@ class AppLocalizationsKo 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 =>
|
||||
@@ -2122,6 +2203,28 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||
|
||||
@override
|
||||
String get queueFlacAction => 'Queue FLAC';
|
||||
|
||||
@override
|
||||
String queueFlacConfirmMessage(int count) {
|
||||
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
|
||||
}
|
||||
|
||||
@override
|
||||
String queueFlacFindingProgress(int current, int total) {
|
||||
return 'Finding FLAC matches... ($current/$total)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueFlacNoReliableMatches =>
|
||||
'No reliable online matches found for the selection';
|
||||
|
||||
@override
|
||||
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
|
||||
return 'Added $addedCount tracks to queue, skipped $skippedCount';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackSaveFailed(String error) {
|
||||
return 'Failed: $error';
|
||||
@@ -2154,6 +2257,18 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackConvertConfirmMessageLossless(
|
||||
String sourceFormat,
|
||||
String targetFormat,
|
||||
) {
|
||||
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertLosslessHint =>
|
||||
'Lossless conversion — no quality loss';
|
||||
|
||||
@override
|
||||
String get trackConvertConverting => 'Converting audio...';
|
||||
|
||||
@@ -2407,6 +2522,17 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertProgress(int current, int total) {
|
||||
return 'Converting $current of $total...';
|
||||
@@ -2532,9 +2658,6 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
return 'Download $count tracks?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get dialogDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
|
||||
|
||||
@@ -2600,6 +2723,22 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get downloadArtistNameFilters => 'Artist Name Filters';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolder =>
|
||||
'Create playlist source folder';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderEnabled =>
|
||||
'Playlist downloads use Playlist/ plus your normal folder structure.';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderDisabled =>
|
||||
'Playlist downloads use the normal folder structure only.';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderRedundant =>
|
||||
'By Playlist already places downloads inside a playlist folder.';
|
||||
|
||||
@override
|
||||
String get downloadSongLinkRegion => 'SongLink Region';
|
||||
|
||||
@@ -2618,12 +2757,6 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get downloadSelectServiceToEnable =>
|
||||
'Select a built-in service to enable';
|
||||
|
||||
@override
|
||||
String get downloadLossy320 => 'Lossy 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyFormat => 'Lossy Format';
|
||||
|
||||
@override
|
||||
String get downloadSelectTidalQobuz =>
|
||||
'Select Tidal or Qobuz above to configure quality';
|
||||
@@ -2700,32 +2833,6 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get downloadMusixmatchAuto => 'Auto';
|
||||
|
||||
@override
|
||||
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320FormatDesc =>
|
||||
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256Subtitle =>
|
||||
'Best quality Opus, ~8MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128 => 'Opus 128kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
|
||||
|
||||
@override
|
||||
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
||||
|
||||
@@ -2742,4 +2849,226 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get cacheRefresh => 'Refresh';
|
||||
|
||||
@override
|
||||
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
trackCount,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
String _temp1 = intl.Intl.pluralLogic(
|
||||
playlistCount,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
|
||||
}
|
||||
|
||||
@override
|
||||
String bulkDownloadPlaylistsButton(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Download $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
|
||||
|
||||
@override
|
||||
String get snackbarSelectedPlaylistsEmpty =>
|
||||
'Selected playlists have no tracks';
|
||||
|
||||
@override
|
||||
String playlistsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count playlists',
|
||||
one: '1 playlist',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFill => 'Auto-fill from online';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillDesc =>
|
||||
'Select fields to fill automatically from online metadata';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillFetch => 'Fetch & Fill';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillSearching => 'Searching online...';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillNoResults =>
|
||||
'No matching metadata found online';
|
||||
|
||||
@override
|
||||
String editMetadataAutoFillDone(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'fields',
|
||||
one: 'field',
|
||||
);
|
||||
return 'Filled $count $_temp0 from online metadata';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillNoneSelected =>
|
||||
'Select at least one field to auto-fill';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTitle => 'Title';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldArtist => 'Artist';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbum => 'Album';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbumArtist => 'Album Artist';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDate => 'Date';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTrackNum => 'Track #';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDiscNum => 'Disc #';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldGenre => 'Genre';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldIsrc => 'ISRC';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldLabel => 'Label';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldCopyright => 'Copyright';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldCover => 'Cover Art';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectAll => 'All';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectEmpty => 'Empty only';
|
||||
|
||||
@override
|
||||
String queueDownloadingCount(int count) {
|
||||
return 'Downloading ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueDownloadedHeader => 'Downloaded';
|
||||
|
||||
@override
|
||||
String get queueFilteringIndicator => 'Filtering...';
|
||||
|
||||
@override
|
||||
String queueTrackCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count tracks',
|
||||
one: '1 track',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String queueAlbumCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count albums',
|
||||
one: '1 album',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueEmptyAlbums => 'No album downloads';
|
||||
|
||||
@override
|
||||
String get queueEmptyAlbumsSubtitle =>
|
||||
'Download multiple tracks from an album to see them here';
|
||||
|
||||
@override
|
||||
String get queueEmptySingles => 'No single downloads';
|
||||
|
||||
@override
|
||||
String get queueEmptySinglesSubtitle =>
|
||||
'Single track downloads will appear here';
|
||||
|
||||
@override
|
||||
String get queueEmptyHistory => 'No download history';
|
||||
|
||||
@override
|
||||
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
|
||||
|
||||
@override
|
||||
String get selectionAllPlaylistsSelected => 'All playlists selected';
|
||||
|
||||
@override
|
||||
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
|
||||
|
||||
@override
|
||||
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
|
||||
|
||||
@override
|
||||
String get audioAnalysisTitle => 'Audio Quality Analysis';
|
||||
|
||||
@override
|
||||
String get audioAnalysisDescription =>
|
||||
'Verify lossless quality with spectrum analysis';
|
||||
|
||||
@override
|
||||
String get audioAnalysisAnalyzing => 'Analyzing audio...';
|
||||
|
||||
@override
|
||||
String get audioAnalysisSampleRate => 'Sample Rate';
|
||||
|
||||
@override
|
||||
String get audioAnalysisBitDepth => 'Bit Depth';
|
||||
|
||||
@override
|
||||
String get audioAnalysisChannels => 'Channels';
|
||||
|
||||
@override
|
||||
String get audioAnalysisDuration => 'Duration';
|
||||
|
||||
@override
|
||||
String get audioAnalysisNyquist => 'Nyquist';
|
||||
|
||||
@override
|
||||
String get audioAnalysisFileSize => 'Size';
|
||||
|
||||
@override
|
||||
String get audioAnalysisDynamicRange => 'Dynamic Range';
|
||||
|
||||
@override
|
||||
String get audioAnalysisPeak => 'Peak';
|
||||
|
||||
@override
|
||||
String get audioAnalysisRms => 'RMS';
|
||||
|
||||
@override
|
||||
String get audioAnalysisSamples => 'Samples';
|
||||
}
|
||||
|
||||
@@ -158,16 +158,16 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get optionsConcurrentDownloads => 'Concurrent Downloads';
|
||||
|
||||
@override
|
||||
String get optionsConcurrentSequential => 'Sequential (1 at a time)';
|
||||
String get optionsConcurrentSequential => 'Sequentiële (1 per keer)';
|
||||
|
||||
@override
|
||||
String optionsConcurrentParallel(int count) {
|
||||
return '$count parallel downloads';
|
||||
return '';
|
||||
}
|
||||
|
||||
@override
|
||||
String get optionsConcurrentWarning =>
|
||||
'Parallel downloads may trigger rate limiting';
|
||||
'Parallel downloaden kan leiden tot rate-limiting';
|
||||
|
||||
@override
|
||||
String get optionsExtensionStore => 'Extension Store';
|
||||
@@ -271,7 +271,7 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get aboutContributors => 'Contributors';
|
||||
|
||||
@override
|
||||
String get aboutMobileDeveloper => 'Mobile version developer';
|
||||
String get aboutMobileDeveloper => '';
|
||||
|
||||
@override
|
||||
String get aboutOriginalCreator => 'Creator of the original SpotiFLAC';
|
||||
@@ -356,7 +356,7 @@ class AppLocalizationsNl 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';
|
||||
@@ -525,6 +525,9 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get dialogImport => 'Import';
|
||||
|
||||
@override
|
||||
String get dialogDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get dialogDiscard => 'Discard';
|
||||
|
||||
@@ -756,6 +759,36 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get searchPlaylists => 'Playlists';
|
||||
|
||||
@override
|
||||
String get searchSortTitle => 'Sort Results';
|
||||
|
||||
@override
|
||||
String get searchSortDefault => 'Default';
|
||||
|
||||
@override
|
||||
String get searchSortTitleAZ => 'Title (A-Z)';
|
||||
|
||||
@override
|
||||
String get searchSortTitleZA => 'Title (Z-A)';
|
||||
|
||||
@override
|
||||
String get searchSortArtistAZ => 'Artist (A-Z)';
|
||||
|
||||
@override
|
||||
String get searchSortArtistZA => 'Artist (Z-A)';
|
||||
|
||||
@override
|
||||
String get searchSortDurationShort => 'Duration (Shortest)';
|
||||
|
||||
@override
|
||||
String get searchSortDurationLong => 'Duration (Longest)';
|
||||
|
||||
@override
|
||||
String get searchSortDateOldest => 'Release Date (Oldest)';
|
||||
|
||||
@override
|
||||
String get searchSortDateNewest => 'Release Date (Newest)';
|
||||
|
||||
@override
|
||||
String get tooltipPlay => 'Play';
|
||||
|
||||
@@ -1386,20 +1419,42 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
||||
|
||||
@override
|
||||
String get downloadLossy320 => 'Lossy 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyFormat => 'Lossy Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320FormatDesc =>
|
||||
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256Subtitle =>
|
||||
'Best quality Opus, ~8MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128 => 'Opus 128kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
|
||||
|
||||
@override
|
||||
String get qualityNote =>
|
||||
'Actual quality depends on track availability from the service';
|
||||
|
||||
@override
|
||||
String get youtubeQualityNote =>
|
||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
||||
|
||||
@override
|
||||
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
|
||||
|
||||
@override
|
||||
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
|
||||
|
||||
@override
|
||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||
|
||||
@@ -1497,6 +1552,13 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||
'Artist/Album/ and Artist/Singles/';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumFlatSubtitle =>
|
||||
'Artist/Album/ and Artist/song.flac';
|
||||
|
||||
@override
|
||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||
|
||||
@@ -1692,6 +1754,25 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Show when searching for existing tracks';
|
||||
|
||||
@override
|
||||
String get libraryAutoScan => 'Auto Scan';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanSubtitle =>
|
||||
'Automatically scan your library for new files';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanOff => 'Off';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanOnOpen => 'Every app open';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanDaily => 'Daily';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanWeekly => 'Weekly';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Actions';
|
||||
|
||||
@@ -1868,7 +1949,7 @@ class AppLocalizationsNl 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 =>
|
||||
@@ -2142,6 +2223,28 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||
|
||||
@override
|
||||
String get queueFlacAction => 'Queue FLAC';
|
||||
|
||||
@override
|
||||
String queueFlacConfirmMessage(int count) {
|
||||
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
|
||||
}
|
||||
|
||||
@override
|
||||
String queueFlacFindingProgress(int current, int total) {
|
||||
return 'Finding FLAC matches... ($current/$total)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueFlacNoReliableMatches =>
|
||||
'No reliable online matches found for the selection';
|
||||
|
||||
@override
|
||||
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
|
||||
return 'Added $addedCount tracks to queue, skipped $skippedCount';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackSaveFailed(String error) {
|
||||
return 'Failed: $error';
|
||||
@@ -2174,6 +2277,18 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackConvertConfirmMessageLossless(
|
||||
String sourceFormat,
|
||||
String targetFormat,
|
||||
) {
|
||||
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertLosslessHint =>
|
||||
'Lossless conversion — no quality loss';
|
||||
|
||||
@override
|
||||
String get trackConvertConverting => 'Converting audio...';
|
||||
|
||||
@@ -2427,6 +2542,17 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertProgress(int current, int total) {
|
||||
return 'Converting $current of $total...';
|
||||
@@ -2552,9 +2678,6 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
return 'Download $count tracks?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get dialogDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
|
||||
|
||||
@@ -2620,6 +2743,22 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get downloadArtistNameFilters => 'Artist Name Filters';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolder =>
|
||||
'Create playlist source folder';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderEnabled =>
|
||||
'Playlist downloads use Playlist/ plus your normal folder structure.';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderDisabled =>
|
||||
'Playlist downloads use the normal folder structure only.';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderRedundant =>
|
||||
'By Playlist already places downloads inside a playlist folder.';
|
||||
|
||||
@override
|
||||
String get downloadSongLinkRegion => 'SongLink Region';
|
||||
|
||||
@@ -2638,12 +2777,6 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get downloadSelectServiceToEnable =>
|
||||
'Select a built-in service to enable';
|
||||
|
||||
@override
|
||||
String get downloadLossy320 => 'Lossy 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyFormat => 'Lossy Format';
|
||||
|
||||
@override
|
||||
String get downloadSelectTidalQobuz =>
|
||||
'Select Tidal or Qobuz above to configure quality';
|
||||
@@ -2720,32 +2853,6 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get downloadMusixmatchAuto => 'Auto';
|
||||
|
||||
@override
|
||||
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320FormatDesc =>
|
||||
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256Subtitle =>
|
||||
'Best quality Opus, ~8MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128 => 'Opus 128kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
|
||||
|
||||
@override
|
||||
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
||||
|
||||
@@ -2762,4 +2869,226 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get cacheRefresh => 'Refresh';
|
||||
|
||||
@override
|
||||
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
trackCount,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
String _temp1 = intl.Intl.pluralLogic(
|
||||
playlistCount,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
|
||||
}
|
||||
|
||||
@override
|
||||
String bulkDownloadPlaylistsButton(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Download $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
|
||||
|
||||
@override
|
||||
String get snackbarSelectedPlaylistsEmpty =>
|
||||
'Selected playlists have no tracks';
|
||||
|
||||
@override
|
||||
String playlistsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count playlists',
|
||||
one: '1 playlist',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFill => 'Auto-fill from online';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillDesc =>
|
||||
'Select fields to fill automatically from online metadata';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillFetch => 'Fetch & Fill';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillSearching => 'Searching online...';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillNoResults =>
|
||||
'No matching metadata found online';
|
||||
|
||||
@override
|
||||
String editMetadataAutoFillDone(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'fields',
|
||||
one: 'field',
|
||||
);
|
||||
return 'Filled $count $_temp0 from online metadata';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillNoneSelected =>
|
||||
'Select at least one field to auto-fill';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTitle => 'Title';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldArtist => 'Artist';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbum => 'Album';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbumArtist => 'Album Artist';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDate => 'Date';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTrackNum => 'Track #';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDiscNum => 'Disc #';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldGenre => 'Genre';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldIsrc => 'ISRC';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldLabel => 'Label';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldCopyright => 'Copyright';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldCover => 'Cover Art';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectAll => 'All';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectEmpty => 'Empty only';
|
||||
|
||||
@override
|
||||
String queueDownloadingCount(int count) {
|
||||
return 'Downloading ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueDownloadedHeader => 'Downloaded';
|
||||
|
||||
@override
|
||||
String get queueFilteringIndicator => 'Filtering...';
|
||||
|
||||
@override
|
||||
String queueTrackCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count tracks',
|
||||
one: '1 track',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String queueAlbumCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count albums',
|
||||
one: '1 album',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueEmptyAlbums => 'No album downloads';
|
||||
|
||||
@override
|
||||
String get queueEmptyAlbumsSubtitle =>
|
||||
'Download multiple tracks from an album to see them here';
|
||||
|
||||
@override
|
||||
String get queueEmptySingles => 'No single downloads';
|
||||
|
||||
@override
|
||||
String get queueEmptySinglesSubtitle =>
|
||||
'Single track downloads will appear here';
|
||||
|
||||
@override
|
||||
String get queueEmptyHistory => 'No download history';
|
||||
|
||||
@override
|
||||
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
|
||||
|
||||
@override
|
||||
String get selectionAllPlaylistsSelected => 'All playlists selected';
|
||||
|
||||
@override
|
||||
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
|
||||
|
||||
@override
|
||||
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
|
||||
|
||||
@override
|
||||
String get audioAnalysisTitle => 'Audio Quality Analysis';
|
||||
|
||||
@override
|
||||
String get audioAnalysisDescription =>
|
||||
'Verify lossless quality with spectrum analysis';
|
||||
|
||||
@override
|
||||
String get audioAnalysisAnalyzing => 'Analyzing audio...';
|
||||
|
||||
@override
|
||||
String get audioAnalysisSampleRate => 'Sample Rate';
|
||||
|
||||
@override
|
||||
String get audioAnalysisBitDepth => 'Bit Depth';
|
||||
|
||||
@override
|
||||
String get audioAnalysisChannels => 'Channels';
|
||||
|
||||
@override
|
||||
String get audioAnalysisDuration => 'Duration';
|
||||
|
||||
@override
|
||||
String get audioAnalysisNyquist => 'Nyquist';
|
||||
|
||||
@override
|
||||
String get audioAnalysisFileSize => 'Size';
|
||||
|
||||
@override
|
||||
String get audioAnalysisDynamicRange => 'Dynamic Range';
|
||||
|
||||
@override
|
||||
String get audioAnalysisPeak => 'Peak';
|
||||
|
||||
@override
|
||||
String get audioAnalysisRms => 'RMS';
|
||||
|
||||
@override
|
||||
String get audioAnalysisSamples => 'Samples';
|
||||
}
|
||||
|
||||
+3071
-2457
File diff suppressed because it is too large
Load Diff
@@ -363,7 +363,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get aboutAppDescription =>
|
||||
'Скачайте треки Spotify в Lossless качестве из Tidal, Qobuz и Amazon Music.';
|
||||
'Скачивайте треки Spotify в lossless качестве с Tidal и Qobuz.';
|
||||
|
||||
@override
|
||||
String get artistAlbums => 'Альбомы';
|
||||
@@ -534,6 +534,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get dialogImport => 'Импорт';
|
||||
|
||||
@override
|
||||
String get dialogDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get dialogDiscard => 'Отменить';
|
||||
|
||||
@@ -703,15 +706,15 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get errorNoTracksFound => 'Треки не найдены';
|
||||
|
||||
@override
|
||||
String get errorUrlNotRecognized => 'Link not recognized';
|
||||
String get errorUrlNotRecognized => 'Ссылка не распознана';
|
||||
|
||||
@override
|
||||
String get errorUrlNotRecognizedMessage =>
|
||||
'This link is not supported. Make sure the URL is correct and a compatible extension is installed.';
|
||||
'Эта ссылка не поддерживается. Убедитесь, что URL-адрес указан правильно и установлено совместимое расширение.';
|
||||
|
||||
@override
|
||||
String get errorUrlFetchFailed =>
|
||||
'Failed to load content from this link. Please try again.';
|
||||
'Не удалось загрузить контент по этой ссылке. Пожалуйста, попробуйте еще раз.';
|
||||
|
||||
@override
|
||||
String errorMissingExtensionSource(String item) {
|
||||
@@ -770,6 +773,36 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get searchPlaylists => 'Плейлисты';
|
||||
|
||||
@override
|
||||
String get searchSortTitle => 'Sort Results';
|
||||
|
||||
@override
|
||||
String get searchSortDefault => 'Default';
|
||||
|
||||
@override
|
||||
String get searchSortTitleAZ => 'Title (A-Z)';
|
||||
|
||||
@override
|
||||
String get searchSortTitleZA => 'Title (Z-A)';
|
||||
|
||||
@override
|
||||
String get searchSortArtistAZ => 'Artist (A-Z)';
|
||||
|
||||
@override
|
||||
String get searchSortArtistZA => 'Artist (Z-A)';
|
||||
|
||||
@override
|
||||
String get searchSortDurationShort => 'Duration (Shortest)';
|
||||
|
||||
@override
|
||||
String get searchSortDurationLong => 'Duration (Longest)';
|
||||
|
||||
@override
|
||||
String get searchSortDateOldest => 'Release Date (Oldest)';
|
||||
|
||||
@override
|
||||
String get searchSortDateNewest => 'Release Date (Newest)';
|
||||
|
||||
@override
|
||||
String get tooltipPlay => 'Воспроизвести';
|
||||
|
||||
@@ -787,11 +820,11 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get folderOrganizationNone => 'Без организации';
|
||||
|
||||
@override
|
||||
String get folderOrganizationByPlaylist => 'By Playlist';
|
||||
String get folderOrganizationByPlaylist => 'По плейлисту';
|
||||
|
||||
@override
|
||||
String get folderOrganizationByPlaylistSubtitle =>
|
||||
'Separate folder for each playlist';
|
||||
'Отдельная папка для каждого плейлиста';
|
||||
|
||||
@override
|
||||
String get folderOrganizationByArtist => 'По исполнителю';
|
||||
@@ -1411,20 +1444,42 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get qualityHiResFlacMaxSubtitle => '24-бит / до 192кГц';
|
||||
|
||||
@override
|
||||
String get downloadLossy320 => 'Lossy 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyFormat => 'Lossy Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320FormatDesc =>
|
||||
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256Subtitle =>
|
||||
'Best quality Opus, ~8MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128 => 'Opus 128kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
|
||||
|
||||
@override
|
||||
String get qualityNote =>
|
||||
'Фактическое качество зависит от доступности треков в сервисе';
|
||||
|
||||
@override
|
||||
String get youtubeQualityNote =>
|
||||
'YouTube обеспечивает только звук с потерями(Lossy).';
|
||||
|
||||
@override
|
||||
String get youtubeOpusBitrateTitle => 'Битрейт YouTube Opus';
|
||||
|
||||
@override
|
||||
String get youtubeMp3BitrateTitle => 'Битрейт YouTube MP3';
|
||||
|
||||
@override
|
||||
String get downloadAskBeforeDownload => 'Спрашивать перед скачиванием';
|
||||
|
||||
@@ -1447,7 +1502,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyEnabled =>
|
||||
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
|
||||
'Список исполнителей, чьи работы были удалены из названия папки (например, Джастин Бибер, Quavo → Джастин Бибер)';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyDisabled =>
|
||||
@@ -1526,6 +1581,13 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||
'Исполнитель/Альбом и Исполнитель/Сингл/';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumFlatSubtitle =>
|
||||
'Artist/Album/ and Artist/song.flac';
|
||||
|
||||
@override
|
||||
String get downloadedAlbumDeleteSelected => 'Удалить выбранные';
|
||||
|
||||
@@ -1728,6 +1790,25 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Показать при поиске существующих треков';
|
||||
|
||||
@override
|
||||
String get libraryAutoScan => 'Auto Scan';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanSubtitle =>
|
||||
'Automatically scan your library for new files';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanOff => 'Off';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanOnOpen => 'Every app open';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanDaily => 'Daily';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanWeekly => 'Weekly';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Действия';
|
||||
|
||||
@@ -1918,7 +1999,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip2 =>
|
||||
'Скачайте FLAC с Tidal, Qobuz или Amazon Music';
|
||||
'Получите аудио в качестве FLAC от Tidal, Qobuz или Deezer';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip3 =>
|
||||
@@ -2014,7 +2095,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String cleanupOrphanedDownloadsResult(int count) {
|
||||
return 'Removed $count orphaned entries from history';
|
||||
return 'Удалено $count утерянных записей из истории';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -2039,7 +2120,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get cacheSectionStorage => 'Кэшированные данные';
|
||||
|
||||
@override
|
||||
String get cacheSectionMaintenance => 'Maintenance';
|
||||
String get cacheSectionMaintenance => 'Обслуживание';
|
||||
|
||||
@override
|
||||
String get cacheAppDirectory => 'Папка кэша приложения';
|
||||
@@ -2085,7 +2166,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get cacheCleanupUnusedDesc =>
|
||||
'Remove orphaned download history and library entries for missing files.';
|
||||
'Удалить записи из истории загрузок и библиотеки, которые остались без файлов.';
|
||||
|
||||
@override
|
||||
String get cacheNoData => 'Нет кэшированных данных';
|
||||
@@ -2133,7 +2214,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get cacheCleanupUnusedSubtitle =>
|
||||
'Remove orphaned download history and missing library entries';
|
||||
'Удалить историю загрузок, оставшихся без просмотра, и отсутствующие записи в библиотеке';
|
||||
|
||||
@override
|
||||
String cacheCleanupResult(int downloadCount, int libraryCount) {
|
||||
@@ -2195,6 +2276,28 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get trackReEnrichFfmpegFailed =>
|
||||
'Ошибка встраивания метаданных FFmpeg';
|
||||
|
||||
@override
|
||||
String get queueFlacAction => 'Queue FLAC';
|
||||
|
||||
@override
|
||||
String queueFlacConfirmMessage(int count) {
|
||||
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
|
||||
}
|
||||
|
||||
@override
|
||||
String queueFlacFindingProgress(int current, int total) {
|
||||
return 'Finding FLAC matches... ($current/$total)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueFlacNoReliableMatches =>
|
||||
'No reliable online matches found for the selection';
|
||||
|
||||
@override
|
||||
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
|
||||
return 'Added $addedCount tracks to queue, skipped $skippedCount';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackSaveFailed(String error) {
|
||||
return 'Ошибка: $error';
|
||||
@@ -2227,6 +2330,18 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
return 'Конвертировать из $sourceFormat в $targetFormat $bitrate?\n\nОригинальный файл будет удален после конвертации.';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackConvertConfirmMessageLossless(
|
||||
String sourceFormat,
|
||||
String targetFormat,
|
||||
) {
|
||||
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertLosslessHint =>
|
||||
'Lossless conversion — no quality loss';
|
||||
|
||||
@override
|
||||
String get trackConvertConverting => 'Конвертация аудио...';
|
||||
|
||||
@@ -2239,52 +2354,52 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get trackConvertFailed => 'Ошибка конвертации';
|
||||
|
||||
@override
|
||||
String get cueSplitTitle => 'Split CUE Sheet';
|
||||
String get cueSplitTitle => 'Разделить CUE Sheet';
|
||||
|
||||
@override
|
||||
String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
|
||||
String get cueSplitSubtitle => 'Разделить файл CUE+FLAC на отдельные треки';
|
||||
|
||||
@override
|
||||
String cueSplitAlbum(String album) {
|
||||
return 'Album: $album';
|
||||
return 'Альбом: $album';
|
||||
}
|
||||
|
||||
@override
|
||||
String cueSplitArtist(String artist) {
|
||||
return 'Artist: $artist';
|
||||
return 'Артист: $artist';
|
||||
}
|
||||
|
||||
@override
|
||||
String cueSplitTrackCount(int count) {
|
||||
return '$count tracks';
|
||||
return '$count треков';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cueSplitConfirmTitle => 'Split CUE Album';
|
||||
String get cueSplitConfirmTitle => 'Разделенный CUE-альбом';
|
||||
|
||||
@override
|
||||
String cueSplitConfirmMessage(String album, int count) {
|
||||
return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
|
||||
return 'Разбить \"$album\" на $count отдельных FLAC-файлов?';
|
||||
}
|
||||
|
||||
@override
|
||||
String cueSplitSplitting(int current, int total) {
|
||||
return 'Splitting CUE sheet... ($current/$total)';
|
||||
return 'Разделение CUE sheet... ($current/$total)';
|
||||
}
|
||||
|
||||
@override
|
||||
String cueSplitSuccess(int count) {
|
||||
return 'Split into $count tracks successfully';
|
||||
return 'Успешно разделено на $count треков';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cueSplitFailed => 'CUE split failed';
|
||||
String get cueSplitFailed => 'Разделение CUE не удалось';
|
||||
|
||||
@override
|
||||
String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
|
||||
String get cueSplitNoAudioFile => 'Аудиофайл для этого CUE sheet не найден';
|
||||
|
||||
@override
|
||||
String get cueSplitButton => 'Split into Tracks';
|
||||
String get cueSplitButton => 'Разделить на Треки';
|
||||
|
||||
@override
|
||||
String get actionCreate => 'Создать';
|
||||
@@ -2450,7 +2565,8 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
}
|
||||
|
||||
@override
|
||||
String get selectionShareNoFiles => 'No shareable files found';
|
||||
String get selectionShareNoFiles =>
|
||||
'Файлы, доступные для совместного доступа, не найдены';
|
||||
|
||||
@override
|
||||
String selectionConvertCount(int count) {
|
||||
@@ -2483,7 +2599,18 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
||||
return 'Преобразовать $count $_temp0 в $format с $bitrate?';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -2611,9 +2738,6 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
return 'Download $count tracks?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get dialogDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
|
||||
|
||||
@@ -2679,6 +2803,22 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get downloadArtistNameFilters => 'Artist Name Filters';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolder =>
|
||||
'Create playlist source folder';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderEnabled =>
|
||||
'Playlist downloads use Playlist/ plus your normal folder structure.';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderDisabled =>
|
||||
'Playlist downloads use the normal folder structure only.';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderRedundant =>
|
||||
'By Playlist already places downloads inside a playlist folder.';
|
||||
|
||||
@override
|
||||
String get downloadSongLinkRegion => 'SongLink Region';
|
||||
|
||||
@@ -2697,12 +2837,6 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get downloadSelectServiceToEnable =>
|
||||
'Select a built-in service to enable';
|
||||
|
||||
@override
|
||||
String get downloadLossy320 => 'Lossy 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyFormat => 'Lossy Format';
|
||||
|
||||
@override
|
||||
String get downloadSelectTidalQobuz =>
|
||||
'Select Tidal or Qobuz above to configure quality';
|
||||
@@ -2779,32 +2913,6 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get downloadMusixmatchAuto => 'Auto';
|
||||
|
||||
@override
|
||||
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320FormatDesc =>
|
||||
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256Subtitle =>
|
||||
'Best quality Opus, ~8MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128 => 'Opus 128kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
|
||||
|
||||
@override
|
||||
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
||||
|
||||
@@ -2821,4 +2929,226 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get cacheRefresh => 'Refresh';
|
||||
|
||||
@override
|
||||
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
trackCount,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
String _temp1 = intl.Intl.pluralLogic(
|
||||
playlistCount,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
|
||||
}
|
||||
|
||||
@override
|
||||
String bulkDownloadPlaylistsButton(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Download $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
|
||||
|
||||
@override
|
||||
String get snackbarSelectedPlaylistsEmpty =>
|
||||
'Selected playlists have no tracks';
|
||||
|
||||
@override
|
||||
String playlistsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count playlists',
|
||||
one: '1 playlist',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFill => 'Auto-fill from online';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillDesc =>
|
||||
'Select fields to fill automatically from online metadata';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillFetch => 'Fetch & Fill';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillSearching => 'Searching online...';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillNoResults =>
|
||||
'No matching metadata found online';
|
||||
|
||||
@override
|
||||
String editMetadataAutoFillDone(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'fields',
|
||||
one: 'field',
|
||||
);
|
||||
return 'Filled $count $_temp0 from online metadata';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillNoneSelected =>
|
||||
'Select at least one field to auto-fill';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTitle => 'Title';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldArtist => 'Artist';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbum => 'Album';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbumArtist => 'Album Artist';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDate => 'Date';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTrackNum => 'Track #';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDiscNum => 'Disc #';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldGenre => 'Genre';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldIsrc => 'ISRC';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldLabel => 'Label';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldCopyright => 'Copyright';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldCover => 'Cover Art';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectAll => 'All';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectEmpty => 'Empty only';
|
||||
|
||||
@override
|
||||
String queueDownloadingCount(int count) {
|
||||
return 'Downloading ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueDownloadedHeader => 'Downloaded';
|
||||
|
||||
@override
|
||||
String get queueFilteringIndicator => 'Filtering...';
|
||||
|
||||
@override
|
||||
String queueTrackCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count tracks',
|
||||
one: '1 track',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String queueAlbumCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count albums',
|
||||
one: '1 album',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueEmptyAlbums => 'No album downloads';
|
||||
|
||||
@override
|
||||
String get queueEmptyAlbumsSubtitle =>
|
||||
'Download multiple tracks from an album to see them here';
|
||||
|
||||
@override
|
||||
String get queueEmptySingles => 'No single downloads';
|
||||
|
||||
@override
|
||||
String get queueEmptySinglesSubtitle =>
|
||||
'Single track downloads will appear here';
|
||||
|
||||
@override
|
||||
String get queueEmptyHistory => 'No download history';
|
||||
|
||||
@override
|
||||
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
|
||||
|
||||
@override
|
||||
String get selectionAllPlaylistsSelected => 'All playlists selected';
|
||||
|
||||
@override
|
||||
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
|
||||
|
||||
@override
|
||||
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
|
||||
|
||||
@override
|
||||
String get audioAnalysisTitle => 'Audio Quality Analysis';
|
||||
|
||||
@override
|
||||
String get audioAnalysisDescription =>
|
||||
'Verify lossless quality with spectrum analysis';
|
||||
|
||||
@override
|
||||
String get audioAnalysisAnalyzing => 'Analyzing audio...';
|
||||
|
||||
@override
|
||||
String get audioAnalysisSampleRate => 'Sample Rate';
|
||||
|
||||
@override
|
||||
String get audioAnalysisBitDepth => 'Bit Depth';
|
||||
|
||||
@override
|
||||
String get audioAnalysisChannels => 'Channels';
|
||||
|
||||
@override
|
||||
String get audioAnalysisDuration => 'Duration';
|
||||
|
||||
@override
|
||||
String get audioAnalysisNyquist => 'Nyquist';
|
||||
|
||||
@override
|
||||
String get audioAnalysisFileSize => 'Size';
|
||||
|
||||
@override
|
||||
String get audioAnalysisDynamicRange => 'Dynamic Range';
|
||||
|
||||
@override
|
||||
String get audioAnalysisPeak => 'Peak';
|
||||
|
||||
@override
|
||||
String get audioAnalysisRms => 'RMS';
|
||||
|
||||
@override
|
||||
String get audioAnalysisSamples => 'Samples';
|
||||
}
|
||||
|
||||
+510
-187
File diff suppressed because it is too large
Load Diff
+5473
-5044
File diff suppressed because it is too large
Load Diff
+143
-51
@@ -450,7 +450,7 @@
|
||||
"@aboutSpotiSaverDesc": {
|
||||
"description": "Credit for SpotiSaver API"
|
||||
},
|
||||
"aboutAppDescription": "Lade Spotify-Titel in verlustfreier Qualität von Tidal, Qobuz und Amazon Music herunter.",
|
||||
"aboutAppDescription": "Lade Spotify-Titel in verlustfreier Qualität von Tidal und Qobuz herunter.",
|
||||
"@aboutAppDescription": {
|
||||
"description": "App description in header card"
|
||||
},
|
||||
@@ -555,7 +555,7 @@
|
||||
"@setupDownloadLocationTitle": {
|
||||
"description": "Download location dialog title"
|
||||
},
|
||||
"setupDownloadLocationIosMessage": "Auf iOS werden Downloads im Dokumentenverzeichnis der App gespeichert. Du kannst sie über die Datei-App aufrufen.",
|
||||
"setupDownloadLocationIosMessage": "Auf iOS werden Downloads im Dokumentenordner der App gespeichert. Du kannst sie über die Datei-App aufrufen.",
|
||||
"@setupDownloadLocationIosMessage": {
|
||||
"description": "iOS-specific folder info"
|
||||
},
|
||||
@@ -897,6 +897,18 @@
|
||||
"@errorNoTracksFound": {
|
||||
"description": "Error - search returned no results"
|
||||
},
|
||||
"errorUrlNotRecognized": "Link wurde nicht erkannt",
|
||||
"@errorUrlNotRecognized": {
|
||||
"description": "Error title - URL not handled by any extension or service"
|
||||
},
|
||||
"errorUrlNotRecognizedMessage": "Dieser Link ist inkompatibel. Prüfe die URL und stelle sicher, dass eine kompatible Erweiterung installiert ist.",
|
||||
"@errorUrlNotRecognizedMessage": {
|
||||
"description": "Error message - URL not recognized explanation"
|
||||
},
|
||||
"errorUrlFetchFailed": "Laden fehlgeschlagen. Bitte erneut versuchen.",
|
||||
"@errorUrlFetchFailed": {
|
||||
"description": "Error message - generic URL fetch failure"
|
||||
},
|
||||
"errorMissingExtensionSource": "Kann {item} nicht lade wegen fehlender Erweiterungsquelle",
|
||||
"@errorMissingExtensionSource": {
|
||||
"description": "Error - extension source not available",
|
||||
@@ -947,7 +959,7 @@
|
||||
"@selectionAllSelected": {
|
||||
"description": "Status - all items selected"
|
||||
},
|
||||
"selectionSelectToDelete": "Titel zum Löschen auswählen",
|
||||
"selectionSelectToDelete": "Titel zum Löschen wählen",
|
||||
"@selectionSelectToDelete": {
|
||||
"description": "Placeholder when nothing selected"
|
||||
},
|
||||
@@ -975,7 +987,7 @@
|
||||
"@searchArtists": {
|
||||
"description": "Search result category - artists"
|
||||
},
|
||||
"searchAlbums": "Albums",
|
||||
"searchAlbums": "Alben",
|
||||
"@searchAlbums": {
|
||||
"description": "Search result category - albums"
|
||||
},
|
||||
@@ -1003,6 +1015,14 @@
|
||||
"@folderOrganizationNone": {
|
||||
"description": "Folder option - flat structure"
|
||||
},
|
||||
"folderOrganizationByPlaylist": "Nach Playlist",
|
||||
"@folderOrganizationByPlaylist": {
|
||||
"description": "Folder option - playlist folders"
|
||||
},
|
||||
"folderOrganizationByPlaylistSubtitle": "Ordner für jede Playlist trennen",
|
||||
"@folderOrganizationByPlaylistSubtitle": {
|
||||
"description": "Subtitle for playlist folder option"
|
||||
},
|
||||
"folderOrganizationByArtist": "Nach Künstler",
|
||||
"@folderOrganizationByArtist": {
|
||||
"description": "Folder option - artist folders"
|
||||
@@ -1019,7 +1039,7 @@
|
||||
"@folderOrganizationDescription": {
|
||||
"description": "Folder organization sheet description"
|
||||
},
|
||||
"folderOrganizationNoneSubtitle": "Alle Dateien im Download-Verzeichnis",
|
||||
"folderOrganizationNoneSubtitle": "Alle Dateien im Download-Ordner",
|
||||
"@folderOrganizationNoneSubtitle": {
|
||||
"description": "Subtitle for no organization option"
|
||||
},
|
||||
@@ -1097,7 +1117,7 @@
|
||||
},
|
||||
"providerBuiltIn": "Integriert",
|
||||
"@providerBuiltIn": {
|
||||
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
|
||||
"description": "Label for built-in providers (Tidal/Qobuz)"
|
||||
},
|
||||
"providerExtension": "Erweiterung",
|
||||
"@providerExtension": {
|
||||
@@ -1753,23 +1773,11 @@
|
||||
"@qualityNote": {
|
||||
"description": "Note about quality availability"
|
||||
},
|
||||
"youtubeQualityNote": "YouTube bietet nur verlustbehaftete Audioqualität. Deswegen ist es kein Teil des verlustfreien Fallbacks.",
|
||||
"@youtubeQualityNote": {
|
||||
"description": "Note for YouTube service explaining lossy-only quality"
|
||||
},
|
||||
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
|
||||
"@youtubeOpusBitrateTitle": {
|
||||
"description": "Title for YouTube Opus bitrate setting"
|
||||
},
|
||||
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
|
||||
"@youtubeMp3BitrateTitle": {
|
||||
"description": "Title for YouTube MP3 bitrate setting"
|
||||
},
|
||||
"downloadAskBeforeDownload": "Qualität vor Download fragen",
|
||||
"@downloadAskBeforeDownload": {
|
||||
"description": "Setting - show quality picker"
|
||||
},
|
||||
"downloadDirectory": "Downloadverzeichnis",
|
||||
"downloadDirectory": "Download-Ordner",
|
||||
"@downloadDirectory": {
|
||||
"description": "Setting - download folder"
|
||||
},
|
||||
@@ -1777,15 +1785,15 @@
|
||||
"@downloadSeparateSinglesFolder": {
|
||||
"description": "Setting - separate folder for singles"
|
||||
},
|
||||
"downloadAlbumFolderStructure": "Album Folder Structure",
|
||||
"downloadAlbumFolderStructure": "Album-Ordnerstruktur",
|
||||
"@downloadAlbumFolderStructure": {
|
||||
"description": "Setting - album folder organization"
|
||||
},
|
||||
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
|
||||
"downloadUseAlbumArtistForFolders": "Album-Künstler für Ordner verwenden",
|
||||
"@downloadUseAlbumArtistForFolders": {
|
||||
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
|
||||
},
|
||||
"downloadUsePrimaryArtistOnly": "Primary artist only for folders",
|
||||
"downloadUsePrimaryArtistOnly": "Primärer Künstler nur für Ordner",
|
||||
"@downloadUsePrimaryArtistOnly": {
|
||||
"description": "Setting - strip featured artists from folder name"
|
||||
},
|
||||
@@ -1793,7 +1801,7 @@
|
||||
"@downloadUsePrimaryArtistOnlyEnabled": {
|
||||
"description": "Subtitle when primary artist only is enabled"
|
||||
},
|
||||
"downloadUsePrimaryArtistOnlyDisabled": "Full artist string used for folder name",
|
||||
"downloadUsePrimaryArtistOnlyDisabled": "Vollständiger Künstler für Ordnername",
|
||||
"@downloadUsePrimaryArtistOnlyDisabled": {
|
||||
"description": "Subtitle when primary artist only is disabled"
|
||||
},
|
||||
@@ -1821,7 +1829,7 @@
|
||||
"@queueClearAllMessage": {
|
||||
"description": "Clear queue confirmation"
|
||||
},
|
||||
"settingsAutoExportFailed": "Auto-export failed downloads",
|
||||
"settingsAutoExportFailed": "Auto-Export fehlgeschlagener Downloads",
|
||||
"@settingsAutoExportFailed": {
|
||||
"description": "Setting toggle for auto-export"
|
||||
},
|
||||
@@ -1849,15 +1857,15 @@
|
||||
"@albumFolderArtistAlbum": {
|
||||
"description": "Album folder option"
|
||||
},
|
||||
"albumFolderArtistAlbumSubtitle": "Albums/Artist Name/Album Name/",
|
||||
"albumFolderArtistAlbumSubtitle": "Alben/Künster Name/Album Name/",
|
||||
"@albumFolderArtistAlbumSubtitle": {
|
||||
"description": "Folder structure example"
|
||||
},
|
||||
"albumFolderArtistYearAlbum": "Artist / [Year] Album",
|
||||
"albumFolderArtistYearAlbum": "Künstler / [Year] Album",
|
||||
"@albumFolderArtistYearAlbum": {
|
||||
"description": "Album folder option with year"
|
||||
},
|
||||
"albumFolderArtistYearAlbumSubtitle": "Albums/Künster Name/[2005] Album Name/",
|
||||
"albumFolderArtistYearAlbumSubtitle": "Alben/Künster Name/[2005] Album Name/",
|
||||
"@albumFolderArtistYearAlbumSubtitle": {
|
||||
"description": "Folder structure example"
|
||||
},
|
||||
@@ -1873,15 +1881,15 @@
|
||||
"@albumFolderYearAlbum": {
|
||||
"description": "Album folder option with year"
|
||||
},
|
||||
"albumFolderYearAlbumSubtitle": "Albums/[2005] Album Name/",
|
||||
"albumFolderYearAlbumSubtitle": "Alben/[2005] Album Name/",
|
||||
"@albumFolderYearAlbumSubtitle": {
|
||||
"description": "Folder structure example"
|
||||
},
|
||||
"albumFolderArtistAlbumSingles": "Artist / Album + Singles",
|
||||
"albumFolderArtistAlbumSingles": "Künstler / Album + Singles",
|
||||
"@albumFolderArtistAlbumSingles": {
|
||||
"description": "Album folder option with singles inside artist"
|
||||
},
|
||||
"albumFolderArtistAlbumSinglesSubtitle": "Artist/Album/ and Artist/Singles/",
|
||||
"albumFolderArtistAlbumSinglesSubtitle": "Künstler/Album/ und Künstler/Singles/",
|
||||
"@albumFolderArtistAlbumSinglesSubtitle": {
|
||||
"description": "Folder structure example"
|
||||
},
|
||||
@@ -1924,7 +1932,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadedAlbumSelectToDelete": "Select tracks to delete",
|
||||
"downloadedAlbumSelectToDelete": "Titel zum Löschen wählen",
|
||||
"@downloadedAlbumSelectToDelete": {
|
||||
"description": "Placeholder when nothing selected"
|
||||
},
|
||||
@@ -1996,7 +2004,7 @@
|
||||
"@discographyAlbumsOnly": {
|
||||
"description": "Option - download only albums"
|
||||
},
|
||||
"discographyAlbumsOnlySubtitle": "{count} Titel von {albumCount} Albums",
|
||||
"discographyAlbumsOnlySubtitle": "{count} Titel aus {albumCount} Alben",
|
||||
"@discographyAlbumsOnlySubtitle": {
|
||||
"description": "Subtitle showing album tracks count",
|
||||
"placeholders": {
|
||||
@@ -2028,7 +2036,7 @@
|
||||
"@discographySelectAlbums": {
|
||||
"description": "Option - manually select albums to download"
|
||||
},
|
||||
"discographySelectAlbumsSubtitle": "Choose specific albums or singles",
|
||||
"discographySelectAlbumsSubtitle": "Wähle bestimmte Alben oder Singles",
|
||||
"@discographySelectAlbumsSubtitle": {
|
||||
"description": "Subtitle for select albums option"
|
||||
},
|
||||
@@ -2036,7 +2044,7 @@
|
||||
"@discographyFetchingTracks": {
|
||||
"description": "Progress - fetching album tracks"
|
||||
},
|
||||
"discographyFetchingAlbum": "Fetching {current} of {total}...",
|
||||
"discographyFetchingAlbum": "Lade {current} von {total}...",
|
||||
"@discographyFetchingAlbum": {
|
||||
"description": "Progress - fetching specific album",
|
||||
"placeholders": {
|
||||
@@ -2061,7 +2069,7 @@
|
||||
"@discographyDownloadSelected": {
|
||||
"description": "Button - download selected albums"
|
||||
},
|
||||
"discographyAddedToQueue": "Added {count} tracks to queue",
|
||||
"discographyAddedToQueue": "{count} Titel zur Warteschlange hinzugefügt",
|
||||
"@discographyAddedToQueue": {
|
||||
"description": "Snackbar - tracks added from discography",
|
||||
"placeholders": {
|
||||
@@ -2086,7 +2094,7 @@
|
||||
"@discographyNoAlbums": {
|
||||
"description": "Error - no albums found for artist"
|
||||
},
|
||||
"discographyFailedToFetch": "Failed to fetch some albums",
|
||||
"discographyFailedToFetch": "Fehler beim Abrufen einiger Alben",
|
||||
"@discographyFailedToFetch": {
|
||||
"description": "Error - some albums failed to load"
|
||||
},
|
||||
@@ -2098,15 +2106,15 @@
|
||||
"@allFilesAccess": {
|
||||
"description": "Toggle for MANAGE_EXTERNAL_STORAGE permission"
|
||||
},
|
||||
"allFilesAccessEnabledSubtitle": "Can write to any folder",
|
||||
"allFilesAccessEnabledSubtitle": "Darf in jeden Ordner schreiben",
|
||||
"@allFilesAccessEnabledSubtitle": {
|
||||
"description": "Subtitle when all files access is enabled"
|
||||
},
|
||||
"allFilesAccessDisabledSubtitle": "Limited to media folders only",
|
||||
"allFilesAccessDisabledSubtitle": "Nur auf Medienordner begrenzt",
|
||||
"@allFilesAccessDisabledSubtitle": {
|
||||
"description": "Subtitle when all files access is disabled"
|
||||
},
|
||||
"allFilesAccessDescription": "Aktiviere die Option, wenn beim Speichern in benutzerdefinierten Ordnern Schreibfehler auftreten. Weil Android 13+ standardmäßig den Zugriff auf bestimmte Verzeichnisse einschränkt.",
|
||||
"allFilesAccessDescription": "Option bei Schreibfehlern bitte aktivieren (erforderlich ab Android 13).",
|
||||
"@allFilesAccessDescription": {
|
||||
"description": "Description explaining when to enable all files access"
|
||||
},
|
||||
@@ -2122,7 +2130,7 @@
|
||||
"@settingsLocalLibrary": {
|
||||
"description": "Settings menu item - local library"
|
||||
},
|
||||
"settingsLocalLibrarySubtitle": "Scan music & detect duplicates",
|
||||
"settingsLocalLibrarySubtitle": "Musik scannen & Duplikate erkennen",
|
||||
"@settingsLocalLibrarySubtitle": {
|
||||
"description": "Subtitle for local library settings"
|
||||
},
|
||||
@@ -2130,7 +2138,7 @@
|
||||
"@settingsCache": {
|
||||
"description": "Settings menu item - cache management"
|
||||
},
|
||||
"settingsCacheSubtitle": "View size and clear cached data",
|
||||
"settingsCacheSubtitle": "Größe anzeigen und Daten im Cache leeren",
|
||||
"@settingsCacheSubtitle": {
|
||||
"description": "Subtitle for cache management menu"
|
||||
},
|
||||
@@ -2146,7 +2154,7 @@
|
||||
"@libraryEnableLocalLibrary": {
|
||||
"description": "Toggle to enable library scanning"
|
||||
},
|
||||
"libraryEnableLocalLibrarySubtitle": "Scan and track your existing music",
|
||||
"libraryEnableLocalLibrarySubtitle": "Scan und verfolge deine bestehende Musik",
|
||||
"@libraryEnableLocalLibrarySubtitle": {
|
||||
"description": "Subtitle for enable toggle"
|
||||
},
|
||||
@@ -2158,7 +2166,7 @@
|
||||
"@libraryFolderHint": {
|
||||
"description": "Placeholder when no folder selected"
|
||||
},
|
||||
"libraryShowDuplicateIndicator": "Show Duplicate Indicator",
|
||||
"libraryShowDuplicateIndicator": "Duplikat Indikator anzeigen",
|
||||
"@libraryShowDuplicateIndicator": {
|
||||
"description": "Toggle for duplicate indicator in search"
|
||||
},
|
||||
@@ -2383,7 +2391,7 @@
|
||||
"@tutorialWelcomeTip1": {
|
||||
"description": "Tutorial welcome tip 1"
|
||||
},
|
||||
"tutorialWelcomeTip2": "Hole dir FLAC Audio von Tidal, Qobuz oder Amazon Musik",
|
||||
"tutorialWelcomeTip2": "Hole dir FLAC Audio von Tidal, Qobuz oder Deezer",
|
||||
"@tutorialWelcomeTip2": {
|
||||
"description": "Tutorial welcome tip 2"
|
||||
},
|
||||
@@ -2455,7 +2463,7 @@
|
||||
"@tutorialSettingsDesc": {
|
||||
"description": "Tutorial settings page description"
|
||||
},
|
||||
"tutorialSettingsTip1": "Downloadverzeichnis und Ordnerorganisation ändern",
|
||||
"tutorialSettingsTip1": "Download-Ordner und Ordner-Organisation ändern",
|
||||
"@tutorialSettingsTip1": {
|
||||
"description": "Tutorial settings tip 1"
|
||||
},
|
||||
@@ -2529,7 +2537,7 @@
|
||||
"@cacheSectionMaintenance": {
|
||||
"description": "Section header for cleanup actions"
|
||||
},
|
||||
"cacheAppDirectory": "App-Cache Verzeichnis",
|
||||
"cacheAppDirectory": "App-Cache Ordner",
|
||||
"@cacheAppDirectory": {
|
||||
"description": "Cache item title for app cache directory"
|
||||
},
|
||||
@@ -2537,7 +2545,7 @@
|
||||
"@cacheAppDirectoryDesc": {
|
||||
"description": "Description of what app cache directory contains"
|
||||
},
|
||||
"cacheTempDirectory": "Temporäres Verzeichnis",
|
||||
"cacheTempDirectory": "Temporärer Ordner",
|
||||
"@cacheTempDirectory": {
|
||||
"description": "Cache item title for temporary files directory"
|
||||
},
|
||||
@@ -2705,7 +2713,7 @@
|
||||
"@trackEditMetadata": {
|
||||
"description": "Menu action - edit embedded metadata"
|
||||
},
|
||||
"trackCoverSaved": "Cover art saved to {fileName}",
|
||||
"trackCoverSaved": "Cover in {fileName} gespeichert",
|
||||
"@trackCoverSaved": {
|
||||
"description": "Snackbar after cover art saved",
|
||||
"placeholders": {
|
||||
@@ -2714,7 +2722,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"trackCoverNoSource": "No cover art source available",
|
||||
"trackCoverNoSource": "Keine Cover Quelle vorhanden",
|
||||
"@trackCoverNoSource": {
|
||||
"description": "Snackbar when no cover art URL or embedded cover"
|
||||
},
|
||||
@@ -2808,6 +2816,90 @@
|
||||
"@trackConvertFailed": {
|
||||
"description": "Snackbar when conversion fails"
|
||||
},
|
||||
"cueSplitTitle": "CUE-Sheet aufteilen",
|
||||
"@cueSplitTitle": {
|
||||
"description": "Title for CUE split bottom sheet"
|
||||
},
|
||||
"cueSplitSubtitle": "CUE+FLAC in einzelne Titel aufteilen",
|
||||
"@cueSplitSubtitle": {
|
||||
"description": "Subtitle for CUE split menu item"
|
||||
},
|
||||
"cueSplitAlbum": "Album: {album}",
|
||||
"@cueSplitAlbum": {
|
||||
"description": "Album name in CUE split sheet",
|
||||
"placeholders": {
|
||||
"album": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitArtist": "Künstler: {artist}",
|
||||
"@cueSplitArtist": {
|
||||
"description": "Artist name in CUE split sheet",
|
||||
"placeholders": {
|
||||
"artist": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitTrackCount": "{count} Titel",
|
||||
"@cueSplitTrackCount": {
|
||||
"description": "Number of tracks in CUE sheet",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitConfirmTitle": "CUE-Album aufteilen",
|
||||
"@cueSplitConfirmTitle": {
|
||||
"description": "CUE split confirmation dialog title"
|
||||
},
|
||||
"cueSplitConfirmMessage": "Soll „{album}“ in {count} einzelne FLAC-Dateien aufgeteilt werden?\n\nDie Dateien werden im selben Ordner gespeichert.",
|
||||
"@cueSplitConfirmMessage": {
|
||||
"description": "CUE split confirmation dialog message",
|
||||
"placeholders": {
|
||||
"album": {
|
||||
"type": "String"
|
||||
},
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitSplitting": "CUE-Sheet wird geteilt... ({current}/{total})",
|
||||
"@cueSplitSplitting": {
|
||||
"description": "Snackbar while splitting CUE",
|
||||
"placeholders": {
|
||||
"current": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitSuccess": "{count} Titel erfolgreich aufgeteilt",
|
||||
"@cueSplitSuccess": {
|
||||
"description": "Snackbar after successful CUE split",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitFailed": "CUE-Aufteilung fehlgeschlagen",
|
||||
"@cueSplitFailed": {
|
||||
"description": "Snackbar when CUE split fails"
|
||||
},
|
||||
"cueSplitNoAudioFile": "Audiodatei für dieses CUE-Sheet nicht gefunden",
|
||||
"@cueSplitNoAudioFile": {
|
||||
"description": "Error when CUE audio file is missing"
|
||||
},
|
||||
"cueSplitButton": "In Titel aufteilen",
|
||||
"@cueSplitButton": {
|
||||
"description": "Button text to start CUE splitting"
|
||||
},
|
||||
"actionCreate": "Erstellen",
|
||||
"@actionCreate": {
|
||||
"description": "Generic action button - create"
|
||||
@@ -3094,11 +3186,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadUseAlbumArtistForFoldersAlbumSubtitle": "Künstlerordner verwenden den Album-Interpreten, wenn verfügbar",
|
||||
"downloadUseAlbumArtistForFoldersAlbumSubtitle": "Interpret-Ordner verwenden Album-Interpret, sofern vorhanden",
|
||||
"@downloadUseAlbumArtistForFoldersAlbumSubtitle": {
|
||||
"description": "Subtitle when Album Artist is used for folder naming"
|
||||
},
|
||||
"downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only",
|
||||
"downloadUseAlbumArtistForFoldersTrackSubtitle": "Künstler-Ordner nur für Titel-Künstler",
|
||||
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
|
||||
"description": "Subtitle when Track Artist is used for folder naming"
|
||||
}
|
||||
|
||||
+460
-54
@@ -671,6 +671,10 @@
|
||||
"@dialogImport": {
|
||||
"description": "Dialog button - import data"
|
||||
},
|
||||
"dialogDownload": "Download",
|
||||
"@dialogDownload": {
|
||||
"description": "Dialog button - download action"
|
||||
},
|
||||
"dialogDiscard": "Discard",
|
||||
"@dialogDiscard": {
|
||||
"description": "Dialog button - discard changes"
|
||||
@@ -995,6 +999,46 @@
|
||||
"@searchPlaylists": {
|
||||
"description": "Search result category - playlists"
|
||||
},
|
||||
"searchSortTitle": "Sort Results",
|
||||
"@searchSortTitle": {
|
||||
"description": "Bottom sheet title for search sort options"
|
||||
},
|
||||
"searchSortDefault": "Default",
|
||||
"@searchSortDefault": {
|
||||
"description": "Sort option - default API order"
|
||||
},
|
||||
"searchSortTitleAZ": "Title (A-Z)",
|
||||
"@searchSortTitleAZ": {
|
||||
"description": "Sort option - title ascending"
|
||||
},
|
||||
"searchSortTitleZA": "Title (Z-A)",
|
||||
"@searchSortTitleZA": {
|
||||
"description": "Sort option - title descending"
|
||||
},
|
||||
"searchSortArtistAZ": "Artist (A-Z)",
|
||||
"@searchSortArtistAZ": {
|
||||
"description": "Sort option - artist ascending"
|
||||
},
|
||||
"searchSortArtistZA": "Artist (Z-A)",
|
||||
"@searchSortArtistZA": {
|
||||
"description": "Sort option - artist descending"
|
||||
},
|
||||
"searchSortDurationShort": "Duration (Shortest)",
|
||||
"@searchSortDurationShort": {
|
||||
"description": "Sort option - shortest duration first"
|
||||
},
|
||||
"searchSortDurationLong": "Duration (Longest)",
|
||||
"@searchSortDurationLong": {
|
||||
"description": "Sort option - longest duration first"
|
||||
},
|
||||
"searchSortDateOldest": "Release Date (Oldest)",
|
||||
"@searchSortDateOldest": {
|
||||
"description": "Sort option - oldest release first"
|
||||
},
|
||||
"searchSortDateNewest": "Release Date (Newest)",
|
||||
"@searchSortDateNewest": {
|
||||
"description": "Sort option - newest release first"
|
||||
},
|
||||
"tooltipPlay": "Play",
|
||||
"@tooltipPlay": {
|
||||
"description": "Tooltip - play button"
|
||||
@@ -1622,7 +1666,7 @@
|
||||
"@storeEmptyNoResults": {
|
||||
"description": "Message when search/filter returns no results"
|
||||
},
|
||||
"extensionDefaultProvider": "Default (Deezer/Spotify)",
|
||||
"extensionDefaultProvider": "Default (Deezer)",
|
||||
"@extensionDefaultProvider": {
|
||||
"description": "Default search provider option"
|
||||
},
|
||||
@@ -1821,22 +1865,50 @@
|
||||
"@qualityHiResFlacMaxSubtitle": {
|
||||
"description": "Technical spec for hi-res max"
|
||||
},
|
||||
"downloadLossy320": "Lossy 320kbps",
|
||||
"@downloadLossy320": {
|
||||
"description": "Quality option label for Tidal lossy 320kbps"
|
||||
},
|
||||
"downloadLossyFormat": "Lossy Format",
|
||||
"@downloadLossyFormat": {
|
||||
"description": "Setting title to pick output format for Tidal lossy downloads"
|
||||
},
|
||||
"downloadLossy320Format": "Lossy 320kbps Format",
|
||||
"@downloadLossy320Format": {
|
||||
"description": "Title of the Tidal lossy format picker bottom sheet"
|
||||
},
|
||||
"downloadLossy320FormatDesc": "Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.",
|
||||
"@downloadLossy320FormatDesc": {
|
||||
"description": "Description in the Tidal lossy format picker"
|
||||
},
|
||||
"downloadLossyMp3": "MP3 320kbps",
|
||||
"@downloadLossyMp3": {
|
||||
"description": "Tidal lossy format option - MP3 320kbps"
|
||||
},
|
||||
"downloadLossyMp3Subtitle": "Best compatibility, ~10MB per track",
|
||||
"@downloadLossyMp3Subtitle": {
|
||||
"description": "Subtitle for MP3 320kbps Tidal lossy option"
|
||||
},
|
||||
"downloadLossyOpus256": "Opus 256kbps",
|
||||
"@downloadLossyOpus256": {
|
||||
"description": "Tidal lossy format option - Opus 256kbps"
|
||||
},
|
||||
"downloadLossyOpus256Subtitle": "Best quality Opus, ~8MB per track",
|
||||
"@downloadLossyOpus256Subtitle": {
|
||||
"description": "Subtitle for Opus 256kbps Tidal lossy option"
|
||||
},
|
||||
"downloadLossyOpus128": "Opus 128kbps",
|
||||
"@downloadLossyOpus128": {
|
||||
"description": "Tidal lossy format option - Opus 128kbps"
|
||||
},
|
||||
"downloadLossyOpus128Subtitle": "Smallest size, ~4MB per track",
|
||||
"@downloadLossyOpus128Subtitle": {
|
||||
"description": "Subtitle for Opus 128kbps Tidal lossy option"
|
||||
},
|
||||
"qualityNote": "Actual quality depends on track availability from the service",
|
||||
"@qualityNote": {
|
||||
"description": "Note about quality availability"
|
||||
},
|
||||
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
|
||||
"@youtubeQualityNote": {
|
||||
"description": "Note for YouTube service explaining lossy-only quality"
|
||||
},
|
||||
"youtubeOpusBitrateTitle": "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"
|
||||
@@ -1957,6 +2029,14 @@
|
||||
"@albumFolderArtistAlbumSinglesSubtitle": {
|
||||
"description": "Folder structure example"
|
||||
},
|
||||
"albumFolderArtistAlbumFlat": "Artist / Album (Singles flat)",
|
||||
"@albumFolderArtistAlbumFlat": {
|
||||
"description": "Album folder option with singles directly in artist folder"
|
||||
},
|
||||
"albumFolderArtistAlbumFlatSubtitle": "Artist/Album/ and Artist/song.flac",
|
||||
"@albumFolderArtistAlbumFlatSubtitle": {
|
||||
"description": "Folder structure example for flat singles"
|
||||
},
|
||||
"downloadedAlbumDeleteSelected": "Delete Selected",
|
||||
"@downloadedAlbumDeleteSelected": {
|
||||
"description": "Button - delete selected tracks"
|
||||
@@ -2238,6 +2318,30 @@
|
||||
"@libraryShowDuplicateIndicatorSubtitle": {
|
||||
"description": "Subtitle for duplicate indicator toggle"
|
||||
},
|
||||
"libraryAutoScan": "Auto Scan",
|
||||
"@libraryAutoScan": {
|
||||
"description": "Setting for automatic library scanning"
|
||||
},
|
||||
"libraryAutoScanSubtitle": "Automatically scan your library for new files",
|
||||
"@libraryAutoScanSubtitle": {
|
||||
"description": "Subtitle for auto scan setting"
|
||||
},
|
||||
"libraryAutoScanOff": "Off",
|
||||
"@libraryAutoScanOff": {
|
||||
"description": "Auto scan disabled"
|
||||
},
|
||||
"libraryAutoScanOnOpen": "Every app open",
|
||||
"@libraryAutoScanOnOpen": {
|
||||
"description": "Auto scan when app opens"
|
||||
},
|
||||
"libraryAutoScanDaily": "Daily",
|
||||
"@libraryAutoScanDaily": {
|
||||
"description": "Auto scan once per day"
|
||||
},
|
||||
"libraryAutoScanWeekly": "Weekly",
|
||||
"@libraryAutoScanWeekly": {
|
||||
"description": "Auto scan once per week"
|
||||
},
|
||||
"libraryActions": "Actions",
|
||||
"@libraryActions": {
|
||||
"description": "Section header for library actions"
|
||||
@@ -2815,6 +2919,47 @@
|
||||
"@trackReEnrichFfmpegFailed": {
|
||||
"description": "Snackbar when FFmpeg embed fails for MP3/Opus"
|
||||
},
|
||||
"queueFlacAction": "Queue FLAC",
|
||||
"@queueFlacAction": {
|
||||
"description": "Action/button label for queueing FLAC redownloads for local tracks"
|
||||
},
|
||||
"queueFlacConfirmMessage": "Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n{count} selected",
|
||||
"@queueFlacConfirmMessage": {
|
||||
"description": "Confirmation dialog body before queueing FLAC redownloads for local tracks",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"queueFlacFindingProgress": "Finding FLAC matches... ({current}/{total})",
|
||||
"@queueFlacFindingProgress": {
|
||||
"description": "Snackbar while resolving remote matches for local FLAC redownloads",
|
||||
"placeholders": {
|
||||
"current": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"queueFlacNoReliableMatches": "No reliable online matches found for the selection",
|
||||
"@queueFlacNoReliableMatches": {
|
||||
"description": "Snackbar when no safe FLAC redownload matches were found"
|
||||
},
|
||||
"queueFlacQueuedWithSkipped": "Added {addedCount} tracks to queue, skipped {skippedCount}",
|
||||
"@queueFlacQueuedWithSkipped": {
|
||||
"description": "Snackbar when some selected local tracks were queued for FLAC redownload and some were skipped",
|
||||
"placeholders": {
|
||||
"addedCount": {
|
||||
"type": "int"
|
||||
},
|
||||
"skippedCount": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"trackSaveFailed": "Failed: {error}",
|
||||
"@trackSaveFailed": {
|
||||
"description": "Snackbar when save operation fails",
|
||||
@@ -2828,7 +2973,7 @@
|
||||
"@trackConvertFormat": {
|
||||
"description": "Menu item - convert audio format"
|
||||
},
|
||||
"trackConvertFormatSubtitle": "Convert to MP3 or Opus",
|
||||
"trackConvertFormatSubtitle": "Convert to MP3, Opus, ALAC, or FLAC",
|
||||
"@trackConvertFormatSubtitle": {
|
||||
"description": "Subtitle for convert format menu item"
|
||||
},
|
||||
@@ -2863,6 +3008,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"trackConvertConfirmMessageLossless": "Convert from {sourceFormat} to {targetFormat}? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.",
|
||||
"@trackConvertConfirmMessageLossless": {
|
||||
"description": "Confirmation dialog message for lossless-to-lossless conversion",
|
||||
"placeholders": {
|
||||
"sourceFormat": {
|
||||
"type": "String"
|
||||
},
|
||||
"targetFormat": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"trackConvertLosslessHint": "Lossless conversion — no quality loss",
|
||||
"@trackConvertLosslessHint": {
|
||||
"description": "Hint shown when converting between lossless formats"
|
||||
},
|
||||
"trackConvertConverting": "Converting audio...",
|
||||
"@trackConvertConverting": {
|
||||
"description": "Snackbar while converting"
|
||||
@@ -3214,6 +3375,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectionBatchConvertConfirmMessageLossless": "Convert {count} {count, plural, =1{track} other{tracks}} to {format}? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.",
|
||||
"@selectionBatchConvertConfirmMessageLossless": {
|
||||
"description": "Confirmation dialog message for lossless batch conversion",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
},
|
||||
"format": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectionBatchConvertProgress": "Converting {current} of {total}...",
|
||||
"@selectionBatchConvertProgress": {
|
||||
"description": "Snackbar during batch conversion progress",
|
||||
@@ -3489,6 +3662,22 @@
|
||||
"@downloadArtistNameFilters": {
|
||||
"description": "Setting title for artist folder filter options"
|
||||
},
|
||||
"downloadCreatePlaylistSourceFolder": "Create playlist source folder",
|
||||
"@downloadCreatePlaylistSourceFolder": {
|
||||
"description": "Setting title for adding a playlist folder prefix before the normal organization structure"
|
||||
},
|
||||
"downloadCreatePlaylistSourceFolderEnabled": "Playlist downloads use Playlist/ plus your normal folder structure.",
|
||||
"@downloadCreatePlaylistSourceFolderEnabled": {
|
||||
"description": "Subtitle when playlist source folder prefix is enabled"
|
||||
},
|
||||
"downloadCreatePlaylistSourceFolderDisabled": "Playlist downloads use the normal folder structure only.",
|
||||
"@downloadCreatePlaylistSourceFolderDisabled": {
|
||||
"description": "Subtitle when playlist source folder prefix is disabled"
|
||||
},
|
||||
"downloadCreatePlaylistSourceFolderRedundant": "By Playlist already places downloads inside a playlist folder.",
|
||||
"@downloadCreatePlaylistSourceFolderRedundant": {
|
||||
"description": "Subtitle when playlist folder prefix setting is redundant because folder organization is already by playlist"
|
||||
},
|
||||
"downloadSongLinkRegion": "SongLink Region",
|
||||
"@downloadSongLinkRegion": {
|
||||
"description": "Setting title for SongLink country region"
|
||||
@@ -3509,14 +3698,7 @@
|
||||
"@downloadSelectServiceToEnable": {
|
||||
"description": "Hint shown instead of Ask-quality subtitle when no built-in service selected"
|
||||
},
|
||||
"downloadLossy320": "Lossy 320kbps",
|
||||
"@downloadLossy320": {
|
||||
"description": "Quality option label for Tidal lossy 320kbps"
|
||||
},
|
||||
"downloadLossyFormat": "Lossy Format",
|
||||
"@downloadLossyFormat": {
|
||||
"description": "Setting title to pick output format for Tidal lossy downloads"
|
||||
},
|
||||
|
||||
"downloadSelectTidalQobuz": "Select Tidal or Qobuz above to configure quality",
|
||||
"@downloadSelectTidalQobuz": {
|
||||
"description": "Info hint when non-Tidal/Qobuz service is selected"
|
||||
@@ -3602,38 +3784,7 @@
|
||||
"@downloadMusixmatchAuto": {
|
||||
"description": "Button to reset Musixmatch language to automatic"
|
||||
},
|
||||
"downloadLossy320Format": "Lossy 320kbps Format",
|
||||
"@downloadLossy320Format": {
|
||||
"description": "Title of the Tidal lossy format picker bottom sheet"
|
||||
},
|
||||
"downloadLossy320FormatDesc": "Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.",
|
||||
"@downloadLossy320FormatDesc": {
|
||||
"description": "Description in the Tidal lossy format picker"
|
||||
},
|
||||
"downloadLossyMp3": "MP3 320kbps",
|
||||
"@downloadLossyMp3": {
|
||||
"description": "Tidal lossy format option - MP3 320kbps"
|
||||
},
|
||||
"downloadLossyMp3Subtitle": "Best compatibility, ~10MB per track",
|
||||
"@downloadLossyMp3Subtitle": {
|
||||
"description": "Subtitle for MP3 320kbps option"
|
||||
},
|
||||
"downloadLossyOpus256": "Opus 256kbps",
|
||||
"@downloadLossyOpus256": {
|
||||
"description": "Tidal lossy format option - Opus 256kbps"
|
||||
},
|
||||
"downloadLossyOpus256Subtitle": "Best quality Opus, ~8MB per track",
|
||||
"@downloadLossyOpus256Subtitle": {
|
||||
"description": "Subtitle for Opus 256kbps option"
|
||||
},
|
||||
"downloadLossyOpus128": "Opus 128kbps",
|
||||
"@downloadLossyOpus128": {
|
||||
"description": "Tidal lossy format option - Opus 128kbps"
|
||||
},
|
||||
"downloadLossyOpus128Subtitle": "Smallest size, ~4MB per track",
|
||||
"@downloadLossyOpus128Subtitle": {
|
||||
"description": "Subtitle for Opus 128kbps option"
|
||||
},
|
||||
|
||||
"downloadNetworkAnySubtitle": "WiFi + Mobile Data",
|
||||
"@downloadNetworkAnySubtitle": {
|
||||
"description": "Subtitle for 'Any' network mode option"
|
||||
@@ -3657,5 +3808,260 @@
|
||||
"cacheRefresh": "Refresh",
|
||||
"@cacheRefresh": {
|
||||
"description": "Tooltip for refresh button on cache management page"
|
||||
},
|
||||
"dialogDownloadAllTitle": "Download All",
|
||||
"@dialogDownloadAllTitle": {
|
||||
"description": "Dialog title for bulk download confirmation"
|
||||
},
|
||||
"dialogDownloadPlaylistsMessage": "Download {trackCount} {trackCount, plural, =1{track} other{tracks}} from {playlistCount} {playlistCount, plural, =1{playlist} other{playlists}}?",
|
||||
"@dialogDownloadPlaylistsMessage": {
|
||||
"description": "Dialog message for bulk playlist download confirmation",
|
||||
"placeholders": {
|
||||
"trackCount": {
|
||||
"type": "int"
|
||||
},
|
||||
"playlistCount": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"bulkDownloadPlaylistsButton": "Download {count} {count, plural, =1{playlist} other{playlists}}",
|
||||
"@bulkDownloadPlaylistsButton": {
|
||||
"description": "Button label for bulk downloading selected playlists",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"bulkDownloadSelectPlaylists": "Select playlists to download",
|
||||
"@bulkDownloadSelectPlaylists": {
|
||||
"description": "Button label when no playlists are selected for download"
|
||||
},
|
||||
"snackbarSelectedPlaylistsEmpty": "Selected playlists have no tracks",
|
||||
"@snackbarSelectedPlaylistsEmpty": {
|
||||
"description": "Snackbar when selected playlists contain no tracks"
|
||||
},
|
||||
"playlistsCount": "{count, plural, =1{1 playlist} other{{count} playlists}}",
|
||||
"@playlistsCount": {
|
||||
"description": "Playlist count display",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"editMetadataAutoFill": "Auto-fill from online",
|
||||
"@editMetadataAutoFill": {
|
||||
"description": "Section title for selective online metadata auto-fill in the edit metadata sheet"
|
||||
},
|
||||
"editMetadataAutoFillDesc": "Select fields to fill automatically from online metadata",
|
||||
"@editMetadataAutoFillDesc": {
|
||||
"description": "Description for the auto-fill section"
|
||||
},
|
||||
"editMetadataAutoFillFetch": "Fetch & Fill",
|
||||
"@editMetadataAutoFillFetch": {
|
||||
"description": "Button label to fetch online metadata and fill selected fields"
|
||||
},
|
||||
"editMetadataAutoFillSearching": "Searching online...",
|
||||
"@editMetadataAutoFillSearching": {
|
||||
"description": "Snackbar shown while searching for online metadata"
|
||||
},
|
||||
"editMetadataAutoFillNoResults": "No matching metadata found online",
|
||||
"@editMetadataAutoFillNoResults": {
|
||||
"description": "Snackbar when online metadata search returns no results"
|
||||
},
|
||||
"editMetadataAutoFillDone": "Filled {count} {count, plural, =1{field} other{fields}} from online metadata",
|
||||
"@editMetadataAutoFillDone": {
|
||||
"description": "Snackbar confirming how many fields were auto-filled",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"editMetadataAutoFillNoneSelected": "Select at least one field to auto-fill",
|
||||
"@editMetadataAutoFillNoneSelected": {
|
||||
"description": "Snackbar when user taps Fetch without selecting any fields"
|
||||
},
|
||||
"editMetadataFieldTitle": "Title",
|
||||
"@editMetadataFieldTitle": {
|
||||
"description": "Chip label for title field in auto-fill selector"
|
||||
},
|
||||
"editMetadataFieldArtist": "Artist",
|
||||
"@editMetadataFieldArtist": {
|
||||
"description": "Chip label for artist field in auto-fill selector"
|
||||
},
|
||||
"editMetadataFieldAlbum": "Album",
|
||||
"@editMetadataFieldAlbum": {
|
||||
"description": "Chip label for album field in auto-fill selector"
|
||||
},
|
||||
"editMetadataFieldAlbumArtist": "Album Artist",
|
||||
"@editMetadataFieldAlbumArtist": {
|
||||
"description": "Chip label for album artist field in auto-fill selector"
|
||||
},
|
||||
"editMetadataFieldDate": "Date",
|
||||
"@editMetadataFieldDate": {
|
||||
"description": "Chip label for date field in auto-fill selector"
|
||||
},
|
||||
"editMetadataFieldTrackNum": "Track #",
|
||||
"@editMetadataFieldTrackNum": {
|
||||
"description": "Chip label for track number field in auto-fill selector"
|
||||
},
|
||||
"editMetadataFieldDiscNum": "Disc #",
|
||||
"@editMetadataFieldDiscNum": {
|
||||
"description": "Chip label for disc number field in auto-fill selector"
|
||||
},
|
||||
"editMetadataFieldGenre": "Genre",
|
||||
"@editMetadataFieldGenre": {
|
||||
"description": "Chip label for genre field in auto-fill selector"
|
||||
},
|
||||
"editMetadataFieldIsrc": "ISRC",
|
||||
"@editMetadataFieldIsrc": {
|
||||
"description": "Chip label for ISRC field in auto-fill selector"
|
||||
},
|
||||
"editMetadataFieldLabel": "Label",
|
||||
"@editMetadataFieldLabel": {
|
||||
"description": "Chip label for label field in auto-fill selector"
|
||||
},
|
||||
"editMetadataFieldCopyright": "Copyright",
|
||||
"@editMetadataFieldCopyright": {
|
||||
"description": "Chip label for copyright field in auto-fill selector"
|
||||
},
|
||||
"editMetadataFieldCover": "Cover Art",
|
||||
"@editMetadataFieldCover": {
|
||||
"description": "Chip label for cover art field in auto-fill selector"
|
||||
},
|
||||
"editMetadataSelectAll": "All",
|
||||
"@editMetadataSelectAll": {
|
||||
"description": "Button to select all fields for auto-fill"
|
||||
},
|
||||
"editMetadataSelectEmpty": "Empty only",
|
||||
"@editMetadataSelectEmpty": {
|
||||
"description": "Button to select only fields that are currently empty"
|
||||
},
|
||||
|
||||
"queueDownloadingCount": "Downloading ({count})",
|
||||
"@queueDownloadingCount": {
|
||||
"description": "Header for active downloads section with count",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"queueDownloadedHeader": "Downloaded",
|
||||
"@queueDownloadedHeader": {
|
||||
"description": "Header label for downloaded items section in library"
|
||||
},
|
||||
"queueFilteringIndicator": "Filtering...",
|
||||
"@queueFilteringIndicator": {
|
||||
"description": "Shown while filter results are being computed"
|
||||
},
|
||||
"queueTrackCount": "{count, plural, =1{1 track} other{{count} tracks}}",
|
||||
"@queueTrackCount": {
|
||||
"description": "Track count label with plural support",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"queueAlbumCount": "{count, plural, =1{1 album} other{{count} albums}}",
|
||||
"@queueAlbumCount": {
|
||||
"description": "Album count label with plural support",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"queueEmptyAlbums": "No album downloads",
|
||||
"@queueEmptyAlbums": {
|
||||
"description": "Empty state title when no album downloads exist"
|
||||
},
|
||||
"queueEmptyAlbumsSubtitle": "Download multiple tracks from an album to see them here",
|
||||
"@queueEmptyAlbumsSubtitle": {
|
||||
"description": "Empty state subtitle for album downloads"
|
||||
},
|
||||
"queueEmptySingles": "No single downloads",
|
||||
"@queueEmptySingles": {
|
||||
"description": "Empty state title when no single track downloads exist"
|
||||
},
|
||||
"queueEmptySinglesSubtitle": "Single track downloads will appear here",
|
||||
"@queueEmptySinglesSubtitle": {
|
||||
"description": "Empty state subtitle for single track downloads"
|
||||
},
|
||||
"queueEmptyHistory": "No download history",
|
||||
"@queueEmptyHistory": {
|
||||
"description": "Empty state title when download history is empty"
|
||||
},
|
||||
"queueEmptyHistorySubtitle": "Downloaded tracks will appear here",
|
||||
"@queueEmptyHistorySubtitle": {
|
||||
"description": "Empty state subtitle for download history"
|
||||
},
|
||||
"selectionAllPlaylistsSelected": "All playlists selected",
|
||||
"@selectionAllPlaylistsSelected": {
|
||||
"description": "Shown when all playlists are selected in selection mode"
|
||||
},
|
||||
"selectionTapPlaylistsToSelect": "Tap playlists to select",
|
||||
"@selectionTapPlaylistsToSelect": {
|
||||
"description": "Hint shown in playlist selection mode"
|
||||
},
|
||||
"selectionSelectPlaylistsToDelete": "Select playlists to delete",
|
||||
"@selectionSelectPlaylistsToDelete": {
|
||||
"description": "Hint shown when no playlists are selected for deletion"
|
||||
},
|
||||
"audioAnalysisTitle": "Audio Quality Analysis",
|
||||
"@audioAnalysisTitle": {
|
||||
"description": "Title for audio analysis section"
|
||||
},
|
||||
"audioAnalysisDescription": "Verify lossless quality with spectrum analysis",
|
||||
"@audioAnalysisDescription": {
|
||||
"description": "Description for audio analysis tap-to-analyze prompt"
|
||||
},
|
||||
"audioAnalysisAnalyzing": "Analyzing audio...",
|
||||
"@audioAnalysisAnalyzing": {
|
||||
"description": "Loading text while analyzing audio"
|
||||
},
|
||||
"audioAnalysisSampleRate": "Sample Rate",
|
||||
"@audioAnalysisSampleRate": {
|
||||
"description": "Sample rate metric label"
|
||||
},
|
||||
"audioAnalysisBitDepth": "Bit Depth",
|
||||
"@audioAnalysisBitDepth": {
|
||||
"description": "Bit depth metric label"
|
||||
},
|
||||
"audioAnalysisChannels": "Channels",
|
||||
"@audioAnalysisChannels": {
|
||||
"description": "Channels metric label"
|
||||
},
|
||||
"audioAnalysisDuration": "Duration",
|
||||
"@audioAnalysisDuration": {
|
||||
"description": "Duration metric label"
|
||||
},
|
||||
"audioAnalysisNyquist": "Nyquist",
|
||||
"@audioAnalysisNyquist": {
|
||||
"description": "Nyquist frequency metric label"
|
||||
},
|
||||
"audioAnalysisFileSize": "Size",
|
||||
"@audioAnalysisFileSize": {
|
||||
"description": "File size metric label"
|
||||
},
|
||||
"audioAnalysisDynamicRange": "Dynamic Range",
|
||||
"@audioAnalysisDynamicRange": {
|
||||
"description": "Dynamic range metric label"
|
||||
},
|
||||
"audioAnalysisPeak": "Peak",
|
||||
"@audioAnalysisPeak": {
|
||||
"description": "Peak amplitude metric label"
|
||||
},
|
||||
"audioAnalysisRms": "RMS",
|
||||
"@audioAnalysisRms": {
|
||||
"description": "RMS level metric label"
|
||||
},
|
||||
"audioAnalysisSamples": "Samples",
|
||||
"@audioAnalysisSamples": {
|
||||
"description": "Total samples metric label"
|
||||
}
|
||||
}
|
||||
|
||||
+401
-7
@@ -450,7 +450,7 @@
|
||||
"@aboutSpotiSaverDesc": {
|
||||
"description": "Credit for SpotiSaver API"
|
||||
},
|
||||
"aboutAppDescription": "Descarga pistas de Spotify con calidad sin pérdida de Tidal y Qobuz.",
|
||||
"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": "No se puede cargar {item}: falta una fuente de extensión",
|
||||
"@errorMissingExtensionSource": {
|
||||
"description": "Error - extension source not available",
|
||||
@@ -991,10 +1003,26 @@
|
||||
"@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": "Ninguna organización",
|
||||
"@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": "Por Artista",
|
||||
"@folderOrganizationByArtist": {
|
||||
"description": "Folder option - artist folders"
|
||||
@@ -1745,10 +1773,6 @@
|
||||
"@qualityNote": {
|
||||
"description": "Note about quality availability"
|
||||
},
|
||||
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
|
||||
"@youtubeQualityNote": {
|
||||
"description": "Note for YouTube service explaining lossy-only quality"
|
||||
},
|
||||
"downloadAskBeforeDownload": "Preguntar antes de descargar",
|
||||
"@downloadAskBeforeDownload": {
|
||||
"description": "Setting - show quality picker"
|
||||
@@ -2198,6 +2222,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",
|
||||
@@ -2358,7 +2391,7 @@
|
||||
"@tutorialWelcomeTip1": {
|
||||
"description": "Tutorial welcome tip 1"
|
||||
},
|
||||
"tutorialWelcomeTip2": "Obtén audio en calidad FLAC de Tidal, Qobuz o Deezer",
|
||||
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer",
|
||||
"@tutorialWelcomeTip2": {
|
||||
"description": "Tutorial welcome tip 2"
|
||||
},
|
||||
@@ -2783,6 +2816,367 @@
|
||||
"@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"
|
||||
},
|
||||
"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} descargado",
|
||||
"@downloadedAlbumDownloadedCount": {
|
||||
"description": "Downloaded tracks count badge",
|
||||
@@ -2800,4 +3194,4 @@
|
||||
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
|
||||
"description": "Subtitle when Track Artist is used for folder naming"
|
||||
}
|
||||
}
|
||||
}
|
||||
+107
-15
@@ -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": {
|
||||
@@ -1753,18 +1773,6 @@
|
||||
"@qualityNote": {
|
||||
"description": "Note about quality availability"
|
||||
},
|
||||
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
|
||||
"@youtubeQualityNote": {
|
||||
"description": "Note for YouTube service explaining lossy-only quality"
|
||||
},
|
||||
"youtubeOpusBitrateTitle": "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"
|
||||
@@ -2383,7 +2391,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 +2816,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"
|
||||
|
||||
+107
-15
@@ -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": {
|
||||
@@ -1753,18 +1773,6 @@
|
||||
"@qualityNote": {
|
||||
"description": "Note about quality availability"
|
||||
},
|
||||
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
|
||||
"@youtubeQualityNote": {
|
||||
"description": "Note for YouTube service explaining lossy-only quality"
|
||||
},
|
||||
"youtubeOpusBitrateTitle": "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"
|
||||
@@ -2383,7 +2391,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 +2816,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"
|
||||
|
||||
+218
-65
@@ -450,7 +450,7 @@
|
||||
"@aboutSpotiSaverDesc": {
|
||||
"description": "Credit for SpotiSaver API"
|
||||
},
|
||||
"aboutAppDescription": "Unduh lagu Spotify dalam kualitas lossless dari Tidal, Qobuz, dan Amazon Music.",
|
||||
"aboutAppDescription": "Unduh lagu-lagu Spotify dalam kualitas lossless dari Tidal dan Qobuz.",
|
||||
"@aboutAppDescription": {
|
||||
"description": "App description in header card"
|
||||
},
|
||||
@@ -1003,11 +1003,11 @@
|
||||
"@filenameFormat": {
|
||||
"description": "Setting title - filename pattern"
|
||||
},
|
||||
"filenameShowAdvancedTags": "Show advanced tags",
|
||||
"filenameShowAdvancedTags": "Tampilkan tag lanjutan",
|
||||
"@filenameShowAdvancedTags": {
|
||||
"description": "Toggle label for showing advanced filename tags"
|
||||
},
|
||||
"filenameShowAdvancedTagsDescription": "Enable formatted tags for track padding and date patterns",
|
||||
"filenameShowAdvancedTagsDescription": "Aktifkan tag yang diformat untuk padding trek dan pola tanggal",
|
||||
"@filenameShowAdvancedTagsDescription": {
|
||||
"description": "Description for advanced filename tag toggle"
|
||||
},
|
||||
@@ -1015,6 +1015,14 @@
|
||||
"@folderOrganizationNone": {
|
||||
"description": "Folder option - flat structure"
|
||||
},
|
||||
"folderOrganizationByPlaylist": "Berdasarkan Daftar Putar",
|
||||
"@folderOrganizationByPlaylist": {
|
||||
"description": "Folder option - playlist folders"
|
||||
},
|
||||
"folderOrganizationByPlaylistSubtitle": "Setiap daftar putar memerlukan folder terpisah",
|
||||
"@folderOrganizationByPlaylistSubtitle": {
|
||||
"description": "Subtitle for playlist folder option"
|
||||
},
|
||||
"folderOrganizationByArtist": "Berdasarkan Artis",
|
||||
"@folderOrganizationByArtist": {
|
||||
"description": "Folder option - artist folders"
|
||||
@@ -1109,7 +1117,7 @@
|
||||
},
|
||||
"providerBuiltIn": "Bawaan",
|
||||
"@providerBuiltIn": {
|
||||
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
|
||||
"description": "Label for built-in providers (Tidal/Qobuz)"
|
||||
},
|
||||
"providerExtension": "Ekstensi",
|
||||
"@providerExtension": {
|
||||
@@ -1209,7 +1217,7 @@
|
||||
"@credentialsDescription": {
|
||||
"description": "Credentials dialog explanation"
|
||||
},
|
||||
"credentialsClientId": "Client ID",
|
||||
"credentialsClientId": "ID Klien",
|
||||
"@credentialsClientId": {
|
||||
"description": "Client ID field label - DO NOT TRANSLATE"
|
||||
},
|
||||
@@ -1217,7 +1225,7 @@
|
||||
"@credentialsClientIdHint": {
|
||||
"description": "Client ID placeholder"
|
||||
},
|
||||
"credentialsClientSecret": "Client Secret",
|
||||
"credentialsClientSecret": "Rahasia Klien",
|
||||
"@credentialsClientSecret": {
|
||||
"description": "Client Secret field label - DO NOT TRANSLATE"
|
||||
},
|
||||
@@ -1229,7 +1237,7 @@
|
||||
"@channelStable": {
|
||||
"description": "Update channel - stable releases"
|
||||
},
|
||||
"channelPreview": "Preview",
|
||||
"channelPreview": "Pratinjau",
|
||||
"@channelPreview": {
|
||||
"description": "Update channel - beta/preview releases"
|
||||
},
|
||||
@@ -1269,39 +1277,39 @@
|
||||
"@sectionFileSettings": {
|
||||
"description": "Settings section header"
|
||||
},
|
||||
"sectionLyrics": "Lyrics",
|
||||
"sectionLyrics": "Lirik",
|
||||
"@sectionLyrics": {
|
||||
"description": "Settings section header"
|
||||
},
|
||||
"lyricsMode": "Lyrics Mode",
|
||||
"lyricsMode": "Mode Lirik",
|
||||
"@lyricsMode": {
|
||||
"description": "Setting - how to save lyrics"
|
||||
},
|
||||
"lyricsModeDescription": "Choose how lyrics are saved with your downloads",
|
||||
"lyricsModeDescription": "Pilih cara lirik disimpan bersama unduhan Anda",
|
||||
"@lyricsModeDescription": {
|
||||
"description": "Lyrics mode picker description"
|
||||
},
|
||||
"lyricsModeEmbed": "Embed in file",
|
||||
"lyricsModeEmbed": "Sematkan dalam file",
|
||||
"@lyricsModeEmbed": {
|
||||
"description": "Lyrics mode option - embed in audio file"
|
||||
},
|
||||
"lyricsModeEmbedSubtitle": "Lyrics stored inside FLAC metadata",
|
||||
"lyricsModeEmbedSubtitle": "Lirik tersimpan di dalam metadata FLAC",
|
||||
"@lyricsModeEmbedSubtitle": {
|
||||
"description": "Subtitle for embed option"
|
||||
},
|
||||
"lyricsModeExternal": "External .lrc file",
|
||||
"lyricsModeExternal": "File .lrc eksternal",
|
||||
"@lyricsModeExternal": {
|
||||
"description": "Lyrics mode option - separate LRC file"
|
||||
},
|
||||
"lyricsModeExternalSubtitle": "Separate .lrc file for players like Samsung Music",
|
||||
"lyricsModeExternalSubtitle": "File .lrc terpisah untuk pemutar musik seperti Samsung Music",
|
||||
"@lyricsModeExternalSubtitle": {
|
||||
"description": "Subtitle for external option"
|
||||
},
|
||||
"lyricsModeBoth": "Both",
|
||||
"lyricsModeBoth": "Keduanya",
|
||||
"@lyricsModeBoth": {
|
||||
"description": "Lyrics mode option - embed and external"
|
||||
},
|
||||
"lyricsModeBothSubtitle": "Embed and save .lrc file",
|
||||
"lyricsModeBothSubtitle": "Sematkan dan simpan file .lrc",
|
||||
"@lyricsModeBothSubtitle": {
|
||||
"description": "Subtitle for both option"
|
||||
},
|
||||
@@ -1447,11 +1455,11 @@
|
||||
"@trackGenre": {
|
||||
"description": "Metadata label - music genre"
|
||||
},
|
||||
"trackLabel": "Label",
|
||||
"trackLabel": "Lebel",
|
||||
"@trackLabel": {
|
||||
"description": "Metadata label - record label"
|
||||
},
|
||||
"trackCopyright": "Copyright",
|
||||
"trackCopyright": "Hak cipta",
|
||||
"@trackCopyright": {
|
||||
"description": "Metadata label - copyright information"
|
||||
},
|
||||
@@ -1475,15 +1483,15 @@
|
||||
"@trackLyricsLoadFailed": {
|
||||
"description": "Message when lyrics loading fails"
|
||||
},
|
||||
"trackEmbedLyrics": "Embed Lyrics",
|
||||
"trackEmbedLyrics": "Sematkan Lirik",
|
||||
"@trackEmbedLyrics": {
|
||||
"description": "Action - embed lyrics into audio file"
|
||||
},
|
||||
"trackLyricsEmbedded": "Lyrics embedded successfully",
|
||||
"trackLyricsEmbedded": "Lirik berhasil disematkan",
|
||||
"@trackLyricsEmbedded": {
|
||||
"description": "Snackbar - lyrics saved to file"
|
||||
},
|
||||
"trackInstrumental": "Instrumental track",
|
||||
"trackInstrumental": "Lagu instrumental",
|
||||
"@trackInstrumental": {
|
||||
"description": "Message when track is instrumental (no lyrics)"
|
||||
},
|
||||
@@ -1562,7 +1570,7 @@
|
||||
"@storeClearFilters": {
|
||||
"description": "Button to clear all filters"
|
||||
},
|
||||
"extensionDefaultProvider": "Default (Deezer/Spotify)",
|
||||
"extensionDefaultProvider": "Bawaan (Deezer/Spotify)",
|
||||
"@extensionDefaultProvider": {
|
||||
"description": "Default search provider option"
|
||||
},
|
||||
@@ -1578,7 +1586,7 @@
|
||||
"@extensionId": {
|
||||
"description": "Extension detail - unique ID"
|
||||
},
|
||||
"extensionError": "Error",
|
||||
"extensionError": "Terjadi kesalahan",
|
||||
"@extensionError": {
|
||||
"description": "Extension detail - error message"
|
||||
},
|
||||
@@ -1765,18 +1773,6 @@
|
||||
"@qualityNote": {
|
||||
"description": "Note about quality availability"
|
||||
},
|
||||
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
|
||||
"@youtubeQualityNote": {
|
||||
"description": "Note for YouTube service explaining lossy-only quality"
|
||||
},
|
||||
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
|
||||
"@youtubeOpusBitrateTitle": {
|
||||
"description": "Title for YouTube Opus bitrate setting"
|
||||
},
|
||||
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
|
||||
"@youtubeMp3BitrateTitle": {
|
||||
"description": "Title for YouTube MP3 bitrate setting"
|
||||
},
|
||||
"downloadAskBeforeDownload": "Tanya Sebelum Unduh",
|
||||
"@downloadAskBeforeDownload": {
|
||||
"description": "Setting - show quality picker"
|
||||
@@ -1793,19 +1789,35 @@
|
||||
"@downloadAlbumFolderStructure": {
|
||||
"description": "Setting - album folder organization"
|
||||
},
|
||||
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
|
||||
"downloadUseAlbumArtistForFolders": "Gunakan Artis Album untuk folder",
|
||||
"@downloadUseAlbumArtistForFolders": {
|
||||
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
|
||||
},
|
||||
"downloadUsePrimaryArtistOnly": "Primary artist only for folders",
|
||||
"downloadCreatePlaylistSourceFolder": "Buat folder sumber playlist",
|
||||
"@downloadCreatePlaylistSourceFolder": {
|
||||
"description": "Setting title for adding a playlist folder prefix before the normal organization structure"
|
||||
},
|
||||
"downloadCreatePlaylistSourceFolderEnabled": "Unduhan dari playlist memakai Playlist/ lalu struktur folder normal Anda.",
|
||||
"@downloadCreatePlaylistSourceFolderEnabled": {
|
||||
"description": "Subtitle when playlist source folder prefix is enabled"
|
||||
},
|
||||
"downloadCreatePlaylistSourceFolderDisabled": "Unduhan dari playlist hanya memakai struktur folder normal.",
|
||||
"@downloadCreatePlaylistSourceFolderDisabled": {
|
||||
"description": "Subtitle when playlist source folder prefix is disabled"
|
||||
},
|
||||
"downloadCreatePlaylistSourceFolderRedundant": "Mode Berdasarkan Playlist sudah menaruh unduhan ke dalam folder playlist.",
|
||||
"@downloadCreatePlaylistSourceFolderRedundant": {
|
||||
"description": "Subtitle when playlist folder prefix setting is redundant because folder organization is already by playlist"
|
||||
},
|
||||
"downloadUsePrimaryArtistOnly": "Hanya artis utama untuk folder",
|
||||
"@downloadUsePrimaryArtistOnly": {
|
||||
"description": "Setting - strip featured artists from folder name"
|
||||
},
|
||||
"downloadUsePrimaryArtistOnlyEnabled": "Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)",
|
||||
"downloadUsePrimaryArtistOnlyEnabled": "Artis unggulan dihapus dari nama folder (misalnya Justin Bieber, Quavo → Justin Bieber)",
|
||||
"@downloadUsePrimaryArtistOnlyEnabled": {
|
||||
"description": "Subtitle when primary artist only is enabled"
|
||||
},
|
||||
"downloadUsePrimaryArtistOnlyDisabled": "Full artist string used for folder name",
|
||||
"downloadUsePrimaryArtistOnlyDisabled": "Nama lengkap artis digunakan untuk nama folder",
|
||||
"@downloadUsePrimaryArtistOnlyDisabled": {
|
||||
"description": "Subtitle when primary artist only is disabled"
|
||||
},
|
||||
@@ -1833,27 +1845,27 @@
|
||||
"@queueClearAllMessage": {
|
||||
"description": "Clear queue confirmation"
|
||||
},
|
||||
"settingsAutoExportFailed": "Auto-export failed downloads",
|
||||
"settingsAutoExportFailed": "Unduhan yang gagal diekspor otomatis",
|
||||
"@settingsAutoExportFailed": {
|
||||
"description": "Setting toggle for auto-export"
|
||||
},
|
||||
"settingsAutoExportFailedSubtitle": "Save failed downloads to TXT file automatically",
|
||||
"settingsAutoExportFailedSubtitle": "Simpan unduhan yang gagal ke file TXT secara otomatis",
|
||||
"@settingsAutoExportFailedSubtitle": {
|
||||
"description": "Subtitle for auto-export setting"
|
||||
},
|
||||
"settingsDownloadNetwork": "Download Network",
|
||||
"settingsDownloadNetwork": "Jaringan Unduhan",
|
||||
"@settingsDownloadNetwork": {
|
||||
"description": "Setting for network type preference"
|
||||
},
|
||||
"settingsDownloadNetworkAny": "WiFi + Mobile Data",
|
||||
"settingsDownloadNetworkAny": "WiFi + Data Seluler",
|
||||
"@settingsDownloadNetworkAny": {
|
||||
"description": "Network option - use any connection"
|
||||
},
|
||||
"settingsDownloadNetworkWifiOnly": "WiFi Only",
|
||||
"settingsDownloadNetworkWifiOnly": "Hanya WiFi",
|
||||
"@settingsDownloadNetworkWifiOnly": {
|
||||
"description": "Network option - only use WiFi"
|
||||
},
|
||||
"settingsDownloadNetworkSubtitle": "Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.",
|
||||
"settingsDownloadNetworkSubtitle": "Pilih jaringan mana yang akan digunakan untuk mengunduh. Jika diatur ke Hanya WiFi, unduhan akan berhenti sementara dan menggunakan data seluler.",
|
||||
"@settingsDownloadNetworkSubtitle": {
|
||||
"description": "Subtitle explaining network preference"
|
||||
},
|
||||
@@ -1889,11 +1901,11 @@
|
||||
"@albumFolderYearAlbumSubtitle": {
|
||||
"description": "Folder structure example"
|
||||
},
|
||||
"albumFolderArtistAlbumSingles": "Artist / Album + Singles",
|
||||
"albumFolderArtistAlbumSingles": "Artis / Album + Singel",
|
||||
"@albumFolderArtistAlbumSingles": {
|
||||
"description": "Album folder option with singles inside artist"
|
||||
},
|
||||
"albumFolderArtistAlbumSinglesSubtitle": "Artist/Album/ and Artist/Singles/",
|
||||
"albumFolderArtistAlbumSinglesSubtitle": "Artis/Album/ dan Artis/Single/",
|
||||
"@albumFolderArtistAlbumSinglesSubtitle": {
|
||||
"description": "Folder structure example"
|
||||
},
|
||||
@@ -1962,19 +1974,19 @@
|
||||
"@recentTypeSong": {
|
||||
"description": "Recent access item type - song/track"
|
||||
},
|
||||
"recentTypePlaylist": "Playlist",
|
||||
"recentTypePlaylist": "Daftar putar",
|
||||
"@recentTypePlaylist": {
|
||||
"description": "Recent access item type - playlist"
|
||||
},
|
||||
"recentEmpty": "No recent items yet",
|
||||
"recentEmpty": "Belum ada item terbaru",
|
||||
"@recentEmpty": {
|
||||
"description": "Empty state text for recent access list"
|
||||
},
|
||||
"recentShowAllDownloads": "Show All Downloads",
|
||||
"recentShowAllDownloads": "Tampilkan Semua Unduhan",
|
||||
"@recentShowAllDownloads": {
|
||||
"description": "Button label to unhide hidden downloads in recent access"
|
||||
},
|
||||
"recentPlaylistInfo": "Playlist: {name}",
|
||||
"recentPlaylistInfo": "Daftar Putar: {name}",
|
||||
"@recentPlaylistInfo": {
|
||||
"description": "Snackbar message when tapping playlist in recent access",
|
||||
"placeholders": {
|
||||
@@ -1984,7 +1996,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographyDownload": "Download Discography",
|
||||
"discographyDownload": "Unduh Diskografi",
|
||||
"@discographyDownload": {
|
||||
"description": "Button - download artist discography"
|
||||
},
|
||||
@@ -2383,47 +2395,47 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"tutorialWelcomeTitle": "Welcome to SpotiFLAC!",
|
||||
"tutorialWelcomeTitle": "Selamat Datang di SpotiFLAC!",
|
||||
"@tutorialWelcomeTitle": {
|
||||
"description": "Tutorial welcome page title"
|
||||
},
|
||||
"tutorialWelcomeDesc": "Let's learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.",
|
||||
"tutorialWelcomeDesc": "Mari kita pelajari cara mengunduh musik favorit Anda dalam kualitas lossless. Tutorial singkat ini akan menunjukkan dasar-dasarnya.",
|
||||
"@tutorialWelcomeDesc": {
|
||||
"description": "Tutorial welcome page description"
|
||||
},
|
||||
"tutorialWelcomeTip1": "Download music from Spotify, Deezer, or paste any supported URL",
|
||||
"tutorialWelcomeTip1": "Unduh musik dari Spotify, Deezer, atau tempel URL yang didukung",
|
||||
"@tutorialWelcomeTip1": {
|
||||
"description": "Tutorial welcome tip 1"
|
||||
},
|
||||
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music",
|
||||
"tutorialWelcomeTip2": "Dapatkan audio berkualitas FLAC dari Tidal, Qobuz, atau Deezer",
|
||||
"@tutorialWelcomeTip2": {
|
||||
"description": "Tutorial welcome tip 2"
|
||||
},
|
||||
"tutorialWelcomeTip3": "Automatic metadata, cover art, and lyrics embedding",
|
||||
"tutorialWelcomeTip3": "Penyematan metadata, sampul album, dan lirik secara otomatis",
|
||||
"@tutorialWelcomeTip3": {
|
||||
"description": "Tutorial welcome tip 3"
|
||||
},
|
||||
"tutorialSearchTitle": "Finding Music",
|
||||
"tutorialSearchTitle": "Menemukan Musik",
|
||||
"@tutorialSearchTitle": {
|
||||
"description": "Tutorial search page title"
|
||||
},
|
||||
"tutorialSearchDesc": "There are two easy ways to find music you want to download.",
|
||||
"tutorialSearchDesc": "Ada dua cara mudah untuk menemukan musik yang ingin Anda unduh.",
|
||||
"@tutorialSearchDesc": {
|
||||
"description": "Tutorial search page description"
|
||||
},
|
||||
"tutorialDownloadTitle": "Downloading Music",
|
||||
"tutorialDownloadTitle": "Mengunduh Musik",
|
||||
"@tutorialDownloadTitle": {
|
||||
"description": "Tutorial download page title"
|
||||
},
|
||||
"tutorialDownloadDesc": "Downloading music is simple and fast. Here's how it works.",
|
||||
"tutorialDownloadDesc": "Mengunduh musik itu mudah dan cepat. Begini cara kerjanya.",
|
||||
"@tutorialDownloadDesc": {
|
||||
"description": "Tutorial download page description"
|
||||
},
|
||||
"tutorialLibraryTitle": "Your Library",
|
||||
"tutorialLibraryTitle": "Perpustakaan Anda",
|
||||
"@tutorialLibraryTitle": {
|
||||
"description": "Tutorial library page title"
|
||||
},
|
||||
"tutorialLibraryDesc": "All your downloaded music is organized in the Library tab.",
|
||||
"tutorialLibraryDesc": "Semua musik yang Anda unduh tersusun rapi di tab Perpustakaan.",
|
||||
"@tutorialLibraryDesc": {
|
||||
"description": "Tutorial library page description"
|
||||
},
|
||||
@@ -2755,6 +2767,47 @@
|
||||
"@trackReEnrichFfmpegFailed": {
|
||||
"description": "Snackbar when FFmpeg embed fails for MP3/Opus"
|
||||
},
|
||||
"queueFlacAction": "Antrekan FLAC",
|
||||
"@queueFlacAction": {
|
||||
"description": "Action/button label for queueing FLAC redownloads for local tracks"
|
||||
},
|
||||
"queueFlacConfirmMessage": "Cari kecocokan online untuk track yang dipilih lalu antrekan download FLAC.\n\nFile yang sudah ada tidak akan diubah atau dihapus.\n\nHanya kecocokan dengan keyakinan tinggi yang akan diantrikan otomatis.\n\n{count} dipilih",
|
||||
"@queueFlacConfirmMessage": {
|
||||
"description": "Confirmation dialog body before queueing FLAC redownloads for local tracks",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"queueFlacFindingProgress": "Mencari kecocokan FLAC... ({current}/{total})",
|
||||
"@queueFlacFindingProgress": {
|
||||
"description": "Snackbar while resolving remote matches for local FLAC redownloads",
|
||||
"placeholders": {
|
||||
"current": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"queueFlacNoReliableMatches": "Tidak ada kecocokan online yang cukup meyakinkan untuk pilihan ini",
|
||||
"@queueFlacNoReliableMatches": {
|
||||
"description": "Snackbar when no safe FLAC redownload matches were found"
|
||||
},
|
||||
"queueFlacQueuedWithSkipped": "Menambahkan {addedCount} track ke antrean, melewati {skippedCount}",
|
||||
"@queueFlacQueuedWithSkipped": {
|
||||
"description": "Snackbar when some selected local tracks were queued for FLAC redownload and some were skipped",
|
||||
"placeholders": {
|
||||
"addedCount": {
|
||||
"type": "int"
|
||||
},
|
||||
"skippedCount": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"trackSaveFailed": "Failed: {error}",
|
||||
"@trackSaveFailed": {
|
||||
"description": "Snackbar when save operation fails",
|
||||
@@ -2768,7 +2821,7 @@
|
||||
"@trackConvertFormat": {
|
||||
"description": "Menu item - convert audio format"
|
||||
},
|
||||
"trackConvertFormatSubtitle": "Convert to MP3 or Opus",
|
||||
"trackConvertFormatSubtitle": "Konversi ke MP3, Opus, ALAC, atau FLAC",
|
||||
"@trackConvertFormatSubtitle": {
|
||||
"description": "Subtitle for convert format menu item"
|
||||
},
|
||||
@@ -2803,6 +2856,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"trackConvertConfirmMessageLossless": "Konversi dari {sourceFormat} ke {targetFormat}? (Lossless — tanpa kehilangan kualitas)\n\nFile asli akan dihapus setelah konversi.",
|
||||
"@trackConvertConfirmMessageLossless": {
|
||||
"description": "Confirmation dialog message for lossless-to-lossless conversion",
|
||||
"placeholders": {
|
||||
"sourceFormat": {
|
||||
"type": "String"
|
||||
},
|
||||
"targetFormat": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"trackConvertLosslessHint": "Konversi lossless — tanpa kehilangan kualitas",
|
||||
"@trackConvertLosslessHint": {
|
||||
"description": "Hint shown when converting between lossless formats"
|
||||
},
|
||||
"trackConvertConverting": "Converting audio...",
|
||||
"@trackConvertConverting": {
|
||||
"description": "Snackbar while converting"
|
||||
@@ -2820,6 +2889,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"
|
||||
@@ -3114,4 +3267,4 @@
|
||||
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
|
||||
"description": "Subtitle when Track Artist is used for folder naming"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+116
-24
@@ -450,7 +450,7 @@
|
||||
"@aboutSpotiSaverDesc": {
|
||||
"description": "Credit for SpotiSaver API"
|
||||
},
|
||||
"aboutAppDescription": "Tidal、Qobuz、Amazon Music から Spotify のトラックをロスレス品質でダウンロードします。",
|
||||
"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": "{item} を読み込めません: 拡張ソースがありません",
|
||||
"@errorMissingExtensionSource": {
|
||||
"description": "Error - extension source not available",
|
||||
@@ -991,7 +1003,7 @@
|
||||
"@filenameFormat": {
|
||||
"description": "Setting title - filename pattern"
|
||||
},
|
||||
"filenameShowAdvancedTags": "Show advanced tags",
|
||||
"filenameShowAdvancedTags": "高度なタグを表示",
|
||||
"@filenameShowAdvancedTags": {
|
||||
"description": "Toggle label for showing advanced filename tags"
|
||||
},
|
||||
@@ -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": "アーティスト別",
|
||||
"@folderOrganizationByArtist": {
|
||||
"description": "Folder option - artist folders"
|
||||
@@ -1097,7 +1117,7 @@
|
||||
},
|
||||
"providerBuiltIn": "内蔵",
|
||||
"@providerBuiltIn": {
|
||||
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
|
||||
"description": "Label for built-in providers (Tidal/Qobuz)"
|
||||
},
|
||||
"providerExtension": "拡張",
|
||||
"@providerExtension": {
|
||||
@@ -1471,7 +1491,7 @@
|
||||
"@trackLyricsEmbedded": {
|
||||
"description": "Snackbar - lyrics saved to file"
|
||||
},
|
||||
"trackInstrumental": "Instrumental track",
|
||||
"trackInstrumental": "インストゥルメンタルのトラック",
|
||||
"@trackInstrumental": {
|
||||
"description": "Message when track is instrumental (no lyrics)"
|
||||
},
|
||||
@@ -1753,18 +1773,6 @@
|
||||
"@qualityNote": {
|
||||
"description": "Note about quality availability"
|
||||
},
|
||||
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
|
||||
"@youtubeQualityNote": {
|
||||
"description": "Note for YouTube service explaining lossy-only quality"
|
||||
},
|
||||
"youtubeOpusBitrateTitle": "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"
|
||||
@@ -2383,7 +2391,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 +2816,90 @@
|
||||
"@trackConvertFailed": {
|
||||
"description": "Snackbar when conversion fails"
|
||||
},
|
||||
"cueSplitTitle": "分割 CUE シート",
|
||||
"@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"
|
||||
@@ -2940,7 +3032,7 @@
|
||||
"@collectionRemoveFromPlaylist": {
|
||||
"description": "Tooltip for removing track from playlist"
|
||||
},
|
||||
"collectionRemoveFromFolder": "Remove from folder",
|
||||
"collectionRemoveFromFolder": "フォルダから削除",
|
||||
"@collectionRemoveFromFolder": {
|
||||
"description": "Tooltip for removing track from wishlist/loved folder"
|
||||
},
|
||||
@@ -2997,23 +3089,23 @@
|
||||
"@trackOptionRemoveFromLoved": {
|
||||
"description": "Bottom sheet action label - remove track from loved folder"
|
||||
},
|
||||
"trackOptionAddToWishlist": "Add to Wishlist",
|
||||
"trackOptionAddToWishlist": "ウィッシュリストに追加",
|
||||
"@trackOptionAddToWishlist": {
|
||||
"description": "Bottom sheet action label - add track to wishlist"
|
||||
},
|
||||
"trackOptionRemoveFromWishlist": "Remove from Wishlist",
|
||||
"trackOptionRemoveFromWishlist": "ウィッシュから削除",
|
||||
"@trackOptionRemoveFromWishlist": {
|
||||
"description": "Bottom sheet action label - remove track from wishlist"
|
||||
},
|
||||
"collectionPlaylistChangeCover": "Change cover image",
|
||||
"collectionPlaylistChangeCover": "カバー画像を変更",
|
||||
"@collectionPlaylistChangeCover": {
|
||||
"description": "Bottom sheet action to pick a custom cover image for a playlist"
|
||||
},
|
||||
"collectionPlaylistRemoveCover": "Remove cover image",
|
||||
"collectionPlaylistRemoveCover": "カバー画像を削除",
|
||||
"@collectionPlaylistRemoveCover": {
|
||||
"description": "Bottom sheet action to remove custom cover image from a playlist"
|
||||
},
|
||||
"selectionShareCount": "Share {count} {count, plural, =1{track} other{tracks}}",
|
||||
"selectionShareCount": "{count} {count, plural, =1{個のトラック} other{個のトラック}}を共有",
|
||||
"@selectionShareCount": {
|
||||
"description": "Share button text with count in selection mode",
|
||||
"placeholders": {
|
||||
@@ -3039,7 +3131,7 @@
|
||||
"@selectionConvertNoConvertible": {
|
||||
"description": "Snackbar when no selected tracks support conversion"
|
||||
},
|
||||
"selectionBatchConvertConfirmTitle": "Batch Convert",
|
||||
"selectionBatchConvertConfirmTitle": "一括変換",
|
||||
"@selectionBatchConvertConfirmTitle": {
|
||||
"description": "Confirmation dialog title for batch conversion"
|
||||
},
|
||||
|
||||
+107
-15
@@ -450,7 +450,7 @@
|
||||
"@aboutSpotiSaverDesc": {
|
||||
"description": "Credit for SpotiSaver API"
|
||||
},
|
||||
"aboutAppDescription": "Tidal, Qobuz, Amazon Music에서 Spotify 트랙을 무손실 음질로 다운로드하세요.",
|
||||
"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": "확장 소스가 누락되어, {item}(을)를 로드할 수 없습니다",
|
||||
"@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": {
|
||||
@@ -1753,18 +1773,6 @@
|
||||
"@qualityNote": {
|
||||
"description": "Note about quality availability"
|
||||
},
|
||||
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
|
||||
"@youtubeQualityNote": {
|
||||
"description": "Note for YouTube service explaining lossy-only quality"
|
||||
},
|
||||
"youtubeOpusBitrateTitle": "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"
|
||||
@@ -2383,7 +2391,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 +2816,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"
|
||||
|
||||
+111
-19
@@ -194,11 +194,11 @@
|
||||
"@optionsConcurrentDownloads": {
|
||||
"description": "Number of parallel downloads"
|
||||
},
|
||||
"optionsConcurrentSequential": "Sequential (1 at a time)",
|
||||
"optionsConcurrentSequential": "Sequentiële (1 per keer)",
|
||||
"@optionsConcurrentSequential": {
|
||||
"description": "Download one at a time"
|
||||
},
|
||||
"optionsConcurrentParallel": "{count} parallel downloads",
|
||||
"optionsConcurrentParallel": "",
|
||||
"@optionsConcurrentParallel": {
|
||||
"description": "Multiple parallel downloads",
|
||||
"placeholders": {
|
||||
@@ -207,7 +207,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"optionsConcurrentWarning": "Parallel downloads may trigger rate limiting",
|
||||
"optionsConcurrentWarning": "Parallel downloaden kan leiden tot rate-limiting",
|
||||
"@optionsConcurrentWarning": {
|
||||
"description": "Warning about rate limits"
|
||||
},
|
||||
@@ -346,7 +346,7 @@
|
||||
"@aboutContributors": {
|
||||
"description": "Section for contributors"
|
||||
},
|
||||
"aboutMobileDeveloper": "Mobile version developer",
|
||||
"aboutMobileDeveloper": "",
|
||||
"@aboutMobileDeveloper": {
|
||||
"description": "Role description for mobile dev"
|
||||
},
|
||||
@@ -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": {
|
||||
@@ -1753,18 +1773,6 @@
|
||||
"@qualityNote": {
|
||||
"description": "Note about quality availability"
|
||||
},
|
||||
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
|
||||
"@youtubeQualityNote": {
|
||||
"description": "Note for YouTube service explaining lossy-only quality"
|
||||
},
|
||||
"youtubeOpusBitrateTitle": "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"
|
||||
@@ -2383,7 +2391,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 +2816,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"
|
||||
|
||||
+401
-7
@@ -450,7 +450,7 @@
|
||||
"@aboutSpotiSaverDesc": {
|
||||
"description": "Credit for SpotiSaver API"
|
||||
},
|
||||
"aboutAppDescription": "Baixe faixas do Spotify em qualidade sem perdas do Tidal e Qobuz.",
|
||||
"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": "Não é possível carregar {item}: faltando a fonte da extensão",
|
||||
"@errorMissingExtensionSource": {
|
||||
"description": "Error - extension source not available",
|
||||
@@ -991,10 +1003,26 @@
|
||||
"@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": "Nenhuma organização",
|
||||
"@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": "Por Artista",
|
||||
"@folderOrganizationByArtist": {
|
||||
"description": "Folder option - artist folders"
|
||||
@@ -1745,10 +1773,6 @@
|
||||
"@qualityNote": {
|
||||
"description": "Note about quality availability"
|
||||
},
|
||||
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
|
||||
"@youtubeQualityNote": {
|
||||
"description": "Note for YouTube service explaining lossy-only quality"
|
||||
},
|
||||
"downloadAskBeforeDownload": "Perguntar qualidade antes de baixar",
|
||||
"@downloadAskBeforeDownload": {
|
||||
"description": "Setting - show quality picker"
|
||||
@@ -2198,6 +2222,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",
|
||||
@@ -2358,7 +2391,7 @@
|
||||
"@tutorialWelcomeTip1": {
|
||||
"description": "Tutorial welcome tip 1"
|
||||
},
|
||||
"tutorialWelcomeTip2": "Obtenha áudio em qualidade FLAC do Tidal, Qobuz ou Deezer",
|
||||
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer",
|
||||
"@tutorialWelcomeTip2": {
|
||||
"description": "Tutorial welcome tip 2"
|
||||
},
|
||||
@@ -2783,6 +2816,367 @@
|
||||
"@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"
|
||||
},
|
||||
"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} baixado(s)",
|
||||
"@downloadedAlbumDownloadedCount": {
|
||||
"description": "Downloaded tracks count badge",
|
||||
@@ -2800,4 +3194,4 @@
|
||||
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
|
||||
"description": "Subtitle when Track Artist is used for folder naming"
|
||||
}
|
||||
}
|
||||
}
|
||||
+114
-22
@@ -450,7 +450,7 @@
|
||||
"@aboutSpotiSaverDesc": {
|
||||
"description": "Credit for SpotiSaver API"
|
||||
},
|
||||
"aboutAppDescription": "Скачайте треки Spotify в Lossless качестве из Tidal, Qobuz и Amazon Music.",
|
||||
"aboutAppDescription": "Скачивайте треки Spotify в lossless качестве с Tidal и Qobuz.",
|
||||
"@aboutAppDescription": {
|
||||
"description": "App description in header card"
|
||||
},
|
||||
@@ -897,6 +897,18 @@
|
||||
"@errorNoTracksFound": {
|
||||
"description": "Error - search returned no results"
|
||||
},
|
||||
"errorUrlNotRecognized": "Ссылка не распознана",
|
||||
"@errorUrlNotRecognized": {
|
||||
"description": "Error title - URL not handled by any extension or service"
|
||||
},
|
||||
"errorUrlNotRecognizedMessage": "Эта ссылка не поддерживается. Убедитесь, что URL-адрес указан правильно и установлено совместимое расширение.",
|
||||
"@errorUrlNotRecognizedMessage": {
|
||||
"description": "Error message - URL not recognized explanation"
|
||||
},
|
||||
"errorUrlFetchFailed": "Не удалось загрузить контент по этой ссылке. Пожалуйста, попробуйте еще раз.",
|
||||
"@errorUrlFetchFailed": {
|
||||
"description": "Error message - generic URL fetch failure"
|
||||
},
|
||||
"errorMissingExtensionSource": "Невозможно загрузить {item}: отсутствует источник расширения",
|
||||
"@errorMissingExtensionSource": {
|
||||
"description": "Error - extension source not available",
|
||||
@@ -1003,6 +1015,14 @@
|
||||
"@folderOrganizationNone": {
|
||||
"description": "Folder option - flat structure"
|
||||
},
|
||||
"folderOrganizationByPlaylist": "По плейлисту",
|
||||
"@folderOrganizationByPlaylist": {
|
||||
"description": "Folder option - playlist folders"
|
||||
},
|
||||
"folderOrganizationByPlaylistSubtitle": "Отдельная папка для каждого плейлиста",
|
||||
"@folderOrganizationByPlaylistSubtitle": {
|
||||
"description": "Subtitle for playlist folder option"
|
||||
},
|
||||
"folderOrganizationByArtist": "По исполнителю",
|
||||
"@folderOrganizationByArtist": {
|
||||
"description": "Folder option - artist folders"
|
||||
@@ -1097,7 +1117,7 @@
|
||||
},
|
||||
"providerBuiltIn": "Встроенные",
|
||||
"@providerBuiltIn": {
|
||||
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
|
||||
"description": "Label for built-in providers (Tidal/Qobuz)"
|
||||
},
|
||||
"providerExtension": "Расширение",
|
||||
"@providerExtension": {
|
||||
@@ -1753,18 +1773,6 @@
|
||||
"@qualityNote": {
|
||||
"description": "Note about quality availability"
|
||||
},
|
||||
"youtubeQualityNote": "YouTube обеспечивает только звук с потерями(Lossy).",
|
||||
"@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"
|
||||
@@ -1789,7 +1797,7 @@
|
||||
"@downloadUsePrimaryArtistOnly": {
|
||||
"description": "Setting - strip featured artists from folder name"
|
||||
},
|
||||
"downloadUsePrimaryArtistOnlyEnabled": "Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)",
|
||||
"downloadUsePrimaryArtistOnlyEnabled": "Список исполнителей, чьи работы были удалены из названия папки (например, Джастин Бибер, Quavo → Джастин Бибер)",
|
||||
"@downloadUsePrimaryArtistOnlyEnabled": {
|
||||
"description": "Subtitle when primary artist only is enabled"
|
||||
},
|
||||
@@ -2383,7 +2391,7 @@
|
||||
"@tutorialWelcomeTip1": {
|
||||
"description": "Tutorial welcome tip 1"
|
||||
},
|
||||
"tutorialWelcomeTip2": "Скачайте FLAC с Tidal, Qobuz или Amazon Music",
|
||||
"tutorialWelcomeTip2": "Получите аудио в качестве FLAC от Tidal, Qobuz или Deezer",
|
||||
"@tutorialWelcomeTip2": {
|
||||
"description": "Tutorial welcome tip 2"
|
||||
},
|
||||
@@ -2487,7 +2495,7 @@
|
||||
"@cleanupOrphanedDownloadsSubtitle": {
|
||||
"description": "Subtitle for orphaned cleanup button"
|
||||
},
|
||||
"cleanupOrphanedDownloadsResult": "Removed {count} orphaned entries from history",
|
||||
"cleanupOrphanedDownloadsResult": "Удалено {count} утерянных записей из истории",
|
||||
"@cleanupOrphanedDownloadsResult": {
|
||||
"description": "Snackbar after orphan cleanup",
|
||||
"placeholders": {
|
||||
@@ -2525,7 +2533,7 @@
|
||||
"@cacheSectionStorage": {
|
||||
"description": "Section header for cache entries"
|
||||
},
|
||||
"cacheSectionMaintenance": "Maintenance",
|
||||
"cacheSectionMaintenance": "Обслуживание",
|
||||
"@cacheSectionMaintenance": {
|
||||
"description": "Section header for cleanup actions"
|
||||
},
|
||||
@@ -2577,7 +2585,7 @@
|
||||
"@cacheTrackLookupDesc": {
|
||||
"description": "Description of what track lookup cache contains"
|
||||
},
|
||||
"cacheCleanupUnusedDesc": "Remove orphaned download history and library entries for missing files.",
|
||||
"cacheCleanupUnusedDesc": "Удалить записи из истории загрузок и библиотеки, которые остались без файлов.",
|
||||
"@cacheCleanupUnusedDesc": {
|
||||
"description": "Description of what cleanup unused data does"
|
||||
},
|
||||
@@ -2653,7 +2661,7 @@
|
||||
"@cacheCleanupUnused": {
|
||||
"description": "Action title for cleaning unused entries"
|
||||
},
|
||||
"cacheCleanupUnusedSubtitle": "Remove orphaned download history and missing library entries",
|
||||
"cacheCleanupUnusedSubtitle": "Удалить историю загрузок, оставшихся без просмотра, и отсутствующие записи в библиотеке",
|
||||
"@cacheCleanupUnusedSubtitle": {
|
||||
"description": "Subtitle for cleanup unused data action"
|
||||
},
|
||||
@@ -2808,6 +2816,90 @@
|
||||
"@trackConvertFailed": {
|
||||
"description": "Snackbar when conversion fails"
|
||||
},
|
||||
"cueSplitTitle": "Разделить CUE Sheet",
|
||||
"@cueSplitTitle": {
|
||||
"description": "Title for CUE split bottom sheet"
|
||||
},
|
||||
"cueSplitSubtitle": "Разделить файл CUE+FLAC на отдельные треки",
|
||||
"@cueSplitSubtitle": {
|
||||
"description": "Subtitle for CUE split menu item"
|
||||
},
|
||||
"cueSplitAlbum": "Альбом: {album}",
|
||||
"@cueSplitAlbum": {
|
||||
"description": "Album name in CUE split sheet",
|
||||
"placeholders": {
|
||||
"album": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitArtist": "Артист: {artist}",
|
||||
"@cueSplitArtist": {
|
||||
"description": "Artist name in CUE split sheet",
|
||||
"placeholders": {
|
||||
"artist": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitTrackCount": "{count} треков",
|
||||
"@cueSplitTrackCount": {
|
||||
"description": "Number of tracks in CUE sheet",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitConfirmTitle": "Разделенный CUE-альбом",
|
||||
"@cueSplitConfirmTitle": {
|
||||
"description": "CUE split confirmation dialog title"
|
||||
},
|
||||
"cueSplitConfirmMessage": "Разбить \"{album}\" на {count} отдельных FLAC-файлов?",
|
||||
"@cueSplitConfirmMessage": {
|
||||
"description": "CUE split confirmation dialog message",
|
||||
"placeholders": {
|
||||
"album": {
|
||||
"type": "String"
|
||||
},
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitSplitting": "Разделение CUE sheet... ({current}/{total})",
|
||||
"@cueSplitSplitting": {
|
||||
"description": "Snackbar while splitting CUE",
|
||||
"placeholders": {
|
||||
"current": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitSuccess": "Успешно разделено на {count} треков",
|
||||
"@cueSplitSuccess": {
|
||||
"description": "Snackbar after successful CUE split",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitFailed": "Разделение CUE не удалось",
|
||||
"@cueSplitFailed": {
|
||||
"description": "Snackbar when CUE split fails"
|
||||
},
|
||||
"cueSplitNoAudioFile": "Аудиофайл для этого CUE sheet не найден",
|
||||
"@cueSplitNoAudioFile": {
|
||||
"description": "Error when CUE audio file is missing"
|
||||
},
|
||||
"cueSplitButton": "Разделить на Треки",
|
||||
"@cueSplitButton": {
|
||||
"description": "Button text to start CUE splitting"
|
||||
},
|
||||
"actionCreate": "Создать",
|
||||
"@actionCreate": {
|
||||
"description": "Generic action button - create"
|
||||
@@ -3022,7 +3114,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectionShareNoFiles": "No shareable files found",
|
||||
"selectionShareNoFiles": "Файлы, доступные для совместного доступа, не найдены",
|
||||
"@selectionShareNoFiles": {
|
||||
"description": "Snackbar when no selected files exist on disk"
|
||||
},
|
||||
@@ -3043,7 +3135,7 @@
|
||||
"@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": "Преобразовать {count} {count, plural, =1{track} other{tracks}} в {format} с {bitrate}?",
|
||||
"@selectionBatchConvertConfirmMessage": {
|
||||
"description": "Confirmation dialog message for batch conversion",
|
||||
"placeholders": {
|
||||
|
||||
+536
-142
File diff suppressed because it is too large
Load Diff
+203
-111
@@ -5,143 +5,143 @@
|
||||
"@appName": {
|
||||
"description": "App name - DO NOT TRANSLATE"
|
||||
},
|
||||
"navHome": "Home",
|
||||
"navHome": "主页",
|
||||
"@navHome": {
|
||||
"description": "Bottom navigation - Home tab"
|
||||
},
|
||||
"navLibrary": "Library",
|
||||
"navLibrary": "乐库",
|
||||
"@navLibrary": {
|
||||
"description": "Bottom navigation - Library tab"
|
||||
},
|
||||
"navSettings": "Settings",
|
||||
"navSettings": "设置",
|
||||
"@navSettings": {
|
||||
"description": "Bottom navigation - Settings tab"
|
||||
},
|
||||
"navStore": "Store",
|
||||
"navStore": "商店",
|
||||
"@navStore": {
|
||||
"description": "Bottom navigation - Extension store tab"
|
||||
},
|
||||
"homeTitle": "Home",
|
||||
"homeTitle": "主页",
|
||||
"@homeTitle": {
|
||||
"description": "Home screen title"
|
||||
},
|
||||
"homeSubtitle": "Paste a Spotify link or search by name",
|
||||
"homeSubtitle": "粘贴 Spotify 链接或按名称搜索",
|
||||
"@homeSubtitle": {
|
||||
"description": "Subtitle shown below search box"
|
||||
},
|
||||
"homeSupports": "Supports: Track, Album, Playlist, Artist URLs",
|
||||
"homeSupports": "支持:歌曲、专辑、播放列表、艺人网址",
|
||||
"@homeSupports": {
|
||||
"description": "Info text about supported URL types"
|
||||
},
|
||||
"homeRecent": "Recent",
|
||||
"homeRecent": "最近",
|
||||
"@homeRecent": {
|
||||
"description": "Section header for recent searches"
|
||||
},
|
||||
"historyFilterAll": "All",
|
||||
"historyFilterAll": "全部",
|
||||
"@historyFilterAll": {
|
||||
"description": "Filter chip - show all items"
|
||||
},
|
||||
"historyFilterAlbums": "Albums",
|
||||
"historyFilterAlbums": "专辑",
|
||||
"@historyFilterAlbums": {
|
||||
"description": "Filter chip - show albums only"
|
||||
},
|
||||
"historyFilterSingles": "Singles",
|
||||
"historyFilterSingles": "单曲",
|
||||
"@historyFilterSingles": {
|
||||
"description": "Filter chip - show singles only"
|
||||
},
|
||||
"historySearchHint": "Search history...",
|
||||
"historySearchHint": "搜索历史……",
|
||||
"@historySearchHint": {
|
||||
"description": "Search bar placeholder in history"
|
||||
},
|
||||
"settingsTitle": "Settings",
|
||||
"settingsTitle": "设置",
|
||||
"@settingsTitle": {
|
||||
"description": "Settings screen title"
|
||||
},
|
||||
"settingsDownload": "Download",
|
||||
"settingsDownload": "下载",
|
||||
"@settingsDownload": {
|
||||
"description": "Settings section - download options"
|
||||
},
|
||||
"settingsAppearance": "Appearance",
|
||||
"settingsAppearance": "外观",
|
||||
"@settingsAppearance": {
|
||||
"description": "Settings section - visual customization"
|
||||
},
|
||||
"settingsOptions": "Options",
|
||||
"settingsOptions": "选项",
|
||||
"@settingsOptions": {
|
||||
"description": "Settings section - app options"
|
||||
},
|
||||
"settingsExtensions": "Extensions",
|
||||
"settingsExtensions": "扩展",
|
||||
"@settingsExtensions": {
|
||||
"description": "Settings section - extension management"
|
||||
},
|
||||
"settingsAbout": "About",
|
||||
"settingsAbout": "关于",
|
||||
"@settingsAbout": {
|
||||
"description": "Settings section - app info"
|
||||
},
|
||||
"downloadTitle": "Download",
|
||||
"downloadTitle": "下载",
|
||||
"@downloadTitle": {
|
||||
"description": "Download settings page title"
|
||||
},
|
||||
"downloadAskQualitySubtitle": "Show quality picker for each download",
|
||||
"downloadAskQualitySubtitle": "为每次下载显示质量选择器",
|
||||
"@downloadAskQualitySubtitle": {
|
||||
"description": "Subtitle for ask quality toggle"
|
||||
},
|
||||
"downloadFilenameFormat": "Filename Format",
|
||||
"downloadFilenameFormat": "文件名格式",
|
||||
"@downloadFilenameFormat": {
|
||||
"description": "Setting for output filename pattern"
|
||||
},
|
||||
"downloadFolderOrganization": "Folder Organization",
|
||||
"downloadFolderOrganization": "文件夹结构",
|
||||
"@downloadFolderOrganization": {
|
||||
"description": "Setting for folder structure"
|
||||
},
|
||||
"appearanceTitle": "Appearance",
|
||||
"appearanceTitle": "外观",
|
||||
"@appearanceTitle": {
|
||||
"description": "Appearance settings page title"
|
||||
},
|
||||
"appearanceThemeSystem": "System",
|
||||
"appearanceThemeSystem": "系统",
|
||||
"@appearanceThemeSystem": {
|
||||
"description": "Follow system theme"
|
||||
},
|
||||
"appearanceThemeLight": "Light",
|
||||
"appearanceThemeLight": "浅色",
|
||||
"@appearanceThemeLight": {
|
||||
"description": "Light theme"
|
||||
},
|
||||
"appearanceThemeDark": "Dark",
|
||||
"appearanceThemeDark": "深色",
|
||||
"@appearanceThemeDark": {
|
||||
"description": "Dark theme"
|
||||
},
|
||||
"appearanceDynamicColor": "Dynamic Color",
|
||||
"appearanceDynamicColor": "动态色彩",
|
||||
"@appearanceDynamicColor": {
|
||||
"description": "Material You dynamic colors"
|
||||
},
|
||||
"appearanceDynamicColorSubtitle": "Use colors from your wallpaper",
|
||||
"appearanceDynamicColorSubtitle": "使用壁纸的颜色",
|
||||
"@appearanceDynamicColorSubtitle": {
|
||||
"description": "Subtitle for dynamic color"
|
||||
},
|
||||
"appearanceHistoryView": "History View",
|
||||
"appearanceHistoryView": "历史记录",
|
||||
"@appearanceHistoryView": {
|
||||
"description": "Layout style for history"
|
||||
},
|
||||
"appearanceHistoryViewList": "List",
|
||||
"appearanceHistoryViewList": "列表",
|
||||
"@appearanceHistoryViewList": {
|
||||
"description": "List layout option"
|
||||
},
|
||||
"appearanceHistoryViewGrid": "Grid",
|
||||
"appearanceHistoryViewGrid": "网格",
|
||||
"@appearanceHistoryViewGrid": {
|
||||
"description": "Grid layout option"
|
||||
},
|
||||
"optionsTitle": "Options",
|
||||
"optionsTitle": "选项",
|
||||
"@optionsTitle": {
|
||||
"description": "Options settings page title"
|
||||
},
|
||||
"optionsPrimaryProvider": "Primary Provider",
|
||||
"optionsPrimaryProvider": "主要提供者",
|
||||
"@optionsPrimaryProvider": {
|
||||
"description": "Main search provider setting"
|
||||
},
|
||||
"optionsPrimaryProviderSubtitle": "Service used when searching by track name.",
|
||||
"optionsPrimaryProviderSubtitle": "按歌曲名称搜索时使用的服务。",
|
||||
"@optionsPrimaryProviderSubtitle": {
|
||||
"description": "Subtitle for primary provider"
|
||||
},
|
||||
"optionsUsingExtension": "Using extension: {extensionName}",
|
||||
"optionsUsingExtension": "使用扩展:{extensionName}",
|
||||
"@optionsUsingExtension": {
|
||||
"description": "Shows active extension name",
|
||||
"placeholders": {
|
||||
@@ -150,55 +150,55 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"optionsSwitchBack": "Tap Deezer or Spotify to switch back from extension",
|
||||
"optionsSwitchBack": "点击 Deezer 或 Spotify 即可从扩展程序切换回来",
|
||||
"@optionsSwitchBack": {
|
||||
"description": "Hint to switch back to built-in providers"
|
||||
},
|
||||
"optionsAutoFallback": "Auto Fallback",
|
||||
"optionsAutoFallback": "自动回退",
|
||||
"@optionsAutoFallback": {
|
||||
"description": "Auto-retry with other services"
|
||||
},
|
||||
"optionsAutoFallbackSubtitle": "Try other services if download fails",
|
||||
"optionsAutoFallbackSubtitle": "如果下载失败,请尝试其他服务",
|
||||
"@optionsAutoFallbackSubtitle": {
|
||||
"description": "Subtitle for auto fallback"
|
||||
},
|
||||
"optionsUseExtensionProviders": "Use Extension Providers",
|
||||
"optionsUseExtensionProviders": "使用扩展提供商",
|
||||
"@optionsUseExtensionProviders": {
|
||||
"description": "Enable extension download providers"
|
||||
},
|
||||
"optionsUseExtensionProvidersOn": "Extensions will be tried first",
|
||||
"optionsUseExtensionProvidersOn": "扩展会被最先尝试",
|
||||
"@optionsUseExtensionProvidersOn": {
|
||||
"description": "Status when extension providers enabled"
|
||||
},
|
||||
"optionsUseExtensionProvidersOff": "Using built-in providers only",
|
||||
"optionsUseExtensionProvidersOff": "仅使用内置提供商",
|
||||
"@optionsUseExtensionProvidersOff": {
|
||||
"description": "Status when extension providers disabled"
|
||||
},
|
||||
"optionsEmbedLyrics": "Embed Lyrics",
|
||||
"optionsEmbedLyrics": "内嵌歌词",
|
||||
"@optionsEmbedLyrics": {
|
||||
"description": "Embed lyrics in audio files"
|
||||
},
|
||||
"optionsEmbedLyricsSubtitle": "Embed synced lyrics into FLAC files",
|
||||
"optionsEmbedLyricsSubtitle": "嵌入已同步歌词到 FLAC 文件",
|
||||
"@optionsEmbedLyricsSubtitle": {
|
||||
"description": "Subtitle for embed lyrics"
|
||||
},
|
||||
"optionsMaxQualityCover": "Max Quality Cover",
|
||||
"optionsMaxQualityCover": "最高质量封面",
|
||||
"@optionsMaxQualityCover": {
|
||||
"description": "Download highest quality album art"
|
||||
},
|
||||
"optionsMaxQualityCoverSubtitle": "Download highest resolution cover art",
|
||||
"optionsMaxQualityCoverSubtitle": "下载最高分辨率封面",
|
||||
"@optionsMaxQualityCoverSubtitle": {
|
||||
"description": "Subtitle for max quality cover"
|
||||
},
|
||||
"optionsConcurrentDownloads": "Concurrent Downloads",
|
||||
"optionsConcurrentDownloads": "并行下载数",
|
||||
"@optionsConcurrentDownloads": {
|
||||
"description": "Number of parallel downloads"
|
||||
},
|
||||
"optionsConcurrentSequential": "Sequential (1 at a time)",
|
||||
"optionsConcurrentSequential": "按顺序下载(一次一首)",
|
||||
"@optionsConcurrentSequential": {
|
||||
"description": "Download one at a time"
|
||||
},
|
||||
"optionsConcurrentParallel": "{count} parallel downloads",
|
||||
"optionsConcurrentParallel": "同时下载 {count} 首",
|
||||
"@optionsConcurrentParallel": {
|
||||
"description": "Multiple parallel downloads",
|
||||
"placeholders": {
|
||||
@@ -207,67 +207,67 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"optionsConcurrentWarning": "Parallel downloads may trigger rate limiting",
|
||||
"optionsConcurrentWarning": "并行下载可能会触发速率限制",
|
||||
"@optionsConcurrentWarning": {
|
||||
"description": "Warning about rate limits"
|
||||
},
|
||||
"optionsExtensionStore": "Extension Store",
|
||||
"optionsExtensionStore": "扩展商店",
|
||||
"@optionsExtensionStore": {
|
||||
"description": "Show/hide store tab"
|
||||
},
|
||||
"optionsExtensionStoreSubtitle": "Show Store tab in navigation",
|
||||
"optionsExtensionStoreSubtitle": "在导航中显示商店标签",
|
||||
"@optionsExtensionStoreSubtitle": {
|
||||
"description": "Subtitle for extension store toggle"
|
||||
},
|
||||
"optionsCheckUpdates": "Check for Updates",
|
||||
"optionsCheckUpdates": "检查更新",
|
||||
"@optionsCheckUpdates": {
|
||||
"description": "Auto update check toggle"
|
||||
},
|
||||
"optionsCheckUpdatesSubtitle": "Notify when new version is available",
|
||||
"optionsCheckUpdatesSubtitle": "当有新版本可用时通知",
|
||||
"@optionsCheckUpdatesSubtitle": {
|
||||
"description": "Subtitle for update check"
|
||||
},
|
||||
"optionsUpdateChannel": "Update Channel",
|
||||
"optionsUpdateChannel": "更新频道",
|
||||
"@optionsUpdateChannel": {
|
||||
"description": "Stable vs preview releases"
|
||||
},
|
||||
"optionsUpdateChannelStable": "Stable releases only",
|
||||
"optionsUpdateChannelStable": "仅稳定版本",
|
||||
"@optionsUpdateChannelStable": {
|
||||
"description": "Only stable updates"
|
||||
},
|
||||
"optionsUpdateChannelPreview": "Get preview releases",
|
||||
"optionsUpdateChannelPreview": "获取预览版本",
|
||||
"@optionsUpdateChannelPreview": {
|
||||
"description": "Include beta/preview updates"
|
||||
},
|
||||
"optionsUpdateChannelWarning": "Preview may contain bugs or incomplete features",
|
||||
"optionsUpdateChannelWarning": "预览版本可能包含错误或者尚未完善的功能",
|
||||
"@optionsUpdateChannelWarning": {
|
||||
"description": "Warning about preview channel"
|
||||
},
|
||||
"optionsClearHistory": "Clear Download History",
|
||||
"optionsClearHistory": "清除下载历史记录",
|
||||
"@optionsClearHistory": {
|
||||
"description": "Delete all download history"
|
||||
},
|
||||
"optionsClearHistorySubtitle": "Remove all downloaded tracks from history",
|
||||
"optionsClearHistorySubtitle": "从历史记录中清除所有已下载的曲目",
|
||||
"@optionsClearHistorySubtitle": {
|
||||
"description": "Subtitle for clear history"
|
||||
},
|
||||
"optionsDetailedLogging": "Detailed Logging",
|
||||
"optionsDetailedLogging": "详细日志",
|
||||
"@optionsDetailedLogging": {
|
||||
"description": "Enable verbose logs for debugging"
|
||||
},
|
||||
"optionsDetailedLoggingOn": "Detailed logs are being recorded",
|
||||
"optionsDetailedLoggingOn": "正在记录详细日志",
|
||||
"@optionsDetailedLoggingOn": {
|
||||
"description": "Status when logging enabled"
|
||||
},
|
||||
"optionsDetailedLoggingOff": "Enable for bug reports",
|
||||
"optionsDetailedLoggingOff": "为错误报告启用",
|
||||
"@optionsDetailedLoggingOff": {
|
||||
"description": "Status when logging disabled"
|
||||
},
|
||||
"optionsSpotifyCredentials": "Spotify Credentials",
|
||||
"optionsSpotifyCredentials": "Spotify 凭据",
|
||||
"@optionsSpotifyCredentials": {
|
||||
"description": "Spotify API credentials setting"
|
||||
},
|
||||
"optionsSpotifyCredentialsConfigured": "Client ID: {clientId}...",
|
||||
"optionsSpotifyCredentialsConfigured": "客户端 ID:{clientId}……",
|
||||
"@optionsSpotifyCredentialsConfigured": {
|
||||
"description": "Shows configured client ID preview",
|
||||
"placeholders": {
|
||||
@@ -276,27 +276,27 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"optionsSpotifyCredentialsRequired": "Required - tap to configure",
|
||||
"optionsSpotifyCredentialsRequired": "必填 - 点击配置",
|
||||
"@optionsSpotifyCredentialsRequired": {
|
||||
"description": "Prompt to set up credentials"
|
||||
},
|
||||
"optionsSpotifyWarning": "Spotify requires your own API credentials. Get them free from developer.spotify.com",
|
||||
"optionsSpotifyWarning": "Spotify 需要您自己的 API 凭据。在 developer.spotify.com 免费获取",
|
||||
"@optionsSpotifyWarning": {
|
||||
"description": "Info about Spotify API requirement"
|
||||
},
|
||||
"optionsSpotifyDeprecationWarning": "Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.",
|
||||
"optionsSpotifyDeprecationWarning": "Spotify 搜索将在 2026 年 3 月 3 日因 Spotify API 更改而被废弃。请切换到 Deezer。",
|
||||
"@optionsSpotifyDeprecationWarning": {
|
||||
"description": "Warning about Spotify API deprecation"
|
||||
},
|
||||
"extensionsTitle": "Extensions",
|
||||
"extensionsTitle": "扩展",
|
||||
"@extensionsTitle": {
|
||||
"description": "Extensions page title"
|
||||
},
|
||||
"extensionsDisabled": "Disabled",
|
||||
"extensionsDisabled": "禁用",
|
||||
"@extensionsDisabled": {
|
||||
"description": "Extension status - inactive"
|
||||
},
|
||||
"extensionsVersion": "Version {version}",
|
||||
"extensionsVersion": "版本 {version}",
|
||||
"@extensionsVersion": {
|
||||
"description": "Extension version display",
|
||||
"placeholders": {
|
||||
@@ -305,7 +305,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"extensionsAuthor": "by {author}",
|
||||
"extensionsAuthor": "来自 {author}",
|
||||
"@extensionsAuthor": {
|
||||
"description": "Extension author credit",
|
||||
"placeholders": {
|
||||
@@ -314,75 +314,75 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"extensionsUninstall": "Uninstall",
|
||||
"extensionsUninstall": "卸载",
|
||||
"@extensionsUninstall": {
|
||||
"description": "Uninstall extension button"
|
||||
},
|
||||
"storeTitle": "Extension Store",
|
||||
"storeTitle": "扩展商店",
|
||||
"@storeTitle": {
|
||||
"description": "Store screen title"
|
||||
},
|
||||
"storeSearch": "Search extensions...",
|
||||
"storeSearch": "搜索扩展……",
|
||||
"@storeSearch": {
|
||||
"description": "Store search placeholder"
|
||||
},
|
||||
"storeInstall": "Install",
|
||||
"storeInstall": "安装",
|
||||
"@storeInstall": {
|
||||
"description": "Install extension button"
|
||||
},
|
||||
"storeInstalled": "Installed",
|
||||
"storeInstalled": "已安装",
|
||||
"@storeInstalled": {
|
||||
"description": "Already installed badge"
|
||||
},
|
||||
"storeUpdate": "Update",
|
||||
"storeUpdate": "更新",
|
||||
"@storeUpdate": {
|
||||
"description": "Update available button"
|
||||
},
|
||||
"aboutTitle": "About",
|
||||
"aboutTitle": "关于",
|
||||
"@aboutTitle": {
|
||||
"description": "About page title"
|
||||
},
|
||||
"aboutContributors": "Contributors",
|
||||
"aboutContributors": "贡献者",
|
||||
"@aboutContributors": {
|
||||
"description": "Section for contributors"
|
||||
},
|
||||
"aboutMobileDeveloper": "Mobile version developer",
|
||||
"aboutMobileDeveloper": "移动版本开发者",
|
||||
"@aboutMobileDeveloper": {
|
||||
"description": "Role description for mobile dev"
|
||||
},
|
||||
"aboutOriginalCreator": "Creator of the original SpotiFLAC",
|
||||
"aboutOriginalCreator": "原 SpotiLDAC 创建者",
|
||||
"@aboutOriginalCreator": {
|
||||
"description": "Role description for original creator"
|
||||
},
|
||||
"aboutLogoArtist": "The talented artist who created our beautiful app logo!",
|
||||
"aboutLogoArtist": "有才华的艺术家创建了我们美丽的应用图标!",
|
||||
"@aboutLogoArtist": {
|
||||
"description": "Role description for logo artist"
|
||||
},
|
||||
"aboutTranslators": "Translators",
|
||||
"aboutTranslators": "译者",
|
||||
"@aboutTranslators": {
|
||||
"description": "Section for translators"
|
||||
},
|
||||
"aboutSpecialThanks": "Special Thanks",
|
||||
"aboutSpecialThanks": "特别鸣谢",
|
||||
"@aboutSpecialThanks": {
|
||||
"description": "Section for special thanks"
|
||||
},
|
||||
"aboutLinks": "Links",
|
||||
"aboutLinks": "相关链接",
|
||||
"@aboutLinks": {
|
||||
"description": "Section for external links"
|
||||
},
|
||||
"aboutMobileSource": "Mobile source code",
|
||||
"aboutMobileSource": "移动版本源代码",
|
||||
"@aboutMobileSource": {
|
||||
"description": "Link to mobile GitHub repo"
|
||||
},
|
||||
"aboutPCSource": "PC source code",
|
||||
"aboutPCSource": "桌面版本源代码",
|
||||
"@aboutPCSource": {
|
||||
"description": "Link to PC GitHub repo"
|
||||
},
|
||||
"aboutReportIssue": "Report an issue",
|
||||
"aboutReportIssue": "报告一个问题",
|
||||
"@aboutReportIssue": {
|
||||
"description": "Link to report bugs"
|
||||
},
|
||||
"aboutReportIssueSubtitle": "Report any problems you encounter",
|
||||
"aboutReportIssueSubtitle": "报告您遇到的任何问题",
|
||||
"@aboutReportIssueSubtitle": {
|
||||
"description": "Subtitle for report issue"
|
||||
},
|
||||
@@ -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"
|
||||
},
|
||||
@@ -603,23 +603,23 @@
|
||||
"@setupNotificationGranted": {
|
||||
"description": "Success message for notification permission"
|
||||
},
|
||||
"setupNotificationEnable": "Enable Notifications",
|
||||
"setupNotificationEnable": "启用通知",
|
||||
"@setupNotificationEnable": {
|
||||
"description": "Button to enable notifications"
|
||||
},
|
||||
"setupFolderChoose": "Choose Download Folder",
|
||||
"setupFolderChoose": "选择下载文件夹",
|
||||
"@setupFolderChoose": {
|
||||
"description": "Button to choose folder"
|
||||
},
|
||||
"setupFolderDescription": "Select a folder where your downloaded music will be saved.",
|
||||
"setupFolderDescription": "选择保存您下载的音乐的文件夹。",
|
||||
"@setupFolderDescription": {
|
||||
"description": "Explanation for folder selection"
|
||||
},
|
||||
"setupSelectFolder": "Select Folder",
|
||||
"setupSelectFolder": "选择文件夹",
|
||||
"@setupSelectFolder": {
|
||||
"description": "Button to select folder"
|
||||
},
|
||||
"setupEnableNotifications": "Enable Notifications",
|
||||
"setupEnableNotifications": "启用通知",
|
||||
"@setupEnableNotifications": {
|
||||
"description": "Button to enable notifications"
|
||||
},
|
||||
@@ -889,14 +889,26 @@
|
||||
"@errorRateLimited": {
|
||||
"description": "Error title - too many requests"
|
||||
},
|
||||
"errorRateLimitedMessage": "Too many requests. Please wait a moment before searching again.",
|
||||
"errorRateLimitedMessage": "请求过多。请等一会再搜索。",
|
||||
"@errorRateLimitedMessage": {
|
||||
"description": "Error message - rate limit explanation"
|
||||
},
|
||||
"errorNoTracksFound": "No tracks found",
|
||||
"errorNoTracksFound": "未找到曲目",
|
||||
"@errorNoTracksFound": {
|
||||
"description": "Error - search returned no results"
|
||||
},
|
||||
"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": {
|
||||
@@ -1753,18 +1773,6 @@
|
||||
"@qualityNote": {
|
||||
"description": "Note about quality availability"
|
||||
},
|
||||
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
|
||||
"@youtubeQualityNote": {
|
||||
"description": "Note for YouTube service explaining lossy-only quality"
|
||||
},
|
||||
"youtubeOpusBitrateTitle": "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"
|
||||
@@ -2383,7 +2391,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 +2816,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"
|
||||
|
||||
+107
-15
@@ -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": {
|
||||
@@ -1753,18 +1773,6 @@
|
||||
"@qualityNote": {
|
||||
"description": "Note about quality availability"
|
||||
},
|
||||
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
|
||||
"@youtubeQualityNote": {
|
||||
"description": "Note for YouTube service explaining lossy-only quality"
|
||||
},
|
||||
"youtubeOpusBitrateTitle": "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"
|
||||
@@ -2383,7 +2391,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 +2816,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"
|
||||
|
||||
+70
-2
@@ -4,6 +4,7 @@ import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:spotiflac_android/app.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
@@ -13,6 +14,7 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/services/notification_service.dart';
|
||||
import 'package:spotiflac_android/services/share_intent_service.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/utils/local_library_scan_prefs.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
@@ -90,16 +92,19 @@ class _EagerInitialization extends ConsumerStatefulWidget {
|
||||
_EagerInitializationState();
|
||||
}
|
||||
|
||||
class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
|
||||
class _EagerInitializationState extends ConsumerState<_EagerInitialization>
|
||||
with WidgetsBindingObserver {
|
||||
ProviderSubscription<bool>? _localLibraryEnabledSub;
|
||||
Timer? _downloadHistoryWarmupTimer;
|
||||
Timer? _libraryCollectionsWarmupTimer;
|
||||
Timer? _localLibraryWarmupTimer;
|
||||
bool _localLibraryWarmupScheduled = false;
|
||||
bool _autoScanTriggeredOnLaunch = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
_initializeAppServices();
|
||||
@@ -110,6 +115,7 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_localLibraryEnabledSub?.close();
|
||||
_downloadHistoryWarmupTimer?.cancel();
|
||||
_libraryCollectionsWarmupTimer?.cancel();
|
||||
@@ -117,6 +123,13 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
_maybeAutoScanLocalLibrary();
|
||||
}
|
||||
}
|
||||
|
||||
void _initializeDeferredProviders() {
|
||||
_downloadHistoryWarmupTimer = _scheduleProviderWarmup(
|
||||
const Duration(milliseconds: 400),
|
||||
@@ -155,10 +168,65 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
|
||||
_localLibraryWarmupScheduled = true;
|
||||
_localLibraryWarmupTimer = _scheduleProviderWarmup(
|
||||
const Duration(milliseconds: 1600),
|
||||
() => ref.read(localLibraryProvider),
|
||||
() {
|
||||
ref.read(localLibraryProvider);
|
||||
// Trigger auto-scan after initial warmup on first app launch.
|
||||
if (!_autoScanTriggeredOnLaunch) {
|
||||
_autoScanTriggeredOnLaunch = true;
|
||||
// Give the provider a moment to load existing data before scanning.
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
if (mounted) _maybeAutoScanLocalLibrary();
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Checks whether an automatic incremental scan should be triggered based on
|
||||
/// the user's auto-scan preference and the time since the last scan.
|
||||
Future<void> _maybeAutoScanLocalLibrary() async {
|
||||
if (!mounted) return;
|
||||
|
||||
final settings = ref.read(settingsProvider);
|
||||
if (!settings.localLibraryEnabled) return;
|
||||
if (settings.localLibraryPath.isEmpty) return;
|
||||
if (settings.localLibraryAutoScan == 'off') return;
|
||||
|
||||
final libraryState = ref.read(localLibraryProvider);
|
||||
if (libraryState.isScanning) return;
|
||||
|
||||
final now = DateTime.now();
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final lastScanned = readLocalLibraryLastScannedAt(prefs);
|
||||
|
||||
if (lastScanned != null) {
|
||||
final elapsed = now.difference(lastScanned);
|
||||
|
||||
switch (settings.localLibraryAutoScan) {
|
||||
case 'on_open':
|
||||
// Cooldown of 10 minutes to prevent rapid re-scans.
|
||||
if (elapsed.inMinutes < 10) return;
|
||||
break;
|
||||
case 'daily':
|
||||
if (elapsed.inHours < 24) return;
|
||||
break;
|
||||
case 'weekly':
|
||||
if (elapsed.inDays < 7) return;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
final iosBookmark = settings.localLibraryBookmark;
|
||||
ref
|
||||
.read(localLibraryProvider.notifier)
|
||||
.startScan(
|
||||
settings.localLibraryPath,
|
||||
iosBookmark: iosBookmark.isNotEmpty ? iosBookmark : null,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _initializeAppServices() async {
|
||||
try {
|
||||
await CoverCacheManager.initialize();
|
||||
|
||||
+16
-10
@@ -20,6 +20,7 @@ class AppSettings {
|
||||
final String updateChannel;
|
||||
final bool hasSearchedBefore;
|
||||
final String folderOrganization;
|
||||
final bool createPlaylistFolder;
|
||||
final bool useAlbumArtistForFolders;
|
||||
final bool usePrimaryArtistOnly; // Strip featured artists from folder name
|
||||
final bool filterContributingArtistsInAlbumArtist;
|
||||
@@ -33,6 +34,7 @@ class AppSettings {
|
||||
final bool enableLogging;
|
||||
final bool useExtensionProviders;
|
||||
final String? searchProvider;
|
||||
final String? homeFeedProvider;
|
||||
final bool separateSingles;
|
||||
final String albumFolderStructure;
|
||||
final bool showExtensionStore;
|
||||
@@ -40,10 +42,6 @@ class AppSettings {
|
||||
final String lyricsMode;
|
||||
final String
|
||||
tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320', 'opus_256', or 'opus_128'
|
||||
final int
|
||||
youtubeOpusBitrate; // YouTube Opus bitrate (supported: 128/256 kbps)
|
||||
final int
|
||||
youtubeMp3Bitrate; // YouTube MP3 bitrate (supported: 128/256/320 kbps)
|
||||
final bool
|
||||
useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE
|
||||
final bool
|
||||
@@ -61,6 +59,8 @@ class AppSettings {
|
||||
localLibraryBookmark; // Base64-encoded iOS security-scoped bookmark
|
||||
final bool
|
||||
localLibraryShowDuplicates; // Show indicator when searching for existing tracks
|
||||
final String
|
||||
localLibraryAutoScan; // Auto-scan mode: 'off', 'on_open', 'daily', 'weekly'
|
||||
|
||||
final bool
|
||||
hasCompletedTutorial; // Track if user has completed the app tutorial
|
||||
@@ -96,6 +96,7 @@ class AppSettings {
|
||||
this.updateChannel = 'stable',
|
||||
this.hasSearchedBefore = false,
|
||||
this.folderOrganization = 'none',
|
||||
this.createPlaylistFolder = false,
|
||||
this.useAlbumArtistForFolders = true,
|
||||
this.usePrimaryArtistOnly = false,
|
||||
this.filterContributingArtistsInAlbumArtist = false,
|
||||
@@ -109,14 +110,13 @@ class AppSettings {
|
||||
this.enableLogging = false,
|
||||
this.useExtensionProviders = true,
|
||||
this.searchProvider,
|
||||
this.homeFeedProvider,
|
||||
this.separateSingles = false,
|
||||
this.albumFolderStructure = 'artist_album',
|
||||
this.showExtensionStore = true,
|
||||
this.locale = 'system',
|
||||
this.lyricsMode = 'embed',
|
||||
this.tidalHighFormat = 'mp3_320',
|
||||
this.youtubeOpusBitrate = 256,
|
||||
this.youtubeMp3Bitrate = 320,
|
||||
this.useAllFilesAccess = false,
|
||||
this.autoExportFailedDownloads = false,
|
||||
this.downloadNetworkMode = 'any',
|
||||
@@ -126,6 +126,7 @@ class AppSettings {
|
||||
this.localLibraryPath = '',
|
||||
this.localLibraryBookmark = '',
|
||||
this.localLibraryShowDuplicates = true,
|
||||
this.localLibraryAutoScan = 'off',
|
||||
this.hasCompletedTutorial = false,
|
||||
this.lyricsProviders = const [
|
||||
'lrclib',
|
||||
@@ -159,6 +160,7 @@ class AppSettings {
|
||||
String? updateChannel,
|
||||
bool? hasSearchedBefore,
|
||||
String? folderOrganization,
|
||||
bool? createPlaylistFolder,
|
||||
bool? useAlbumArtistForFolders,
|
||||
bool? usePrimaryArtistOnly,
|
||||
bool? filterContributingArtistsInAlbumArtist,
|
||||
@@ -173,14 +175,14 @@ class AppSettings {
|
||||
bool? useExtensionProviders,
|
||||
String? searchProvider,
|
||||
bool clearSearchProvider = false,
|
||||
String? homeFeedProvider,
|
||||
bool clearHomeFeedProvider = false,
|
||||
bool? separateSingles,
|
||||
String? albumFolderStructure,
|
||||
bool? showExtensionStore,
|
||||
String? locale,
|
||||
String? lyricsMode,
|
||||
String? tidalHighFormat,
|
||||
int? youtubeOpusBitrate,
|
||||
int? youtubeMp3Bitrate,
|
||||
bool? useAllFilesAccess,
|
||||
bool? autoExportFailedDownloads,
|
||||
String? downloadNetworkMode,
|
||||
@@ -190,6 +192,7 @@ class AppSettings {
|
||||
String? localLibraryPath,
|
||||
String? localLibraryBookmark,
|
||||
bool? localLibraryShowDuplicates,
|
||||
String? localLibraryAutoScan,
|
||||
bool? hasCompletedTutorial,
|
||||
List<String>? lyricsProviders,
|
||||
bool? lyricsIncludeTranslationNetease,
|
||||
@@ -215,6 +218,7 @@ class AppSettings {
|
||||
updateChannel: updateChannel ?? this.updateChannel,
|
||||
hasSearchedBefore: hasSearchedBefore ?? this.hasSearchedBefore,
|
||||
folderOrganization: folderOrganization ?? this.folderOrganization,
|
||||
createPlaylistFolder: createPlaylistFolder ?? this.createPlaylistFolder,
|
||||
useAlbumArtistForFolders:
|
||||
useAlbumArtistForFolders ?? this.useAlbumArtistForFolders,
|
||||
usePrimaryArtistOnly: usePrimaryArtistOnly ?? this.usePrimaryArtistOnly,
|
||||
@@ -236,14 +240,15 @@ class AppSettings {
|
||||
searchProvider: clearSearchProvider
|
||||
? null
|
||||
: (searchProvider ?? this.searchProvider),
|
||||
homeFeedProvider: clearHomeFeedProvider
|
||||
? null
|
||||
: (homeFeedProvider ?? this.homeFeedProvider),
|
||||
separateSingles: separateSingles ?? this.separateSingles,
|
||||
albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure,
|
||||
showExtensionStore: showExtensionStore ?? this.showExtensionStore,
|
||||
locale: locale ?? this.locale,
|
||||
lyricsMode: lyricsMode ?? this.lyricsMode,
|
||||
tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat,
|
||||
youtubeOpusBitrate: youtubeOpusBitrate ?? this.youtubeOpusBitrate,
|
||||
youtubeMp3Bitrate: youtubeMp3Bitrate ?? this.youtubeMp3Bitrate,
|
||||
useAllFilesAccess: useAllFilesAccess ?? this.useAllFilesAccess,
|
||||
autoExportFailedDownloads:
|
||||
autoExportFailedDownloads ?? this.autoExportFailedDownloads,
|
||||
@@ -256,6 +261,7 @@ class AppSettings {
|
||||
localLibraryBookmark: localLibraryBookmark ?? this.localLibraryBookmark,
|
||||
localLibraryShowDuplicates:
|
||||
localLibraryShowDuplicates ?? this.localLibraryShowDuplicates,
|
||||
localLibraryAutoScan: localLibraryAutoScan ?? this.localLibraryAutoScan,
|
||||
hasCompletedTutorial: hasCompletedTutorial ?? this.hasCompletedTutorial,
|
||||
lyricsProviders: lyricsProviders ?? this.lyricsProviders,
|
||||
lyricsIncludeTranslationNetease:
|
||||
|
||||
@@ -23,6 +23,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
updateChannel: json['updateChannel'] as String? ?? 'stable',
|
||||
hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false,
|
||||
folderOrganization: json['folderOrganization'] as String? ?? 'none',
|
||||
createPlaylistFolder: json['createPlaylistFolder'] as bool? ?? false,
|
||||
useAlbumArtistForFolders: json['useAlbumArtistForFolders'] as bool? ?? true,
|
||||
usePrimaryArtistOnly: json['usePrimaryArtistOnly'] as bool? ?? false,
|
||||
filterContributingArtistsInAlbumArtist:
|
||||
@@ -38,6 +39,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
enableLogging: json['enableLogging'] as bool? ?? false,
|
||||
useExtensionProviders: json['useExtensionProviders'] as bool? ?? true,
|
||||
searchProvider: json['searchProvider'] as String?,
|
||||
homeFeedProvider: json['homeFeedProvider'] as String?,
|
||||
separateSingles: json['separateSingles'] as bool? ?? false,
|
||||
albumFolderStructure:
|
||||
json['albumFolderStructure'] as String? ?? 'artist_album',
|
||||
@@ -45,8 +47,6 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
locale: json['locale'] as String? ?? 'system',
|
||||
lyricsMode: json['lyricsMode'] as String? ?? 'embed',
|
||||
tidalHighFormat: json['tidalHighFormat'] as String? ?? 'mp3_320',
|
||||
youtubeOpusBitrate: (json['youtubeOpusBitrate'] as num?)?.toInt() ?? 256,
|
||||
youtubeMp3Bitrate: (json['youtubeMp3Bitrate'] as num?)?.toInt() ?? 320,
|
||||
useAllFilesAccess: json['useAllFilesAccess'] as bool? ?? false,
|
||||
autoExportFailedDownloads:
|
||||
json['autoExportFailedDownloads'] as bool? ?? false,
|
||||
@@ -58,6 +58,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
localLibraryBookmark: json['localLibraryBookmark'] as String? ?? '',
|
||||
localLibraryShowDuplicates:
|
||||
json['localLibraryShowDuplicates'] as bool? ?? true,
|
||||
localLibraryAutoScan: json['localLibraryAutoScan'] as String? ?? 'off',
|
||||
hasCompletedTutorial: json['hasCompletedTutorial'] as bool? ?? false,
|
||||
lyricsProviders:
|
||||
(json['lyricsProviders'] as List<dynamic>?)
|
||||
@@ -100,6 +101,7 @@ Map<String, dynamic> _$AppSettingsToJson(
|
||||
'updateChannel': instance.updateChannel,
|
||||
'hasSearchedBefore': instance.hasSearchedBefore,
|
||||
'folderOrganization': instance.folderOrganization,
|
||||
'createPlaylistFolder': instance.createPlaylistFolder,
|
||||
'useAlbumArtistForFolders': instance.useAlbumArtistForFolders,
|
||||
'usePrimaryArtistOnly': instance.usePrimaryArtistOnly,
|
||||
'filterContributingArtistsInAlbumArtist':
|
||||
@@ -114,14 +116,13 @@ Map<String, dynamic> _$AppSettingsToJson(
|
||||
'enableLogging': instance.enableLogging,
|
||||
'useExtensionProviders': instance.useExtensionProviders,
|
||||
'searchProvider': instance.searchProvider,
|
||||
'homeFeedProvider': instance.homeFeedProvider,
|
||||
'separateSingles': instance.separateSingles,
|
||||
'albumFolderStructure': instance.albumFolderStructure,
|
||||
'showExtensionStore': instance.showExtensionStore,
|
||||
'locale': instance.locale,
|
||||
'lyricsMode': instance.lyricsMode,
|
||||
'tidalHighFormat': instance.tidalHighFormat,
|
||||
'youtubeOpusBitrate': instance.youtubeOpusBitrate,
|
||||
'youtubeMp3Bitrate': instance.youtubeMp3Bitrate,
|
||||
'useAllFilesAccess': instance.useAllFilesAccess,
|
||||
'autoExportFailedDownloads': instance.autoExportFailedDownloads,
|
||||
'downloadNetworkMode': instance.downloadNetworkMode,
|
||||
@@ -131,6 +132,7 @@ Map<String, dynamic> _$AppSettingsToJson(
|
||||
'localLibraryPath': instance.localLibraryPath,
|
||||
'localLibraryBookmark': instance.localLibraryBookmark,
|
||||
'localLibraryShowDuplicates': instance.localLibraryShowDuplicates,
|
||||
'localLibraryAutoScan': instance.localLibraryAutoScan,
|
||||
'hasCompletedTutorial': instance.hasCompletedTutorial,
|
||||
'lyricsProviders': instance.lyricsProviders,
|
||||
'lyricsIncludeTranslationNetease': instance.lyricsIncludeTranslationNetease,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,13 +4,14 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
|
||||
final _log = AppLogger('ExploreProvider');
|
||||
|
||||
class ExploreItem {
|
||||
final String id;
|
||||
final String uri;
|
||||
final String type; // track, album, playlist, artist, station
|
||||
final String type;
|
||||
final String name;
|
||||
final String artists;
|
||||
final String? description;
|
||||
@@ -167,7 +168,6 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
||||
return const ExploreState();
|
||||
}
|
||||
|
||||
/// Restore cached home feed from SharedPreferences immediately on startup
|
||||
Future<void> _restoreFromCache() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
@@ -198,13 +198,10 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Save home feed to SharedPreferences for instant restore on next launch
|
||||
Future<void> _saveToCache(List<ExploreSection> sections) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final data = {
|
||||
'sections': sections.map((s) => s.toJson()).toList(),
|
||||
};
|
||||
final data = {'sections': sections.map((s) => s.toJson()).toList()};
|
||||
await prefs.setString(_cacheKey, jsonEncode(data));
|
||||
await prefs.setInt(_cacheTsKey, DateTime.now().millisecondsSinceEpoch);
|
||||
_log.d('Saved ${sections.length} explore sections to cache');
|
||||
@@ -213,45 +210,52 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch home feed from spotify-web extension
|
||||
Future<void> fetchHomeFeed({bool forceRefresh = false}) async {
|
||||
_log.i('fetchHomeFeed called, forceRefresh=$forceRefresh');
|
||||
|
||||
// If we have cached content and it's fresh enough, skip network fetch
|
||||
if (!forceRefresh &&
|
||||
state.hasContent &&
|
||||
|
||||
if (!forceRefresh &&
|
||||
state.hasContent &&
|
||||
state.lastFetched != null &&
|
||||
DateTime.now().difference(state.lastFetched!).inMinutes < 5) {
|
||||
_log.d('Using cached home feed (fresh enough)');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (state.isLoading) {
|
||||
_log.d('Home feed fetch already in progress');
|
||||
return;
|
||||
}
|
||||
|
||||
// Only show loading spinner if we have no cached content to display
|
||||
final showLoading = !state.hasContent;
|
||||
state = state.copyWith(isLoading: showLoading, error: null);
|
||||
|
||||
try {
|
||||
final extState = ref.read(extensionProvider);
|
||||
_log.d('Extensions count: ${extState.extensions.length}');
|
||||
|
||||
final settings = ref.read(settingsProvider);
|
||||
final preferredId = settings.homeFeedProvider;
|
||||
_log.d(
|
||||
'Extensions count: ${extState.extensions.length}, preferred home feed: $preferredId',
|
||||
);
|
||||
|
||||
Extension? targetExt;
|
||||
for (final extension in extState.extensions) {
|
||||
if (!extension.enabled || !extension.hasHomeFeed) {
|
||||
continue;
|
||||
}
|
||||
if (preferredId != null &&
|
||||
preferredId.isNotEmpty &&
|
||||
extension.id == preferredId) {
|
||||
targetExt = extension;
|
||||
break;
|
||||
}
|
||||
if (targetExt == null || extension.id == 'spotify-web') {
|
||||
targetExt = extension;
|
||||
if (extension.id == 'spotify-web') {
|
||||
if (preferredId == null && extension.id == 'spotify-web') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (targetExt == null) {
|
||||
_log.w('No extension with homeFeed capability found');
|
||||
state = state.copyWith(
|
||||
@@ -260,7 +264,7 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
_log.i('Fetching home feed from ${targetExt.id}...');
|
||||
final result = await PlatformBridge.getExtensionHomeFeed(targetExt.id);
|
||||
|
||||
@@ -276,10 +280,7 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
||||
_log.d('getExtensionHomeFeed success=$success');
|
||||
if (!success) {
|
||||
final error = result['error'] as String? ?? 'Unknown error';
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
error: error,
|
||||
);
|
||||
state = state.copyWith(isLoading: false, error: error);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -291,10 +292,12 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
||||
.toList();
|
||||
|
||||
_log.i('Fetched ${sections.length} sections');
|
||||
|
||||
|
||||
if (sections.isNotEmpty && sections.first.items.isNotEmpty) {
|
||||
final firstItem = sections.first.items.first;
|
||||
_log.d('First item: name=${firstItem.name}, artists=${firstItem.artists}, type=${firstItem.type}');
|
||||
_log.d(
|
||||
'First item: name=${firstItem.name}, artists=${firstItem.artists}, type=${firstItem.type}',
|
||||
);
|
||||
}
|
||||
|
||||
final localGreeting = _getLocalGreeting();
|
||||
@@ -307,14 +310,10 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
||||
lastFetched: DateTime.now(),
|
||||
);
|
||||
|
||||
// Save to disk cache for instant restore on next app launch
|
||||
_saveToCache(sections);
|
||||
} catch (e, stack) {
|
||||
_log.e('Error fetching home feed: $e', e, stack);
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
error: e.toString(),
|
||||
);
|
||||
state = state.copyWith(isLoading: false, error: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,7 +324,6 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
||||
Future<void> refresh() => fetchHomeFeed(forceRefresh: true);
|
||||
}
|
||||
|
||||
|
||||
final exploreProvider = NotifierProvider<ExploreNotifier, ExploreState>(() {
|
||||
return ExploreNotifier();
|
||||
});
|
||||
|
||||
@@ -32,14 +32,12 @@ class Extension {
|
||||
final bool hasMetadataProvider;
|
||||
final bool hasDownloadProvider;
|
||||
final bool hasLyricsProvider;
|
||||
final bool
|
||||
skipMetadataEnrichment; // If true, use metadata from extension instead of enriching
|
||||
final bool skipMetadataEnrichment;
|
||||
final SearchBehavior? searchBehavior;
|
||||
final URLHandler? urlHandler;
|
||||
final TrackMatching? trackMatching;
|
||||
final PostProcessing? postProcessing;
|
||||
final Map<String, dynamic>
|
||||
capabilities; // Extension capabilities (homeFeed, browseCategories, etc.)
|
||||
final Map<String, dynamic> capabilities;
|
||||
|
||||
const Extension({
|
||||
required this.id,
|
||||
@@ -198,12 +196,10 @@ class SearchBehavior {
|
||||
final String? placeholder;
|
||||
final bool primary;
|
||||
final String? icon;
|
||||
final String?
|
||||
thumbnailRatio; // "square" (1:1), "wide" (16:9), "portrait" (2:3)
|
||||
final String? thumbnailRatio;
|
||||
final int? thumbnailWidth;
|
||||
final int? thumbnailHeight;
|
||||
final List<SearchFilter>
|
||||
filters; // Available search filters (e.g., track, album, artist, playlist)
|
||||
final List<SearchFilter> filters;
|
||||
|
||||
const SearchBehavior({
|
||||
required this.enabled,
|
||||
@@ -239,11 +235,11 @@ class SearchBehavior {
|
||||
}
|
||||
|
||||
switch (thumbnailRatio) {
|
||||
case 'wide': // 16:9 - YouTube style
|
||||
case 'wide':
|
||||
return (defaultSize * 16 / 9, defaultSize);
|
||||
case 'portrait': // 2:3 - Poster style
|
||||
case 'portrait':
|
||||
return (defaultSize * 2 / 3, defaultSize);
|
||||
case 'square': // 1:1 - Album art style
|
||||
case 'square':
|
||||
default:
|
||||
return (defaultSize, defaultSize);
|
||||
}
|
||||
@@ -290,7 +286,6 @@ class PostProcessing {
|
||||
}
|
||||
}
|
||||
|
||||
/// URL handler configuration for custom URL patterns
|
||||
class URLHandler {
|
||||
final bool enabled;
|
||||
final List<String> patterns;
|
||||
@@ -304,7 +299,6 @@ class URLHandler {
|
||||
);
|
||||
}
|
||||
|
||||
/// Check if a URL matches any of the patterns
|
||||
bool matchesURL(String url) {
|
||||
if (!enabled || patterns.isEmpty) return false;
|
||||
final lowerUrl = url.toLowerCase();
|
||||
@@ -504,6 +498,11 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
|
||||
Future<void> _cleanupExtensions({required String reason}) async {
|
||||
if (!PlatformBridge.supportsExtensionSystem) {
|
||||
_cleanupInFlight = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await PlatformBridge.cleanupExtensions();
|
||||
_log.d('Extensions cleaned up ($reason)');
|
||||
@@ -519,6 +518,17 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
if (!PlatformBridge.supportsExtensionSystem) {
|
||||
state = state.copyWith(
|
||||
isInitialized: true,
|
||||
isLoading: false,
|
||||
extensions: const [],
|
||||
error: null,
|
||||
);
|
||||
_log.i('Extension system disabled on this platform');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await PlatformBridge.initExtensionSystem(extensionsDir, dataDir);
|
||||
await loadExtensions(extensionsDir);
|
||||
|
||||
@@ -666,7 +666,6 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
|
||||
final destPath = p.join(coversDir.path, '$playlistId$ext');
|
||||
if (playlist.coverImagePath == destPath) return;
|
||||
|
||||
// Copy image to persistent location
|
||||
await File(sourceFilePath).copy(destPath);
|
||||
|
||||
final now = DateTime.now();
|
||||
@@ -686,7 +685,6 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
|
||||
final playlist = state.playlistById(playlistId);
|
||||
if (playlist == null || playlist.coverImagePath == null) return;
|
||||
|
||||
// Delete the file if it exists
|
||||
final path = playlist.coverImagePath;
|
||||
if (path != null) {
|
||||
final file = File(path);
|
||||
|
||||
@@ -9,11 +9,11 @@ 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/local_library_scan_prefs.dart';
|
||||
import 'package:spotiflac_android/utils/path_match_keys.dart';
|
||||
|
||||
final _log = AppLogger('LocalLibrary');
|
||||
|
||||
const _lastScannedAtKey = 'local_library_last_scanned_at';
|
||||
const _excludedDownloadedCountKey = 'local_library_excluded_downloaded_count';
|
||||
final _prefs = SharedPreferences.getInstance();
|
||||
|
||||
@@ -165,10 +165,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
var excludedDownloadedCount = 0;
|
||||
try {
|
||||
final prefs = await prefsFuture;
|
||||
final lastScannedAtStr = prefs.getString(_lastScannedAtKey);
|
||||
if (lastScannedAtStr != null && lastScannedAtStr.isNotEmpty) {
|
||||
lastScannedAt = DateTime.tryParse(lastScannedAtStr);
|
||||
}
|
||||
lastScannedAt = readLocalLibraryLastScannedAt(prefs);
|
||||
excludedDownloadedCount =
|
||||
prefs.getInt(_excludedDownloadedCountKey) ?? 0;
|
||||
} catch (e) {
|
||||
@@ -255,8 +252,6 @@ 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) {
|
||||
@@ -278,9 +273,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
try {
|
||||
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
|
||||
// been flushed to SQLite yet.
|
||||
final downloadedPaths = await _historyDb.getAllFilePaths();
|
||||
final inMemoryHistoryPaths = ref
|
||||
.read(downloadHistoryProvider)
|
||||
@@ -301,7 +293,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
);
|
||||
|
||||
if (forceFullScan) {
|
||||
// Full scan path - ignores existing data
|
||||
final results = isSaf
|
||||
? await PlatformBridge.scanSafTree(effectiveFolderPath)
|
||||
: await PlatformBridge.scanLibraryFolder(effectiveFolderPath);
|
||||
@@ -327,16 +318,13 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
_log.i('Skipped $skippedDownloads files already in download history');
|
||||
}
|
||||
|
||||
// Full scan should replace library index entirely.
|
||||
await _db.clearAll();
|
||||
if (items.isNotEmpty) {
|
||||
await _db.upsertBatch(items.map((e) => e.toJson()).toList());
|
||||
}
|
||||
await _db.replaceAll(items.map((e) => e.toJson()).toList());
|
||||
final persistedItems = [...items]..sort(_compareLibraryItems);
|
||||
|
||||
final now = DateTime.now();
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_lastScannedAtKey, now.toIso8601String());
|
||||
await writeLocalLibraryLastScannedAt(prefs, now);
|
||||
await prefs.setInt(_excludedDownloadedCountKey, skippedDownloads);
|
||||
_log.d('Saved lastScannedAt: $now');
|
||||
} catch (e) {
|
||||
@@ -344,7 +332,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
}
|
||||
|
||||
state = state.copyWith(
|
||||
items: items,
|
||||
items: persistedItems,
|
||||
isScanning: false,
|
||||
scanProgress: 100,
|
||||
lastScannedAt: now,
|
||||
@@ -353,16 +341,15 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
);
|
||||
|
||||
_log.i(
|
||||
'Full scan complete: ${items.length} tracks found, '
|
||||
'Full scan complete: ${persistedItems.length} tracks found, '
|
||||
'$skippedDownloads already in downloads',
|
||||
);
|
||||
await _showScanCompleteNotification(
|
||||
totalTracks: items.length,
|
||||
totalTracks: persistedItems.length,
|
||||
excludedDownloadedCount: skippedDownloads,
|
||||
errorCount: state.scanErrorCount,
|
||||
);
|
||||
} else {
|
||||
// Incremental scan path - only scans new/modified files
|
||||
final existingFiles = await _db.getFileModTimes();
|
||||
_log.i(
|
||||
'Incremental scan: ${existingFiles.length} existing files in database',
|
||||
@@ -421,7 +408,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
return;
|
||||
}
|
||||
|
||||
// SAF returns 'files' and 'removedUris', non-SAF returns 'scanned' and 'deletedPaths'
|
||||
final scannedList =
|
||||
(result['files'] as List<dynamic>?) ??
|
||||
(result['scanned'] as List<dynamic>?) ??
|
||||
@@ -442,8 +428,10 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
'$skippedCount skipped, ${deletedPaths.length} deleted, $totalFiles total',
|
||||
);
|
||||
|
||||
final existingJson = await _db.getAll();
|
||||
final currentByPath = <String, LocalLibraryItem>{
|
||||
for (final item in state.items) item.filePath: item,
|
||||
for (final item in existingJson.map(LocalLibraryItem.fromJson))
|
||||
item.filePath: item,
|
||||
};
|
||||
final existingDownloadedPaths = <String>[];
|
||||
currentByPath.removeWhere((path, _) {
|
||||
@@ -460,7 +448,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
);
|
||||
}
|
||||
|
||||
// Upsert new/modified items (excluding downloaded files)
|
||||
final updatedItems = <LocalLibraryItem>[];
|
||||
int skippedDownloads = existingDownloadedPaths.length;
|
||||
if (scannedList.isNotEmpty) {
|
||||
@@ -500,7 +487,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
final now = DateTime.now();
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_lastScannedAtKey, now.toIso8601String());
|
||||
await writeLocalLibraryLastScannedAt(prefs, now);
|
||||
await prefs.setInt(_excludedDownloadedCountKey, skippedDownloads);
|
||||
_log.d('Saved lastScannedAt: $now');
|
||||
} catch (e) {
|
||||
@@ -818,7 +805,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_lastScannedAtKey);
|
||||
await clearLocalLibraryLastScannedAt(prefs);
|
||||
await prefs.remove(_excludedDownloadedCountKey);
|
||||
} catch (e) {
|
||||
_log.w('Failed to clear lastScannedAt: $e');
|
||||
|
||||
@@ -5,18 +5,16 @@ import 'package:spotiflac_android/services/app_state_database.dart';
|
||||
|
||||
const _maxRecentItems = 20;
|
||||
|
||||
/// Types of items that can be accessed
|
||||
enum RecentAccessType { artist, album, track, playlist }
|
||||
|
||||
/// Represents a recently accessed item
|
||||
class RecentAccessItem {
|
||||
final String id;
|
||||
final String name;
|
||||
final String? subtitle; // Artist name for tracks/albums, null for artists
|
||||
final String? subtitle;
|
||||
final String? imageUrl;
|
||||
final RecentAccessType type;
|
||||
final DateTime accessedAt;
|
||||
final String? providerId; // Extension ID or 'deezer' for built-in
|
||||
final String? providerId;
|
||||
|
||||
const RecentAccessItem({
|
||||
required this.id,
|
||||
@@ -53,7 +51,6 @@ class RecentAccessItem {
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a unique key for deduplication
|
||||
String get uniqueKey => '${type.name}:${providerId ?? 'default'}:$id';
|
||||
|
||||
@override
|
||||
@@ -67,7 +64,6 @@ class RecentAccessItem {
|
||||
int get hashCode => uniqueKey.hashCode;
|
||||
}
|
||||
|
||||
/// State for recent access history
|
||||
class RecentAccessState {
|
||||
final List<RecentAccessItem> items;
|
||||
final Set<String> hiddenDownloadIds;
|
||||
@@ -92,7 +88,6 @@ class RecentAccessState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider for managing recent access history
|
||||
class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
||||
final AppStateDatabase _appStateDb = AppStateDatabase.instance;
|
||||
|
||||
@@ -135,7 +130,6 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Record an access to an artist
|
||||
void recordArtistAccess({
|
||||
required String id,
|
||||
required String name,
|
||||
@@ -154,7 +148,6 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Record an access to an album
|
||||
void recordAlbumAccess({
|
||||
required String id,
|
||||
required String name,
|
||||
@@ -175,7 +168,6 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Record an access to a track
|
||||
void recordTrackAccess({
|
||||
required String id,
|
||||
required String name,
|
||||
@@ -196,7 +188,6 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Record an access to a playlist
|
||||
void recordPlaylistAccess({
|
||||
required String id,
|
||||
required String name,
|
||||
@@ -242,7 +233,6 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a specific item from history
|
||||
void removeItem(RecentAccessItem item) {
|
||||
final updatedItems = state.items
|
||||
.where((e) => e.uniqueKey != item.uniqueKey)
|
||||
@@ -251,25 +241,21 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
||||
unawaited(_appStateDb.deleteRecentAccessRow(item.uniqueKey));
|
||||
}
|
||||
|
||||
/// Hide a download item from recents (without deleting the actual download)
|
||||
void hideDownloadFromRecents(String downloadId) {
|
||||
final updatedHidden = {...state.hiddenDownloadIds, downloadId};
|
||||
state = state.copyWith(hiddenDownloadIds: updatedHidden);
|
||||
unawaited(_appStateDb.addHiddenRecentDownloadId(downloadId));
|
||||
}
|
||||
|
||||
/// Check if a download is hidden from recents
|
||||
bool isDownloadHidden(String downloadId) {
|
||||
return state.hiddenDownloadIds.contains(downloadId);
|
||||
}
|
||||
|
||||
/// Clear all history
|
||||
void clearHistory() {
|
||||
state = state.copyWith(items: []);
|
||||
unawaited(_appStateDb.clearRecentAccessRows());
|
||||
}
|
||||
|
||||
/// Clear hidden downloads (show all again)
|
||||
void clearHiddenDownloads() {
|
||||
state = state.copyWith(hiddenDownloadIds: {});
|
||||
unawaited(_appStateDb.clearHiddenRecentDownloadIds());
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:spotiflac_android/models/settings.dart';
|
||||
import 'package:spotiflac_android/constants/app_info.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
const _settingsKey = 'app_settings';
|
||||
const _migrationVersionKey = 'settings_migration_version';
|
||||
const _currentMigrationVersion = 5;
|
||||
const _currentMigrationVersion = 7;
|
||||
const _spotifyClientSecretKey = 'spotify_client_secret';
|
||||
final _log = AppLogger('SettingsProvider');
|
||||
|
||||
class SettingsNotifier extends Notifier<AppSettings> {
|
||||
static const List<int> _youtubeOpusSupportedBitrates = [128, 256];
|
||||
static const List<int> _youtubeMp3SupportedBitrates = [128, 256, 320];
|
||||
static final RegExp _isoRegionPattern = RegExp(r'^[A-Z]{2}$');
|
||||
|
||||
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
|
||||
@@ -37,7 +37,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
state = AppSettings.fromJson(jsonDecode(json));
|
||||
|
||||
await _runMigrations(prefs);
|
||||
await _normalizeYouTubeBitratesIfNeeded();
|
||||
await _normalizeIosDownloadDirectoryIfNeeded();
|
||||
await _normalizeSongLinkRegionIfNeeded();
|
||||
}
|
||||
|
||||
@@ -50,6 +50,8 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
}
|
||||
|
||||
void _syncLyricsSettingsToBackend() {
|
||||
if (!PlatformBridge.supportsCoreBackend) return;
|
||||
|
||||
PlatformBridge.setLyricsProviders(state.lyricsProviders).catchError((e) {
|
||||
_log.w('Failed to sync lyrics providers to backend: $e');
|
||||
});
|
||||
@@ -65,6 +67,8 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
}
|
||||
|
||||
void _syncNetworkCompatibilitySettingsToBackend() {
|
||||
if (!PlatformBridge.supportsCoreBackend) return;
|
||||
|
||||
final compatibilityMode = state.networkCompatibilityMode;
|
||||
PlatformBridge.setNetworkCompatibilityOptions(
|
||||
allowHttp: compatibilityMode,
|
||||
@@ -115,6 +119,10 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
);
|
||||
}
|
||||
state = state.copyWith(lastSeenVersion: AppInfo.version);
|
||||
// Migration 7: YouTube is no longer a built-in service — reset to Tidal
|
||||
if (state.defaultService == 'youtube') {
|
||||
state = state.copyWith(defaultService: 'tidal');
|
||||
}
|
||||
await prefs.setInt(_migrationVersionKey, _currentMigrationVersion);
|
||||
await _saveSettings();
|
||||
}
|
||||
@@ -146,46 +154,17 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
}
|
||||
}
|
||||
|
||||
int _nearestSupportedBitrate(int value, List<int> supported) {
|
||||
var nearest = supported.first;
|
||||
var nearestDistance = (value - nearest).abs();
|
||||
Future<void> _normalizeIosDownloadDirectoryIfNeeded() async {
|
||||
if (!Platform.isIOS) return;
|
||||
|
||||
for (final option in supported.skip(1)) {
|
||||
final distance = (value - option).abs();
|
||||
// On tie, prefer higher quality bitrate.
|
||||
if (distance < nearestDistance ||
|
||||
(distance == nearestDistance && option > nearest)) {
|
||||
nearest = option;
|
||||
nearestDistance = distance;
|
||||
}
|
||||
}
|
||||
final currentDir = state.downloadDirectory.trim();
|
||||
if (currentDir.isEmpty) return;
|
||||
|
||||
return nearest;
|
||||
}
|
||||
final normalizedDir = await validateOrFixIosPath(currentDir);
|
||||
if (normalizedDir == currentDir) return;
|
||||
|
||||
int _normalizeYouTubeOpusBitrate(int bitrate) {
|
||||
return _nearestSupportedBitrate(bitrate, _youtubeOpusSupportedBitrates);
|
||||
}
|
||||
|
||||
int _normalizeYouTubeMp3Bitrate(int bitrate) {
|
||||
return _nearestSupportedBitrate(bitrate, _youtubeMp3SupportedBitrates);
|
||||
}
|
||||
|
||||
Future<void> _normalizeYouTubeBitratesIfNeeded() async {
|
||||
final normalizedOpus = _normalizeYouTubeOpusBitrate(
|
||||
state.youtubeOpusBitrate,
|
||||
);
|
||||
final normalizedMp3 = _normalizeYouTubeMp3Bitrate(state.youtubeMp3Bitrate);
|
||||
|
||||
if (normalizedOpus == state.youtubeOpusBitrate &&
|
||||
normalizedMp3 == state.youtubeMp3Bitrate) {
|
||||
return;
|
||||
}
|
||||
|
||||
state = state.copyWith(
|
||||
youtubeOpusBitrate: normalizedOpus,
|
||||
youtubeMp3Bitrate: normalizedMp3,
|
||||
);
|
||||
_log.i('Normalized iOS download directory: $currentDir -> $normalizedDir');
|
||||
state = state.copyWith(downloadDirectory: normalizedDir);
|
||||
await _saveSettings();
|
||||
}
|
||||
|
||||
@@ -354,6 +333,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setCreatePlaylistFolder(bool enabled) {
|
||||
state = state.copyWith(createPlaylistFolder: enabled);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setUseAlbumArtistForFolders(bool enabled) {
|
||||
state = state.copyWith(useAlbumArtistForFolders: enabled);
|
||||
_saveSettings();
|
||||
@@ -385,8 +369,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
}
|
||||
|
||||
void setMetadataSource(String source) {
|
||||
final normalized = source == 'deezer' ? 'deezer' : 'deezer';
|
||||
state = state.copyWith(metadataSource: normalized);
|
||||
state = state.copyWith(metadataSource: source);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
@@ -399,6 +382,15 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setHomeFeedProvider(String? provider) {
|
||||
if (provider == null || provider.isEmpty) {
|
||||
state = state.copyWith(clearHomeFeedProvider: true);
|
||||
} else {
|
||||
state = state.copyWith(homeFeedProvider: provider);
|
||||
}
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setEnableLogging(bool enabled) {
|
||||
state = state.copyWith(enableLogging: enabled);
|
||||
_saveSettings();
|
||||
@@ -435,18 +427,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setYoutubeOpusBitrate(int bitrate) {
|
||||
final normalized = _normalizeYouTubeOpusBitrate(bitrate);
|
||||
state = state.copyWith(youtubeOpusBitrate: normalized);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setYoutubeMp3Bitrate(int bitrate) {
|
||||
final normalized = _normalizeYouTubeMp3Bitrate(bitrate);
|
||||
state = state.copyWith(youtubeMp3Bitrate: normalized);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setUseAllFilesAccess(bool enabled) {
|
||||
state = state.copyWith(useAllFilesAccess: enabled);
|
||||
_saveSettings();
|
||||
@@ -502,6 +482,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setLocalLibraryAutoScan(String mode) {
|
||||
state = state.copyWith(localLibraryAutoScan: mode);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setTutorialComplete() {
|
||||
state = state.copyWith(hasCompletedTutorial: true);
|
||||
_saveSettings();
|
||||
|
||||
@@ -12,13 +12,13 @@ const _registryUrlPrefKey = 'store_registry_url';
|
||||
int compareVersions(String v1, String v2) {
|
||||
final parts1 = v1.replaceAll(_leadingVersionPrefix, '').split('.');
|
||||
final parts2 = v2.replaceAll(_leadingVersionPrefix, '').split('.');
|
||||
|
||||
|
||||
final maxLen = parts1.length > parts2.length ? parts1.length : parts2.length;
|
||||
|
||||
|
||||
for (var i = 0; i < maxLen; i++) {
|
||||
final n1 = i < parts1.length ? (int.tryParse(parts1[i]) ?? 0) : 0;
|
||||
final n2 = i < parts2.length ? (int.tryParse(parts2[i]) ?? 0) : 0;
|
||||
|
||||
|
||||
if (n1 < n2) return -1;
|
||||
if (n1 > n2) return 1;
|
||||
}
|
||||
@@ -26,14 +26,19 @@ int compareVersions(String v1, String v2) {
|
||||
}
|
||||
|
||||
class StoreCategory {
|
||||
|
||||
static const String metadata = 'metadata';
|
||||
static const String download = 'download';
|
||||
static const String utility = 'utility';
|
||||
static const String lyrics = 'lyrics';
|
||||
static const String integration = 'integration';
|
||||
|
||||
static const List<String> all = [metadata, download, utility, lyrics, integration];
|
||||
static const List<String> all = [
|
||||
metadata,
|
||||
download,
|
||||
utility,
|
||||
lyrics,
|
||||
integration,
|
||||
];
|
||||
|
||||
static String getDisplayName(String category) {
|
||||
switch (category) {
|
||||
@@ -94,7 +99,8 @@ class StoreExtension {
|
||||
return StoreExtension(
|
||||
id: json['id'] as String? ?? '',
|
||||
name: json['name'] as String? ?? '',
|
||||
displayName: json['display_name'] as String? ?? json['name'] as String? ?? '',
|
||||
displayName:
|
||||
json['display_name'] as String? ?? json['name'] as String? ?? '',
|
||||
version: json['version'] as String? ?? '0.0.0',
|
||||
author: json['author'] as String? ?? 'Unknown',
|
||||
description: json['description'] as String? ?? '',
|
||||
@@ -117,7 +123,6 @@ class StoreExtension {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class StoreState {
|
||||
final List<StoreExtension> extensions;
|
||||
final String? selectedCategory;
|
||||
@@ -160,11 +165,15 @@ class StoreState {
|
||||
}) {
|
||||
return StoreState(
|
||||
extensions: extensions ?? this.extensions,
|
||||
selectedCategory: clearCategory ? null : (selectedCategory ?? this.selectedCategory),
|
||||
selectedCategory: clearCategory
|
||||
? null
|
||||
: (selectedCategory ?? this.selectedCategory),
|
||||
searchQuery: searchQuery ?? this.searchQuery,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
isDownloading: isDownloading ?? this.isDownloading,
|
||||
downloadingId: clearDownloadingId ? null : (downloadingId ?? this.downloadingId),
|
||||
downloadingId: clearDownloadingId
|
||||
? null
|
||||
: (downloadingId ?? this.downloadingId),
|
||||
error: clearError ? null : (error ?? this.error),
|
||||
isInitialized: isInitialized ?? this.isInitialized,
|
||||
registryUrl: registryUrl ?? this.registryUrl,
|
||||
@@ -180,13 +189,16 @@ class StoreState {
|
||||
|
||||
if (searchQuery.isNotEmpty) {
|
||||
final query = searchQuery.toLowerCase();
|
||||
result = result.where((e) =>
|
||||
e.name.toLowerCase().contains(query) ||
|
||||
e.displayName.toLowerCase().contains(query) ||
|
||||
e.description.toLowerCase().contains(query) ||
|
||||
e.author.toLowerCase().contains(query) ||
|
||||
e.tags.any((t) => t.toLowerCase().contains(query))
|
||||
).toList();
|
||||
result = result
|
||||
.where(
|
||||
(e) =>
|
||||
e.name.toLowerCase().contains(query) ||
|
||||
e.displayName.toLowerCase().contains(query) ||
|
||||
e.description.toLowerCase().contains(query) ||
|
||||
e.author.toLowerCase().contains(query) ||
|
||||
e.tags.any((t) => t.toLowerCase().contains(query)),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -206,23 +218,28 @@ class StoreNotifier extends Notifier<StoreState> {
|
||||
Future<void> initialize(String cacheDir) async {
|
||||
if (state.isInitialized) return;
|
||||
|
||||
state = state.copyWith(isLoading: true, clearError: true);
|
||||
// Load saved registry URL early to avoid UI flash (empty → setup screen)
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final savedUrl = prefs.getString(_registryUrlPrefKey) ?? '';
|
||||
|
||||
state = state.copyWith(
|
||||
isLoading: true,
|
||||
clearError: true,
|
||||
registryUrl: savedUrl,
|
||||
);
|
||||
|
||||
try {
|
||||
await PlatformBridge.initExtensionStore(cacheDir);
|
||||
|
||||
// Load saved registry URL from SharedPreferences
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final savedUrl = prefs.getString(_registryUrlPrefKey) ?? '';
|
||||
|
||||
if (savedUrl.isNotEmpty) {
|
||||
await PlatformBridge.setStoreRegistryUrl(savedUrl);
|
||||
state = state.copyWith(registryUrl: savedUrl);
|
||||
await refresh();
|
||||
}
|
||||
|
||||
state = state.copyWith(isInitialized: true, isLoading: false);
|
||||
_log.i('Extension store initialized (registryUrl: ${savedUrl.isEmpty ? "not set" : savedUrl})');
|
||||
_log.i(
|
||||
'Extension store initialized (registryUrl: ${savedUrl.isEmpty ? "not set" : savedUrl})',
|
||||
);
|
||||
} catch (e) {
|
||||
_log.e('Failed to initialize store: $e');
|
||||
state = state.copyWith(isLoading: false, error: e.toString());
|
||||
@@ -247,13 +264,12 @@ class StoreNotifier extends Notifier<StoreState> {
|
||||
// Read back the resolved URL (may differ from input after normalisation).
|
||||
final resolvedUrl = await PlatformBridge.getStoreRegistryUrl();
|
||||
|
||||
// Persist to SharedPreferences
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_registryUrlPrefKey, resolvedUrl);
|
||||
|
||||
state = state.copyWith(
|
||||
registryUrl: resolvedUrl,
|
||||
extensions: const [], // Clear old extensions
|
||||
extensions: const [],
|
||||
);
|
||||
|
||||
_log.i('Registry URL set to: $resolvedUrl');
|
||||
@@ -292,7 +308,9 @@ class StoreNotifier extends Notifier<StoreState> {
|
||||
state = state.copyWith(isLoading: true, clearError: true);
|
||||
|
||||
try {
|
||||
final extensions = await PlatformBridge.getStoreExtensions(forceRefresh: forceRefresh);
|
||||
final extensions = await PlatformBridge.getStoreExtensions(
|
||||
forceRefresh: forceRefresh,
|
||||
);
|
||||
state = state.copyWith(
|
||||
extensions: extensions.map((e) => StoreExtension.fromJson(e)).toList(),
|
||||
isLoading: false,
|
||||
@@ -320,12 +338,23 @@ class StoreNotifier extends Notifier<StoreState> {
|
||||
state = state.copyWith(searchQuery: '', clearCategory: true);
|
||||
}
|
||||
|
||||
Future<bool> installExtension(String extensionId, String tempDir, String extensionsDir) async {
|
||||
state = state.copyWith(isDownloading: true, downloadingId: extensionId, clearError: true);
|
||||
Future<bool> installExtension(
|
||||
String extensionId,
|
||||
String tempDir,
|
||||
String extensionsDir,
|
||||
) async {
|
||||
state = state.copyWith(
|
||||
isDownloading: true,
|
||||
downloadingId: extensionId,
|
||||
clearError: true,
|
||||
);
|
||||
|
||||
try {
|
||||
_log.i('Downloading extension: $extensionId');
|
||||
final downloadPath = await PlatformBridge.downloadStoreExtension(extensionId, tempDir);
|
||||
final downloadPath = await PlatformBridge.downloadStoreExtension(
|
||||
extensionId,
|
||||
tempDir,
|
||||
);
|
||||
|
||||
_log.i('Installing extension from: $downloadPath');
|
||||
final extNotifier = ref.read(extensionProvider.notifier);
|
||||
@@ -340,18 +369,28 @@ class StoreNotifier extends Notifier<StoreState> {
|
||||
return success;
|
||||
} catch (e) {
|
||||
_log.e('Failed to install extension: $e');
|
||||
state = state.copyWith(isDownloading: false, clearDownloadingId: true, error: e.toString());
|
||||
state = state.copyWith(
|
||||
isDownloading: false,
|
||||
clearDownloadingId: true,
|
||||
error: e.toString(),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Future<bool> updateExtension(String extensionId, String tempDir) async {
|
||||
state = state.copyWith(isDownloading: true, downloadingId: extensionId, clearError: true);
|
||||
state = state.copyWith(
|
||||
isDownloading: true,
|
||||
downloadingId: extensionId,
|
||||
clearError: true,
|
||||
);
|
||||
|
||||
try {
|
||||
_log.i('Downloading update for: $extensionId');
|
||||
final downloadPath = await PlatformBridge.downloadStoreExtension(extensionId, tempDir);
|
||||
final downloadPath = await PlatformBridge.downloadStoreExtension(
|
||||
extensionId,
|
||||
tempDir,
|
||||
);
|
||||
|
||||
_log.i('Upgrading extension from: $downloadPath');
|
||||
final extNotifier = ref.read(extensionProvider.notifier);
|
||||
@@ -366,7 +405,11 @@ class StoreNotifier extends Notifier<StoreState> {
|
||||
return success;
|
||||
} catch (e) {
|
||||
_log.e('Failed to update extension: $e');
|
||||
state = state.copyWith(isDownloading: false, clearDownloadingId: true, error: e.toString());
|
||||
state = state.copyWith(
|
||||
isDownloading: false,
|
||||
clearDownloadingId: true,
|
||||
error: e.toString(),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,6 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
|
||||
await _saveToStorage();
|
||||
}
|
||||
|
||||
/// Set custom seed color (used when dynamic color is disabled)
|
||||
Future<void> setSeedColor(Color color) async {
|
||||
state = state.copyWith(seedColorValue: color.toARGB32());
|
||||
await _saveToStorage();
|
||||
@@ -81,4 +80,3 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
|
||||
@@ -17,19 +18,18 @@ class TrackState {
|
||||
final String? artistId;
|
||||
final String? artistName;
|
||||
final String? coverUrl;
|
||||
final String? headerImageUrl; // Artist header image for background
|
||||
final String? headerImageUrl;
|
||||
final int? monthlyListeners;
|
||||
final List<ArtistAlbum>? artistAlbums; // For artist page
|
||||
final List<Track>? artistTopTracks; // Artist's popular tracks
|
||||
final List<SearchArtist>? searchArtists; // For search results
|
||||
final List<SearchAlbum>? searchAlbums; // For search results (albums)
|
||||
final List<SearchPlaylist>? searchPlaylists; // For search results (playlists)
|
||||
final bool hasSearchText; // For back button handling
|
||||
final bool isShowingRecentAccess; // For recent access mode
|
||||
final String?
|
||||
searchExtensionId; // Extension ID used for current search results
|
||||
final String?
|
||||
selectedSearchFilter; // Currently selected search filter (e.g., "track", "album", "artist", "playlist")
|
||||
final List<ArtistAlbum>? artistAlbums;
|
||||
final List<Track>? artistTopTracks;
|
||||
final List<SearchArtist>? searchArtists;
|
||||
final List<SearchAlbum>? searchAlbums;
|
||||
final List<SearchPlaylist>? searchPlaylists;
|
||||
final bool hasSearchText;
|
||||
final bool isShowingRecentAccess;
|
||||
final String? searchExtensionId;
|
||||
final String? selectedSearchFilter;
|
||||
final String? searchSource;
|
||||
|
||||
const TrackState({
|
||||
this.tracks = const [],
|
||||
@@ -52,6 +52,7 @@ class TrackState {
|
||||
this.isShowingRecentAccess = false,
|
||||
this.searchExtensionId,
|
||||
this.selectedSearchFilter,
|
||||
this.searchSource,
|
||||
});
|
||||
|
||||
bool get hasContent =>
|
||||
@@ -83,6 +84,8 @@ class TrackState {
|
||||
String? searchExtensionId,
|
||||
String? selectedSearchFilter,
|
||||
bool clearSelectedSearchFilter = false,
|
||||
String? searchSource,
|
||||
bool clearSearchSource = false,
|
||||
}) {
|
||||
return TrackState(
|
||||
tracks: tracks ?? this.tracks,
|
||||
@@ -108,6 +111,9 @@ class TrackState {
|
||||
selectedSearchFilter: clearSelectedSearchFilter
|
||||
? null
|
||||
: (selectedSearchFilter ?? this.selectedSearchFilter),
|
||||
searchSource: clearSearchSource
|
||||
? null
|
||||
: (searchSource ?? this.searchSource),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -118,9 +124,9 @@ class ArtistAlbum {
|
||||
final String releaseDate;
|
||||
final int totalTracks;
|
||||
final String? coverUrl;
|
||||
final String albumType; // album, single, compilation
|
||||
final String albumType;
|
||||
final String artists;
|
||||
final String? providerId; // Extension ID if from extension
|
||||
final String? providerId;
|
||||
|
||||
const ArtistAlbum({
|
||||
required this.id,
|
||||
@@ -195,7 +201,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
return const TrackState();
|
||||
}
|
||||
|
||||
/// Check if request is still valid (not cancelled by newer request)
|
||||
bool _isRequestValid(int requestId) => requestId == _currentRequestId;
|
||||
|
||||
Future<void> fetchFromUrl(String url, {bool useDeezerFallback = true}) async {
|
||||
@@ -208,7 +213,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
if (extensionHandler != null) {
|
||||
_log.i('Found extension URL handler: $extensionHandler for URL: $url');
|
||||
|
||||
// Retry logic for extension URL handlers (up to 3 attempts)
|
||||
Map<String, dynamic>? result;
|
||||
for (int attempt = 1; attempt <= 3; attempt++) {
|
||||
result = await PlatformBridge.handleURLWithExtension(url);
|
||||
@@ -278,7 +282,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
playlistName: type == 'playlist'
|
||||
? result['name'] as String?
|
||||
: null,
|
||||
coverUrl: result['cover_url'] as String?,
|
||||
coverUrl: normalizeCoverReference(
|
||||
result['cover_url']?.toString(),
|
||||
),
|
||||
searchExtensionId: extensionId,
|
||||
);
|
||||
return;
|
||||
@@ -305,10 +311,12 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
isLoading: false,
|
||||
artistId: artistData['id'] as String?,
|
||||
artistName: artistData['name'] as String?,
|
||||
coverUrl:
|
||||
artistData['image_url'] as String? ??
|
||||
artistData['images'] as String?,
|
||||
headerImageUrl: artistData['header_image'] as String?,
|
||||
coverUrl: normalizeRemoteHttpUrl(
|
||||
(artistData['image_url'] ?? artistData['images'])?.toString(),
|
||||
),
|
||||
headerImageUrl: normalizeRemoteHttpUrl(
|
||||
artistData['header_image']?.toString(),
|
||||
),
|
||||
monthlyListeners: artistData['listeners'] as int?,
|
||||
artistAlbums: albums,
|
||||
artistTopTracks: topTracks.isNotEmpty ? topTracks : null,
|
||||
@@ -349,7 +357,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
isLoading: false,
|
||||
albumId: id,
|
||||
albumName: albumInfo['name'] as String?,
|
||||
coverUrl: albumInfo['images'] as String?,
|
||||
coverUrl: normalizeRemoteHttpUrl(albumInfo['images']?.toString()),
|
||||
);
|
||||
_preWarmCacheForTracks(tracks);
|
||||
} else if (type == 'playlist') {
|
||||
@@ -363,7 +371,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
tracks: tracks,
|
||||
isLoading: false,
|
||||
playlistName: playlistInfo['name'] as String?,
|
||||
coverUrl: playlistInfo['images'] as String?,
|
||||
coverUrl: normalizeRemoteHttpUrl(
|
||||
playlistInfo['images']?.toString(),
|
||||
),
|
||||
);
|
||||
_preWarmCacheForTracks(tracks);
|
||||
} else if (type == 'artist') {
|
||||
@@ -377,7 +387,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
isLoading: false,
|
||||
artistId: artistInfo['id'] as String?,
|
||||
artistName: artistInfo['name'] as String?,
|
||||
coverUrl: artistInfo['images'] as String?,
|
||||
coverUrl: normalizeRemoteHttpUrl(artistInfo['images']?.toString()),
|
||||
artistAlbums: albums,
|
||||
);
|
||||
}
|
||||
@@ -414,7 +424,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
isLoading: false,
|
||||
albumId: 'qobuz:$id',
|
||||
albumName: albumInfo['name'] as String?,
|
||||
coverUrl: albumInfo['images'] as String?,
|
||||
coverUrl: normalizeRemoteHttpUrl(albumInfo['images']?.toString()),
|
||||
);
|
||||
_preWarmCacheForTracks(tracks);
|
||||
} else if (type == 'playlist') {
|
||||
@@ -427,8 +437,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
final owner = playlistInfo['owner'] as Map<String, dynamic>?;
|
||||
final playlistName =
|
||||
(playlistInfo['name'] ?? owner?['name']) as String?;
|
||||
final coverUrl =
|
||||
(playlistInfo['images'] ?? owner?['images']) as String?;
|
||||
final coverUrl = normalizeRemoteHttpUrl(
|
||||
(playlistInfo['images'] ?? owner?['images'])?.toString(),
|
||||
);
|
||||
state = TrackState(
|
||||
tracks: tracks,
|
||||
isLoading: false,
|
||||
@@ -447,7 +458,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
isLoading: false,
|
||||
artistId: artistInfo['id'] as String?,
|
||||
artistName: artistInfo['name'] as String?,
|
||||
coverUrl: artistInfo['images'] as String?,
|
||||
coverUrl: normalizeRemoteHttpUrl(artistInfo['images']?.toString()),
|
||||
artistAlbums: albums,
|
||||
);
|
||||
}
|
||||
@@ -484,7 +495,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
isLoading: false,
|
||||
albumId: 'tidal:$id',
|
||||
albumName: albumInfo['name'] as String?,
|
||||
coverUrl: albumInfo['images'] as String?,
|
||||
coverUrl: normalizeRemoteHttpUrl(albumInfo['images']?.toString()),
|
||||
);
|
||||
_preWarmCacheForTracks(tracks);
|
||||
} else if (type == 'playlist') {
|
||||
@@ -497,8 +508,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
final owner = playlistInfo['owner'] as Map<String, dynamic>?;
|
||||
final playlistName =
|
||||
(playlistInfo['name'] ?? owner?['name']) as String?;
|
||||
final coverUrl =
|
||||
(playlistInfo['images'] ?? owner?['images']) as String?;
|
||||
final coverUrl = normalizeRemoteHttpUrl(
|
||||
(playlistInfo['images'] ?? owner?['images'])?.toString(),
|
||||
);
|
||||
state = TrackState(
|
||||
tracks: tracks,
|
||||
isLoading: false,
|
||||
@@ -517,14 +529,13 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
isLoading: false,
|
||||
artistId: artistInfo['id'] as String?,
|
||||
artistName: artistInfo['name'] as String?,
|
||||
coverUrl: artistInfo['images'] as String?,
|
||||
coverUrl: normalizeRemoteHttpUrl(artistInfo['images']?.toString()),
|
||||
artistAlbums: albums,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// If URL doesn't match any known service, it's unrecognized
|
||||
final isSpotifyUrl =
|
||||
url.contains('open.spotify.com') ||
|
||||
url.contains('spotify.link') ||
|
||||
@@ -572,7 +583,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
isLoading: false,
|
||||
albumId: parsed['id'] as String?,
|
||||
albumName: albumInfo['name'] as String?,
|
||||
coverUrl: albumInfo['images'] as String?,
|
||||
coverUrl: normalizeRemoteHttpUrl(albumInfo['images']?.toString()),
|
||||
);
|
||||
_preWarmCacheForTracks(tracks);
|
||||
} else if (type == 'playlist') {
|
||||
@@ -584,8 +595,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
final owner = playlistInfo['owner'] as Map<String, dynamic>?;
|
||||
final playlistName =
|
||||
(playlistInfo['name'] ?? owner?['name']) as String?;
|
||||
final coverUrl =
|
||||
(playlistInfo['images'] ?? owner?['images']) as String?;
|
||||
final coverUrl = normalizeRemoteHttpUrl(
|
||||
(playlistInfo['images'] ?? owner?['images'])?.toString(),
|
||||
);
|
||||
state = TrackState(
|
||||
tracks: tracks,
|
||||
isLoading: false,
|
||||
@@ -604,7 +616,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
isLoading: false,
|
||||
artistId: artistInfo['id'] as String?,
|
||||
artistName: artistInfo['name'] as String?,
|
||||
coverUrl: artistInfo['images'] as String?,
|
||||
coverUrl: normalizeRemoteHttpUrl(artistInfo['images']?.toString()),
|
||||
artistAlbums: albums,
|
||||
);
|
||||
}
|
||||
@@ -618,10 +630,13 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> search(String query, {String? filterOverride}) async {
|
||||
Future<void> search(
|
||||
String query, {
|
||||
String? filterOverride,
|
||||
String? builtInSearchProvider,
|
||||
}) async {
|
||||
final requestId = ++_currentRequestId;
|
||||
|
||||
// Preserve selected filter during loading
|
||||
final currentFilter = filterOverride ?? state.selectedSearchFilter;
|
||||
|
||||
state = TrackState(
|
||||
@@ -640,39 +655,65 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
final includeExtensions =
|
||||
settings.useExtensionProviders && hasActiveMetadataExtensions;
|
||||
|
||||
final effectiveProvider = builtInSearchProvider ?? 'deezer';
|
||||
|
||||
_log.i(
|
||||
'Search started: metadataProviders, query="$query", includeExtensions=$includeExtensions, filter=$currentFilter',
|
||||
'Search started: provider=$effectiveProvider, query="$query", includeExtensions=$includeExtensions, filter=$currentFilter',
|
||||
);
|
||||
|
||||
Map<String, dynamic> results;
|
||||
List<Map<String, dynamic>> metadataTrackResults = [];
|
||||
|
||||
try {
|
||||
_log.d('Calling metadata provider search API...');
|
||||
metadataTrackResults =
|
||||
await PlatformBridge.searchTracksWithMetadataProviders(
|
||||
query,
|
||||
limit: 20,
|
||||
includeExtensions: includeExtensions,
|
||||
);
|
||||
_log.i(
|
||||
'Metadata providers returned ${metadataTrackResults.length} tracks',
|
||||
);
|
||||
} catch (e) {
|
||||
_log.w(
|
||||
'Metadata provider search failed, falling back to Deezer tracks: $e',
|
||||
);
|
||||
if (effectiveProvider == 'deezer') {
|
||||
try {
|
||||
_log.d('Calling metadata provider search API...');
|
||||
metadataTrackResults =
|
||||
await PlatformBridge.searchTracksWithMetadataProviders(
|
||||
query,
|
||||
limit: 20,
|
||||
includeExtensions: includeExtensions,
|
||||
);
|
||||
_log.i(
|
||||
'Metadata providers returned ${metadataTrackResults.length} tracks',
|
||||
);
|
||||
} catch (e) {
|
||||
_log.w(
|
||||
'Metadata provider search failed, falling back to Deezer tracks: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
_log.d('Calling Deezer search API...');
|
||||
results = await PlatformBridge.searchDeezerAll(
|
||||
query,
|
||||
trackLimit: 20,
|
||||
artistLimit: 2,
|
||||
filter: currentFilter,
|
||||
);
|
||||
switch (effectiveProvider) {
|
||||
case 'tidal':
|
||||
_log.d('Calling Tidal search API...');
|
||||
results = await PlatformBridge.searchTidalAll(
|
||||
query,
|
||||
trackLimit: 20,
|
||||
artistLimit: 2,
|
||||
filter: currentFilter,
|
||||
);
|
||||
break;
|
||||
case 'qobuz':
|
||||
_log.d('Calling Qobuz search API...');
|
||||
results = await PlatformBridge.searchQobuzAll(
|
||||
query,
|
||||
trackLimit: 20,
|
||||
artistLimit: 2,
|
||||
filter: currentFilter,
|
||||
);
|
||||
break;
|
||||
default:
|
||||
_log.d('Calling Deezer search API...');
|
||||
results = await PlatformBridge.searchDeezerAll(
|
||||
query,
|
||||
trackLimit: 20,
|
||||
artistLimit: 2,
|
||||
filter: currentFilter,
|
||||
);
|
||||
break;
|
||||
}
|
||||
_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',
|
||||
'$effectiveProvider 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)) {
|
||||
@@ -757,7 +798,8 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
isLoading: false,
|
||||
hasSearchText: state.hasSearchText,
|
||||
isShowingRecentAccess: state.isShowingRecentAccess,
|
||||
selectedSearchFilter: currentFilter, // Preserve filter in results
|
||||
selectedSearchFilter: currentFilter,
|
||||
searchSource: effectiveProvider,
|
||||
);
|
||||
} catch (e, stackTrace) {
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
@@ -784,7 +826,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
hasSearchText: state.hasSearchText,
|
||||
isShowingRecentAccess: state.isShowingRecentAccess,
|
||||
selectedSearchFilter:
|
||||
state.selectedSearchFilter, // Preserve filter during loading
|
||||
state.selectedSearchFilter,
|
||||
);
|
||||
|
||||
try {
|
||||
@@ -823,9 +865,8 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
isLoading: false,
|
||||
hasSearchText: state.hasSearchText,
|
||||
isShowingRecentAccess: state.isShowingRecentAccess,
|
||||
searchExtensionId: extensionId, // Store which extension was used
|
||||
selectedSearchFilter:
|
||||
state.selectedSearchFilter, // Preserve selected filter
|
||||
searchExtensionId: extensionId,
|
||||
selectedSearchFilter: state.selectedSearchFilter,
|
||||
);
|
||||
} catch (e, stackTrace) {
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
@@ -881,7 +922,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
tracks[index] = updatedTrack;
|
||||
state = state.copyWith(tracks: tracks);
|
||||
} catch (_) {
|
||||
// Silently ignore update failures - track may have been removed
|
||||
}
|
||||
}
|
||||
|
||||
@@ -889,7 +929,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
state = const TrackState();
|
||||
}
|
||||
|
||||
/// Set selected search filter for extension search
|
||||
void setSearchFilter(String? filter) {
|
||||
if (state.selectedSearchFilter == filter) return;
|
||||
state = state.copyWith(
|
||||
@@ -898,7 +937,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Set search text state for back button handling
|
||||
void setSearchText(bool hasText) {
|
||||
if (state.hasSearchText == hasText) {
|
||||
return;
|
||||
@@ -913,7 +951,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
state = state.copyWith(isShowingRecentAccess: showing);
|
||||
}
|
||||
|
||||
/// Set tracks from a collection (album/playlist) opened from search results
|
||||
void setTracksFromCollection({
|
||||
required List<Track> tracks,
|
||||
String? albumName,
|
||||
@@ -943,7 +980,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
albumArtist: data['album_artist'] as String?,
|
||||
artistId: (data['artist_id'] ?? data['artistId'])?.toString(),
|
||||
albumId: data['album_id']?.toString(),
|
||||
coverUrl: data['images'] as String?,
|
||||
coverUrl: normalizeCoverReference(data['images']?.toString()),
|
||||
isrc: data['isrc'] as String?,
|
||||
duration: (durationMs / 1000).round(),
|
||||
trackNumber: data['track_number'] as int?,
|
||||
@@ -958,26 +995,32 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
final durationMs = _extractDurationMs(data);
|
||||
|
||||
final itemType = data['item_type']?.toString();
|
||||
final effectiveSource =
|
||||
source ?? data['source']?.toString() ?? data['provider_id']?.toString();
|
||||
final spotifyId = (data['spotify_id'] ?? '').toString();
|
||||
final nativeId = (data['id'] ?? '').toString();
|
||||
final preferredId = effectiveSource != null && effectiveSource.isNotEmpty
|
||||
? (nativeId.isNotEmpty ? nativeId : spotifyId)
|
||||
: (spotifyId.isNotEmpty ? spotifyId : nativeId);
|
||||
|
||||
return Track(
|
||||
id: (data['spotify_id'] ?? data['id'] ?? '').toString(),
|
||||
id: preferredId,
|
||||
name: (data['name'] ?? '').toString(),
|
||||
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
|
||||
albumName: (data['album_name'] ?? data['album'] ?? '').toString(),
|
||||
albumArtist: data['album_artist']?.toString(),
|
||||
artistId: (data['artist_id'] ?? data['artistId'])?.toString(),
|
||||
albumId: data['album_id']?.toString(),
|
||||
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
|
||||
coverUrl: normalizeCoverReference(
|
||||
(data['cover_url'] ?? data['images'])?.toString(),
|
||||
),
|
||||
isrc: data['isrc']?.toString(),
|
||||
duration: (durationMs / 1000).round(),
|
||||
trackNumber: data['track_number'] as int?,
|
||||
discNumber: data['disc_number'] as int?,
|
||||
releaseDate: data['release_date']?.toString(),
|
||||
totalTracks: data['total_tracks'] as int?,
|
||||
source:
|
||||
source ??
|
||||
data['source']?.toString() ??
|
||||
data['provider_id']?.toString(),
|
||||
source: effectiveSource,
|
||||
albumType: data['album_type']?.toString(),
|
||||
itemType: itemType,
|
||||
);
|
||||
@@ -1015,7 +1058,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
name: data['name'] as String? ?? '',
|
||||
releaseDate: data['release_date'] as String? ?? '',
|
||||
totalTracks: data['total_tracks'] as int? ?? 0,
|
||||
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
|
||||
coverUrl: normalizeCoverReference(
|
||||
(data['cover_url'] ?? data['images'])?.toString(),
|
||||
),
|
||||
albumType: data['album_type'] as String? ?? 'album',
|
||||
artists: data['artists'] as String? ?? '',
|
||||
providerId: data['provider_id']?.toString(),
|
||||
@@ -1026,7 +1071,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
return SearchArtist(
|
||||
id: data['id'] as String? ?? '',
|
||||
name: data['name'] as String? ?? '',
|
||||
imageUrl: data['images'] as String?,
|
||||
imageUrl: normalizeRemoteHttpUrl(data['images']?.toString()),
|
||||
followers: data['followers'] as int? ?? 0,
|
||||
popularity: data['popularity'] as int? ?? 0,
|
||||
);
|
||||
@@ -1037,7 +1082,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
id: data['id'] as String? ?? '',
|
||||
name: data['name'] as String? ?? '',
|
||||
artists: data['artists'] as String? ?? '',
|
||||
imageUrl: data['images'] as String?,
|
||||
imageUrl: normalizeRemoteHttpUrl(data['images']?.toString()),
|
||||
releaseDate: data['release_date'] as String?,
|
||||
totalTracks: data['total_tracks'] as int? ?? 0,
|
||||
albumType: data['album_type'] as String? ?? 'album',
|
||||
@@ -1049,7 +1094,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
id: data['id'] as String? ?? '',
|
||||
name: data['name'] as String? ?? '',
|
||||
owner: data['owner'] as String? ?? '',
|
||||
imageUrl: data['images'] as String?,
|
||||
imageUrl: normalizeRemoteHttpUrl(data['images']?.toString()),
|
||||
totalTracks: data['total_tracks'] as int? ?? 0,
|
||||
);
|
||||
}
|
||||
@@ -1066,7 +1111,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
'isrc': isrc,
|
||||
'track_name': track.name,
|
||||
'artist_name': track.artistName,
|
||||
'spotify_id': track.id, // Include Spotify ID for Amazon lookup
|
||||
'spotify_id': track.id,
|
||||
'service': 'tidal',
|
||||
});
|
||||
if (cacheRequests.length >= _maxPreWarmTracksPerRequest) {
|
||||
|
||||
@@ -11,8 +11,10 @@ import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||
import 'package:spotiflac_android/providers/playback_provider.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
|
||||
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||
import 'package:spotiflac_android/providers/library_collections_provider.dart';
|
||||
import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart';
|
||||
import 'package:spotiflac_android/utils/clickable_metadata.dart';
|
||||
@@ -81,7 +83,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
_scrollController.addListener(_onScroll);
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
// Use extensionId if available, otherwise detect from albumId prefix
|
||||
final providerId =
|
||||
widget.extensionId ??
|
||||
(() {
|
||||
@@ -95,7 +96,10 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
.recordAlbumAccess(
|
||||
id: widget.albumId,
|
||||
name: widget.albumName,
|
||||
artistName: widget.tracks?.firstOrNull?.artistName,
|
||||
artistName:
|
||||
widget.artistName ??
|
||||
widget.tracks?.firstOrNull?.albumArtist ??
|
||||
widget.tracks?.firstOrNull?.artistName,
|
||||
imageUrl: widget.coverUrl,
|
||||
providerId: providerId,
|
||||
);
|
||||
@@ -134,9 +138,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
return (mediaSize.height * 0.55).clamp(360.0, 520.0);
|
||||
}
|
||||
|
||||
/// Upgrade cover URL to a reasonable resolution for full-screen display.
|
||||
/// Spotify CDN only has 300, 640, ~2000 — we stay at 640 (no intermediate).
|
||||
/// Deezer CDN: upgrade to 1000x1000 (available: 56, 250, 500, 1000, 1400, 1800).
|
||||
/// Upgrade cover URL to a higher resolution for full-screen display.
|
||||
String? _highResCoverUrl(String? url) {
|
||||
if (url == null) return null;
|
||||
// Spotify CDN: upgrade 300 → 640 only (no intermediate between 640 and 2000)
|
||||
@@ -229,7 +231,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
artistId:
|
||||
(data['artist_id'] ?? data['artistId'])?.toString() ?? _artistId,
|
||||
albumId: data['album_id']?.toString() ?? widget.albumId,
|
||||
coverUrl: data['images'] as String?,
|
||||
coverUrl: normalizeCoverReference(data['images']?.toString()),
|
||||
isrc: data['isrc'] as String?,
|
||||
duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(),
|
||||
trackNumber: data['track_number'] as int?,
|
||||
@@ -240,6 +242,16 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
String? _recommendedDownloadService() {
|
||||
if (widget.extensionId != null && widget.extensionId!.isNotEmpty) {
|
||||
return widget.extensionId;
|
||||
}
|
||||
if (widget.albumId.startsWith('tidal:')) return 'tidal';
|
||||
if (widget.albumId.startsWith('qobuz:')) return 'qobuz';
|
||||
if (widget.albumId.startsWith('deezer:')) return 'deezer';
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
@@ -256,8 +268,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
if (_isLoading)
|
||||
const SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
padding: EdgeInsets.all(16),
|
||||
child: AlbumTrackListSkeleton(itemCount: 10),
|
||||
),
|
||||
),
|
||||
if (_error != null)
|
||||
@@ -283,7 +295,11 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
) {
|
||||
final expandedHeight = _calculateExpandedHeight(context);
|
||||
final tracks = _tracks ?? [];
|
||||
final artistName = tracks.isNotEmpty ? tracks.first.artistName : null;
|
||||
final artistName =
|
||||
widget.artistName ??
|
||||
(tracks.isNotEmpty
|
||||
? (tracks.first.albumArtist ?? tracks.first.artistName)
|
||||
: null);
|
||||
final releaseDate = tracks.isNotEmpty ? tracks.first.releaseDate : null;
|
||||
|
||||
return SliverAppBar(
|
||||
@@ -516,7 +532,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
}
|
||||
|
||||
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
|
||||
// Info is now displayed in the full-screen cover overlay
|
||||
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
||||
}
|
||||
|
||||
@@ -530,9 +545,12 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
final track = tracks[index];
|
||||
return KeyedSubtree(
|
||||
key: ValueKey(track.id),
|
||||
child: _AlbumTrackItem(
|
||||
track: track,
|
||||
onDownload: () => _downloadTrack(context, track),
|
||||
child: StaggeredListItem(
|
||||
index: index,
|
||||
child: _AlbumTrackItem(
|
||||
track: track,
|
||||
onDownload: () => _downloadTrack(context, track),
|
||||
),
|
||||
),
|
||||
);
|
||||
}, childCount: tracks.length),
|
||||
@@ -547,6 +565,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
trackName: track.name,
|
||||
artistName: track.artistName,
|
||||
coverUrl: track.coverUrl,
|
||||
recommendedService: _recommendedDownloadService(),
|
||||
onSelect: (quality, service) {
|
||||
ref
|
||||
.read(downloadQueueProvider.notifier)
|
||||
@@ -571,37 +590,82 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
void _downloadAll(BuildContext context) {
|
||||
final tracks = _tracks;
|
||||
if (tracks == null || tracks.isEmpty) return;
|
||||
|
||||
final historyState = ref.read(downloadHistoryProvider);
|
||||
final settings = ref.read(settingsProvider);
|
||||
final localLibState =
|
||||
(settings.localLibraryEnabled && settings.localLibraryShowDuplicates)
|
||||
? ref.read(localLibraryProvider)
|
||||
: null;
|
||||
final tracksToQueue = <Track>[];
|
||||
int skippedCount = 0;
|
||||
|
||||
for (final track in tracks) {
|
||||
final isInHistory =
|
||||
historyState.isDownloaded(track.id) ||
|
||||
(track.isrc != null && historyState.getByIsrc(track.isrc!) != null) ||
|
||||
historyState.findByTrackAndArtist(track.name, track.artistName) !=
|
||||
null;
|
||||
final isInLocal =
|
||||
localLibState?.existsInLibrary(
|
||||
isrc: track.isrc,
|
||||
trackName: track.name,
|
||||
artistName: track.artistName,
|
||||
) ??
|
||||
false;
|
||||
|
||||
if (isInHistory || isInLocal) {
|
||||
skippedCount++;
|
||||
} else {
|
||||
tracksToQueue.add(track);
|
||||
}
|
||||
}
|
||||
|
||||
if (tracksToQueue.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.l10n.discographySkippedDownloaded(0, skippedCount),
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (settings.askQualityBeforeDownload) {
|
||||
DownloadServicePicker.show(
|
||||
context,
|
||||
trackName: '${tracks.length} tracks',
|
||||
trackName: '${tracksToQueue.length} tracks',
|
||||
artistName: widget.albumName,
|
||||
recommendedService: _recommendedDownloadService(),
|
||||
onSelect: (quality, service) {
|
||||
ref
|
||||
.read(downloadQueueProvider.notifier)
|
||||
.addMultipleToQueue(tracks, service, qualityOverride: quality);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.l10n.snackbarAddedTracksToQueue(tracks.length),
|
||||
),
|
||||
),
|
||||
);
|
||||
.addMultipleToQueue(
|
||||
tracksToQueue,
|
||||
service,
|
||||
qualityOverride: quality,
|
||||
);
|
||||
_showQueuedSnackbar(context, tracksToQueue.length, skippedCount);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
ref
|
||||
.read(downloadQueueProvider.notifier)
|
||||
.addMultipleToQueue(tracks, settings.defaultService);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)),
|
||||
),
|
||||
);
|
||||
.addMultipleToQueue(tracksToQueue, settings.defaultService);
|
||||
_showQueuedSnackbar(context, tracksToQueue.length, skippedCount);
|
||||
}
|
||||
}
|
||||
|
||||
void _showQueuedSnackbar(BuildContext context, int added, int skipped) {
|
||||
final message = skipped > 0
|
||||
? context.l10n.discographySkippedDownloaded(added, skipped)
|
||||
: context.l10n.snackbarAddedTracksToQueue(added);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
|
||||
Widget _buildLoveAllButton() {
|
||||
final collectionsState = ref.watch(libraryCollectionsProvider);
|
||||
final tracks = _tracks;
|
||||
|
||||
@@ -14,11 +14,13 @@ import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||
import 'package:spotiflac_android/providers/playback_provider.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
import 'package:spotiflac_android/screens/album_screen.dart';
|
||||
import 'package:spotiflac_android/screens/home_tab.dart'
|
||||
show ExtensionAlbumScreen;
|
||||
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
|
||||
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||
import 'package:spotiflac_android/utils/clickable_metadata.dart';
|
||||
|
||||
class _ArtistCache {
|
||||
@@ -151,6 +153,16 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
return tileSize + 64 + ((textScale - 1) * 14);
|
||||
}
|
||||
|
||||
String? _recommendedDownloadService() {
|
||||
if (widget.extensionId != null && widget.extensionId!.isNotEmpty) {
|
||||
return widget.extensionId;
|
||||
}
|
||||
if (widget.artistId.startsWith('tidal:')) return 'tidal';
|
||||
if (widget.artistId.startsWith('qobuz:')) return 'qobuz';
|
||||
if (widget.artistId.startsWith('deezer:')) return 'deezer';
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -297,8 +309,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
.toList();
|
||||
}
|
||||
|
||||
final topTracksList =
|
||||
artistData['top_tracks'] as List<dynamic>? ?? [];
|
||||
final topTracksList = artistData['top_tracks'] as List<dynamic>? ?? [];
|
||||
if (topTracksList.isNotEmpty) {
|
||||
topTracks = topTracksList
|
||||
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
||||
@@ -399,8 +410,9 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
(data['artist_id'] ?? data['artistId'])?.toString() ??
|
||||
widget.artistId,
|
||||
albumId: data['album_id']?.toString() ?? album?.id,
|
||||
coverUrl: (data['cover_url'] ?? data['images'] ?? album?.coverUrl)
|
||||
?.toString(),
|
||||
coverUrl: normalizeCoverReference(
|
||||
(data['cover_url'] ?? data['images'] ?? album?.coverUrl)?.toString(),
|
||||
),
|
||||
isrc: data['isrc']?.toString(),
|
||||
duration: (durationMs / 1000).round(),
|
||||
trackNumber: data['track_number'] as int?,
|
||||
@@ -414,18 +426,18 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
|
||||
ArtistAlbum _parseArtistAlbum(Map<String, dynamic> data) {
|
||||
final totalTracksValue = data['total_tracks'];
|
||||
final totalTracks =
|
||||
totalTracksValue is int
|
||||
? totalTracksValue
|
||||
: int.tryParse(totalTracksValue?.toString() ?? '') ?? 0;
|
||||
final totalTracks = totalTracksValue is int
|
||||
? totalTracksValue
|
||||
: int.tryParse(totalTracksValue?.toString() ?? '') ?? 0;
|
||||
|
||||
return ArtistAlbum(
|
||||
id: data['id'] as String? ?? '',
|
||||
name: (data['name'] ?? data['title'] ?? '').toString(),
|
||||
releaseDate: (data['release_date'] ?? '').toString(),
|
||||
totalTracks: totalTracks,
|
||||
coverUrl: (data['cover_url'] ?? data['images'] ?? data['cover_art'])
|
||||
?.toString(),
|
||||
coverUrl: normalizeCoverReference(
|
||||
(data['cover_url'] ?? data['images'] ?? data['cover_art'])?.toString(),
|
||||
),
|
||||
albumType: (data['album_type'] ?? data['type'] ?? 'album').toString(),
|
||||
artists: (data['artists'] ?? data['artist'] ?? widget.artistName)
|
||||
.toString(),
|
||||
@@ -480,12 +492,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
hasDiscography: hasDiscography,
|
||||
),
|
||||
if (_isLoadingDiscography)
|
||||
const SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
),
|
||||
const SliverToBoxAdapter(child: ArtistScreenSkeleton()),
|
||||
if (_error != null)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
@@ -888,6 +895,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
if (settings.askQualityBeforeDownload) {
|
||||
DownloadServicePicker.show(
|
||||
context,
|
||||
recommendedService: _recommendedDownloadService(),
|
||||
onSelect: (quality, service) {
|
||||
_fetchAndQueueAlbums(albums, service, quality);
|
||||
},
|
||||
@@ -947,7 +955,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
|
||||
fetchedCount++;
|
||||
|
||||
// Update progress dialog
|
||||
if (mounted) {
|
||||
_FetchingProgressDialog.updateProgress(
|
||||
context,
|
||||
@@ -978,7 +985,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check which tracks are already downloaded
|
||||
final historyState = ref.read(downloadHistoryProvider);
|
||||
final tracksToQueue = <Track>[];
|
||||
int skippedCount = 0;
|
||||
@@ -1029,10 +1035,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
content: Text(message),
|
||||
action: SnackBarAction(
|
||||
label: context.l10n.snackbarViewQueue,
|
||||
onPressed: () {
|
||||
// Navigate to queue tab (index 1)
|
||||
// This will be handled by the navigation system
|
||||
},
|
||||
onPressed: () {},
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -1153,6 +1156,8 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
imageUrl.isNotEmpty &&
|
||||
Uri.tryParse(imageUrl)?.hasAuthority == true;
|
||||
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
String? listenersText;
|
||||
final listeners = _monthlyListeners ?? widget.monthlyListeners;
|
||||
if (listeners != null && listeners > 0) {
|
||||
@@ -1223,7 +1228,9 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
Colors.transparent,
|
||||
Colors.black.withValues(alpha: 0.3),
|
||||
Colors.black.withValues(alpha: 0.7),
|
||||
colorScheme.surface,
|
||||
isDark
|
||||
? colorScheme.surface
|
||||
: Colors.black.withValues(alpha: 0.85),
|
||||
],
|
||||
stops: const [0.0, 0.5, 0.75, 1.0],
|
||||
),
|
||||
@@ -1264,7 +1271,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
listenersText,
|
||||
style: Theme.of(context).textTheme.bodyMedium
|
||||
?.copyWith(
|
||||
color: Colors.white.withValues(alpha: 0.8),
|
||||
color: Colors.white,
|
||||
shadows: [
|
||||
Shadow(
|
||||
offset: const Offset(0, 1),
|
||||
@@ -1359,8 +1366,10 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
},
|
||||
itemBuilder: (context, pageIndex) {
|
||||
final startIndex = pageIndex * tracksPerPage;
|
||||
final endIndex =
|
||||
(startIndex + tracksPerPage).clamp(0, tracks.length);
|
||||
final endIndex = (startIndex + tracksPerPage).clamp(
|
||||
0,
|
||||
tracks.length,
|
||||
);
|
||||
final pageTracks = tracks.sublist(startIndex, endIndex);
|
||||
|
||||
return Column(
|
||||
@@ -1686,6 +1695,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
if (settings.askQualityBeforeDownload) {
|
||||
DownloadServicePicker.show(
|
||||
context,
|
||||
recommendedService: _recommendedDownloadService(),
|
||||
onSelect: (quality, service) {
|
||||
if (!mounted) return;
|
||||
enqueue(service, quality: quality);
|
||||
@@ -1836,29 +1846,14 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
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,
|
||||
),
|
||||
child: AnimatedSelectionCheckbox(
|
||||
visible: true,
|
||||
selected: isSelected,
|
||||
colorScheme: colorScheme,
|
||||
size: 28,
|
||||
unselectedColor: colorScheme.surface.withValues(
|
||||
alpha: 0.9,
|
||||
),
|
||||
child: isSelected
|
||||
? Icon(
|
||||
Icons.check,
|
||||
color: colorScheme.onPrimary,
|
||||
size: 18,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
if (showTypeBadge)
|
||||
@@ -2067,7 +2062,6 @@ class _FetchingProgressDialog extends StatefulWidget {
|
||||
required this.onCancel,
|
||||
});
|
||||
|
||||
// Static method to update progress from outside
|
||||
static void updateProgress(BuildContext context, int current, int total) {
|
||||
final state = context
|
||||
.findAncestorStateOfType<_FetchingProgressDialogState>();
|
||||
@@ -2140,7 +2134,6 @@ class _FetchingProgressDialogState extends State<_FetchingProgressDialog> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Progress bar
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: LinearProgressIndicator(
|
||||
|
||||
@@ -17,6 +17,7 @@ import 'package:spotiflac_android/providers/playback_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
||||
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
|
||||
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||
|
||||
class DownloadedAlbumScreen extends ConsumerStatefulWidget {
|
||||
final String albumName;
|
||||
@@ -120,7 +121,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
|
||||
final tracks =
|
||||
allItems.where((item) {
|
||||
// Use albumArtist if available and not empty, otherwise artistName
|
||||
final itemArtist =
|
||||
(item.albumArtist != null && item.albumArtist!.isNotEmpty)
|
||||
? item.albumArtist!
|
||||
@@ -129,7 +129,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
'${item.albumName.toLowerCase()}|${itemArtist.toLowerCase()}';
|
||||
return itemKey == _albumLookupKey;
|
||||
}).toList()..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);
|
||||
@@ -310,14 +309,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
if (!mounted) return;
|
||||
|
||||
final result = await navigator.push(
|
||||
PageRouteBuilder(
|
||||
transitionDuration: const Duration(milliseconds: 300),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 250),
|
||||
pageBuilder: (context, animation, secondaryAnimation) =>
|
||||
TrackMetadataScreen(item: item),
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
|
||||
FadeTransition(opacity: animation, child: child),
|
||||
),
|
||||
slidePageRoute(page: TrackMetadataScreen(item: item)),
|
||||
);
|
||||
await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath(
|
||||
item.filePath,
|
||||
@@ -693,7 +685,10 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
final track = tracks[index];
|
||||
return KeyedSubtree(
|
||||
key: ValueKey(track.id),
|
||||
child: _buildTrackItem(context, colorScheme, track),
|
||||
child: StaggeredListItem(
|
||||
index: index,
|
||||
child: _buildTrackItem(context, colorScheme, track),
|
||||
),
|
||||
);
|
||||
}, childCount: tracks.length),
|
||||
);
|
||||
@@ -701,6 +696,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
|
||||
final discNumbers = _getSortedDiscNumbers(tracks);
|
||||
final List<Widget> children = [];
|
||||
var revealIndex = 0;
|
||||
|
||||
for (final discNumber in discNumbers) {
|
||||
final discTracks = discMap[discNumber];
|
||||
@@ -712,7 +708,10 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
children.add(
|
||||
KeyedSubtree(
|
||||
key: ValueKey(track.id),
|
||||
child: _buildTrackItem(context, colorScheme, track),
|
||||
child: StaggeredListItem(
|
||||
index: revealIndex++,
|
||||
child: _buildTrackItem(context, colorScheme, track),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -796,28 +795,11 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (_isSelectionMode) ...[
|
||||
Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? colorScheme.primary
|
||||
: Colors.transparent,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? colorScheme.primary
|
||||
: colorScheme.outline,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: isSelected
|
||||
? Icon(
|
||||
Icons.check,
|
||||
color: colorScheme.onPrimary,
|
||||
size: 16,
|
||||
)
|
||||
: null,
|
||||
AnimatedSelectionCheckbox(
|
||||
visible: true,
|
||||
selected: isSelected,
|
||||
colorScheme: colorScheme,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
@@ -910,8 +892,45 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
BuildContext context,
|
||||
List<DownloadHistoryItem> allTracks,
|
||||
) {
|
||||
String selectedFormat = 'MP3';
|
||||
String selectedBitrate = '320k';
|
||||
final tracksById = {for (final t in allTracks) t.id: t};
|
||||
final sourceFormats = <String>{};
|
||||
for (final id in _selectedIds) {
|
||||
final item = tracksById[id];
|
||||
if (item == null) continue;
|
||||
final nameToCheck =
|
||||
(item.safFileName != null && item.safFileName!.isNotEmpty)
|
||||
? item.safFileName!.toLowerCase()
|
||||
: item.filePath.toLowerCase();
|
||||
final ext = nameToCheck.endsWith('.flac')
|
||||
? 'FLAC'
|
||||
: nameToCheck.endsWith('.m4a')
|
||||
? 'M4A'
|
||||
: nameToCheck.endsWith('.mp3')
|
||||
? 'MP3'
|
||||
: (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg'))
|
||||
? 'Opus'
|
||||
: null;
|
||||
if (ext != null) sourceFormats.add(ext);
|
||||
}
|
||||
|
||||
final formats = ['ALAC', 'FLAC', 'MP3', 'Opus'].where((target) {
|
||||
return sourceFormats.any((src) {
|
||||
if (src == target) return false;
|
||||
final isLosslessTarget = target == 'ALAC' || target == 'FLAC';
|
||||
final isLosslessSource = src == 'FLAC' || src == 'M4A';
|
||||
if (isLosslessTarget && !isLosslessSource) return false;
|
||||
return true;
|
||||
});
|
||||
}).toList();
|
||||
|
||||
if (formats.isEmpty) return;
|
||||
|
||||
String selectedFormat = formats.first;
|
||||
bool isLosslessTarget =
|
||||
selectedFormat == 'ALAC' || selectedFormat == 'FLAC';
|
||||
String selectedBitrate = isLosslessTarget
|
||||
? '320k'
|
||||
: (selectedFormat == 'Opus' ? '128k' : '320k');
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
@@ -923,7 +942,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setSheetState) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final formats = ['MP3', 'Opus'];
|
||||
final bitrates = ['128k', '192k', '256k', '320k'];
|
||||
|
||||
return SafeArea(
|
||||
@@ -960,51 +978,73 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: formats.map((format) {
|
||||
final isSelected = format == selectedFormat;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ChoiceChip(
|
||||
label: Text(format),
|
||||
selected: isSelected,
|
||||
onSelected: (selected) {
|
||||
if (selected) {
|
||||
setSheetState(() {
|
||||
selectedFormat = format;
|
||||
selectedBitrate = format == 'Opus'
|
||||
? '128k'
|
||||
: '320k';
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
context.l10n.trackConvertBitrate,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: bitrates.map((br) {
|
||||
final isSelected = br == selectedBitrate;
|
||||
children: formats.map((format) {
|
||||
final isSelected = format == selectedFormat;
|
||||
return ChoiceChip(
|
||||
label: Text(br),
|
||||
label: Text(format),
|
||||
selected: isSelected,
|
||||
onSelected: (selected) {
|
||||
if (selected) {
|
||||
setSheetState(() => selectedBitrate = br);
|
||||
setSheetState(() {
|
||||
selectedFormat = format;
|
||||
isLosslessTarget =
|
||||
format == 'ALAC' || format == 'FLAC';
|
||||
if (!isLosslessTarget) {
|
||||
selectedBitrate = format == 'Opus'
|
||||
? '128k'
|
||||
: '320k';
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
if (!isLosslessTarget) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
context.l10n.trackConvertBitrate,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: bitrates.map((br) {
|
||||
final isSelected = br == selectedBitrate;
|
||||
return ChoiceChip(
|
||||
label: Text(br),
|
||||
selected: isSelected,
|
||||
onSelected: (selected) {
|
||||
if (selected) {
|
||||
setSheetState(() => selectedBitrate = br);
|
||||
}
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
if (isLosslessTarget) ...[
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.verified,
|
||||
size: 16,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
context.l10n.trackConvertLosslessHint,
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(color: colorScheme.primary),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
@@ -1057,12 +1097,18 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
: item.filePath.toLowerCase();
|
||||
final ext = nameToCheck.endsWith('.flac')
|
||||
? 'FLAC'
|
||||
: nameToCheck.endsWith('.m4a')
|
||||
? 'M4A'
|
||||
: nameToCheck.endsWith('.mp3')
|
||||
? 'MP3'
|
||||
: (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg'))
|
||||
? 'Opus'
|
||||
: null;
|
||||
if (ext != null && ext != targetFormat) selected.add(item);
|
||||
if (ext == null || ext == targetFormat) continue;
|
||||
final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC';
|
||||
final isLosslessSource = ext == 'FLAC' || ext == 'M4A';
|
||||
if (isLosslessTarget && !isLosslessSource) continue;
|
||||
selected.add(item);
|
||||
}
|
||||
|
||||
if (selected.isEmpty) {
|
||||
@@ -1074,16 +1120,22 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
final isLossless = targetFormat == 'ALAC' || targetFormat == 'FLAC';
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(context.l10n.selectionBatchConvertConfirmTitle),
|
||||
content: Text(
|
||||
context.l10n.selectionBatchConvertConfirmMessage(
|
||||
selected.length,
|
||||
targetFormat,
|
||||
bitrate,
|
||||
),
|
||||
isLossless
|
||||
? context.l10n.selectionBatchConvertConfirmMessageLossless(
|
||||
selected.length,
|
||||
targetFormat,
|
||||
)
|
||||
: context.l10n.selectionBatchConvertConfirmMessage(
|
||||
selected.length,
|
||||
targetFormat,
|
||||
bitrate,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
@@ -1104,7 +1156,10 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
final total = selected.length;
|
||||
final historyDb = HistoryDatabase.instance;
|
||||
final newQuality =
|
||||
'${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}';
|
||||
(targetFormat.toUpperCase() == 'ALAC' ||
|
||||
targetFormat.toUpperCase() == 'FLAC')
|
||||
? '${targetFormat.toUpperCase()} Lossless'
|
||||
: '${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}';
|
||||
final settings = ref.read(settingsProvider);
|
||||
final shouldEmbedLyrics =
|
||||
settings.embedLyrics && settings.lyricsMode != 'external';
|
||||
@@ -1132,12 +1187,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
try {
|
||||
final result = await PlatformBridge.readFileMetadata(item.filePath);
|
||||
if (result['error'] == null) {
|
||||
result.forEach((key, value) {
|
||||
if (key == 'error' || value == null) return;
|
||||
final v = value.toString().trim();
|
||||
if (v.isEmpty) return;
|
||||
metadata[key.toUpperCase()] = v;
|
||||
});
|
||||
mergePlatformMetadataForTagEmbed(target: metadata, source: result);
|
||||
}
|
||||
} catch (_) {}
|
||||
await ensureLyricsMetadataForConversion(
|
||||
@@ -1207,13 +1257,27 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
final baseName = dotIdx > 0
|
||||
? oldFileName.substring(0, dotIdx)
|
||||
: oldFileName;
|
||||
final newExt = targetFormat.toLowerCase() == 'opus'
|
||||
? '.opus'
|
||||
: '.mp3';
|
||||
String newExt;
|
||||
String mimeType;
|
||||
switch (targetFormat.toLowerCase()) {
|
||||
case 'opus':
|
||||
newExt = '.opus';
|
||||
mimeType = 'audio/opus';
|
||||
break;
|
||||
case 'alac':
|
||||
newExt = '.m4a';
|
||||
mimeType = 'audio/mp4';
|
||||
break;
|
||||
case 'flac':
|
||||
newExt = '.flac';
|
||||
mimeType = 'audio/flac';
|
||||
break;
|
||||
default:
|
||||
newExt = '.mp3';
|
||||
mimeType = 'audio/mpeg';
|
||||
break;
|
||||
}
|
||||
final newFileName = '$baseName$newExt';
|
||||
final mimeType = targetFormat.toLowerCase() == 'opus'
|
||||
? 'audio/opus'
|
||||
: 'audio/mpeg';
|
||||
|
||||
final safUri = await PlatformBridge.createSafFileFromPath(
|
||||
treeUri: treeUri,
|
||||
|
||||
+471
-109
@@ -23,10 +23,12 @@ import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.da
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
import 'package:spotiflac_android/screens/playlist_screen.dart';
|
||||
import 'package:spotiflac_android/screens/downloaded_album_screen.dart';
|
||||
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
|
||||
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||
import 'package:spotiflac_android/utils/clickable_metadata.dart';
|
||||
|
||||
class HomeTab extends ConsumerStatefulWidget {
|
||||
@@ -82,6 +84,18 @@ class _SearchResultBuckets {
|
||||
});
|
||||
}
|
||||
|
||||
enum _SearchSortOption {
|
||||
defaultOrder,
|
||||
titleAsc,
|
||||
titleDesc,
|
||||
artistAsc,
|
||||
artistDesc,
|
||||
durationAsc,
|
||||
durationDesc,
|
||||
dateAsc,
|
||||
dateDesc,
|
||||
}
|
||||
|
||||
const _homeHistoryPreviewLimit = 48;
|
||||
|
||||
class _HomeHistoryPreview {
|
||||
@@ -243,6 +257,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
Map<String, (double, double)>? _thumbnailSizesCache;
|
||||
List<Track>? _searchBucketsSourceTracks;
|
||||
_SearchResultBuckets? _searchBucketsCache;
|
||||
_SearchSortOption _searchSortOption = _SearchSortOption.defaultOrder;
|
||||
|
||||
double _responsiveScale({
|
||||
required BuildContext context,
|
||||
@@ -279,13 +294,13 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
double _exploreCardSize(BuildContext context) {
|
||||
final scale = _responsiveScale(context: context, min: 0.82, max: 1.08);
|
||||
final textScale = _effectiveTextScale(context);
|
||||
return 120 * scale * (1 + (textScale - 1) * 0.12);
|
||||
return 145 * scale * (1 + (textScale - 1) * 0.12);
|
||||
}
|
||||
|
||||
double _exploreSectionHeight(BuildContext context) {
|
||||
final cardSize = _exploreCardSize(context);
|
||||
final textScale = _effectiveTextScale(context);
|
||||
return cardSize + 55 + ((textScale - 1) * 12);
|
||||
return cardSize + 58 + ((textScale - 1) * 12);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -489,6 +504,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
|
||||
if (searchProvider == null || searchProvider.isEmpty) return false;
|
||||
|
||||
// Built-in providers (tidal, qobuz) also support live search
|
||||
if (_builtInSearchProviders.contains(searchProvider)) return true;
|
||||
|
||||
final extension = extState.extensions
|
||||
.where((e) => e.id == searchProvider && e.enabled)
|
||||
.firstOrNull;
|
||||
@@ -546,6 +564,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
}
|
||||
}
|
||||
|
||||
/// Built-in search providers that are not extensions
|
||||
static const _builtInSearchProviders = {'tidal', 'qobuz'};
|
||||
|
||||
Future<void> _performSearch(String query, {String? filterOverride}) async {
|
||||
final settings = ref.read(settingsProvider);
|
||||
final extState = ref.read(extensionProvider);
|
||||
@@ -557,10 +578,16 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
'${searchProvider ?? 'default'}:$query:${selectedFilter ?? 'all'}';
|
||||
if (_lastSearchQuery == searchKey) return;
|
||||
_lastSearchQuery = searchKey;
|
||||
_searchSortOption = _SearchSortOption.defaultOrder;
|
||||
|
||||
final isBuiltInProvider =
|
||||
searchProvider != null &&
|
||||
_builtInSearchProviders.contains(searchProvider);
|
||||
|
||||
final isExtensionEnabled =
|
||||
searchProvider != null &&
|
||||
searchProvider.isNotEmpty &&
|
||||
!isBuiltInProvider &&
|
||||
extState.extensions.any((e) => e.id == searchProvider && e.enabled);
|
||||
|
||||
if (isExtensionEnabled) {
|
||||
@@ -571,10 +598,20 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
await ref
|
||||
.read(trackProvider.notifier)
|
||||
.customSearch(searchProvider, query, options: options);
|
||||
} else if (isBuiltInProvider) {
|
||||
// Use built-in Tidal or Qobuz search
|
||||
await ref
|
||||
.read(trackProvider.notifier)
|
||||
.search(
|
||||
query,
|
||||
filterOverride: selectedFilter,
|
||||
builtInSearchProvider: searchProvider,
|
||||
);
|
||||
} else {
|
||||
if (searchProvider != null &&
|
||||
searchProvider.isNotEmpty &&
|
||||
!isExtensionEnabled) {
|
||||
!isExtensionEnabled &&
|
||||
!isBuiltInProvider) {
|
||||
ref.read(settingsProvider.notifier).setSearchProvider(null);
|
||||
}
|
||||
await ref
|
||||
@@ -676,6 +713,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
playlistName: trackState.playlistName!,
|
||||
coverUrl: trackState.coverUrl,
|
||||
tracks: trackState.tracks,
|
||||
recommendedService:
|
||||
trackState.searchExtensionId ?? trackState.searchSource,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -718,6 +757,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
trackName: track.name,
|
||||
artistName: track.artistName,
|
||||
coverUrl: track.coverUrl,
|
||||
recommendedService: trackState.searchSource,
|
||||
onSelect: (quality, service) {
|
||||
ref
|
||||
.read(downloadQueueProvider.notifier)
|
||||
@@ -1258,8 +1298,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
exploreLoading)
|
||||
const SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
padding: EdgeInsets.all(16),
|
||||
child: TrackListSkeleton(itemCount: 5),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1462,7 +1502,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
if (hasGreeting && index == 0) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||
child: Text(
|
||||
greeting,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
@@ -1477,7 +1517,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
return _buildExploreSection(sections[sectionIndex], colorScheme);
|
||||
}
|
||||
|
||||
return const SizedBox(height: 16);
|
||||
return const SizedBox(height: 24);
|
||||
}, childCount: totalCount),
|
||||
),
|
||||
];
|
||||
@@ -1493,7 +1533,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
|
||||
padding: const EdgeInsets.fromLTRB(16, 20, 16, 12),
|
||||
child: Text(
|
||||
section.title,
|
||||
style: Theme.of(
|
||||
@@ -1509,7 +1549,11 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
itemCount: section.items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = section.items[index];
|
||||
return _buildExploreItem(item, colorScheme);
|
||||
return StaggeredListItem(
|
||||
index: index,
|
||||
staggerDelay: const Duration(milliseconds: 50),
|
||||
child: _buildExploreItem(item, colorScheme),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -1556,7 +1600,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(
|
||||
isArtist ? cardSize / 2 : 8,
|
||||
isArtist ? cardSize / 2 : 10,
|
||||
),
|
||||
child: item.coverUrl != null && item.coverUrl!.isNotEmpty
|
||||
? CachedNetworkImage(
|
||||
@@ -1595,8 +1639,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: isArtist ? TextAlign.center : TextAlign.start,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
@@ -1609,7 +1653,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontSize: 11,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -2099,7 +2143,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
if (item.providerId != null &&
|
||||
item.providerId!.isNotEmpty &&
|
||||
item.providerId != 'deezer' &&
|
||||
item.providerId != 'spotify') {
|
||||
item.providerId != 'spotify' &&
|
||||
item.providerId != 'tidal' &&
|
||||
item.providerId != 'qobuz') {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
@@ -2139,7 +2185,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
} else if (item.providerId != null &&
|
||||
item.providerId!.isNotEmpty &&
|
||||
item.providerId != 'deezer' &&
|
||||
item.providerId != 'spotify') {
|
||||
item.providerId != 'spotify' &&
|
||||
item.providerId != 'tidal' &&
|
||||
item.providerId != 'qobuz') {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
@@ -2187,7 +2235,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
if (item.providerId != null &&
|
||||
item.providerId!.isNotEmpty &&
|
||||
item.providerId != 'deezer' &&
|
||||
item.providerId != 'spotify') {
|
||||
item.providerId != 'spotify' &&
|
||||
item.providerId != 'tidal' &&
|
||||
item.providerId != 'qobuz') {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
@@ -2225,14 +2275,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
);
|
||||
if (!mounted) return;
|
||||
final result = await navigator.push(
|
||||
PageRouteBuilder(
|
||||
transitionDuration: const Duration(milliseconds: 300),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 250),
|
||||
pageBuilder: (context, animation, secondaryAnimation) =>
|
||||
TrackMetadataScreen(item: item),
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
|
||||
FadeTransition(opacity: animation, child: child),
|
||||
),
|
||||
slidePageRoute(page: TrackMetadataScreen(item: item)),
|
||||
);
|
||||
await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath(
|
||||
item.filePath,
|
||||
@@ -2370,6 +2413,168 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Search result sorting ──────────────────────────────────────────────
|
||||
|
||||
String _sortOptionLabel(_SearchSortOption option) {
|
||||
switch (option) {
|
||||
case _SearchSortOption.defaultOrder:
|
||||
return context.l10n.searchSortDefault;
|
||||
case _SearchSortOption.titleAsc:
|
||||
return context.l10n.searchSortTitleAZ;
|
||||
case _SearchSortOption.titleDesc:
|
||||
return context.l10n.searchSortTitleZA;
|
||||
case _SearchSortOption.artistAsc:
|
||||
return context.l10n.searchSortArtistAZ;
|
||||
case _SearchSortOption.artistDesc:
|
||||
return context.l10n.searchSortArtistZA;
|
||||
case _SearchSortOption.durationAsc:
|
||||
return context.l10n.searchSortDurationShort;
|
||||
case _SearchSortOption.durationDesc:
|
||||
return context.l10n.searchSortDurationLong;
|
||||
case _SearchSortOption.dateAsc:
|
||||
return context.l10n.searchSortDateOldest;
|
||||
case _SearchSortOption.dateDesc:
|
||||
return context.l10n.searchSortDateNewest;
|
||||
}
|
||||
}
|
||||
|
||||
void _showSortOptions(ColorScheme colorScheme) {
|
||||
var tempSort = _searchSortOption;
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: colorScheme.surfaceContainerLow,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
),
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (ctx, setSheetState) {
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 32,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.outlineVariant,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.searchSortTitle,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
TextButton(
|
||||
onPressed: () => setSheetState(
|
||||
() => tempSort = _SearchSortOption.defaultOrder,
|
||||
),
|
||||
child: Text(context.l10n.libraryFilterReset),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: _SearchSortOption.values.map((option) {
|
||||
return FilterChip(
|
||||
label: Text(_sortOptionLabel(option)),
|
||||
selected: tempSort == option,
|
||||
showCheckmark: false,
|
||||
onSelected: (_) =>
|
||||
setSheetState(() => tempSort = option),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(ctx);
|
||||
if (_searchSortOption != tempSort) {
|
||||
setState(() {
|
||||
_searchSortOption = tempSort;
|
||||
});
|
||||
}
|
||||
},
|
||||
child: Text(context.l10n.libraryFilterApply),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<T> _applySortToList<T>(
|
||||
List<T> items,
|
||||
String Function(T) getName,
|
||||
String Function(T) getArtist,
|
||||
int Function(T) getDuration,
|
||||
String? Function(T) getDate,
|
||||
) {
|
||||
if (_searchSortOption == _SearchSortOption.defaultOrder) return items;
|
||||
final sorted = List<T>.of(items);
|
||||
switch (_searchSortOption) {
|
||||
case _SearchSortOption.defaultOrder:
|
||||
break;
|
||||
case _SearchSortOption.titleAsc:
|
||||
sorted.sort(
|
||||
(a, b) =>
|
||||
getName(a).toLowerCase().compareTo(getName(b).toLowerCase()),
|
||||
);
|
||||
case _SearchSortOption.titleDesc:
|
||||
sorted.sort(
|
||||
(a, b) =>
|
||||
getName(b).toLowerCase().compareTo(getName(a).toLowerCase()),
|
||||
);
|
||||
case _SearchSortOption.artistAsc:
|
||||
sorted.sort(
|
||||
(a, b) =>
|
||||
getArtist(a).toLowerCase().compareTo(getArtist(b).toLowerCase()),
|
||||
);
|
||||
case _SearchSortOption.artistDesc:
|
||||
sorted.sort(
|
||||
(a, b) =>
|
||||
getArtist(b).toLowerCase().compareTo(getArtist(a).toLowerCase()),
|
||||
);
|
||||
case _SearchSortOption.durationAsc:
|
||||
sorted.sort((a, b) => getDuration(a).compareTo(getDuration(b)));
|
||||
case _SearchSortOption.durationDesc:
|
||||
sorted.sort((a, b) => getDuration(b).compareTo(getDuration(a)));
|
||||
case _SearchSortOption.dateAsc:
|
||||
sorted.sort((a, b) {
|
||||
final da = getDate(a) ?? '';
|
||||
final db = getDate(b) ?? '';
|
||||
return da.compareTo(db);
|
||||
});
|
||||
case _SearchSortOption.dateDesc:
|
||||
sorted.sort((a, b) {
|
||||
final da = getDate(a) ?? '';
|
||||
final db = getDate(b) ?? '';
|
||||
return db.compareTo(da);
|
||||
});
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
|
||||
List<Widget> _buildSearchResults({
|
||||
required List<Track> tracks,
|
||||
required List<SearchArtist>? searchArtists,
|
||||
@@ -2383,6 +2588,15 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
required bool showLocalLibraryIndicator,
|
||||
required Map<String, (double, double)> thumbnailSizesByExtensionId,
|
||||
}) {
|
||||
final hasActualData =
|
||||
tracks.isNotEmpty ||
|
||||
(searchArtists != null && searchArtists.isNotEmpty) ||
|
||||
(searchAlbums != null && searchAlbums.isNotEmpty) ||
|
||||
(searchPlaylists != null && searchPlaylists.isNotEmpty);
|
||||
|
||||
if (!hasActualData && isLoading) {
|
||||
return [const SliverToBoxAdapter(child: HomeSearchSkeleton())];
|
||||
}
|
||||
if (!hasResults) {
|
||||
return [const SliverToBoxAdapter(child: SizedBox.shrink())];
|
||||
}
|
||||
@@ -2394,6 +2608,59 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
final playlistItems = buckets.playlistItems;
|
||||
final artistItems = buckets.artistItems;
|
||||
|
||||
final sortedArtists = searchArtists != null && searchArtists.isNotEmpty
|
||||
? _applySortToList<SearchArtist>(
|
||||
searchArtists,
|
||||
(a) => a.name,
|
||||
(a) => a.name,
|
||||
(a) => 0,
|
||||
(a) => null,
|
||||
)
|
||||
: searchArtists;
|
||||
|
||||
final sortedAlbums = searchAlbums != null && searchAlbums.isNotEmpty
|
||||
? _applySortToList<SearchAlbum>(
|
||||
searchAlbums,
|
||||
(a) => a.name,
|
||||
(a) => a.artists,
|
||||
(a) => 0,
|
||||
(a) => a.releaseDate,
|
||||
)
|
||||
: searchAlbums;
|
||||
|
||||
final sortedPlaylists =
|
||||
searchPlaylists != null && searchPlaylists.isNotEmpty
|
||||
? _applySortToList<SearchPlaylist>(
|
||||
searchPlaylists,
|
||||
(p) => p.name,
|
||||
(p) => p.owner,
|
||||
(p) => 0,
|
||||
(p) => null,
|
||||
)
|
||||
: searchPlaylists;
|
||||
|
||||
List<Track> sortedTracks;
|
||||
List<int> sortedTrackIndexes;
|
||||
if (realTracks.isNotEmpty &&
|
||||
_searchSortOption != _SearchSortOption.defaultOrder) {
|
||||
final paired = List.generate(
|
||||
realTracks.length,
|
||||
(i) => (realTracks[i], realTrackIndexes[i]),
|
||||
);
|
||||
final sortedPairs = _applySortToList<(Track, int)>(
|
||||
paired,
|
||||
(p) => p.$1.name,
|
||||
(p) => p.$1.artistName,
|
||||
(p) => p.$1.duration,
|
||||
(p) => p.$1.releaseDate,
|
||||
);
|
||||
sortedTracks = sortedPairs.map((p) => p.$1).toList();
|
||||
sortedTrackIndexes = sortedPairs.map((p) => p.$2).toList();
|
||||
} else {
|
||||
sortedTracks = realTracks;
|
||||
sortedTrackIndexes = realTrackIndexes;
|
||||
}
|
||||
|
||||
final slivers = <Widget>[
|
||||
if (error != null)
|
||||
SliverToBoxAdapter(
|
||||
@@ -2411,24 +2678,28 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
),
|
||||
];
|
||||
|
||||
if (searchArtists != null && searchArtists.isNotEmpty) {
|
||||
bool sortButtonShown = false;
|
||||
|
||||
if (sortedArtists != null && sortedArtists.isNotEmpty) {
|
||||
slivers.addAll(
|
||||
_buildVirtualizedResultSection(
|
||||
title: context.l10n.searchArtists,
|
||||
itemCount: searchArtists.length,
|
||||
itemCount: sortedArtists.length,
|
||||
colorScheme: colorScheme,
|
||||
showSortButton: !sortButtonShown,
|
||||
itemBuilder: (index, showDivider) => _SearchArtistItemWidget(
|
||||
key: ValueKey('search-artist-${searchArtists[index].id}'),
|
||||
artist: searchArtists[index],
|
||||
key: ValueKey('search-artist-${sortedArtists[index].id}'),
|
||||
artist: sortedArtists[index],
|
||||
showDivider: showDivider,
|
||||
onTap: () => _navigateToArtist(
|
||||
searchArtists[index].id,
|
||||
searchArtists[index].name,
|
||||
searchArtists[index].imageUrl,
|
||||
sortedArtists[index].id,
|
||||
sortedArtists[index].name,
|
||||
sortedArtists[index].imageUrl,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
sortButtonShown = true;
|
||||
}
|
||||
|
||||
if (artistItems.isNotEmpty) {
|
||||
@@ -2437,6 +2708,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
title: context.l10n.searchArtists,
|
||||
itemCount: artistItems.length,
|
||||
colorScheme: colorScheme,
|
||||
showSortButton: !sortButtonShown,
|
||||
itemBuilder: (index, showDivider) => _CollectionItemWidget(
|
||||
key: ValueKey('artist-${artistItems[index].id}'),
|
||||
item: artistItems[index],
|
||||
@@ -2445,22 +2717,25 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
),
|
||||
),
|
||||
);
|
||||
sortButtonShown = true;
|
||||
}
|
||||
|
||||
if (searchAlbums != null && searchAlbums.isNotEmpty) {
|
||||
if (sortedAlbums != null && sortedAlbums.isNotEmpty) {
|
||||
slivers.addAll(
|
||||
_buildVirtualizedResultSection(
|
||||
title: context.l10n.searchAlbums,
|
||||
itemCount: searchAlbums.length,
|
||||
itemCount: sortedAlbums.length,
|
||||
colorScheme: colorScheme,
|
||||
showSortButton: !sortButtonShown,
|
||||
itemBuilder: (index, showDivider) => _SearchAlbumItemWidget(
|
||||
key: ValueKey('search-album-${searchAlbums[index].id}'),
|
||||
album: searchAlbums[index],
|
||||
key: ValueKey('search-album-${sortedAlbums[index].id}'),
|
||||
album: sortedAlbums[index],
|
||||
showDivider: showDivider,
|
||||
onTap: () => _navigateToSearchAlbum(searchAlbums[index]),
|
||||
onTap: () => _navigateToSearchAlbum(sortedAlbums[index]),
|
||||
),
|
||||
),
|
||||
);
|
||||
sortButtonShown = true;
|
||||
}
|
||||
|
||||
if (albumItems.isNotEmpty) {
|
||||
@@ -2469,6 +2744,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
title: context.l10n.searchAlbums,
|
||||
itemCount: albumItems.length,
|
||||
colorScheme: colorScheme,
|
||||
showSortButton: !sortButtonShown,
|
||||
itemBuilder: (index, showDivider) => _CollectionItemWidget(
|
||||
key: ValueKey('album-${albumItems[index].id}'),
|
||||
item: albumItems[index],
|
||||
@@ -2477,22 +2753,25 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
),
|
||||
),
|
||||
);
|
||||
sortButtonShown = true;
|
||||
}
|
||||
|
||||
if (searchPlaylists != null && searchPlaylists.isNotEmpty) {
|
||||
if (sortedPlaylists != null && sortedPlaylists.isNotEmpty) {
|
||||
slivers.addAll(
|
||||
_buildVirtualizedResultSection(
|
||||
title: context.l10n.searchPlaylists,
|
||||
itemCount: searchPlaylists.length,
|
||||
itemCount: sortedPlaylists.length,
|
||||
colorScheme: colorScheme,
|
||||
showSortButton: !sortButtonShown,
|
||||
itemBuilder: (index, showDivider) => _SearchPlaylistItemWidget(
|
||||
key: ValueKey('search-playlist-${searchPlaylists[index].id}'),
|
||||
playlist: searchPlaylists[index],
|
||||
key: ValueKey('search-playlist-${sortedPlaylists[index].id}'),
|
||||
playlist: sortedPlaylists[index],
|
||||
showDivider: showDivider,
|
||||
onTap: () => _navigateToSearchPlaylist(searchPlaylists[index]),
|
||||
onTap: () => _navigateToSearchPlaylist(sortedPlaylists[index]),
|
||||
),
|
||||
),
|
||||
);
|
||||
sortButtonShown = true;
|
||||
}
|
||||
|
||||
if (playlistItems.isNotEmpty) {
|
||||
@@ -2501,6 +2780,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
title: context.l10n.searchPlaylists,
|
||||
itemCount: playlistItems.length,
|
||||
colorScheme: colorScheme,
|
||||
showSortButton: !sortButtonShown,
|
||||
itemBuilder: (index, showDivider) => _CollectionItemWidget(
|
||||
key: ValueKey('playlist-${playlistItems[index].id}'),
|
||||
item: playlistItems[index],
|
||||
@@ -2509,20 +2789,22 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
),
|
||||
),
|
||||
);
|
||||
sortButtonShown = true;
|
||||
}
|
||||
|
||||
if (realTracks.isNotEmpty) {
|
||||
if (sortedTracks.isNotEmpty) {
|
||||
slivers.addAll(
|
||||
_buildVirtualizedResultSection(
|
||||
title: context.l10n.searchSongs,
|
||||
itemCount: realTracks.length,
|
||||
itemCount: sortedTracks.length,
|
||||
colorScheme: colorScheme,
|
||||
showSortButton: !sortButtonShown,
|
||||
itemBuilder: (index, showDivider) => _TrackItemWithStatus(
|
||||
key: ValueKey(realTracks[index].id),
|
||||
track: realTracks[index],
|
||||
index: realTrackIndexes[index],
|
||||
key: ValueKey(sortedTracks[index].id),
|
||||
track: sortedTracks[index],
|
||||
index: sortedTrackIndexes[index],
|
||||
showDivider: showDivider,
|
||||
onDownload: () => _downloadTrack(realTrackIndexes[index]),
|
||||
onDownload: () => _downloadTrack(sortedTrackIndexes[index]),
|
||||
searchExtensionId: searchExtensionId,
|
||||
showLocalLibraryIndicator: showLocalLibraryIndicator,
|
||||
thumbnailSizesByExtensionId: thumbnailSizesByExtensionId,
|
||||
@@ -2540,6 +2822,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
required int itemCount,
|
||||
required ColorScheme colorScheme,
|
||||
required Widget Function(int index, bool showDivider) itemBuilder,
|
||||
bool showSortButton = false,
|
||||
}) {
|
||||
final sectionColor = Theme.of(context).brightness == Brightness.dark
|
||||
? Color.alphaBlend(
|
||||
@@ -2551,12 +2834,47 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
return [
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 8, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showSortButton)
|
||||
SizedBox(
|
||||
height: 32,
|
||||
child: TextButton.icon(
|
||||
onPressed: () => _showSortOptions(colorScheme),
|
||||
icon: Icon(
|
||||
Icons.swap_vert,
|
||||
size: 18,
|
||||
color: _searchSortOption != _SearchSortOption.defaultOrder
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
label: Text(
|
||||
_searchSortOption != _SearchSortOption.defaultOrder
|
||||
? _sortOptionLabel(_searchSortOption)
|
||||
: context.l10n.libraryFilterSort,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color:
|
||||
_searchSortOption != _SearchSortOption.defaultOrder
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -2564,19 +2882,22 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final isFirst = index == 0;
|
||||
final isLast = index == itemCount - 1;
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: sectionColor,
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: isFirst ? const Radius.circular(20) : Radius.zero,
|
||||
bottom: isLast ? const Radius.circular(20) : Radius.zero,
|
||||
return StaggeredListItem(
|
||||
index: index,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: sectionColor,
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: isFirst ? const Radius.circular(20) : Radius.zero,
|
||||
bottom: isLast ? const Radius.circular(20) : Radius.zero,
|
||||
),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: itemBuilder(index, !isLast),
|
||||
),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: itemBuilder(index, !isLast),
|
||||
),
|
||||
);
|
||||
}, childCount: itemCount),
|
||||
@@ -2770,6 +3091,13 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
}
|
||||
|
||||
if (searchProvider != null && searchProvider.isNotEmpty) {
|
||||
if (searchProvider == 'tidal') {
|
||||
return 'Search with Tidal...';
|
||||
}
|
||||
if (searchProvider == 'qobuz') {
|
||||
return 'Search with Qobuz...';
|
||||
}
|
||||
|
||||
final ext = extState.extensions
|
||||
.where((e) => e.id == searchProvider)
|
||||
.firstOrNull;
|
||||
@@ -2804,16 +3132,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
_triggerSearchWithFilter(null);
|
||||
},
|
||||
showCheckmark: false,
|
||||
selectedColor: colorScheme.primaryContainer,
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
labelStyle: TextStyle(
|
||||
color: selectedFilter == null
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
fontWeight: selectedFilter == null
|
||||
? FontWeight.w600
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
...filters.map((filter) {
|
||||
@@ -2828,24 +3146,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
_triggerSearchWithFilter(filter.id);
|
||||
},
|
||||
showCheckmark: false,
|
||||
selectedColor: colorScheme.primaryContainer,
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
labelStyle: TextStyle(
|
||||
color: isSelected
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
fontWeight: isSelected
|
||||
? FontWeight.w600
|
||||
: FontWeight.normal,
|
||||
),
|
||||
avatar: filter.icon != null
|
||||
? Icon(
|
||||
_getFilterIcon(filter.icon!),
|
||||
size: 18,
|
||||
color: isSelected
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
)
|
||||
? Icon(_getFilterIcon(filter.icon!), size: 18)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
@@ -2882,7 +3184,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
if (text.isEmpty || text.length < _minLiveSearchChars) return;
|
||||
if (text.startsWith('http') || text.startsWith('spotify:')) return;
|
||||
|
||||
// Reset last search query to force new search
|
||||
_lastSearchQuery = null;
|
||||
_performSearch(text, filterOverride: filter);
|
||||
}
|
||||
@@ -2900,15 +3201,11 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
fillColor: colorScheme.surfaceContainerHighest,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
borderSide: BorderSide(
|
||||
color: colorScheme.outline.withValues(alpha: 0.5),
|
||||
),
|
||||
borderSide: BorderSide(color: colorScheme.outlineVariant),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
borderSide: BorderSide(
|
||||
color: colorScheme.outline.withValues(alpha: 0.5),
|
||||
),
|
||||
borderSide: BorderSide(color: colorScheme.outlineVariant),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
@@ -2956,6 +3253,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
),
|
||||
),
|
||||
onSubmitted: (_) => _onSearchSubmitted(),
|
||||
onTapOutside: (_) {
|
||||
FocusScope.of(context).unfocus();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3004,6 +3304,10 @@ class _SearchProviderDropdown extends ConsumerWidget {
|
||||
.firstOrNull;
|
||||
}
|
||||
|
||||
const builtInProviders = {'tidal', 'qobuz'};
|
||||
final isBuiltInProvider =
|
||||
currentProvider != null && builtInProviders.contains(currentProvider);
|
||||
|
||||
IconData displayIcon = Icons.search;
|
||||
String? iconPath;
|
||||
if (currentExt != null) {
|
||||
@@ -3011,10 +3315,8 @@ class _SearchProviderDropdown extends ConsumerWidget {
|
||||
if (currentExt.searchBehavior?.icon != null) {
|
||||
displayIcon = _getIconFromName(currentExt.searchBehavior!.icon!);
|
||||
}
|
||||
}
|
||||
|
||||
if (searchProviders.isEmpty) {
|
||||
return const Icon(Icons.search);
|
||||
} else if (isBuiltInProvider) {
|
||||
displayIcon = Icons.music_note;
|
||||
}
|
||||
|
||||
return Padding(
|
||||
@@ -3081,6 +3383,60 @@ class _SearchProviderDropdown extends ConsumerWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem<String>(
|
||||
value: 'tidal',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.music_note,
|
||||
size: 20,
|
||||
color: currentProvider == 'tidal'
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Tidal',
|
||||
style: TextStyle(
|
||||
fontWeight: currentProvider == 'tidal'
|
||||
? FontWeight.w600
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (currentProvider == 'tidal')
|
||||
Icon(Icons.check, size: 18, color: colorScheme.primary),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem<String>(
|
||||
value: 'qobuz',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.music_note,
|
||||
size: 20,
|
||||
color: currentProvider == 'qobuz'
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Qobuz',
|
||||
style: TextStyle(
|
||||
fontWeight: currentProvider == 'qobuz'
|
||||
? FontWeight.w600
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (currentProvider == 'qobuz')
|
||||
Icon(Icons.check, size: 18, color: colorScheme.primary),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (searchProviders.isNotEmpty) const PopupMenuDivider(),
|
||||
...searchProviders.map(
|
||||
(ext) => PopupMenuItem<String>(
|
||||
@@ -3876,7 +4232,6 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
|
||||
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
// Extract artist info from album response
|
||||
final artistId = (result['artist_id'] ?? result['artistId'])?.toString();
|
||||
final artistName = result['artists'] as String?;
|
||||
|
||||
@@ -3909,6 +4264,7 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
|
||||
name: (data['name'] ?? '').toString(),
|
||||
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
|
||||
albumName: (data['album_name'] ?? widget.albumName).toString(),
|
||||
albumArtist: (data['album_artist'] ?? _artistName)?.toString(),
|
||||
artistId:
|
||||
(data['artist_id'] ?? data['artistId'])?.toString() ?? _artistId,
|
||||
albumId: data['album_id']?.toString() ?? widget.albumId,
|
||||
@@ -3933,7 +4289,10 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
|
||||
if (_isLoading) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(widget.albumName)),
|
||||
body: const Center(child: CircularProgressIndicator()),
|
||||
body: const AlbumTrackListSkeleton(
|
||||
itemCount: 10,
|
||||
showCoverHeader: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4087,7 +4446,7 @@ class _ExtensionPlaylistScreenState
|
||||
if (_isLoading) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(widget.playlistName)),
|
||||
body: const Center(child: CircularProgressIndicator()),
|
||||
body: const TrackListSkeleton(itemCount: 8, showCoverHeader: true),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4117,6 +4476,7 @@ class _ExtensionPlaylistScreenState
|
||||
playlistName: widget.playlistName,
|
||||
coverUrl: widget.coverUrl,
|
||||
tracks: _tracks!,
|
||||
recommendedService: widget.extensionId,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4216,7 +4576,7 @@ class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen> {
|
||||
artists: (data['artists'] ?? '').toString(),
|
||||
releaseDate: (data['release_date'] ?? '').toString(),
|
||||
totalTracks: data['total_tracks'] as int? ?? 0,
|
||||
coverUrl: data['cover_url']?.toString(),
|
||||
coverUrl: normalizeCoverReference(data['cover_url']?.toString()),
|
||||
albumType: (data['album_type'] ?? 'album').toString(),
|
||||
providerId: (data['provider_id'] ?? widget.extensionId).toString(),
|
||||
);
|
||||
@@ -4241,7 +4601,9 @@ class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen> {
|
||||
(data['artist_id'] ?? data['artistId'])?.toString() ??
|
||||
widget.artistId,
|
||||
albumId: data['album_id']?.toString(),
|
||||
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
|
||||
coverUrl: normalizeCoverReference(
|
||||
(data['cover_url'] ?? data['images'])?.toString(),
|
||||
),
|
||||
isrc: data['isrc']?.toString(),
|
||||
duration: (durationMs / 1000).round(),
|
||||
trackNumber: data['track_number'] as int?,
|
||||
@@ -4256,7 +4618,7 @@ class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen> {
|
||||
if (_isLoading) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(widget.artistName)),
|
||||
body: const Center(child: CircularProgressIndicator()),
|
||||
body: const ArtistScreenSkeleton(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/providers/library_collections_provider.dart';
|
||||
import 'package:spotiflac_android/screens/library_tracks_folder_screen.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/widgets/bottom_sheet_option_tile.dart';
|
||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||
|
||||
class LibraryPlaylistsScreen extends ConsumerWidget {
|
||||
@@ -210,7 +211,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||
),
|
||||
|
||||
_PlaylistOptionTile(
|
||||
BottomSheetOptionTile(
|
||||
icon: Icons.edit_outlined,
|
||||
title: context.l10n.collectionRenamePlaylist,
|
||||
onTap: () {
|
||||
@@ -224,7 +225,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
|
||||
},
|
||||
),
|
||||
|
||||
_PlaylistOptionTile(
|
||||
BottomSheetOptionTile(
|
||||
icon: Icons.image_outlined,
|
||||
title: context.l10n.collectionPlaylistChangeCover,
|
||||
onTap: () {
|
||||
@@ -233,7 +234,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
|
||||
},
|
||||
),
|
||||
|
||||
_PlaylistOptionTile(
|
||||
BottomSheetOptionTile(
|
||||
icon: Icons.delete_outline,
|
||||
iconColor: colorScheme.error,
|
||||
title: context.l10n.collectionDeletePlaylist,
|
||||
@@ -543,40 +544,3 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Styled like _OptionTile in track_collection_quick_actions.dart
|
||||
class _PlaylistOptionTile extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final Color? iconColor;
|
||||
final String title;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _PlaylistOptionTile({
|
||||
required this.icon,
|
||||
this.iconColor,
|
||||
required this.title,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
|
||||
leading: Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
color: iconColor ?? colorScheme.onPrimaryContainer,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,9 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
||||
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||
import 'package:spotiflac_android/widgets/bottom_sheet_option_tile.dart';
|
||||
import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart';
|
||||
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||
|
||||
class LibraryTracksFolderScreen extends ConsumerStatefulWidget {
|
||||
final LibraryTracksFolderMode mode;
|
||||
@@ -39,6 +41,7 @@ class _LibraryTracksFolderScreenState
|
||||
|
||||
bool _isSelectionMode = false;
|
||||
final Set<String> _selectedKeys = {};
|
||||
UserPlaylistCollection? playlist;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -243,7 +246,6 @@ class _LibraryTracksFolderScreenState
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
ref.watch(localLibraryProvider.select((s) => s.items));
|
||||
final localState = ref.read(localLibraryProvider);
|
||||
final UserPlaylistCollection? playlist;
|
||||
final List<CollectionTrackEntry> entries;
|
||||
|
||||
switch (widget.mode) {
|
||||
@@ -272,7 +274,6 @@ class _LibraryTracksFolderScreenState
|
||||
break;
|
||||
}
|
||||
|
||||
// Stale selection cleanup
|
||||
if (_isSelectionMode) {
|
||||
final validKeys = entries.map((e) => e.key).toSet();
|
||||
_selectedKeys.removeWhere((key) => !validKeys.contains(key));
|
||||
@@ -348,20 +349,23 @@ class _LibraryTracksFolderScreenState
|
||||
final isSelected = _selectedKeys.contains(entry.key);
|
||||
return KeyedSubtree(
|
||||
key: ValueKey(entry.key),
|
||||
child: _CollectionTrackTile(
|
||||
entry: entry,
|
||||
mode: widget.mode,
|
||||
playlistId: widget.playlistId,
|
||||
localLibraryState: localState,
|
||||
folderTracks: folderTracks,
|
||||
isSelectionMode: _isSelectionMode,
|
||||
isSelected: isSelected,
|
||||
onTap: _isSelectionMode
|
||||
? () => _toggleSelection(entry.key)
|
||||
: null,
|
||||
onLongPress: _isSelectionMode
|
||||
? null
|
||||
: () => _enterSelectionMode(entry.key),
|
||||
child: StaggeredListItem(
|
||||
index: index,
|
||||
child: _CollectionTrackTile(
|
||||
entry: entry,
|
||||
mode: widget.mode,
|
||||
playlistId: widget.playlistId,
|
||||
localLibraryState: localState,
|
||||
folderTracks: folderTracks,
|
||||
isSelectionMode: _isSelectionMode,
|
||||
isSelected: isSelected,
|
||||
onTap: _isSelectionMode
|
||||
? () => _toggleSelection(entry.key)
|
||||
: null,
|
||||
onLongPress: _isSelectionMode
|
||||
? null
|
||||
: () => _enterSelectionMode(entry.key),
|
||||
),
|
||||
),
|
||||
);
|
||||
}, childCount: entries.length),
|
||||
@@ -372,7 +376,6 @@ class _LibraryTracksFolderScreenState
|
||||
],
|
||||
),
|
||||
|
||||
// Selection bottom bar
|
||||
AnimatedPositioned(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeOutCubic,
|
||||
@@ -872,11 +875,54 @@ class _LibraryTracksFolderScreenState
|
||||
|
||||
void _downloadAll(List<Track> tracks) {
|
||||
if (tracks.isEmpty) return;
|
||||
final historyState = ref.read(downloadHistoryProvider);
|
||||
final settings = ref.read(settingsProvider);
|
||||
final localLibState =
|
||||
(settings.localLibraryEnabled && settings.localLibraryShowDuplicates)
|
||||
? ref.read(localLibraryProvider)
|
||||
: null;
|
||||
final playlistName = widget.mode == LibraryTracksFolderMode.playlist
|
||||
? playlist?.name ?? context.l10n.collectionPlaylist
|
||||
: null;
|
||||
final tracksToQueue = <Track>[];
|
||||
var skippedCount = 0;
|
||||
|
||||
for (final track in tracks) {
|
||||
final isInHistory =
|
||||
historyState.isDownloaded(track.id) ||
|
||||
(track.isrc != null && historyState.getByIsrc(track.isrc!) != null) ||
|
||||
historyState.findByTrackAndArtist(track.name, track.artistName) !=
|
||||
null;
|
||||
final isInLocal =
|
||||
localLibState?.existsInLibrary(
|
||||
isrc: track.isrc,
|
||||
trackName: track.name,
|
||||
artistName: track.artistName,
|
||||
) ??
|
||||
false;
|
||||
|
||||
if (isInHistory || isInLocal) {
|
||||
skippedCount++;
|
||||
} else {
|
||||
tracksToQueue.add(track);
|
||||
}
|
||||
}
|
||||
|
||||
if (tracksToQueue.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.l10n.discographySkippedDownloaded(0, skippedCount),
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (settings.askQualityBeforeDownload) {
|
||||
DownloadServicePicker.show(
|
||||
context,
|
||||
trackName: '${tracks.length} tracks',
|
||||
trackName: '${tracksToQueue.length} tracks',
|
||||
artistName: switch (widget.mode) {
|
||||
LibraryTracksFolderMode.wishlist => context.l10n.collectionWishlist,
|
||||
LibraryTracksFolderMode.loved => context.l10n.collectionLoved,
|
||||
@@ -885,12 +931,24 @@ class _LibraryTracksFolderScreenState
|
||||
onSelect: (quality, service) {
|
||||
ref
|
||||
.read(downloadQueueProvider.notifier)
|
||||
.addMultipleToQueue(tracks, service, qualityOverride: quality);
|
||||
.addMultipleToQueue(
|
||||
tracksToQueue,
|
||||
service,
|
||||
qualityOverride: quality,
|
||||
playlistName: playlistName,
|
||||
);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.l10n.snackbarAddedTracksToQueue(tracks.length),
|
||||
skippedCount > 0
|
||||
? context.l10n.discographySkippedDownloaded(
|
||||
tracksToQueue.length,
|
||||
skippedCount,
|
||||
)
|
||||
: context.l10n.snackbarAddedTracksToQueue(
|
||||
tracksToQueue.length,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -899,10 +957,21 @@ class _LibraryTracksFolderScreenState
|
||||
} else {
|
||||
ref
|
||||
.read(downloadQueueProvider.notifier)
|
||||
.addMultipleToQueue(tracks, settings.defaultService);
|
||||
.addMultipleToQueue(
|
||||
tracksToQueue,
|
||||
settings.defaultService,
|
||||
playlistName: playlistName,
|
||||
);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)),
|
||||
content: Text(
|
||||
skippedCount > 0
|
||||
? context.l10n.discographySkippedDownloaded(
|
||||
tracksToQueue.length,
|
||||
skippedCount,
|
||||
)
|
||||
: context.l10n.snackbarAddedTracksToQueue(tracksToQueue.length),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1015,14 +1084,19 @@ class _CollectionTrackTile extends ConsumerWidget {
|
||||
final track = entry.track;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final effectiveCoverUrl = _resolveCoverUrl(track);
|
||||
final isInHistory = ref.watch(
|
||||
|
||||
// Fine-grained provider watches – only this tile rebuilds when its own
|
||||
// history / local-library entry changes.
|
||||
final historyItem = ref.watch(
|
||||
downloadHistoryProvider.select((state) {
|
||||
if (state.isDownloaded(track.id)) return true;
|
||||
final byId = state.getBySpotifyId(track.id);
|
||||
if (byId != null) return byId;
|
||||
final isrc = track.isrc?.trim();
|
||||
if (isrc != null && isrc.isNotEmpty && state.getByIsrc(isrc) != null) {
|
||||
return true;
|
||||
if (isrc != null && isrc.isNotEmpty) {
|
||||
final byIsrc = state.getByIsrc(isrc);
|
||||
if (byIsrc != null) return byIsrc;
|
||||
}
|
||||
return state.findByTrackAndArtist(track.name, track.artistName) != null;
|
||||
return state.findByTrackAndArtist(track.name, track.artistName);
|
||||
}),
|
||||
);
|
||||
final showLocalLibraryIndicator = ref.watch(
|
||||
@@ -1030,17 +1104,26 @@ class _CollectionTrackTile extends ConsumerWidget {
|
||||
(s) => s.localLibraryEnabled && s.localLibraryShowDuplicates,
|
||||
),
|
||||
);
|
||||
final isInLocalLibrary = showLocalLibraryIndicator
|
||||
final localItem = showLocalLibraryIndicator
|
||||
? ref.watch(
|
||||
localLibraryProvider.select(
|
||||
(state) => state.existsInLibrary(
|
||||
isrc: track.isrc,
|
||||
trackName: track.name,
|
||||
artistName: track.artistName,
|
||||
),
|
||||
),
|
||||
localLibraryProvider.select((state) {
|
||||
final isrc = track.isrc?.trim();
|
||||
if (isrc != null && isrc.isNotEmpty) {
|
||||
final byIsrc = state.getByIsrc(isrc);
|
||||
if (byIsrc != null) return byIsrc;
|
||||
}
|
||||
return state.findByTrackAndArtist(track.name, track.artistName);
|
||||
}),
|
||||
)
|
||||
: false;
|
||||
: null;
|
||||
|
||||
final isInHistory = historyItem != null;
|
||||
final isInLocalLibrary = localItem != null;
|
||||
final heroTag = historyItem != null
|
||||
? 'cover_${historyItem.id}'
|
||||
: localItem != null
|
||||
? 'cover_lib_${localItem.id}'
|
||||
: null;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
@@ -1058,43 +1141,51 @@ class _CollectionTrackTile extends ConsumerWidget {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (isSelectionMode) ...[
|
||||
Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? colorScheme.primary
|
||||
: Colors.transparent,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? colorScheme.primary
|
||||
: colorScheme.outline,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: isSelected
|
||||
? Icon(
|
||||
Icons.check,
|
||||
color: colorScheme.onPrimary,
|
||||
size: 16,
|
||||
)
|
||||
: null,
|
||||
AnimatedSelectionCheckbox(
|
||||
visible: true,
|
||||
selected: isSelected,
|
||||
colorScheme: colorScheme,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: effectiveCoverUrl != null && effectiveCoverUrl.isNotEmpty
|
||||
? _buildTrackCover(context, effectiveCoverUrl, 52)
|
||||
: Container(
|
||||
width: 52,
|
||||
height: 52,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Icons.music_note,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
HeroMode(
|
||||
enabled: heroTag != null,
|
||||
child: heroTag != null
|
||||
? Hero(
|
||||
tag: heroTag,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child:
|
||||
effectiveCoverUrl != null &&
|
||||
effectiveCoverUrl.isNotEmpty
|
||||
? _buildTrackCover(context, effectiveCoverUrl, 52)
|
||||
: Container(
|
||||
width: 52,
|
||||
height: 52,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Icons.music_note,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child:
|
||||
effectiveCoverUrl != null &&
|
||||
effectiveCoverUrl.isNotEmpty
|
||||
? _buildTrackCover(context, effectiveCoverUrl, 52)
|
||||
: Container(
|
||||
width: 52,
|
||||
height: 52,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Icons.music_note,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -1324,9 +1415,8 @@ class _CollectionTrackTile extends ConsumerWidget {
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||
),
|
||||
|
||||
// Add to playlist (hidden in wishlist unless already downloaded)
|
||||
if (showAddToPlaylist)
|
||||
_CollectionOptionTile(
|
||||
BottomSheetOptionTile(
|
||||
icon: Icons.playlist_add,
|
||||
title: context.l10n.collectionAddToPlaylist,
|
||||
onTap: () {
|
||||
@@ -1335,8 +1425,7 @@ class _CollectionTrackTile extends ConsumerWidget {
|
||||
},
|
||||
),
|
||||
|
||||
// Remove from folder / playlist
|
||||
_CollectionOptionTile(
|
||||
BottomSheetOptionTile(
|
||||
icon: Icons.remove_circle_outline,
|
||||
iconColor: colorScheme.error,
|
||||
title: mode == LibraryTracksFolderMode.playlist
|
||||
@@ -1434,16 +1523,9 @@ class _CollectionTrackTile extends ConsumerWidget {
|
||||
);
|
||||
|
||||
if (historyItem != null) {
|
||||
await Navigator.of(context).push(
|
||||
PageRouteBuilder(
|
||||
transitionDuration: const Duration(milliseconds: 300),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 250),
|
||||
pageBuilder: (context, animation, secondaryAnimation) =>
|
||||
TrackMetadataScreen(item: historyItem),
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
|
||||
FadeTransition(opacity: animation, child: child),
|
||||
),
|
||||
);
|
||||
await Navigator.of(
|
||||
context,
|
||||
).push(slidePageRoute(page: TrackMetadataScreen(item: historyItem)));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1458,16 +1540,9 @@ class _CollectionTrackTile extends ConsumerWidget {
|
||||
localItem ??= localState.findByTrackAndArtist(track.name, track.artistName);
|
||||
|
||||
if (localItem != null) {
|
||||
await Navigator.of(context).push(
|
||||
PageRouteBuilder(
|
||||
transitionDuration: const Duration(milliseconds: 300),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 250),
|
||||
pageBuilder: (context, animation, secondaryAnimation) =>
|
||||
TrackMetadataScreen(localItem: localItem),
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
|
||||
FadeTransition(opacity: animation, child: child),
|
||||
),
|
||||
);
|
||||
await Navigator.of(
|
||||
context,
|
||||
).push(slidePageRoute(page: TrackMetadataScreen(localItem: localItem)));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1476,43 +1551,6 @@ class _CollectionTrackTile extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Styled like _OptionTile in track_collection_quick_actions.dart
|
||||
class _CollectionOptionTile extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final Color? iconColor;
|
||||
final String title;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _CollectionOptionTile({
|
||||
required this.icon,
|
||||
this.iconColor,
|
||||
required this.title,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
|
||||
leading: Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
color: iconColor ?? colorScheme.onPrimaryContainer,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SelectionActionButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
|
||||
@@ -4,14 +4,19 @@ import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
|
||||
import 'package:spotiflac_android/services/library_database.dart';
|
||||
import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
||||
import 'package:spotiflac_android/services/local_track_redownload_service.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||
import 'package:spotiflac_android/providers/playback_provider.dart';
|
||||
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||
|
||||
class LocalAlbumScreen extends ConsumerStatefulWidget {
|
||||
final String albumName;
|
||||
@@ -41,11 +46,10 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
|
||||
void _showCueVirtualTrackSnackBar() {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(cueVirtualTrackRequiresSplitMessage),
|
||||
),
|
||||
const SnackBar(content: Text(cueVirtualTrackRequiresSplitMessage)),
|
||||
);
|
||||
}
|
||||
|
||||
late List<int> _sortedDiscNumbersCache;
|
||||
late bool _hasMultipleDiscsCache;
|
||||
String? _commonQualityCache;
|
||||
@@ -528,7 +532,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
if (tracks.isEmpty) return null;
|
||||
final first = tracks.first;
|
||||
|
||||
// For lossy formats, use bitrate
|
||||
if (first.bitrate != null && first.bitrate! > 0) {
|
||||
final fmt = first.format?.toUpperCase() ?? '';
|
||||
final firstBitrate = first.bitrate;
|
||||
@@ -540,7 +543,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
return '$fmt ${firstBitrate}kbps'.trim();
|
||||
}
|
||||
|
||||
// For lossless formats, use bit depth / sample rate
|
||||
if (first.bitDepth == null ||
|
||||
first.bitDepth == 0 ||
|
||||
first.sampleRate == null) {
|
||||
@@ -623,11 +625,16 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
|
||||
slivers.add(
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) =>
|
||||
_buildTrackItem(context, colorScheme, discTracks[index]),
|
||||
childCount: discTracks.length,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final track = discTracks[index];
|
||||
return KeyedSubtree(
|
||||
key: ValueKey(track.id),
|
||||
child: StaggeredListItem(
|
||||
index: index,
|
||||
child: _buildTrackItem(context, colorScheme, track),
|
||||
),
|
||||
);
|
||||
}, childCount: discTracks.length),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -664,28 +671,11 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (_isSelectionMode) ...[
|
||||
Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? colorScheme.primary
|
||||
: Colors.transparent,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? colorScheme.primary
|
||||
: colorScheme.outline,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: isSelected
|
||||
? Icon(
|
||||
Icons.check,
|
||||
color: colorScheme.onPrimary,
|
||||
size: 16,
|
||||
)
|
||||
: null,
|
||||
AnimatedSelectionCheckbox(
|
||||
visible: true,
|
||||
selected: isSelected,
|
||||
colorScheme: colorScheme,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
@@ -815,6 +805,11 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
final format = item.format?.toLowerCase();
|
||||
final lowerPath = item.filePath.toLowerCase();
|
||||
final isMp3 = format == 'mp3' || lowerPath.endsWith('.mp3');
|
||||
final isM4A =
|
||||
format == 'm4a' ||
|
||||
format == 'aac' ||
|
||||
lowerPath.endsWith('.m4a') ||
|
||||
lowerPath.endsWith('.aac');
|
||||
final isOpus =
|
||||
format == 'opus' ||
|
||||
format == 'ogg' ||
|
||||
@@ -828,6 +823,12 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
coverPath: effectiveCoverPath,
|
||||
metadata: metadata,
|
||||
);
|
||||
} else if (isM4A) {
|
||||
ffmpegResult = await FFmpegService.embedMetadataToM4a(
|
||||
m4aPath: ffmpegTarget,
|
||||
coverPath: effectiveCoverPath,
|
||||
metadata: metadata,
|
||||
);
|
||||
} else if (isOpus) {
|
||||
ffmpegResult = await FFmpegService.embedMetadataToOpus(
|
||||
opusPath: ffmpegTarget,
|
||||
@@ -897,6 +898,128 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
return false;
|
||||
}
|
||||
|
||||
List<LocalLibraryItem> _selectedFlacEligibleItems(
|
||||
List<LocalLibraryItem> allTracks,
|
||||
) {
|
||||
final tracksById = {for (final t in allTracks) t.id: t};
|
||||
return _selectedIds
|
||||
.map((id) => tracksById[id])
|
||||
.whereType<LocalLibraryItem>()
|
||||
.where(LocalTrackRedownloadService.isFlacUpgradeEligible)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Future<void> _queueSelectedAsFlac(List<LocalLibraryItem> allTracks) async {
|
||||
final selected = _selectedFlacEligibleItems(allTracks);
|
||||
|
||||
if (selected.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(context.l10n.queueFlacAction),
|
||||
content: Text(context.l10n.queueFlacConfirmMessage(selected.length)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: Text(context.l10n.queueFlacAction),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed != true || !mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final settings = ref.read(settingsProvider);
|
||||
final extensionState = ref.read(extensionProvider);
|
||||
final includeExtensions =
|
||||
settings.useExtensionProviders &&
|
||||
extensionState.extensions.any(
|
||||
(ext) => ext.enabled && ext.hasMetadataProvider,
|
||||
);
|
||||
final targetService = LocalTrackRedownloadService.preferredFlacService(
|
||||
settings,
|
||||
);
|
||||
final targetQuality =
|
||||
LocalTrackRedownloadService.preferredFlacQualityForService(
|
||||
targetService,
|
||||
);
|
||||
|
||||
final matchedTracks = <Track>[];
|
||||
var skippedCount = 0;
|
||||
final total = selected.length;
|
||||
|
||||
for (var i = 0; i < total; i++) {
|
||||
if (!mounted) break;
|
||||
|
||||
ScaffoldMessenger.of(context).clearSnackBars();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.queueFlacFindingProgress(i + 1, total)),
|
||||
duration: const Duration(seconds: 30),
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
final resolution = await LocalTrackRedownloadService.resolveBestMatch(
|
||||
selected[i],
|
||||
includeExtensions: includeExtensions,
|
||||
);
|
||||
if (resolution.canQueue && resolution.match != null) {
|
||||
matchedTracks.add(resolution.match!);
|
||||
} else {
|
||||
skippedCount++;
|
||||
}
|
||||
} catch (_) {
|
||||
skippedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
ScaffoldMessenger.of(context).clearSnackBars();
|
||||
|
||||
if (matchedTracks.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.queueFlacNoReliableMatches)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
ref
|
||||
.read(downloadQueueProvider.notifier)
|
||||
.addMultipleToQueue(
|
||||
matchedTracks,
|
||||
targetService,
|
||||
qualityOverride: targetQuality,
|
||||
);
|
||||
|
||||
final summary = skippedCount == 0
|
||||
? context.l10n.snackbarAddedTracksToQueue(matchedTracks.length)
|
||||
: context.l10n.queueFlacQueuedWithSkipped(
|
||||
matchedTracks.length,
|
||||
skippedCount,
|
||||
);
|
||||
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(summary)));
|
||||
setState(() {
|
||||
_selectedIds.clear();
|
||||
_isSelectionMode = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _reEnrichSelected(List<LocalLibraryItem> allTracks) async {
|
||||
final tracksById = {for (final t in allTracks) t.id: t};
|
||||
final selected = <LocalLibraryItem>[];
|
||||
@@ -1005,8 +1128,57 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
BuildContext context,
|
||||
List<LocalLibraryItem> allTracks,
|
||||
) {
|
||||
String selectedFormat = 'MP3';
|
||||
String selectedBitrate = '320k';
|
||||
final tracksById = {for (final t in allTracks) t.id: t};
|
||||
final sourceFormats = <String>{};
|
||||
for (final id in _selectedIds) {
|
||||
final item = tracksById[id];
|
||||
if (item == null) continue;
|
||||
String? ext;
|
||||
if (item.format != null && item.format!.isNotEmpty) {
|
||||
final fmt = item.format!.toLowerCase();
|
||||
if (fmt == 'flac') {
|
||||
ext = 'FLAC';
|
||||
} else if (fmt == 'm4a') {
|
||||
ext = 'M4A';
|
||||
} else if (fmt == 'mp3') {
|
||||
ext = 'MP3';
|
||||
} else if (fmt == 'opus' || fmt == 'ogg') {
|
||||
ext = 'Opus';
|
||||
}
|
||||
}
|
||||
if (ext == null) {
|
||||
final lower = item.filePath.toLowerCase();
|
||||
if (lower.endsWith('.flac')) {
|
||||
ext = 'FLAC';
|
||||
} else if (lower.endsWith('.m4a')) {
|
||||
ext = 'M4A';
|
||||
} else if (lower.endsWith('.mp3')) {
|
||||
ext = 'MP3';
|
||||
} else if (lower.endsWith('.opus') || lower.endsWith('.ogg')) {
|
||||
ext = 'Opus';
|
||||
}
|
||||
}
|
||||
if (ext != null) sourceFormats.add(ext);
|
||||
}
|
||||
|
||||
final formats = ['ALAC', 'FLAC', 'MP3', 'Opus'].where((target) {
|
||||
return sourceFormats.any((src) {
|
||||
if (src == target) return false;
|
||||
final isLosslessTarget = target == 'ALAC' || target == 'FLAC';
|
||||
final isLosslessSource = src == 'FLAC' || src == 'M4A';
|
||||
if (isLosslessTarget && !isLosslessSource) return false;
|
||||
return true;
|
||||
});
|
||||
}).toList();
|
||||
|
||||
if (formats.isEmpty) return;
|
||||
|
||||
String selectedFormat = formats.first;
|
||||
bool isLosslessTarget =
|
||||
selectedFormat == 'ALAC' || selectedFormat == 'FLAC';
|
||||
String selectedBitrate = isLosslessTarget
|
||||
? '320k'
|
||||
: (selectedFormat == 'Opus' ? '128k' : '320k');
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
@@ -1018,7 +1190,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setSheetState) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final formats = ['MP3', 'Opus'];
|
||||
final bitrates = ['128k', '192k', '256k', '320k'];
|
||||
|
||||
return SafeArea(
|
||||
@@ -1055,51 +1226,73 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: formats.map((format) {
|
||||
final isSelected = format == selectedFormat;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ChoiceChip(
|
||||
label: Text(format),
|
||||
selected: isSelected,
|
||||
onSelected: (selected) {
|
||||
if (selected) {
|
||||
setSheetState(() {
|
||||
selectedFormat = format;
|
||||
selectedBitrate = format == 'Opus'
|
||||
? '128k'
|
||||
: '320k';
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
context.l10n.trackConvertBitrate,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: bitrates.map((br) {
|
||||
final isSelected = br == selectedBitrate;
|
||||
children: formats.map((format) {
|
||||
final isSelected = format == selectedFormat;
|
||||
return ChoiceChip(
|
||||
label: Text(br),
|
||||
label: Text(format),
|
||||
selected: isSelected,
|
||||
onSelected: (selected) {
|
||||
if (selected) {
|
||||
setSheetState(() => selectedBitrate = br);
|
||||
setSheetState(() {
|
||||
selectedFormat = format;
|
||||
isLosslessTarget =
|
||||
format == 'ALAC' || format == 'FLAC';
|
||||
if (!isLosslessTarget) {
|
||||
selectedBitrate = format == 'Opus'
|
||||
? '128k'
|
||||
: '320k';
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
if (!isLosslessTarget) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
context.l10n.trackConvertBitrate,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: bitrates.map((br) {
|
||||
final isSelected = br == selectedBitrate;
|
||||
return ChoiceChip(
|
||||
label: Text(br),
|
||||
selected: isSelected,
|
||||
onSelected: (selected) {
|
||||
if (selected) {
|
||||
setSheetState(() => selectedBitrate = br);
|
||||
}
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
if (isLosslessTarget) ...[
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.verified,
|
||||
size: 16,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
context.l10n.trackConvertLosslessHint,
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(color: colorScheme.primary),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
@@ -1152,6 +1345,8 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
final fmt = item.format!.toLowerCase();
|
||||
if (fmt == 'flac') {
|
||||
currentFormat = 'FLAC';
|
||||
} else if (fmt == 'm4a') {
|
||||
currentFormat = 'M4A';
|
||||
} else if (fmt == 'mp3') {
|
||||
currentFormat = 'MP3';
|
||||
} else if (fmt == 'opus' || fmt == 'ogg') {
|
||||
@@ -1163,15 +1358,20 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
final lower = item.filePath.toLowerCase();
|
||||
if (lower.endsWith('.flac')) {
|
||||
currentFormat = 'FLAC';
|
||||
} else if (lower.endsWith('.m4a')) {
|
||||
currentFormat = 'M4A';
|
||||
} else if (lower.endsWith('.mp3')) {
|
||||
currentFormat = 'MP3';
|
||||
} else if (lower.endsWith('.opus') || lower.endsWith('.ogg')) {
|
||||
currentFormat = 'Opus';
|
||||
}
|
||||
}
|
||||
if (currentFormat != null && currentFormat != targetFormat) {
|
||||
selected.add(item);
|
||||
}
|
||||
if (currentFormat == null || currentFormat == targetFormat) continue;
|
||||
final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC';
|
||||
final isLosslessSource =
|
||||
currentFormat == 'FLAC' || currentFormat == 'M4A';
|
||||
if (isLosslessTarget && !isLosslessSource) continue;
|
||||
selected.add(item);
|
||||
}
|
||||
|
||||
if (selected.isEmpty) {
|
||||
@@ -1183,16 +1383,22 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
final isLossless = targetFormat == 'ALAC' || targetFormat == 'FLAC';
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(context.l10n.selectionBatchConvertConfirmTitle),
|
||||
content: Text(
|
||||
context.l10n.selectionBatchConvertConfirmMessage(
|
||||
selected.length,
|
||||
targetFormat,
|
||||
bitrate,
|
||||
),
|
||||
isLossless
|
||||
? context.l10n.selectionBatchConvertConfirmMessageLossless(
|
||||
selected.length,
|
||||
targetFormat,
|
||||
)
|
||||
: context.l10n.selectionBatchConvertConfirmMessage(
|
||||
selected.length,
|
||||
targetFormat,
|
||||
bitrate,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
@@ -1239,12 +1445,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
try {
|
||||
final result = await PlatformBridge.readFileMetadata(item.filePath);
|
||||
if (result['error'] == null) {
|
||||
result.forEach((key, value) {
|
||||
if (key == 'error' || value == null) return;
|
||||
final v = value.toString().trim();
|
||||
if (v.isEmpty) return;
|
||||
metadata[key.toUpperCase()] = v;
|
||||
});
|
||||
mergePlatformMetadataForTagEmbed(target: metadata, source: result);
|
||||
}
|
||||
} catch (_) {}
|
||||
await ensureLyricsMetadataForConversion(
|
||||
@@ -1286,7 +1487,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
bitrate: bitrate,
|
||||
metadata: metadata,
|
||||
coverPath: coverPath,
|
||||
deleteOriginal: !isSaf, // Only delete original for regular files
|
||||
deleteOriginal: !isSaf,
|
||||
);
|
||||
|
||||
if (coverPath != null) {
|
||||
@@ -1305,15 +1506,9 @@ 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
|
||||
final uri = Uri.parse(item.filePath);
|
||||
final pathSegments = uri.pathSegments;
|
||||
|
||||
// Try to find 'tree' and 'document' segments
|
||||
String? treeUri;
|
||||
String relativeDir = '';
|
||||
String oldFileName = '';
|
||||
@@ -1357,13 +1552,27 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
final baseName = dotIdx > 0
|
||||
? oldFileName.substring(0, dotIdx)
|
||||
: oldFileName;
|
||||
final newExt = targetFormat.toLowerCase() == 'opus'
|
||||
? '.opus'
|
||||
: '.mp3';
|
||||
String newExt;
|
||||
String mimeType;
|
||||
switch (targetFormat.toLowerCase()) {
|
||||
case 'opus':
|
||||
newExt = '.opus';
|
||||
mimeType = 'audio/opus';
|
||||
break;
|
||||
case 'alac':
|
||||
newExt = '.m4a';
|
||||
mimeType = 'audio/mp4';
|
||||
break;
|
||||
case 'flac':
|
||||
newExt = '.flac';
|
||||
mimeType = 'audio/flac';
|
||||
break;
|
||||
default:
|
||||
newExt = '.mp3';
|
||||
mimeType = 'audio/mpeg';
|
||||
break;
|
||||
}
|
||||
final newFileName = '$baseName$newExt';
|
||||
final mimeType = targetFormat.toLowerCase() == 'opus'
|
||||
? 'audio/opus'
|
||||
: 'audio/mpeg';
|
||||
|
||||
final safUri = await PlatformBridge.createSafFileFromPath(
|
||||
treeUri: treeUri,
|
||||
@@ -1434,6 +1643,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
double bottomPadding,
|
||||
) {
|
||||
final selectedCount = _selectedIds.length;
|
||||
final flacEligibleCount = _selectedFlacEligibleItems(tracks).length;
|
||||
final allSelected = selectedCount == tracks.length && tracks.isNotEmpty;
|
||||
|
||||
return Container(
|
||||
@@ -1525,6 +1735,18 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
|
||||
Row(
|
||||
children: [
|
||||
if (flacEligibleCount > 0) ...[
|
||||
Expanded(
|
||||
child: _LocalAlbumSelectionActionButton(
|
||||
icon: Icons.download_for_offline_outlined,
|
||||
label:
|
||||
'${context.l10n.queueFlacAction} ($flacEligibleCount)',
|
||||
onPressed: () => _queueSelectedAsFlac(tracks),
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Expanded(
|
||||
child: _LocalAlbumSelectionActionButton(
|
||||
icon: Icons.auto_fix_high_outlined,
|
||||
|
||||
+71
-38
@@ -20,6 +20,7 @@ import 'package:spotiflac_android/services/shell_navigation_service.dart';
|
||||
import 'package:spotiflac_android/services/share_intent_service.dart';
|
||||
import 'package:spotiflac_android/services/update_checker.dart';
|
||||
import 'package:spotiflac_android/widgets/update_dialog.dart';
|
||||
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
final _log = AppLogger('MainShell');
|
||||
@@ -31,9 +32,11 @@ class MainShell extends ConsumerStatefulWidget {
|
||||
ConsumerState<MainShell> createState() => _MainShellState();
|
||||
}
|
||||
|
||||
class _MainShellState extends ConsumerState<MainShell> {
|
||||
class _MainShellState extends ConsumerState<MainShell>
|
||||
with SingleTickerProviderStateMixin {
|
||||
int _currentIndex = 0;
|
||||
late final PageController _pageController;
|
||||
late final AnimationController _tabJumpTransitionController;
|
||||
bool _hasCheckedUpdate = false;
|
||||
StreamSubscription<String>? _shareSubscription;
|
||||
DateTime? _lastBackPress;
|
||||
@@ -48,6 +51,11 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_pageController = PageController(initialPage: _currentIndex);
|
||||
_tabJumpTransitionController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 180),
|
||||
value: 1,
|
||||
);
|
||||
ShellNavigationService.syncState(
|
||||
currentTabIndex: _currentIndex,
|
||||
showStoreTab: false,
|
||||
@@ -83,7 +91,6 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
final extState = ref.read(extensionProvider);
|
||||
if (!extState.isInitialized) {
|
||||
_log.d('Waiting for extensions to initialize before handling URL...');
|
||||
// Wait up to 5 seconds for extensions to initialize
|
||||
for (int i = 0; i < 50; i++) {
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
if (!mounted) return;
|
||||
@@ -155,7 +162,6 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
if (!Platform.isAndroid) return;
|
||||
|
||||
final settings = ref.read(settingsProvider);
|
||||
// Only show if user is still on legacy storage mode with a download dir set
|
||||
if (settings.storageMode == 'saf') return;
|
||||
if (settings.downloadDirectory.isEmpty) return;
|
||||
|
||||
@@ -230,6 +236,7 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
void dispose() {
|
||||
_shareSubscription?.cancel();
|
||||
_pageController.dispose();
|
||||
_tabJumpTransitionController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -252,7 +259,8 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
}
|
||||
|
||||
if (_currentIndex != index) {
|
||||
final shouldResetHome = index == 0;
|
||||
final previousIndex = _currentIndex;
|
||||
final isNonAdjacentJump = (previousIndex - index).abs() > 1;
|
||||
HapticFeedback.selectionClick();
|
||||
setState(() => _currentIndex = index);
|
||||
final showStore = ref.read(
|
||||
@@ -263,19 +271,23 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
showStoreTab: showStore,
|
||||
);
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
if (shouldResetHome) {
|
||||
_resetHomeToMain();
|
||||
// Jump directly when skipping intermediate tabs to avoid
|
||||
// sliding through them. For those jumps, keep a short fade-in
|
||||
// so the transition still feels intentional.
|
||||
if (isNonAdjacentJump) {
|
||||
_pageController.jumpToPage(index);
|
||||
_tabJumpTransitionController.forward(from: 0);
|
||||
} else {
|
||||
_pageController.animateToPage(
|
||||
index,
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
}
|
||||
_pageController.animateToPage(
|
||||
index,
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _onPageChanged(int index) {
|
||||
final previousIndex = _currentIndex;
|
||||
if (_currentIndex != index) {
|
||||
setState(() => _currentIndex = index);
|
||||
final showStore = ref.read(
|
||||
@@ -286,9 +298,6 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
showStoreTab: showStore,
|
||||
);
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
if (index == 0 && previousIndex != 0) {
|
||||
_resetHomeToMain();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -452,32 +461,44 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
label: l10n.navHome,
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Badge(
|
||||
isLabelVisible: queueState > 0,
|
||||
label: Text('$queueState'),
|
||||
child: const Icon(Icons.library_music_outlined),
|
||||
),
|
||||
selectedIcon: SlidingIcon(
|
||||
icon: AnimatedBadge(
|
||||
count: queueState,
|
||||
child: Badge(
|
||||
isLabelVisible: queueState > 0,
|
||||
label: Text('$queueState'),
|
||||
child: const Icon(Icons.library_music),
|
||||
child: const Icon(Icons.library_music_outlined),
|
||||
),
|
||||
),
|
||||
selectedIcon: SlidingIcon(
|
||||
child: AnimatedBadge(
|
||||
count: queueState,
|
||||
child: Badge(
|
||||
isLabelVisible: queueState > 0,
|
||||
label: Text('$queueState'),
|
||||
child: const Icon(Icons.library_music),
|
||||
),
|
||||
),
|
||||
),
|
||||
label: l10n.navLibrary,
|
||||
),
|
||||
if (showStore)
|
||||
NavigationDestination(
|
||||
icon: Badge(
|
||||
isLabelVisible: storeUpdatesCount > 0,
|
||||
label: Text('$storeUpdatesCount'),
|
||||
child: const Icon(Icons.store_outlined),
|
||||
),
|
||||
selectedIcon: SwingIcon(
|
||||
icon: AnimatedBadge(
|
||||
count: storeUpdatesCount,
|
||||
child: Badge(
|
||||
isLabelVisible: storeUpdatesCount > 0,
|
||||
label: Text('$storeUpdatesCount'),
|
||||
child: const Icon(Icons.store),
|
||||
child: const Icon(Icons.store_outlined),
|
||||
),
|
||||
),
|
||||
selectedIcon: SwingIcon(
|
||||
child: AnimatedBadge(
|
||||
count: storeUpdatesCount,
|
||||
child: Badge(
|
||||
isLabelVisible: storeUpdatesCount > 0,
|
||||
label: Text('$storeUpdatesCount'),
|
||||
child: const Icon(Icons.store),
|
||||
),
|
||||
),
|
||||
),
|
||||
label: l10n.navStore,
|
||||
@@ -505,15 +526,27 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
return true;
|
||||
},
|
||||
child: Scaffold(
|
||||
body: PageView.builder(
|
||||
controller: _pageController,
|
||||
itemCount: tabs.length,
|
||||
onPageChanged: _onPageChanged,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemBuilder: (context, index) => _KeepAliveTabPage(
|
||||
key: ValueKey('page-$index'),
|
||||
child: tabs[index],
|
||||
body: AnimatedBuilder(
|
||||
animation: _tabJumpTransitionController,
|
||||
child: PageView.builder(
|
||||
controller: _pageController,
|
||||
itemCount: tabs.length,
|
||||
onPageChanged: _onPageChanged,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemBuilder: (context, index) => _KeepAliveTabPage(
|
||||
key: ValueKey('page-$index'),
|
||||
child: tabs[index],
|
||||
),
|
||||
),
|
||||
builder: (context, child) {
|
||||
final t = Curves.easeOutCubic.transform(
|
||||
_tabJumpTransitionController.value,
|
||||
);
|
||||
return Opacity(
|
||||
opacity: t,
|
||||
child: Transform.scale(scale: 0.985 + (0.015 * t), child: child),
|
||||
);
|
||||
},
|
||||
),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: _currentIndex.clamp(0, maxIndex),
|
||||
@@ -708,7 +741,7 @@ class _SwingIconState extends State<SwingIcon>
|
||||
TweenSequenceItem(tween: Tween(begin: 0.15, end: -0.1), weight: 20),
|
||||
TweenSequenceItem(tween: Tween(begin: -0.1, end: 0.05), weight: 20),
|
||||
TweenSequenceItem(tween: Tween(begin: 0.05, end: 0.0), weight: 20),
|
||||
]).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut));
|
||||
]).animate(_controller);
|
||||
|
||||
_controller.forward();
|
||||
}
|
||||
|
||||
@@ -8,18 +8,21 @@ import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/library_collections_provider.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||
import 'package:spotiflac_android/providers/playback_provider.dart';
|
||||
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||
import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart';
|
||||
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
|
||||
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||
|
||||
class PlaylistScreen extends ConsumerStatefulWidget {
|
||||
final String playlistName;
|
||||
final String? coverUrl;
|
||||
final List<Track> tracks;
|
||||
final String? playlistId;
|
||||
final String? recommendedService;
|
||||
|
||||
const PlaylistScreen({
|
||||
super.key,
|
||||
@@ -27,6 +30,7 @@ class PlaylistScreen extends ConsumerStatefulWidget {
|
||||
this.coverUrl,
|
||||
required this.tracks,
|
||||
this.playlistId,
|
||||
this.recommendedService,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -46,6 +50,31 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
String get _playlistName => _resolvedPlaylistName ?? widget.playlistName;
|
||||
String? get _coverUrl => _resolvedCoverUrl ?? widget.coverUrl;
|
||||
|
||||
String? _recommendedDownloadService() {
|
||||
final explicit = widget.recommendedService;
|
||||
if (explicit != null && explicit.isNotEmpty) {
|
||||
return explicit;
|
||||
}
|
||||
|
||||
final playlistId = widget.playlistId;
|
||||
if (playlistId != null) {
|
||||
if (playlistId.startsWith('tidal:')) return 'tidal';
|
||||
if (playlistId.startsWith('qobuz:')) return 'qobuz';
|
||||
if (playlistId.startsWith('deezer:')) return 'deezer';
|
||||
}
|
||||
|
||||
final source = _tracks.firstOrNull?.source;
|
||||
if (source != null && source.isNotEmpty) {
|
||||
return source;
|
||||
}
|
||||
|
||||
final trackId = _tracks.firstOrNull?.id ?? '';
|
||||
if (trackId.startsWith('tidal:')) return 'tidal';
|
||||
if (trackId.startsWith('qobuz:')) return 'qobuz';
|
||||
if (trackId.startsWith('deezer:')) return 'deezer';
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -128,7 +157,9 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
albumArtist: data['album_artist']?.toString(),
|
||||
artistId: (data['artist_id'] ?? data['artistId'])?.toString(),
|
||||
albumId: data['album_id']?.toString(),
|
||||
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
|
||||
coverUrl: normalizeCoverReference(
|
||||
(data['cover_url'] ?? data['images'])?.toString(),
|
||||
),
|
||||
isrc: data['isrc']?.toString(),
|
||||
duration: (durationMs / 1000).round(),
|
||||
trackNumber: data['track_number'] as int?,
|
||||
@@ -350,7 +381,6 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
}
|
||||
|
||||
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
|
||||
// Info is now displayed in the full-screen cover overlay
|
||||
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
||||
}
|
||||
|
||||
@@ -358,8 +388,8 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
if (_isLoading) {
|
||||
return const SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
padding: EdgeInsets.all(16),
|
||||
child: TrackListSkeleton(itemCount: 8),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -409,9 +439,12 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
final track = _tracks[index];
|
||||
return KeyedSubtree(
|
||||
key: ValueKey(track.id),
|
||||
child: _PlaylistTrackItem(
|
||||
track: track,
|
||||
onDownload: () => _downloadTrack(context, track),
|
||||
child: StaggeredListItem(
|
||||
index: index,
|
||||
child: _PlaylistTrackItem(
|
||||
track: track,
|
||||
onDownload: () => _downloadTrack(context, track),
|
||||
),
|
||||
),
|
||||
);
|
||||
}, childCount: _tracks.length),
|
||||
@@ -427,6 +460,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
trackName: track.name,
|
||||
artistName: track.artistName,
|
||||
coverUrl: track.coverUrl,
|
||||
recommendedService: _recommendedDownloadService(),
|
||||
onSelect: (quality, service) {
|
||||
ref
|
||||
.read(downloadQueueProvider.notifier)
|
||||
@@ -533,7 +567,12 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
tooltip: context.l10n.tooltipAddToPlaylist,
|
||||
onPressed: _tracks.isEmpty
|
||||
? null
|
||||
: () => showAddTracksToPlaylistSheet(context, ref, _tracks),
|
||||
: () => showAddTracksToPlaylistSheet(
|
||||
context,
|
||||
ref,
|
||||
_tracks,
|
||||
playlistNamePrefill: widget.playlistName,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -608,45 +647,86 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
|
||||
void _downloadTracks(BuildContext context, List<Track> tracks) {
|
||||
if (tracks.isEmpty) return;
|
||||
|
||||
final historyState = ref.read(downloadHistoryProvider);
|
||||
final settings = ref.read(settingsProvider);
|
||||
final localLibState =
|
||||
(settings.localLibraryEnabled && settings.localLibraryShowDuplicates)
|
||||
? ref.read(localLibraryProvider)
|
||||
: null;
|
||||
final tracksToQueue = <Track>[];
|
||||
int skippedCount = 0;
|
||||
|
||||
for (final track in tracks) {
|
||||
final isInHistory =
|
||||
historyState.isDownloaded(track.id) ||
|
||||
(track.isrc != null && historyState.getByIsrc(track.isrc!) != null) ||
|
||||
historyState.findByTrackAndArtist(track.name, track.artistName) !=
|
||||
null;
|
||||
final isInLocal =
|
||||
localLibState?.existsInLibrary(
|
||||
isrc: track.isrc,
|
||||
trackName: track.name,
|
||||
artistName: track.artistName,
|
||||
) ??
|
||||
false;
|
||||
|
||||
if (isInHistory || isInLocal) {
|
||||
skippedCount++;
|
||||
} else {
|
||||
tracksToQueue.add(track);
|
||||
}
|
||||
}
|
||||
|
||||
if (tracksToQueue.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.l10n.discographySkippedDownloaded(0, skippedCount),
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (settings.askQualityBeforeDownload) {
|
||||
DownloadServicePicker.show(
|
||||
context,
|
||||
trackName: '${tracks.length} tracks',
|
||||
trackName: '${tracksToQueue.length} tracks',
|
||||
artistName: _playlistName,
|
||||
recommendedService: _recommendedDownloadService(),
|
||||
onSelect: (quality, service) {
|
||||
ref
|
||||
.read(downloadQueueProvider.notifier)
|
||||
.addMultipleToQueue(
|
||||
tracks,
|
||||
tracksToQueue,
|
||||
service,
|
||||
qualityOverride: quality,
|
||||
playlistName: _playlistName,
|
||||
);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.l10n.snackbarAddedTracksToQueue(tracks.length),
|
||||
),
|
||||
),
|
||||
);
|
||||
_showQueuedSnackbar(context, tracksToQueue.length, skippedCount);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
ref
|
||||
.read(downloadQueueProvider.notifier)
|
||||
.addMultipleToQueue(
|
||||
tracks,
|
||||
tracksToQueue,
|
||||
settings.defaultService,
|
||||
playlistName: _playlistName,
|
||||
);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)),
|
||||
),
|
||||
);
|
||||
_showQueuedSnackbar(context, tracksToQueue.length, skippedCount);
|
||||
}
|
||||
}
|
||||
|
||||
void _showQueuedSnackbar(BuildContext context, int added, int skipped) {
|
||||
final message = skipped > 0
|
||||
? context.l10n.discographySkippedDownloaded(added, skipped)
|
||||
: context.l10n.snackbarAddedTracksToQueue(added);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
}
|
||||
|
||||
/// Separate Consumer widget for each track - only rebuilds when this specific track's status changes
|
||||
@@ -677,7 +757,6 @@ class _PlaylistTrackItem extends ConsumerWidget {
|
||||
}),
|
||||
);
|
||||
|
||||
// Check local library for duplicate detection
|
||||
final showLocalLibraryIndicator = ref.watch(
|
||||
settingsProvider.select(
|
||||
(s) => s.localLibraryEnabled && s.localLibraryShowDuplicates,
|
||||
|
||||
+1343
-1138
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@ import 'package:spotiflac_android/providers/track_provider.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
|
||||
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||
import 'package:spotiflac_android/utils/clickable_metadata.dart';
|
||||
|
||||
class SearchScreen extends ConsumerStatefulWidget {
|
||||
@@ -51,9 +52,9 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
ref
|
||||
.read(downloadQueueProvider.notifier)
|
||||
.addToQueue(track, settings.defaultService);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -95,13 +96,20 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
child: Text(error, style: TextStyle(color: colorScheme.error)),
|
||||
),
|
||||
Expanded(
|
||||
child: tracks.isEmpty
|
||||
? _buildEmptyState(colorScheme)
|
||||
: ListView.builder(
|
||||
itemCount: tracks.length,
|
||||
itemBuilder: (context, index) =>
|
||||
_buildTrackTile(tracks[index], colorScheme),
|
||||
),
|
||||
child: AnimatedStateSwitcher(
|
||||
child: isLoading && tracks.isEmpty
|
||||
? const TrackListSkeleton(key: ValueKey('loading'))
|
||||
: tracks.isEmpty
|
||||
? _buildEmptyState(colorScheme)
|
||||
: ListView.builder(
|
||||
key: const ValueKey('results'),
|
||||
itemCount: tracks.length,
|
||||
itemBuilder: (context, index) => StaggeredListItem(
|
||||
index: index,
|
||||
child: _buildTrackTile(tracks[index], colorScheme),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -127,32 +135,30 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
}
|
||||
|
||||
Widget _buildTrackTile(Track track, ColorScheme colorScheme) {
|
||||
return ListTile(
|
||||
leading: track.coverUrl != null
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: track.coverUrl!,
|
||||
width: 48,
|
||||
height: 48,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 144,
|
||||
memCacheHeight: 144,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
final coverWidget = track.coverUrl != null
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: track.coverUrl!,
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.music_note,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 144,
|
||||
memCacheHeight: 144,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
|
||||
);
|
||||
return ListTile(
|
||||
leading: coverWidget,
|
||||
title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
||||
@@ -511,6 +511,30 @@ class _TranslatorsSection extends StatelessWidget {
|
||||
language: 'Japanese',
|
||||
flag: '🇯🇵',
|
||||
),
|
||||
_Translator(
|
||||
name: 'unkn0wn',
|
||||
crowdinUsername: 'rdclvi',
|
||||
language: 'Indonesian',
|
||||
flag: '🇮🇩',
|
||||
),
|
||||
_Translator(
|
||||
name: 'lunching1272',
|
||||
crowdinUsername: 'lunching1272',
|
||||
language: 'Chinese Simplified',
|
||||
flag: '🇨🇳',
|
||||
),
|
||||
_Translator(
|
||||
name: 'Сергей Ильченко',
|
||||
crowdinUsername: 'Sega_Mostky',
|
||||
language: 'Russian',
|
||||
flag: '🇷🇺',
|
||||
),
|
||||
_Translator(
|
||||
name: 'Girl-lass',
|
||||
crowdinUsername: 'Girl-lass',
|
||||
language: 'Chinese Simplified',
|
||||
flag: '🇨🇳',
|
||||
),
|
||||
_Translator(
|
||||
name: 'Kaan',
|
||||
crowdinUsername: 'glai',
|
||||
|
||||
@@ -164,7 +164,13 @@ class _RecentDonorsCard extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
const donorNames = <String>['a fan'];
|
||||
const donorNames = <String>[
|
||||
'McNuggets Jimmy',
|
||||
'zcc09',
|
||||
'micahRichie',
|
||||
'a fan',
|
||||
'CJBGR',
|
||||
];
|
||||
|
||||
// Match SettingsGroup color logic
|
||||
final cardColor = isDark
|
||||
@@ -471,17 +477,6 @@ class _CryptoWalletItem extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
int _cr(String v) {
|
||||
int r = 0x1F;
|
||||
for (final c in v.codeUnits) {
|
||||
r = (r * 31 + c) & 0x7FFFFFFF;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
// Highlighted supporters (hashes of names): none for now.
|
||||
const _cv = <int>{};
|
||||
|
||||
class _SupporterChip extends StatelessWidget {
|
||||
final String name;
|
||||
final ColorScheme colorScheme;
|
||||
@@ -490,53 +485,32 @@ class _SupporterChip extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final e = _cv.contains(_cr(name));
|
||||
const goldChipColor = Color(0xFFFFF8DC);
|
||||
const goldAccentColor = Color(0xFFB8860B);
|
||||
const goldDarkChipColor = Color(0xFF3A3000);
|
||||
|
||||
final chipColor = e ? goldChipColor : colorScheme.secondaryContainer;
|
||||
final accentColor = e ? goldAccentColor : colorScheme.primary;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final effectiveChipColor = e && isDark ? goldDarkChipColor : chipColor;
|
||||
|
||||
return Material(
|
||||
color: effectiveChipColor,
|
||||
color: colorScheme.secondaryContainer,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Container(
|
||||
decoration: e
|
||||
? BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: accentColor.withValues(alpha: 0.4),
|
||||
width: 1,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 10,
|
||||
backgroundColor: accentColor.withValues(alpha: 0.2),
|
||||
child: e
|
||||
? Icon(Icons.star_rounded, size: 12, color: accentColor)
|
||||
: Text(
|
||||
name.isNotEmpty ? name[0].toUpperCase() : '?',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: accentColor,
|
||||
),
|
||||
),
|
||||
backgroundColor: colorScheme.primary.withValues(alpha: 0.2),
|
||||
child: Text(
|
||||
name.isNotEmpty ? name[0].toUpperCase() : '?',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
name,
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color: e ? accentColor : colorScheme.onSecondaryContainer,
|
||||
fontWeight: e ? FontWeight.w600 : FontWeight.w500,
|
||||
color: colorScheme.onSecondaryContainer,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user