mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-03 11:18:04 +02:00
Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fb90c73f42 | |||
| c6cf65f075 | |||
| 25de009ebc | |||
| 8918d74bb5 | |||
| f9de8d45d9 | |||
| 48eef0853d | |||
| fc70a912bf | |||
| cd3e5b4b28 | |||
| 482ca82eb4 | |||
| 6d87ae5484 | |||
| bd3e2b999b | |||
| 186196e12b | |||
| bd73eb292d | |||
| 8ee2919934 | |||
| f29177216d | |||
| 18d3612674 | |||
| f7c0e417d7 | |||
| 3fd13e9930 | |||
| 0b20cb895e | |||
| 8979210804 | |||
| e9b24712c5 | |||
| 3d6e5615fa | |||
| fc7220b572 | |||
| 198ed5ce6f | |||
| b48462a945 | |||
| 0f327cd1f6 | |||
| 4f2e677e8b | |||
| 79a69f8f70 | |||
| bf0f4bdf3e | |||
| 5e1cc3ecb5 | |||
| d4b37edc2f | |||
| 9483614bc7 | |||
| a73f2e1a13 | |||
| 091e3fadd9 | |||
| 5340ca7b16 | |||
| 85d3e58a26 | |||
| 1125c757fe | |||
| 66d714d368 | |||
| 49c2501fbc | |||
| e487817f21 | |||
| d8bbeb1e67 | |||
| 9693616645 | |||
| 0423e36d34 | |||
| c8d605fdee | |||
| 03fd734048 | |||
| da9d64ccfd | |||
| 02e64b7a3c | |||
| a435009d4d | |||
| 9ca73a99a6 | |||
| 2ca6c737c0 | |||
| 2a451ec2a3 | |||
| 346e79b247 | |||
| 999317eba1 | |||
| 16991476ed | |||
| ba33639818 | |||
| b2873378fc | |||
| bffeb55a7a |
@@ -14,6 +14,17 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||||
|
[](https://www.virustotal.com/gui/file/31d1bf3c3b2015c13e83c4f909a7c6093a9423e3e702f0c582a3e0035c849424)
|
||||||
|
[](https://crowdin.com/project/spotiflac-mobile)
|
||||||
|
|
||||||
|
[](https://t.me/spotiflac)
|
||||||
|
[](https://t.me/spotiflac_chat)
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -23,89 +34,141 @@
|
|||||||
<img src="assets/images/4.jpg?v=2" width="200" />
|
<img src="assets/images/4.jpg?v=2" width="200" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div align="center">
|
---
|
||||||
|
|
||||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
|
||||||
[](https://www.virustotal.com/gui/file/63a445a956fa71ea347ad3695a62d543e14e341933326b9dbb9a15d79614ef58)
|
|
||||||
[](https://crowdin.com/project/spotiflac-mobile)
|
|
||||||
|
|
||||||
[](https://t.me/spotiflac)
|
|
||||||
[](https://t.me/spotiflac_chat)
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
## Extensions
|
## 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
|
### Installing Extensions
|
||||||
1. Go to **Store** tab in the app
|
|
||||||
2. When opening the Store for the first time, you will be asked to enter an **Extension Repository URL**
|
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
|
3. Browse and install extensions with one tap
|
||||||
4. Or download a `.spotiflac-ext` file and install manually via **Settings > Extensions**
|
4. Or download a `.spotiflac-ext` file and install manually via **Settings > Extensions**
|
||||||
5. Configure extension settings if needed
|
5. Configure extension settings if needed
|
||||||
6. Set provider priority in **Settings > Extensions > Provider Priority**
|
6. Set provider priority under **Settings > Extensions > Provider Priority**
|
||||||
|
|
||||||
### Developing Extensions
|
### 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)
|
### [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
|
## FAQ
|
||||||
|
|
||||||
**Q: Why does the Store tab ask me to enter a URL?**
|
<details>
|
||||||
A: 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.
|
<summary><b>Why does the Store tab ask me to enter a URL?</b></summary>
|
||||||
|
<br>
|
||||||
|
|
||||||
**Q: Why is my download failing with "Song not found"?**
|
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.
|
||||||
A: The track may not be available on the streaming services. Try enabling more download services in Settings > Download > Provider Priority, or install additional extensions like Amazon Music from the Store.
|
|
||||||
|
|
||||||
**Q: Why are some tracks downloading in lower quality?**
|
</details>
|
||||||
A: Quality depends on what's available from the streaming service and extensions. Built-in providers: Tidal offers up to 24-bit/192kHz, Qobuz up to 24-bit/192kHz, and Deezer up to 16-bit/44.1kHz.
|
|
||||||
|
|
||||||
**Q: Can I download playlists?**
|
<details>
|
||||||
A: Yes! Just paste the playlist URL in the search bar. The app will fetch all tracks and queue them for download.
|
<summary><b>Why is my download failing with "Song not found"?</b></summary>
|
||||||
|
<br>
|
||||||
|
|
||||||
**Q: Why do I need to grant storage permission?**
|
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.
|
||||||
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.
|
|
||||||
|
|
||||||
**Q: Is this app safe?**
|
</details>
|
||||||
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).
|
|
||||||
|
|
||||||
**Q: Why is download not working in my country?**
|
<details>
|
||||||
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.
|
<summary><b>Why are some tracks downloading in lower quality?</b></summary>
|
||||||
|
<br>
|
||||||
|
|
||||||
**Q: Can I add SpotiFLAC to AltStore or SideStore?**
|
Quality depends on what's available from the streaming service and its extensions. Built-in providers:
|
||||||
A: Yes! You can add the official source to receive updates directly within the app. Just copy this link:
|
- **Tidal** up to 24-bit/192kHz
|
||||||
|
- **Qobuz** up to 24-bit/192kHz
|
||||||
|
- **Deezer** up to 16-bit/44.1kHz
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<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
|
https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/refs/heads/main/apps.json
|
||||||
In AltStore/SideStore, go to the Browse tab, tap Sources at the top, then tap the + icon and paste the link.
|
```
|
||||||
|
|
||||||
|
In AltStore/SideStore, go to **Browse > Sources**, tap **+**, and paste the link.
|
||||||
|
|
||||||
### Want to support SpotiFLAC-Mobile?
|
</details>
|
||||||
|
|
||||||
_If this software is useful and brings you value, consider supporting the project. Your support helps keep development going._
|
> [!NOTE]
|
||||||
|
> If SpotiFLAC is useful to you, consider supporting development:
|
||||||
|
>
|
||||||
|
> [](https://ko-fi.com/zarzet)
|
||||||
|
|
||||||
[](https://ko-fi.com/zarzet)
|
---
|
||||||
|
|
||||||
## Contributors
|
## Contributors
|
||||||
|
|
||||||
Thanks to all the amazing people who have contributed to SpotiFLAC Mobile!
|
Thanks to everyone who has contributed to SpotiFLAC Mobile!
|
||||||
|
|
||||||
<a href="https://github.com/zarzet/SpotiFLAC-Mobile/graphs/contributors">
|
<a href="https://github.com/zarzet/SpotiFLAC-Mobile/graphs/contributors">
|
||||||
<img src="https://contrib.rocks/image?repo=zarzet/SpotiFLAC-Mobile" />
|
<img src="https://contrib.rocks/image?repo=zarzet/SpotiFLAC-Mobile" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
We also appreciate everyone who has helped with [translations on Crowdin](https://crowdin.com/project/spotiflac-mobile), reported bugs, suggested features, and spread the word about SpotiFLAC Mobile.
|
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 our [Contributing Guide](CONTRIBUTING.md) to get started!
|
Interested in contributing? Check out the [Contributing Guide](CONTRIBUTING.md) to get started!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## API Credits
|
## 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]
|
> [!TIP]
|
||||||
>
|
> **Star the repo** to get notified about all new releases directly from GitHub.
|
||||||
> **Star Us**, You will receive all release notifications from GitHub without any delay
|
|
||||||
|
|||||||
@@ -9,6 +9,19 @@
|
|||||||
# packages, and plugins designed to encourage good coding practices.
|
# packages, and plugins designed to encourage good coding practices.
|
||||||
include: package:flutter_lints/flutter.yaml
|
include: package:flutter_lints/flutter.yaml
|
||||||
|
|
||||||
|
analyzer:
|
||||||
|
exclude:
|
||||||
|
- build/**
|
||||||
|
- .dart_tool/**
|
||||||
|
- lib/**/*.g.dart
|
||||||
|
- lib/l10n/*.dart
|
||||||
|
language:
|
||||||
|
strict-casts: true
|
||||||
|
strict-inference: true
|
||||||
|
strict-raw-types: true
|
||||||
|
plugins:
|
||||||
|
- custom_lint
|
||||||
|
|
||||||
linter:
|
linter:
|
||||||
# The lint rules applied to this project can be customized in the
|
# The lint rules applied to this project can be customized in the
|
||||||
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||||
@@ -23,6 +36,13 @@ linter:
|
|||||||
rules:
|
rules:
|
||||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||||
|
avoid_dynamic_calls: true
|
||||||
|
cancel_subscriptions: true
|
||||||
|
close_sinks: true
|
||||||
|
|
||||||
|
custom_lint:
|
||||||
|
rules:
|
||||||
|
- avoid_public_notifier_properties
|
||||||
|
|
||||||
# Additional information about this file can be found at
|
# Additional information about this file can be found at
|
||||||
# https://dart.dev/guides/language/analysis-options
|
# https://dart.dev/guides/language/analysis-options
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ class DownloadService : Service() {
|
|||||||
updateNotification(progress, total)
|
updateNotification(progress, total)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return START_STICKY
|
return START_NOT_STICKY
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBind(intent: Intent?): IBinder? = null
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
@@ -137,14 +137,13 @@ class DownloadService : Service() {
|
|||||||
|
|
||||||
private fun startForegroundService() {
|
private fun startForegroundService() {
|
||||||
isRunning = true
|
isRunning = true
|
||||||
|
|
||||||
// Acquire wake lock to prevent CPU sleep
|
|
||||||
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
|
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||||
wakeLock = powerManager.newWakeLock(
|
wakeLock = powerManager.newWakeLock(
|
||||||
PowerManager.PARTIAL_WAKE_LOCK,
|
PowerManager.PARTIAL_WAKE_LOCK,
|
||||||
WAKELOCK_TAG
|
WAKELOCK_TAG
|
||||||
).apply {
|
).apply {
|
||||||
acquire(60 * 60 * 1000L) // 1 hour max
|
acquire(60 * 60 * 1000L)
|
||||||
}
|
}
|
||||||
|
|
||||||
val notification = buildNotification(0, 0)
|
val notification = buildNotification(0, 0)
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
import org.json.JSONTokener
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileInputStream
|
import java.io.FileInputStream
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
@@ -129,39 +130,35 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
// Minimum API level we consider "safe" for Impeller (Android 10+)
|
|
||||||
private const val SAFE_API_FOR_IMPELLER = 29
|
private const val SAFE_API_FOR_IMPELLER = 29
|
||||||
|
|
||||||
// Known problematic GPU patterns (lowercase)
|
|
||||||
private val PROBLEMATIC_GPU_PATTERNS = listOf(
|
private val PROBLEMATIC_GPU_PATTERNS = listOf(
|
||||||
"adreno (tm) 3", // Adreno 300 series (305, 320, 330, etc.) - old Qualcomm
|
"adreno (tm) 3",
|
||||||
"adreno (tm) 4", // Adreno 400 series - some have issues
|
"adreno (tm) 4",
|
||||||
"mali-4", // Mali-400 series - old ARM GPUs
|
"mali-4",
|
||||||
"mali-t6", // Mali-T600 series
|
"mali-t6",
|
||||||
"mali-t7", // Mali-T700 series (some)
|
"mali-t7",
|
||||||
"powervr sgx", // PowerVR SGX series - old Imagination GPUs
|
"powervr sgx",
|
||||||
"powervr ge8320", // PowerVR GE8320 - known issues
|
"powervr ge8320",
|
||||||
"gc1000", // Vivante GC1000
|
"gc1000",
|
||||||
"gc2000", // Vivante GC2000
|
"gc2000",
|
||||||
)
|
)
|
||||||
|
|
||||||
// Known problematic chipsets/hardware (lowercase)
|
|
||||||
private val PROBLEMATIC_CHIPSETS = listOf(
|
private val PROBLEMATIC_CHIPSETS = listOf(
|
||||||
"mt6762", // MediaTek Helio P22 with PowerVR GE8320
|
"mt6762",
|
||||||
"mt6765", // MediaTek Helio P35 with PowerVR GE8320
|
"mt6765",
|
||||||
"mt8768", // MediaTek tablet chip
|
"mt8768",
|
||||||
"mp0873", // MediaTek variant
|
"mp0873",
|
||||||
"msm8974", // Snapdragon 800/801 with Adreno 330
|
"msm8974",
|
||||||
"msm8226", // Snapdragon 400 with Adreno 305
|
"msm8226",
|
||||||
"msm8926", // Snapdragon 400 with Adreno 305
|
"msm8926",
|
||||||
"apq8084", // Snapdragon 805 (some issues)
|
"apq8084",
|
||||||
)
|
)
|
||||||
|
|
||||||
// Known problematic device models (lowercase)
|
|
||||||
private val PROBLEMATIC_MODELS = listOf(
|
private val PROBLEMATIC_MODELS = listOf(
|
||||||
"sm-t220", // Samsung Tab A7 Lite
|
"sm-t220",
|
||||||
"sm-t225", // Samsung Tab A7 Lite LTE
|
"sm-t225",
|
||||||
"hammerhead", // Nexus 5 (Adreno 330)
|
"hammerhead",
|
||||||
)
|
)
|
||||||
/**
|
/**
|
||||||
* Check if device should use Skia instead of Impeller.
|
* Check if device should use Skia instead of Impeller.
|
||||||
@@ -173,7 +170,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
val model = Build.MODEL.lowercase(Locale.ROOT)
|
val model = Build.MODEL.lowercase(Locale.ROOT)
|
||||||
val device = Build.DEVICE.lowercase(Locale.ROOT)
|
val device = Build.DEVICE.lowercase(Locale.ROOT)
|
||||||
|
|
||||||
// 1. Check for explicitly problematic device models
|
|
||||||
for (problematicModel in PROBLEMATIC_MODELS) {
|
for (problematicModel in PROBLEMATIC_MODELS) {
|
||||||
if (model.contains(problematicModel) || device.contains(problematicModel)) {
|
if (model.contains(problematicModel) || device.contains(problematicModel)) {
|
||||||
android.util.Log.i("SpotiFLAC", "Matched problematic model: $problematicModel")
|
android.util.Log.i("SpotiFLAC", "Matched problematic model: $problematicModel")
|
||||||
@@ -181,7 +177,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Check for problematic chipsets
|
|
||||||
for (chipset in PROBLEMATIC_CHIPSETS) {
|
for (chipset in PROBLEMATIC_CHIPSETS) {
|
||||||
if (hardware.contains(chipset) || board.contains(chipset)) {
|
if (hardware.contains(chipset) || board.contains(chipset)) {
|
||||||
android.util.Log.i("SpotiFLAC", "Matched problematic chipset: $chipset")
|
android.util.Log.i("SpotiFLAC", "Matched problematic chipset: $chipset")
|
||||||
@@ -189,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) {
|
if (Build.VERSION.SDK_INT < SAFE_API_FOR_IMPELLER) {
|
||||||
// For older Android, check GPU renderer if available
|
|
||||||
val gpuRenderer = getGpuRenderer().lowercase(Locale.ROOT)
|
val gpuRenderer = getGpuRenderer().lowercase(Locale.ROOT)
|
||||||
|
|
||||||
// Check for known problematic GPUs
|
|
||||||
for (pattern in PROBLEMATIC_GPU_PATTERNS) {
|
for (pattern in PROBLEMATIC_GPU_PATTERNS) {
|
||||||
if (gpuRenderer.contains(pattern)) {
|
if (gpuRenderer.contains(pattern)) {
|
||||||
android.util.Log.i("SpotiFLAC", "Matched problematic GPU on old Android: $pattern")
|
android.util.Log.i("SpotiFLAC", "Matched problematic GPU on old Android: $pattern")
|
||||||
@@ -202,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) {
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||||
android.util.Log.i("SpotiFLAC", "Android < 8.0, using Skia for safety")
|
android.util.Log.i("SpotiFLAC", "Android < 8.0, using Skia for safety")
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. For Android 10+, still check for known problematic GPUs
|
|
||||||
val gpuRenderer = getGpuRenderer().lowercase(Locale.ROOT)
|
val gpuRenderer = getGpuRenderer().lowercase(Locale.ROOT)
|
||||||
for (pattern in PROBLEMATIC_GPU_PATTERNS) {
|
for (pattern in PROBLEMATIC_GPU_PATTERNS) {
|
||||||
if (gpuRenderer.contains(pattern)) {
|
if (gpuRenderer.contains(pattern)) {
|
||||||
@@ -227,8 +217,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
*/
|
*/
|
||||||
private fun getGpuRenderer(): String {
|
private fun getGpuRenderer(): String {
|
||||||
return try {
|
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) ?: ""
|
android.opengl.GLES20.glGetString(android.opengl.GLES20.GL_RENDERER) ?: ""
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
""
|
""
|
||||||
@@ -316,6 +304,7 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
".mp3" -> "audio/mpeg"
|
".mp3" -> "audio/mpeg"
|
||||||
".opus" -> "audio/ogg"
|
".opus" -> "audio/ogg"
|
||||||
".flac" -> "audio/flac"
|
".flac" -> "audio/flac"
|
||||||
|
".lrc" -> "application/octet-stream"
|
||||||
else -> "application/octet-stream"
|
else -> "application/octet-stream"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -413,6 +402,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) {
|
private fun startDownloadProgressStream(sink: EventChannel.EventSink) {
|
||||||
stopDownloadProgressStream()
|
stopDownloadProgressStream()
|
||||||
downloadProgressEventSink = sink
|
downloadProgressEventSink = sink
|
||||||
@@ -425,7 +446,7 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
if (payload != lastDownloadProgressPayload) {
|
if (payload != lastDownloadProgressPayload) {
|
||||||
lastDownloadProgressPayload = payload
|
lastDownloadProgressPayload = payload
|
||||||
sink.success(payload)
|
sink.success(parseJsonPayload(payload))
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
android.util.Log.w(
|
android.util.Log.w(
|
||||||
@@ -457,7 +478,7 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
if (payload != lastLibraryScanProgressPayload) {
|
if (payload != lastLibraryScanProgressPayload) {
|
||||||
lastLibraryScanProgressPayload = payload
|
lastLibraryScanProgressPayload = payload
|
||||||
sink.success(payload)
|
sink.success(parseJsonPayload(payload))
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
android.util.Log.w(
|
android.util.Log.w(
|
||||||
@@ -599,7 +620,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
* Resolve extension from a MediaStore URI by querying DISPLAY_NAME or MIME_TYPE.
|
* Resolve extension from a MediaStore URI by querying DISPLAY_NAME or MIME_TYPE.
|
||||||
*/
|
*/
|
||||||
private fun resolveMediaStoreExt(uri: Uri, fallbackExt: String?): String {
|
private fun resolveMediaStoreExt(uri: Uri, fallbackExt: String?): String {
|
||||||
// Try DISPLAY_NAME first
|
|
||||||
try {
|
try {
|
||||||
contentResolver.query(uri, arrayOf(android.provider.MediaStore.MediaColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
|
contentResolver.query(uri, arrayOf(android.provider.MediaStore.MediaColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
|
||||||
if (cursor.moveToFirst()) {
|
if (cursor.moveToFirst()) {
|
||||||
@@ -610,7 +630,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
} catch (_: Exception) {}
|
} catch (_: Exception) {}
|
||||||
|
|
||||||
// Try MIME_TYPE
|
|
||||||
try {
|
try {
|
||||||
val mime = contentResolver.getType(uri)
|
val mime = contentResolver.getType(uri)
|
||||||
val ext = extFromMimeType(mime)
|
val ext = extFromMimeType(mime)
|
||||||
@@ -836,8 +855,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
val mimeType = mimeTypeForExt(outputExt)
|
val mimeType = mimeTypeForExt(outputExt)
|
||||||
val fileName = buildSafFileName(req, 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)
|
val existingDir = findDocumentDir(treeUri, relativeDir)
|
||||||
if (existingDir != null) {
|
if (existingDir != null) {
|
||||||
val existing = existingDir.findFile(fileName)
|
val existing = existingDir.findFile(fileName)
|
||||||
@@ -852,7 +869,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only create the directory now that we know we need to download
|
|
||||||
val targetDir = ensureDocumentDir(treeUri, relativeDir)
|
val targetDir = ensureDocumentDir(treeUri, relativeDir)
|
||||||
?: return errorJson("Failed to access SAF directory")
|
?: return errorJson("Failed to access SAF directory")
|
||||||
|
|
||||||
@@ -875,7 +891,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
val respObj = JSONObject(response)
|
val respObj = JSONObject(response)
|
||||||
if (respObj.optBoolean("success", false)) {
|
if (respObj.optBoolean("success", false)) {
|
||||||
// Extension providers write to a local temp path instead of the SAF FD.
|
// 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", "")
|
val goFilePath = respObj.optString("file_path", "")
|
||||||
if (goFilePath.isNotEmpty() &&
|
if (goFilePath.isNotEmpty() &&
|
||||||
!goFilePath.startsWith("content://") &&
|
!goFilePath.startsWith("content://") &&
|
||||||
@@ -924,15 +939,10 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
try {
|
try {
|
||||||
val docId = android.provider.DocumentsContract.getDocumentId(childUri)
|
val docId = android.provider.DocumentsContract.getDocumentId(childUri)
|
||||||
if (docId.isNullOrEmpty()) return null
|
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('/')
|
val lastSlash = docId.lastIndexOf('/')
|
||||||
if (lastSlash <= 0) return null
|
if (lastSlash <= 0) return null
|
||||||
|
|
||||||
val parentDocId = docId.substring(0, lastSlash)
|
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)
|
val treeDocId = android.provider.DocumentsContract.getTreeDocumentId(childUri)
|
||||||
if (treeDocId.isNullOrEmpty()) return null
|
if (treeDocId.isNullOrEmpty()) return null
|
||||||
|
|
||||||
@@ -957,21 +967,17 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
val lines = File(cueTempPath).readLines()
|
val lines = File(cueTempPath).readLines()
|
||||||
for (line in lines) {
|
for (line in lines) {
|
||||||
val trimmed = line.trim().let { l ->
|
val trimmed = line.trim().let { l ->
|
||||||
// Strip BOM
|
|
||||||
if (l.startsWith("\uFEFF")) l.removePrefix("\uFEFF").trim() else l
|
if (l.startsWith("\uFEFF")) l.removePrefix("\uFEFF").trim() else l
|
||||||
}
|
}
|
||||||
if (trimmed.uppercase(Locale.ROOT).startsWith("FILE ")) {
|
if (trimmed.uppercase(Locale.ROOT).startsWith("FILE ")) {
|
||||||
val rest = trimmed.substring(5).trim()
|
val rest = trimmed.substring(5).trim()
|
||||||
// Parse: "filename" TYPE or filename TYPE
|
|
||||||
val filename = if (rest.startsWith("\"")) {
|
val filename = if (rest.startsWith("\"")) {
|
||||||
val endQuote = rest.indexOf('"', 1)
|
val endQuote = rest.indexOf('"', 1)
|
||||||
if (endQuote > 0) rest.substring(1, endQuote) else rest
|
if (endQuote > 0) rest.substring(1, endQuote) else rest
|
||||||
} else {
|
} else {
|
||||||
// Last word is the type, everything else is the filename
|
|
||||||
val parts = rest.split("\\s+".toRegex())
|
val parts = rest.split("\\s+".toRegex())
|
||||||
if (parts.size >= 2) parts.dropLast(1).joinToString(" ") else rest
|
if (parts.size >= 2) parts.dropLast(1).joinToString(" ") else rest
|
||||||
}
|
}
|
||||||
// Return just the filename (strip any path separators)
|
|
||||||
return filename.substringAfterLast("/").substringAfterLast("\\")
|
return filename.substringAfterLast("/").substringAfterLast("\\")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1056,7 +1062,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
|
|
||||||
val supportedAudioExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg")
|
val supportedAudioExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg")
|
||||||
val audioFiles = mutableListOf<Pair<DocumentFile, String>>()
|
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 cueFiles = mutableListOf<Pair<DocumentFile, DocumentFile>>()
|
||||||
val visitedDirUris = mutableSetOf<String>()
|
val visitedDirUris = mutableSetOf<String>()
|
||||||
val safChildLookupCache = mutableMapOf<String, Map<String, DocumentFile>>()
|
val safChildLookupCache = mutableMapOf<String, Map<String, DocumentFile>>()
|
||||||
@@ -1141,7 +1146,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
var scanned = 0
|
var scanned = 0
|
||||||
var errors = traversalErrors
|
var errors = traversalErrors
|
||||||
|
|
||||||
// --- CUE first pass: parse CUE sheets, expand to tracks, track referenced audio ---
|
|
||||||
val cueReferencedAudioUris = mutableSetOf<String>()
|
val cueReferencedAudioUris = mutableSetOf<String>()
|
||||||
|
|
||||||
for ((cueDoc, parentDir) in cueFiles) {
|
for ((cueDoc, parentDir) in cueFiles) {
|
||||||
@@ -1180,10 +1184,8 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark this audio file so we skip it in the regular audio pass
|
|
||||||
cueReferencedAudioUris.add(audioDoc.uri.toString())
|
cueReferencedAudioUris.add(audioDoc.uri.toString())
|
||||||
|
|
||||||
// Copy audio to same temp dir so Go can resolve it
|
|
||||||
val tempDir = File(tempCuePath).parent ?: cacheDir.absolutePath
|
val tempDir = File(tempCuePath).parent ?: cacheDir.absolutePath
|
||||||
val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" }
|
val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" }
|
||||||
val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT)
|
val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT)
|
||||||
@@ -1197,7 +1199,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rename temp audio to its original name so Go can find it by name
|
|
||||||
val renamedAudio = File(tempDir, audioName)
|
val renamedAudio = File(tempDir, audioName)
|
||||||
val tempAudioFile = File(tempAudioPath)
|
val tempAudioFile = File(tempAudioPath)
|
||||||
if (renamedAudio.absolutePath != tempAudioFile.absolutePath) {
|
if (renamedAudio.absolutePath != tempAudioFile.absolutePath) {
|
||||||
@@ -1240,14 +1241,12 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Regular audio file pass: skip files referenced by CUE sheets ---
|
|
||||||
for ((doc, _) in audioFiles) {
|
for ((doc, _) in audioFiles) {
|
||||||
if (safScanCancel) {
|
if (safScanCancel) {
|
||||||
updateSafScanProgress { it.isComplete = true }
|
updateSafScanProgress { it.isComplete = true }
|
||||||
return "[]"
|
return "[]"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip audio files that are represented by CUE track entries
|
|
||||||
if (cueReferencedAudioUris.contains(doc.uri.toString())) {
|
if (cueReferencedAudioUris.contains(doc.uri.toString())) {
|
||||||
scanned++
|
scanned++
|
||||||
val pct = scanned.toDouble() / totalItems.toDouble() * 100.0
|
val pct = scanned.toDouble() / totalItems.toDouble() * 100.0
|
||||||
@@ -1326,7 +1325,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
return result.toString()
|
return result.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse existing files map: URI -> lastModified
|
|
||||||
val existingFiles = mutableMapOf<String, Long>()
|
val existingFiles = mutableMapOf<String, Long>()
|
||||||
try {
|
try {
|
||||||
val obj = JSONObject(existingFilesJson)
|
val obj = JSONObject(existingFilesJson)
|
||||||
@@ -1345,20 +1343,15 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val supportedAudioExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg")
|
val supportedAudioExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg")
|
||||||
val audioFiles = mutableListOf<Triple<DocumentFile, String, Long>>() // doc, path, lastModified
|
val audioFiles = mutableListOf<Triple<DocumentFile, String, Long>>()
|
||||||
// CUE files to scan: (cueDoc, parentDir, lastModified)
|
|
||||||
val cueFilesToScan = mutableListOf<Triple<DocumentFile, DocumentFile, 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 unchangedCueFiles = mutableListOf<Pair<DocumentFile, DocumentFile>>()
|
||||||
val currentUris = mutableSetOf<String>()
|
val currentUris = mutableSetOf<String>()
|
||||||
val visitedDirUris = mutableSetOf<String>()
|
val visitedDirUris = mutableSetOf<String>()
|
||||||
val safChildLookupCache = mutableMapOf<String, Map<String, DocumentFile>>()
|
val safChildLookupCache = mutableMapOf<String, Map<String, DocumentFile>>()
|
||||||
var traversalErrors = 0
|
var traversalErrors = 0
|
||||||
|
|
||||||
// Build a map of CUE base URIs -> existing virtual track URIs from the database.
|
val existingCueVirtualPaths = mutableMapOf<String, MutableList<String>>()
|
||||||
// Virtual paths look like "content://...album.cue#track01".
|
|
||||||
// We need this to preserve virtual paths for unchanged CUE files.
|
|
||||||
val existingCueVirtualPaths = mutableMapOf<String, MutableList<String>>() // cueUri -> [virtualPaths]
|
|
||||||
for (key in existingFiles.keys) {
|
for (key in existingFiles.keys) {
|
||||||
val hashIdx = key.indexOf("#track")
|
val hashIdx = key.indexOf("#track")
|
||||||
if (hashIdx > 0) {
|
if (hashIdx > 0) {
|
||||||
@@ -1367,7 +1360,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect all files with lastModified
|
|
||||||
val queue: ArrayDeque<Pair<DocumentFile, String>> = ArrayDeque()
|
val queue: ArrayDeque<Pair<DocumentFile, String>> = ArrayDeque()
|
||||||
queue.add(root to "")
|
queue.add(root to "")
|
||||||
|
|
||||||
@@ -1423,8 +1415,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
queue.add(child to childPath)
|
queue.add(child to childPath)
|
||||||
} else if (child.isFile) {
|
} 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()
|
val uriStr = child.uri.toString()
|
||||||
currentUris.add(uriStr)
|
currentUris.add(uriStr)
|
||||||
|
|
||||||
@@ -1436,18 +1426,15 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
child.lastModified()
|
child.lastModified()
|
||||||
} catch (_: Exception) { 0L }
|
} catch (_: Exception) { 0L }
|
||||||
|
|
||||||
// Check if any virtual track from this CUE exists with matching modTime
|
|
||||||
val virtualPaths = existingCueVirtualPaths[uriStr]
|
val virtualPaths = existingCueVirtualPaths[uriStr]
|
||||||
val existingModified = virtualPaths?.firstOrNull()?.let { existingFiles[it] }
|
val existingModified = virtualPaths?.firstOrNull()?.let { existingFiles[it] }
|
||||||
|
|
||||||
if (existingModified != null && existingModified == lastModified) {
|
if (existingModified != null && existingModified == lastModified) {
|
||||||
// CUE is unchanged — mark virtual paths as current so they aren't removed
|
|
||||||
unchangedCueFiles.add(child to dir)
|
unchangedCueFiles.add(child to dir)
|
||||||
for (vp in virtualPaths) {
|
for (vp in virtualPaths) {
|
||||||
currentUris.add(vp)
|
currentUris.add(vp)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// CUE is new or modified — needs scanning
|
|
||||||
cueFilesToScan.add(Triple(child, dir, lastModified))
|
cueFilesToScan.add(Triple(child, dir, lastModified))
|
||||||
}
|
}
|
||||||
} else if (ext.isNotBlank() && supportedAudioExt.contains(".$ext")) {
|
} else if (ext.isNotBlank() && supportedAudioExt.contains(".$ext")) {
|
||||||
@@ -1458,7 +1445,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
existingModified ?: 0L
|
existingModified ?: 0L
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if file is new or modified
|
|
||||||
if (existingModified == null || existingModified != lastModified) {
|
if (existingModified == null || existingModified != lastModified) {
|
||||||
audioFiles.add(Triple(child, path, lastModified))
|
audioFiles.add(Triple(child, path, lastModified))
|
||||||
}
|
}
|
||||||
@@ -1475,7 +1461,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find removed files (in existing but not in current)
|
|
||||||
val removedUris = existingFiles.keys.filter { !currentUris.contains(it) }
|
val removedUris = existingFiles.keys.filter { !currentUris.contains(it) }
|
||||||
val totalFiles = currentUris.size
|
val totalFiles = currentUris.size
|
||||||
val filesToProcess = audioFiles.size + cueFilesToScan.size
|
val filesToProcess = audioFiles.size + cueFilesToScan.size
|
||||||
@@ -1503,7 +1488,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
var scanned = 0
|
var scanned = 0
|
||||||
var errors = traversalErrors
|
var errors = traversalErrors
|
||||||
|
|
||||||
// --- CUE first pass: parse new/modified CUE sheets ---
|
|
||||||
val cueReferencedAudioUris = mutableSetOf<String>()
|
val cueReferencedAudioUris = mutableSetOf<String>()
|
||||||
|
|
||||||
for ((cueDoc, parentDir, cueLastModified) in cueFilesToScan) {
|
for ((cueDoc, parentDir, cueLastModified) in cueFilesToScan) {
|
||||||
@@ -1524,7 +1508,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
var tempCuePath: String? = null
|
var tempCuePath: String? = null
|
||||||
var tempAudioPath: String? = null
|
var tempAudioPath: String? = null
|
||||||
try {
|
try {
|
||||||
// Copy CUE to temp
|
|
||||||
tempCuePath = copyUriToTemp(cueDoc.uri, ".cue")
|
tempCuePath = copyUriToTemp(cueDoc.uri, ".cue")
|
||||||
if (tempCuePath == null) {
|
if (tempCuePath == null) {
|
||||||
errors++
|
errors++
|
||||||
@@ -1533,10 +1516,8 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract the audio filename from the CUE sheet text
|
|
||||||
val audioFileName = extractCueAudioFileName(tempCuePath)
|
val audioFileName = extractCueAudioFileName(tempCuePath)
|
||||||
|
|
||||||
// Find the referenced audio file as a sibling in the same SAF directory
|
|
||||||
val audioDoc = resolveCueAudioSibling(
|
val audioDoc = resolveCueAudioSibling(
|
||||||
parentDir = parentDir,
|
parentDir = parentDir,
|
||||||
cueName = cueName,
|
cueName = cueName,
|
||||||
@@ -1551,10 +1532,8 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark this audio file so we skip it in the regular audio pass
|
|
||||||
cueReferencedAudioUris.add(audioDoc.uri.toString())
|
cueReferencedAudioUris.add(audioDoc.uri.toString())
|
||||||
|
|
||||||
// Copy audio to same temp dir so Go can resolve it
|
|
||||||
val tempDir = File(tempCuePath).parent ?: cacheDir.absolutePath
|
val tempDir = File(tempCuePath).parent ?: cacheDir.absolutePath
|
||||||
val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" }
|
val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" }
|
||||||
val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT)
|
val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT)
|
||||||
@@ -1568,7 +1547,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rename temp audio to its original name so Go can find it by name
|
|
||||||
val renamedAudio = File(tempDir, audioName)
|
val renamedAudio = File(tempDir, audioName)
|
||||||
val tempAudioFile = File(tempAudioPath)
|
val tempAudioFile = File(tempAudioPath)
|
||||||
if (renamedAudio.absolutePath != tempAudioFile.absolutePath) {
|
if (renamedAudio.absolutePath != tempAudioFile.absolutePath) {
|
||||||
@@ -1576,7 +1554,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
tempAudioPath = renamedAudio.absolutePath
|
tempAudioPath = renamedAudio.absolutePath
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call Go to produce library scan entries for each CUE track
|
|
||||||
val cueResultsJson = Gobackend.scanCueSheetForLibrary(
|
val cueResultsJson = Gobackend.scanCueSheetForLibrary(
|
||||||
tempCuePath,
|
tempCuePath,
|
||||||
tempDir,
|
tempDir,
|
||||||
@@ -1588,7 +1565,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
for (j in 0 until cueArray.length()) {
|
for (j in 0 until cueArray.length()) {
|
||||||
val trackObj = cueArray.getJSONObject(j)
|
val trackObj = cueArray.getJSONObject(j)
|
||||||
results.put(trackObj)
|
results.put(trackObj)
|
||||||
// Register each virtual path as current so deletion detection works
|
|
||||||
val virtualPath = trackObj.optString("filePath", "")
|
val virtualPath = trackObj.optString("filePath", "")
|
||||||
if (virtualPath.isNotBlank()) {
|
if (virtualPath.isNotBlank()) {
|
||||||
currentUris.add(virtualPath)
|
currentUris.add(virtualPath)
|
||||||
@@ -1621,9 +1597,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) {
|
for ((cueDoc, parentDir) in unchangedCueFiles) {
|
||||||
var tempCue: String? = null
|
var tempCue: String? = null
|
||||||
try {
|
try {
|
||||||
@@ -1648,7 +1621,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Regular audio file pass: skip files referenced by CUE sheets ---
|
|
||||||
for ((doc, _, lastModified) in audioFiles) {
|
for ((doc, _, lastModified) in audioFiles) {
|
||||||
if (safScanCancel) {
|
if (safScanCancel) {
|
||||||
updateSafScanProgress { it.isComplete = true }
|
updateSafScanProgress { it.isComplete = true }
|
||||||
@@ -1661,7 +1633,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
return result.toString()
|
return result.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip audio files that are represented by CUE track entries
|
|
||||||
if (cueReferencedAudioUris.contains(doc.uri.toString())) {
|
if (cueReferencedAudioUris.contains(doc.uri.toString())) {
|
||||||
scanned++
|
scanned++
|
||||||
val processed = skippedCount + scanned
|
val processed = skippedCount + scanned
|
||||||
@@ -1715,7 +1686,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recalculate removedUris now that CUE virtual paths have been registered
|
|
||||||
val finalRemovedUris = existingFiles.keys.filter { !currentUris.contains(it) }
|
val finalRemovedUris = existingFiles.keys.filter { !currentUris.contains(it) }
|
||||||
|
|
||||||
updateSafScanProgress {
|
updateSafScanProgress {
|
||||||
@@ -1893,7 +1863,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
// Update the intent so receive_sharing_intent can access the new data
|
|
||||||
setIntent(intent)
|
setIntent(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1972,13 +1941,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
"parseSpotifyUrl" -> {
|
|
||||||
val url = call.argument<String>("url") ?: ""
|
|
||||||
val response = withContext(Dispatchers.IO) {
|
|
||||||
Gobackend.parseSpotifyURL(url)
|
|
||||||
}
|
|
||||||
result.success(response)
|
|
||||||
}
|
|
||||||
"checkAvailability" -> {
|
"checkAvailability" -> {
|
||||||
val spotifyId = call.argument<String>("spotify_id") ?: ""
|
val spotifyId = call.argument<String>("spotify_id") ?: ""
|
||||||
val isrc = call.argument<String>("isrc") ?: ""
|
val isrc = call.argument<String>("isrc") ?: ""
|
||||||
@@ -2000,13 +1962,13 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
val response = withContext(Dispatchers.IO) {
|
val response = withContext(Dispatchers.IO) {
|
||||||
Gobackend.getDownloadProgress()
|
Gobackend.getDownloadProgress()
|
||||||
}
|
}
|
||||||
result.success(response)
|
result.success(parseJsonPayload(response))
|
||||||
}
|
}
|
||||||
"getAllDownloadProgress" -> {
|
"getAllDownloadProgress" -> {
|
||||||
val response = withContext(Dispatchers.IO) {
|
val response = withContext(Dispatchers.IO) {
|
||||||
Gobackend.getAllDownloadProgress()
|
Gobackend.getAllDownloadProgress()
|
||||||
}
|
}
|
||||||
result.success(response)
|
result.success(parseJsonPayload(response))
|
||||||
}
|
}
|
||||||
"initItemProgress" -> {
|
"initItemProgress" -> {
|
||||||
val itemId = call.argument<String>("item_id") ?: ""
|
val itemId = call.argument<String>("item_id") ?: ""
|
||||||
@@ -2553,7 +2515,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
val tempPath = copyUriToTemp(uri)
|
val tempPath = copyUriToTemp(uri)
|
||||||
?: return@withContext """{"error":"Failed to copy SAF file to temp"}"""
|
?: return@withContext """{"error":"Failed to copy SAF file to temp"}"""
|
||||||
try {
|
try {
|
||||||
// Replace file_path with temp path for Go
|
|
||||||
reqObj.put("file_path", tempPath)
|
reqObj.put("file_path", tempPath)
|
||||||
val raw = Gobackend.reEnrichFile(reqObj.toString())
|
val raw = Gobackend.reEnrichFile(reqObj.toString())
|
||||||
val obj = JSONObject(raw)
|
val obj = JSONObject(raw)
|
||||||
@@ -2631,7 +2592,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
// Deezer API methods
|
|
||||||
"searchDeezerAll" -> {
|
"searchDeezerAll" -> {
|
||||||
val query = call.argument<String>("query") ?: ""
|
val query = call.argument<String>("query") ?: ""
|
||||||
val trackLimit = call.argument<Int>("track_limit") ?: 15
|
val trackLimit = call.argument<Int>("track_limit") ?: 15
|
||||||
@@ -2642,7 +2602,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
// Tidal search API
|
|
||||||
"searchTidalAll" -> {
|
"searchTidalAll" -> {
|
||||||
val query = call.argument<String>("query") ?: ""
|
val query = call.argument<String>("query") ?: ""
|
||||||
val trackLimit = call.argument<Int>("track_limit") ?: 15
|
val trackLimit = call.argument<Int>("track_limit") ?: 15
|
||||||
@@ -2653,7 +2612,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
// Qobuz search API
|
|
||||||
"searchQobuzAll" -> {
|
"searchQobuzAll" -> {
|
||||||
val query = call.argument<String>("query") ?: ""
|
val query = call.argument<String>("query") ?: ""
|
||||||
val trackLimit = call.argument<Int>("track_limit") ?: 15
|
val trackLimit = call.argument<Int>("track_limit") ?: 15
|
||||||
@@ -2746,13 +2704,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
"getSpotifyMetadataWithFallback" -> {
|
|
||||||
val url = call.argument<String>("url") ?: ""
|
|
||||||
val response = withContext(Dispatchers.IO) {
|
|
||||||
Gobackend.getSpotifyMetadataWithDeezerFallback(url)
|
|
||||||
}
|
|
||||||
result.success(response)
|
|
||||||
}
|
|
||||||
"checkAvailabilityFromDeezerID" -> {
|
"checkAvailabilityFromDeezerID" -> {
|
||||||
val deezerTrackId = call.argument<String>("deezer_track_id") ?: ""
|
val deezerTrackId = call.argument<String>("deezer_track_id") ?: ""
|
||||||
val response = withContext(Dispatchers.IO) {
|
val response = withContext(Dispatchers.IO) {
|
||||||
@@ -2783,7 +2734,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
// Log methods
|
|
||||||
"getLogs" -> {
|
"getLogs" -> {
|
||||||
val response = withContext(Dispatchers.IO) {
|
val response = withContext(Dispatchers.IO) {
|
||||||
Gobackend.getLogs()
|
Gobackend.getLogs()
|
||||||
@@ -2816,7 +2766,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
// Extension System methods
|
|
||||||
"initExtensionSystem" -> {
|
"initExtensionSystem" -> {
|
||||||
val extensionsDir = call.argument<String>("extensions_dir") ?: ""
|
val extensionsDir = call.argument<String>("extensions_dir") ?: ""
|
||||||
val dataDir = call.argument<String>("data_dir") ?: ""
|
val dataDir = call.argument<String>("data_dir") ?: ""
|
||||||
@@ -2961,7 +2910,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
// Extension Auth API methods
|
|
||||||
"getExtensionPendingAuth" -> {
|
"getExtensionPendingAuth" -> {
|
||||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||||
val response = withContext(Dispatchers.IO) {
|
val response = withContext(Dispatchers.IO) {
|
||||||
@@ -3011,7 +2959,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
// Extension FFmpeg API
|
|
||||||
"getPendingFFmpegCommand" -> {
|
"getPendingFFmpegCommand" -> {
|
||||||
val commandId = call.argument<String>("command_id") ?: ""
|
val commandId = call.argument<String>("command_id") ?: ""
|
||||||
val response = withContext(Dispatchers.IO) {
|
val response = withContext(Dispatchers.IO) {
|
||||||
@@ -3039,7 +2986,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
// Extension Custom Search API
|
|
||||||
"customSearchWithExtension" -> {
|
"customSearchWithExtension" -> {
|
||||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||||
val query = call.argument<String>("query") ?: ""
|
val query = call.argument<String>("query") ?: ""
|
||||||
@@ -3055,7 +3001,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
// Extension URL Handler API
|
|
||||||
"handleURLWithExtension" -> {
|
"handleURLWithExtension" -> {
|
||||||
val url = call.argument<String>("url") ?: ""
|
val url = call.argument<String>("url") ?: ""
|
||||||
val response = withContext(Dispatchers.IO) {
|
val response = withContext(Dispatchers.IO) {
|
||||||
@@ -3100,7 +3045,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
// Extension Post-Processing API
|
|
||||||
"runPostProcessing" -> {
|
"runPostProcessing" -> {
|
||||||
val filePath = call.argument<String>("file_path") ?: ""
|
val filePath = call.argument<String>("file_path") ?: ""
|
||||||
val metadataJson = call.argument<String>("metadata") ?: ""
|
val metadataJson = call.argument<String>("metadata") ?: ""
|
||||||
@@ -3144,7 +3088,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
// Extension Store
|
|
||||||
"initExtensionStore" -> {
|
"initExtensionStore" -> {
|
||||||
val cacheDir = call.argument<String>("cache_dir") ?: ""
|
val cacheDir = call.argument<String>("cache_dir") ?: ""
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
@@ -3206,7 +3149,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
// Extension Home Feed (Explore)
|
|
||||||
"getExtensionHomeFeed" -> {
|
"getExtensionHomeFeed" -> {
|
||||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||||
val response = withContext(Dispatchers.IO) {
|
val response = withContext(Dispatchers.IO) {
|
||||||
@@ -3221,7 +3163,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
// Local Library Scanning
|
|
||||||
"setLibraryCoverCacheDir" -> {
|
"setLibraryCoverCacheDir" -> {
|
||||||
val cacheDir = call.argument<String>("cache_dir") ?: ""
|
val cacheDir = call.argument<String>("cache_dir") ?: ""
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
@@ -3298,7 +3239,7 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
Gobackend.getLibraryScanProgressJSON()
|
Gobackend.getLibraryScanProgressJSON()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
result.success(response)
|
result.success(parseJsonPayload(response))
|
||||||
}
|
}
|
||||||
"cancelLibraryScan" -> {
|
"cancelLibraryScan" -> {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
@@ -3326,7 +3267,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
// CUE Sheet Parsing
|
|
||||||
"parseCueSheet" -> {
|
"parseCueSheet" -> {
|
||||||
val cuePath = call.argument<String>("cue_path") ?: ""
|
val cuePath = call.argument<String>("cue_path") ?: ""
|
||||||
val audioDir = call.argument<String>("audio_dir") ?: ""
|
val audioDir = call.argument<String>("audio_dir") ?: ""
|
||||||
@@ -3338,17 +3278,14 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
?: return@withContext """{"error":"Failed to copy CUE file to temp"}"""
|
?: return@withContext """{"error":"Failed to copy CUE file to temp"}"""
|
||||||
var tempAudioPath: String? = null
|
var tempAudioPath: String? = null
|
||||||
try {
|
try {
|
||||||
// Extract audio filename from CUE text
|
|
||||||
val audioFileName = extractCueAudioFileName(tempCuePath)
|
val audioFileName = extractCueAudioFileName(tempCuePath)
|
||||||
|
|
||||||
// Try to find the audio sibling in SAF
|
|
||||||
var audioDoc: DocumentFile? = null
|
var audioDoc: DocumentFile? = null
|
||||||
val parentDir = safParentDir(uri)
|
val parentDir = safParentDir(uri)
|
||||||
if (parentDir != null && !audioFileName.isNullOrBlank()) {
|
if (parentDir != null && !audioFileName.isNullOrBlank()) {
|
||||||
audioDoc = try { parentDir.findFile(audioFileName) } catch (_: Exception) { null }
|
audioDoc = try { parentDir.findFile(audioFileName) } catch (_: Exception) { null }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: try common extensions with the CUE base name
|
|
||||||
if (audioDoc == null && parentDir != null) {
|
if (audioDoc == null && parentDir != null) {
|
||||||
val cueName = try {
|
val cueName = try {
|
||||||
DocumentFile.fromSingleUri(this@MainActivity, uri)?.name ?: ""
|
DocumentFile.fromSingleUri(this@MainActivity, uri)?.name ?: ""
|
||||||
@@ -3367,7 +3304,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
|
|
||||||
val tempDir = File(tempCuePath).parent ?: cacheDir.absolutePath
|
val tempDir = File(tempCuePath).parent ?: cacheDir.absolutePath
|
||||||
if (audioDoc != null) {
|
if (audioDoc != null) {
|
||||||
// Copy audio to same temp dir with original name
|
|
||||||
val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" }
|
val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" }
|
||||||
val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT)
|
val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT)
|
||||||
val fallbackExt = if (audioExt.isNotBlank()) ".$audioExt" else null
|
val fallbackExt = if (audioExt.isNotBlank()) ".$audioExt" else null
|
||||||
@@ -3382,15 +3318,11 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse with audio in temp dir; Go will resolve there
|
|
||||||
val resultJson = Gobackend.parseCueSheet(tempCuePath, tempDir)
|
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) {
|
if (audioDoc != null) {
|
||||||
val resultObj = JSONObject(resultJson)
|
val resultObj = JSONObject(resultJson)
|
||||||
resultObj.put("audio_path", audioDoc.uri.toString())
|
resultObj.put("audio_path", audioDoc.uri.toString())
|
||||||
// Also pass the original CUE URI for reference
|
|
||||||
resultObj.put("cue_path", cuePath)
|
resultObj.put("cue_path", cuePath)
|
||||||
resultObj.toString()
|
resultObj.toString()
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -7,12 +7,12 @@
|
|||||||
"name": "SpotiFLAC",
|
"name": "SpotiFLAC",
|
||||||
"bundleIdentifier": "com.zarzet.spotiflac",
|
"bundleIdentifier": "com.zarzet.spotiflac",
|
||||||
"developerName": "zarzet",
|
"developerName": "zarzet",
|
||||||
"version": "3.8.6",
|
"version": "3.9.0",
|
||||||
"versionDate": "2026-03-16",
|
"versionDate": "2026-03-25",
|
||||||
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v3.8.6/SpotiFLAC-v3.8.6-ios-unsigned.ipa",
|
"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.",
|
"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",
|
"iconURL": "https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/main/assets/images/logo.png",
|
||||||
"size": 33676960
|
"size": 34477323
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -498,7 +498,13 @@ func extractUserTextFrame(data []byte) (string, string) {
|
|||||||
|
|
||||||
func isLyricsDescription(description string) bool {
|
func isLyricsDescription(description string) bool {
|
||||||
switch strings.ToLower(strings.TrimSpace(description)) {
|
switch strings.ToLower(strings.TrimSpace(description)) {
|
||||||
case "lyrics", "lyric", "unsyncedlyrics", "unsynced lyrics", "lrc":
|
case
|
||||||
|
"lyrics",
|
||||||
|
"lyric",
|
||||||
|
"unsyncedlyrics",
|
||||||
|
"unsynced lyrics",
|
||||||
|
"uslt",
|
||||||
|
"lrc":
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -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
|
// Deezer CDN supports these sizes: 56, 250, 500, 1000, 1400, 1800
|
||||||
var deezerSizeRegex = regexp.MustCompile(`/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$`)
|
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 {
|
func convertSmallToMedium(imageURL string) string {
|
||||||
if strings.Contains(imageURL, spotifySize300) {
|
if strings.Contains(imageURL, spotifySize300) {
|
||||||
return strings.Replace(imageURL, spotifySize300, spotifySize640, 1)
|
return strings.Replace(imageURL, spotifySize300, spotifySize640, 1)
|
||||||
@@ -40,7 +42,6 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
|
|||||||
maxURL := upgradeToMaxQuality(downloadURL)
|
maxURL := upgradeToMaxQuality(downloadURL)
|
||||||
if maxURL != downloadURL {
|
if maxURL != downloadURL {
|
||||||
downloadURL = maxURL
|
downloadURL = maxURL
|
||||||
// Log already printed by upgradeToMaxQuality for Deezer
|
|
||||||
if strings.Contains(coverURL, "scdn.co") || strings.Contains(coverURL, "spotifycdn") {
|
if strings.Contains(coverURL, "scdn.co") || strings.Contains(coverURL, "spotifycdn") {
|
||||||
GoLog("[Cover] Spotify: upgraded to max resolution (~2000x2000)")
|
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 {
|
func upgradeToMaxQuality(coverURL string) string {
|
||||||
// Spotify CDN upgrade
|
|
||||||
if strings.Contains(coverURL, spotifySize640) {
|
if strings.Contains(coverURL, spotifySize640) {
|
||||||
return strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
|
return strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deezer CDN upgrade
|
|
||||||
if strings.Contains(coverURL, "cdn-images.dzcdn.net") {
|
if strings.Contains(coverURL, "cdn-images.dzcdn.net") {
|
||||||
return upgradeDeezerCover(coverURL)
|
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
|
return coverURL
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,12 +118,35 @@ func upgradeDeezerCover(coverURL string) string {
|
|||||||
return upgraded
|
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 {
|
func GetCoverFromSpotify(imageURL string, maxQuality bool) string {
|
||||||
if imageURL == "" {
|
if imageURL == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always upgrade small to medium first
|
|
||||||
result := convertSmallToMedium(imageURL)
|
result := convertSmallToMedium(imageURL)
|
||||||
|
|
||||||
if maxQuality {
|
if maxQuality {
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import (
|
|||||||
|
|
||||||
// CueSheet represents a parsed .cue file
|
// CueSheet represents a parsed .cue file
|
||||||
type CueSheet struct {
|
type CueSheet struct {
|
||||||
// Album-level metadata
|
|
||||||
Performer string `json:"performer"`
|
Performer string `json:"performer"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
FileName string `json:"file_name"`
|
FileName string `json:"file_name"`
|
||||||
@@ -32,7 +31,6 @@ type CueTrack struct {
|
|||||||
Performer string `json:"performer"`
|
Performer string `json:"performer"`
|
||||||
ISRC string `json:"isrc,omitempty"`
|
ISRC string `json:"isrc,omitempty"`
|
||||||
Composer string `json:"composer,omitempty"`
|
Composer string `json:"composer,omitempty"`
|
||||||
// Index positions in seconds (fractional)
|
|
||||||
StartTime float64 `json:"start_time"` // INDEX 01 in seconds
|
StartTime float64 `json:"start_time"` // INDEX 01 in seconds
|
||||||
PreGap float64 `json:"pre_gap"` // INDEX 00 in seconds (or -1 if not present)
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle BOM at start of file
|
|
||||||
if strings.HasPrefix(line, "\xef\xbb\xbf") {
|
if strings.HasPrefix(line, "\xef\xbb\xbf") {
|
||||||
line = strings.TrimPrefix(line, "\xef\xbb\xbf")
|
line = strings.TrimPrefix(line, "\xef\xbb\xbf")
|
||||||
line = strings.TrimSpace(line)
|
line = strings.TrimSpace(line)
|
||||||
@@ -90,7 +87,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
|
|||||||
|
|
||||||
upper := strings.ToUpper(line)
|
upper := strings.ToUpper(line)
|
||||||
|
|
||||||
// REM commands (album-level metadata)
|
|
||||||
if strings.HasPrefix(upper, "REM ") {
|
if strings.HasPrefix(upper, "REM ") {
|
||||||
matches := reRemCommand.FindStringSubmatch(line)
|
matches := reRemCommand.FindStringSubmatch(line)
|
||||||
if len(matches) == 3 {
|
if len(matches) == 3 {
|
||||||
@@ -136,9 +132,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
|
|||||||
|
|
||||||
if strings.HasPrefix(upper, "FILE ") {
|
if strings.HasPrefix(upper, "FILE ") {
|
||||||
rest := line[len("FILE "):]
|
rest := line[len("FILE "):]
|
||||||
// Extract filename and type
|
|
||||||
// Format: FILE "filename.flac" WAVE
|
|
||||||
// or: FILE filename.flac WAVE
|
|
||||||
fname, ftype := parseCueFileLine(rest)
|
fname, ftype := parseCueFileLine(rest)
|
||||||
sheet.FileName = fname
|
sheet.FileName = fname
|
||||||
sheet.FileType = ftype
|
sheet.FileType = ftype
|
||||||
@@ -146,7 +139,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if strings.HasPrefix(upper, "TRACK ") {
|
if strings.HasPrefix(upper, "TRACK ") {
|
||||||
// Save previous track
|
|
||||||
if currentTrack != nil {
|
if currentTrack != nil {
|
||||||
sheet.Tracks = append(sheet.Tracks, *currentTrack)
|
sheet.Tracks = append(sheet.Tracks, *currentTrack)
|
||||||
}
|
}
|
||||||
@@ -184,7 +176,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// SONGWRITER (used as composer sometimes)
|
|
||||||
if strings.HasPrefix(upper, "SONGWRITER ") {
|
if strings.HasPrefix(upper, "SONGWRITER ") {
|
||||||
value := unquoteCue(line[len("SONGWRITER "):])
|
value := unquoteCue(line[len("SONGWRITER "):])
|
||||||
if currentTrack != nil {
|
if currentTrack != nil {
|
||||||
@@ -196,7 +187,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't forget the last track
|
|
||||||
if currentTrack != nil {
|
if currentTrack != nil {
|
||||||
sheet.Tracks = append(sheet.Tracks, *currentTrack)
|
sheet.Tracks = append(sheet.Tracks, *currentTrack)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1181,7 +1181,7 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa
|
|||||||
|
|
||||||
for attempt := 0; attempt <= deezerMaxRetries; attempt++ {
|
for attempt := 0; attempt <= deezerMaxRetries; attempt++ {
|
||||||
if attempt > 0 {
|
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)
|
GoLog("[Deezer] Retry %d/%d after %v...\n", attempt, deezerMaxRetries, delay)
|
||||||
time.Sleep(delay)
|
time.Sleep(delay)
|
||||||
}
|
}
|
||||||
@@ -1194,7 +1194,6 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa
|
|||||||
lastErr = err
|
lastErr = err
|
||||||
errStr := err.Error()
|
errStr := err.Error()
|
||||||
|
|
||||||
// Check if error is retryable
|
|
||||||
isRetryable := strings.Contains(errStr, "timeout") ||
|
isRetryable := strings.Contains(errStr, "timeout") ||
|
||||||
strings.Contains(errStr, "connection reset") ||
|
strings.Contains(errStr, "connection reset") ||
|
||||||
strings.Contains(errStr, "connection refused") ||
|
strings.Contains(errStr, "connection refused") ||
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ func resolveDeezerTrackURL(req DownloadRequest) (string, error) {
|
|||||||
}
|
}
|
||||||
if deezerID != "" {
|
if deezerID != "" {
|
||||||
trackURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerID)
|
trackURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerID)
|
||||||
if err := verifyDeezerTrack(req, deezerID); err != nil {
|
if err := verifyDeezerTrack(req, deezerID, false); err != nil {
|
||||||
GoLog("[Deezer] Direct ID %s verification failed: %v\n", deezerID, err)
|
GoLog("[Deezer] Direct ID %s verification failed: %v\n", deezerID, err)
|
||||||
// Don't reject direct IDs from request payload — they're presumably correct.
|
// Don't reject direct IDs from request payload — they're presumably correct.
|
||||||
}
|
}
|
||||||
@@ -219,7 +219,7 @@ func resolveDeezerTrackURL(req DownloadRequest) (string, error) {
|
|||||||
if err == nil && availability.Deezer && availability.DeezerURL != "" {
|
if err == nil && availability.Deezer && availability.DeezerURL != "" {
|
||||||
resolvedID := extractDeezerIDFromURL(availability.DeezerURL)
|
resolvedID := extractDeezerIDFromURL(availability.DeezerURL)
|
||||||
if resolvedID != "" {
|
if resolvedID != "" {
|
||||||
if verifyErr := verifyDeezerTrack(req, resolvedID); verifyErr != nil {
|
if verifyErr := verifyDeezerTrack(req, resolvedID, true); verifyErr != nil {
|
||||||
GoLog("[Deezer] SongLink ID %s rejected: %v\n", resolvedID, verifyErr)
|
GoLog("[Deezer] SongLink ID %s rejected: %v\n", resolvedID, verifyErr)
|
||||||
// Fall through to ISRC search instead of using wrong track.
|
// Fall through to ISRC search instead of using wrong track.
|
||||||
} else {
|
} else {
|
||||||
@@ -240,7 +240,7 @@ func resolveDeezerTrackURL(req DownloadRequest) (string, error) {
|
|||||||
if err == nil && track != nil {
|
if err == nil && track != nil {
|
||||||
resolvedID := songLinkExtractDeezerTrackID(track)
|
resolvedID := songLinkExtractDeezerTrackID(track)
|
||||||
if resolvedID != "" {
|
if resolvedID != "" {
|
||||||
if verifyErr := verifyDeezerTrack(req, resolvedID); verifyErr != nil {
|
if verifyErr := verifyDeezerTrack(req, resolvedID, false); verifyErr != nil {
|
||||||
GoLog("[Deezer] ISRC-resolved ID %s rejected: %v\n", resolvedID, verifyErr)
|
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.Errorf("deezer track resolved via ISRC does not match: %w", verifyErr)
|
||||||
}
|
}
|
||||||
@@ -252,7 +252,7 @@ func resolveDeezerTrackURL(req DownloadRequest) (string, error) {
|
|||||||
return "", fmt.Errorf("could not resolve Deezer track URL")
|
return "", fmt.Errorf("could not resolve Deezer track URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
func verifyDeezerTrack(req DownloadRequest, deezerID string) error {
|
func verifyDeezerTrack(req DownloadRequest, deezerID string, skipNameVerification bool) error {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout)
|
ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
trackResp, err := GetDeezerClient().GetTrack(ctx, deezerID)
|
trackResp, err := GetDeezerClient().GetTrack(ctx, deezerID)
|
||||||
@@ -260,9 +260,11 @@ func verifyDeezerTrack(req DownloadRequest, deezerID string) error {
|
|||||||
return nil // Can't verify — don't block the download.
|
return nil // Can't verify — don't block the download.
|
||||||
}
|
}
|
||||||
resolved := resolvedTrackInfo{
|
resolved := resolvedTrackInfo{
|
||||||
Title: trackResp.Track.Name,
|
Title: trackResp.Track.Name,
|
||||||
ArtistName: trackResp.Track.Artists,
|
ArtistName: trackResp.Track.Artists,
|
||||||
Duration: trackResp.Track.DurationMS / 1000,
|
ISRC: trackResp.Track.ISRC,
|
||||||
|
Duration: trackResp.Track.DurationMS / 1000,
|
||||||
|
SkipNameVerification: skipNameVerification,
|
||||||
}
|
}
|
||||||
if !trackMatchesRequest(req, resolved, "Deezer") {
|
if !trackMatchesRequest(req, resolved, "Deezer") {
|
||||||
return fmt.Errorf("expected '%s - %s', got '%s - %s'",
|
return fmt.Errorf("expected '%s - %s', got '%s - %s'",
|
||||||
@@ -319,7 +321,6 @@ func (c *DeezerClient) GetMusicDLDownloadURL(deezerTrackURL string) (string, err
|
|||||||
return "", fmt.Errorf("MusicDL error: %s", errMsg)
|
return "", fmt.Errorf("MusicDL error: %s", errMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try various response fields for download URL
|
|
||||||
for _, key := range []string{"download_url", "url", "link"} {
|
for _, key := range []string{"download_url", "url", "link"} {
|
||||||
if urlVal, ok := raw[key].(string); ok && strings.TrimSpace(urlVal) != "" {
|
if urlVal, ok := raw[key].(string); ok && strings.TrimSpace(urlVal) != "" {
|
||||||
return strings.TrimSpace(urlVal), nil
|
return strings.TrimSpace(urlVal), nil
|
||||||
|
|||||||
+354
-282
@@ -13,25 +13,6 @@ import (
|
|||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
)
|
)
|
||||||
|
|
||||||
func ParseSpotifyURL(url string) (string, error) {
|
|
||||||
parsed, err := parseSpotifyURI(url)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
result := map[string]string{
|
|
||||||
"type": parsed.Type,
|
|
||||||
"id": parsed.ID,
|
|
||||||
}
|
|
||||||
|
|
||||||
jsonBytes, err := json.Marshal(result)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
return string(jsonBytes), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func CheckAvailability(spotifyID, isrc string) (string, error) {
|
func CheckAvailability(spotifyID, isrc string) (string, error) {
|
||||||
client := NewSongLinkClient()
|
client := NewSongLinkClient()
|
||||||
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
|
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
|
||||||
@@ -48,7 +29,6 @@ func CheckAvailability(spotifyID, isrc string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SetSongLinkNetworkOptions is kept for backward compatibility.
|
// SetSongLinkNetworkOptions is kept for backward compatibility.
|
||||||
// It now applies global network compatibility options for all backend API requests.
|
|
||||||
func SetSongLinkNetworkOptions(allowHTTP, insecureTLS bool) {
|
func SetSongLinkNetworkOptions(allowHTTP, insecureTLS bool) {
|
||||||
SetNetworkCompatibilityOptions(allowHTTP, insecureTLS)
|
SetNetworkCompatibilityOptions(allowHTTP, insecureTLS)
|
||||||
}
|
}
|
||||||
@@ -136,6 +116,270 @@ type DownloadResult struct {
|
|||||||
DecryptionKey string
|
DecryptionKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type reEnrichRequest struct {
|
||||||
|
FilePath string `json:"file_path"`
|
||||||
|
CoverURL string `json:"cover_url"`
|
||||||
|
MaxQuality bool `json:"max_quality"`
|
||||||
|
EmbedLyrics bool `json:"embed_lyrics"`
|
||||||
|
SpotifyID string `json:"spotify_id"`
|
||||||
|
TrackName string `json:"track_name"`
|
||||||
|
ArtistName string `json:"artist_name"`
|
||||||
|
AlbumName string `json:"album_name"`
|
||||||
|
AlbumArtist string `json:"album_artist"`
|
||||||
|
TrackNumber int `json:"track_number"`
|
||||||
|
DiscNumber int `json:"disc_number"`
|
||||||
|
ReleaseDate string `json:"release_date"`
|
||||||
|
ISRC string `json:"isrc"`
|
||||||
|
Genre string `json:"genre"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Copyright string `json:"copyright"`
|
||||||
|
DurationMs int64 `json:"duration_ms"`
|
||||||
|
SearchOnline bool `json:"search_online"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyReEnrichTrackMetadata(req *reEnrichRequest, track ExtTrackMetadata) {
|
||||||
|
if req == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if track.SpotifyID != "" {
|
||||||
|
req.SpotifyID = track.SpotifyID
|
||||||
|
} else if track.DeezerID != "" {
|
||||||
|
req.SpotifyID = "deezer:" + track.DeezerID
|
||||||
|
} else if track.QobuzID != "" {
|
||||||
|
req.SpotifyID = "qobuz:" + track.QobuzID
|
||||||
|
} else if track.TidalID != "" {
|
||||||
|
req.SpotifyID = "tidal:" + track.TidalID
|
||||||
|
} else if track.ID != "" {
|
||||||
|
req.SpotifyID = track.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
if track.AlbumName != "" {
|
||||||
|
req.AlbumName = track.AlbumName
|
||||||
|
}
|
||||||
|
if track.AlbumArtist != "" {
|
||||||
|
req.AlbumArtist = track.AlbumArtist
|
||||||
|
}
|
||||||
|
if track.TrackNumber > 0 {
|
||||||
|
req.TrackNumber = track.TrackNumber
|
||||||
|
}
|
||||||
|
if track.DiscNumber > 0 {
|
||||||
|
req.DiscNumber = track.DiscNumber
|
||||||
|
}
|
||||||
|
if track.ReleaseDate != "" {
|
||||||
|
req.ReleaseDate = track.ReleaseDate
|
||||||
|
}
|
||||||
|
if track.ISRC != "" {
|
||||||
|
req.ISRC = track.ISRC
|
||||||
|
}
|
||||||
|
if coverURL := track.ResolvedCoverURL(); coverURL != "" {
|
||||||
|
req.CoverURL = coverURL
|
||||||
|
}
|
||||||
|
if track.DurationMS > 0 {
|
||||||
|
req.DurationMs = int64(track.DurationMS)
|
||||||
|
}
|
||||||
|
if track.Genre != "" {
|
||||||
|
req.Genre = track.Genre
|
||||||
|
}
|
||||||
|
if track.Label != "" {
|
||||||
|
req.Label = track.Label
|
||||||
|
}
|
||||||
|
if track.Copyright != "" {
|
||||||
|
req.Copyright = track.Copyright
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func reEnrichDownloadRequest(req reEnrichRequest) DownloadRequest {
|
||||||
|
return DownloadRequest{
|
||||||
|
TrackName: req.TrackName,
|
||||||
|
ArtistName: req.ArtistName,
|
||||||
|
AlbumName: req.AlbumName,
|
||||||
|
ReleaseDate: req.ReleaseDate,
|
||||||
|
ISRC: req.ISRC,
|
||||||
|
DurationMS: int(req.DurationMs),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectBestReEnrichTrack(req reEnrichRequest, tracks []ExtTrackMetadata) *ExtTrackMetadata {
|
||||||
|
if len(tracks) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadReq := reEnrichDownloadRequest(req)
|
||||||
|
currentISRC := strings.TrimSpace(req.ISRC)
|
||||||
|
currentAlbum := strings.TrimSpace(req.AlbumName)
|
||||||
|
var best *ExtTrackMetadata
|
||||||
|
bestScore := -1 << 30
|
||||||
|
|
||||||
|
for i := range tracks {
|
||||||
|
track := &tracks[i]
|
||||||
|
score := 0
|
||||||
|
|
||||||
|
resolved := resolvedTrackInfo{
|
||||||
|
Title: track.Name,
|
||||||
|
ArtistName: track.Artists,
|
||||||
|
ISRC: track.ISRC,
|
||||||
|
Duration: track.DurationMS / 1000,
|
||||||
|
}
|
||||||
|
if trackMatchesRequest(downloadReq, resolved, "ReEnrich") {
|
||||||
|
score += 2000
|
||||||
|
}
|
||||||
|
|
||||||
|
if currentISRC != "" && strings.EqualFold(currentISRC, strings.TrimSpace(track.ISRC)) {
|
||||||
|
score += 10000
|
||||||
|
}
|
||||||
|
if req.TrackName != "" && track.Name != "" && titlesMatch(req.TrackName, track.Name) {
|
||||||
|
score += 400
|
||||||
|
}
|
||||||
|
if req.ArtistName != "" && track.Artists != "" && artistsMatch(req.ArtistName, track.Artists) {
|
||||||
|
score += 320
|
||||||
|
}
|
||||||
|
if currentAlbum != "" && track.AlbumName != "" {
|
||||||
|
switch {
|
||||||
|
case titlesMatch(currentAlbum, track.AlbumName):
|
||||||
|
score += 120
|
||||||
|
case strings.Contains(strings.ToLower(track.AlbumName), strings.ToLower(currentAlbum)),
|
||||||
|
strings.Contains(strings.ToLower(currentAlbum), strings.ToLower(track.AlbumName)):
|
||||||
|
score += 50
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.DurationMs > 0 && track.DurationMS > 0 {
|
||||||
|
diff := int(req.DurationMs/1000) - (track.DurationMS / 1000)
|
||||||
|
if diff < 0 {
|
||||||
|
diff = -diff
|
||||||
|
}
|
||||||
|
if diff <= 10 {
|
||||||
|
score += 80
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if track.ReleaseDate != "" {
|
||||||
|
score += 70
|
||||||
|
}
|
||||||
|
if track.TrackNumber > 0 {
|
||||||
|
score += 20
|
||||||
|
}
|
||||||
|
if track.DiscNumber > 0 {
|
||||||
|
score += 10
|
||||||
|
}
|
||||||
|
if track.ISRC != "" {
|
||||||
|
score += 40
|
||||||
|
}
|
||||||
|
|
||||||
|
if best == nil || score > bestScore {
|
||||||
|
best = track
|
||||||
|
bestScore = score
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
func extTrackFromTrackMetadata(track *TrackMetadata, providerID string) *ExtTrackMetadata {
|
||||||
|
if track == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
deezerID := strings.TrimSpace(strings.TrimPrefix(track.SpotifyID, "deezer:"))
|
||||||
|
return &ExtTrackMetadata{
|
||||||
|
ID: track.SpotifyID,
|
||||||
|
Name: track.Name,
|
||||||
|
Artists: track.Artists,
|
||||||
|
AlbumName: track.AlbumName,
|
||||||
|
AlbumArtist: track.AlbumArtist,
|
||||||
|
DurationMS: track.DurationMS,
|
||||||
|
CoverURL: track.Images,
|
||||||
|
Images: track.Images,
|
||||||
|
ReleaseDate: track.ReleaseDate,
|
||||||
|
TrackNumber: track.TrackNumber,
|
||||||
|
DiscNumber: track.DiscNumber,
|
||||||
|
ISRC: track.ISRC,
|
||||||
|
ProviderID: providerID,
|
||||||
|
DeezerID: deezerID,
|
||||||
|
SpotifyID: track.SpotifyID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeReEnrichSpotifyTrackID(raw string) string {
|
||||||
|
trimmed := strings.TrimSpace(raw)
|
||||||
|
if trimmed == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if extracted := extractSpotifyIDFromURL(trimmed); extracted != "" {
|
||||||
|
return extracted
|
||||||
|
}
|
||||||
|
if len(trimmed) == 22 && !strings.Contains(trimmed, ":") && !strings.Contains(trimmed, "/") {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveReEnrichTrackFromIdentifiers(req reEnrichRequest) (*ExtTrackMetadata, error) {
|
||||||
|
deezerClient := GetDeezerClient()
|
||||||
|
downloadReq := reEnrichDownloadRequest(req)
|
||||||
|
|
||||||
|
if isrc := strings.TrimSpace(req.ISRC); isrc != "" {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
track, err := deezerClient.SearchByISRC(ctx, isrc)
|
||||||
|
cancel()
|
||||||
|
if err == nil && track != nil {
|
||||||
|
resolved := resolvedTrackInfo{
|
||||||
|
Title: track.Name,
|
||||||
|
ArtistName: track.Artists,
|
||||||
|
ISRC: track.ISRC,
|
||||||
|
Duration: track.DurationMS / 1000,
|
||||||
|
}
|
||||||
|
if trackMatchesRequest(downloadReq, resolved, "ReEnrich") {
|
||||||
|
return extTrackFromTrackMetadata(track, "deezer"), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceTrackID := strings.TrimSpace(req.SpotifyID)
|
||||||
|
if sourceTrackID == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
deezerID := strings.TrimSpace(strings.TrimPrefix(sourceTrackID, "deezer:"))
|
||||||
|
if deezerID == sourceTrackID {
|
||||||
|
deezerID = extractDeezerIDFromURL(sourceTrackID)
|
||||||
|
}
|
||||||
|
if deezerID == "" {
|
||||||
|
spotifyID := normalizeReEnrichSpotifyTrackID(sourceTrackID)
|
||||||
|
if spotifyID != "" {
|
||||||
|
resolvedDeezerID, err := NewSongLinkClient().GetDeezerIDFromSpotify(spotifyID)
|
||||||
|
if err == nil {
|
||||||
|
deezerID = strings.TrimSpace(resolvedDeezerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if deezerID == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
trackResp, err := deezerClient.GetTrack(ctx, deezerID)
|
||||||
|
if err != nil || trackResp == nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
track := &trackResp.Track
|
||||||
|
resolved := resolvedTrackInfo{
|
||||||
|
Title: track.Name,
|
||||||
|
ArtistName: track.Artists,
|
||||||
|
ISRC: track.ISRC,
|
||||||
|
Duration: track.DurationMS / 1000,
|
||||||
|
}
|
||||||
|
if !trackMatchesRequest(downloadReq, resolved, "ReEnrich") {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return extTrackFromTrackMetadata(track, "deezer"), nil
|
||||||
|
}
|
||||||
|
|
||||||
func preferredReleaseMetadata(
|
func preferredReleaseMetadata(
|
||||||
req DownloadRequest,
|
req DownloadRequest,
|
||||||
album string,
|
album string,
|
||||||
@@ -407,24 +651,6 @@ func DownloadTrack(requestJSON string) (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
err = deezerErr
|
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:
|
default:
|
||||||
return errorResponse("Unknown service: " + req.Service)
|
return errorResponse("Unknown service: " + req.Service)
|
||||||
}
|
}
|
||||||
@@ -476,7 +702,7 @@ func DownloadByStrategy(requestJSON string) (string, error) {
|
|||||||
serviceNormalized := strings.ToLower(serviceRaw)
|
serviceNormalized := strings.ToLower(serviceRaw)
|
||||||
|
|
||||||
normalizedReq := req
|
normalizedReq := req
|
||||||
if serviceNormalized == "youtube" || isBuiltInProvider(serviceNormalized) {
|
if isBuiltInProvider(serviceNormalized) {
|
||||||
normalizedReq.Service = serviceNormalized
|
normalizedReq.Service = serviceNormalized
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -486,10 +712,6 @@ func DownloadByStrategy(requestJSON string) (string, error) {
|
|||||||
}
|
}
|
||||||
normalizedJSON := string(normalizedBytes)
|
normalizedJSON := string(normalizedBytes)
|
||||||
|
|
||||||
if serviceNormalized == "youtube" {
|
|
||||||
return DownloadFromYouTube(normalizedJSON)
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.UseExtensions {
|
if req.UseExtensions {
|
||||||
// Respect strict mode when auto fallback is disabled:
|
// Respect strict mode when auto fallback is disabled:
|
||||||
// for built-in providers, route directly to selected service only.
|
// for built-in providers, route directly to selected service only.
|
||||||
@@ -721,29 +943,57 @@ func ReadFileMetadata(filePath string) (string, error) {
|
|||||||
if isFlac {
|
if isFlac {
|
||||||
metadata, err := ReadMetadata(filePath)
|
metadata, err := ReadMetadata(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to read metadata: %w", err)
|
// File may have wrong extension (e.g. opus saved as .flac).
|
||||||
}
|
// Try Ogg/Opus parser as fallback before giving up.
|
||||||
result["title"] = metadata.Title
|
GoLog("[ReadFileMetadata] FLAC parse failed for %s, trying Ogg fallback: %v\n", filePath, err)
|
||||||
result["artist"] = metadata.Artist
|
oggMeta, oggErr := ReadOggVorbisComments(filePath)
|
||||||
result["album"] = metadata.Album
|
if oggErr == nil && oggMeta != nil {
|
||||||
result["album_artist"] = metadata.AlbumArtist
|
result["title"] = oggMeta.Title
|
||||||
result["date"] = metadata.Date
|
result["artist"] = oggMeta.Artist
|
||||||
result["track_number"] = metadata.TrackNumber
|
result["album"] = oggMeta.Album
|
||||||
result["disc_number"] = metadata.DiscNumber
|
result["album_artist"] = oggMeta.AlbumArtist
|
||||||
result["isrc"] = metadata.ISRC
|
result["date"] = oggMeta.Date
|
||||||
result["lyrics"] = metadata.Lyrics
|
if oggMeta.Date == "" {
|
||||||
result["genre"] = metadata.Genre
|
result["date"] = oggMeta.Year
|
||||||
result["label"] = metadata.Label
|
}
|
||||||
result["copyright"] = metadata.Copyright
|
result["track_number"] = oggMeta.TrackNumber
|
||||||
result["composer"] = metadata.Composer
|
result["disc_number"] = oggMeta.DiscNumber
|
||||||
result["comment"] = metadata.Comment
|
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)
|
quality, qualityErr := GetAudioQuality(filePath)
|
||||||
if qualityErr == nil {
|
if qualityErr == nil {
|
||||||
result["bit_depth"] = quality.BitDepth
|
result["bit_depth"] = quality.BitDepth
|
||||||
result["sample_rate"] = quality.SampleRate
|
result["sample_rate"] = quality.SampleRate
|
||||||
if quality.SampleRate > 0 && quality.TotalSamples > 0 {
|
if quality.SampleRate > 0 && quality.TotalSamples > 0 {
|
||||||
result["duration"] = int(quality.TotalSamples / int64(quality.SampleRate))
|
result["duration"] = int(quality.TotalSamples / int64(quality.SampleRate))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if isM4A {
|
} else if isM4A {
|
||||||
@@ -910,7 +1160,6 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MP3/Opus: return metadata for Dart-side FFmpeg embedding
|
|
||||||
resp := map[string]any{
|
resp := map[string]any{
|
||||||
"success": true,
|
"success": true,
|
||||||
"method": "ffmpeg",
|
"method": "ffmpeg",
|
||||||
@@ -1522,72 +1771,6 @@ func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
|
|||||||
return "", fmt.Errorf("Spotify to Deezer conversion only supported for tracks and albums. Please search by name for %s", resourceType)
|
return "", fmt.Errorf("Spotify to Deezer conversion only supported for tracks and albums. Please search by name for %s", resourceType)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
spotFetchData, apiErr := GetSpotifyDataWithAPI(ctx, spotifyURL, DefaultSpotFetchAPIBaseURL)
|
|
||||||
if apiErr == nil {
|
|
||||||
GoLog("[Fallback] Spotify metadata fetched via SpotFetch API\n")
|
|
||||||
jsonBytes, err := json.Marshal(spotFetchData)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return string(jsonBytes), nil
|
|
||||||
}
|
|
||||||
GoLog("[Fallback] SpotFetch API fallback failed: %v\n", apiErr)
|
|
||||||
|
|
||||||
parsed, parseErr := parseSpotifyURI(spotifyURL)
|
|
||||||
if parseErr != nil {
|
|
||||||
return "", fmt.Errorf("SpotFetch fallback failed (%v) and URL parsing failed: %w", apiErr, parseErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
GoLog("[Fallback] Trying Deezer conversion fallback for %s...\n", parsed.Type)
|
|
||||||
|
|
||||||
if parsed.Type == "track" || parsed.Type == "album" {
|
|
||||||
return ConvertSpotifyToDeezer(parsed.Type, parsed.ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
if parsed.Type == "artist" {
|
|
||||||
return "", fmt.Errorf("SpotFetch fallback failed (%v). Artist pages now require SpotFetch or a metadata extension such as spotify-web", apiErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", fmt.Errorf("SpotFetch fallback failed (%v), and Deezer conversion is unavailable for playlists", apiErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
func shouldTrySpotFetchFallback(err error) bool {
|
|
||||||
if err == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if errors.Is(err, ErrNoSpotifyCredentials) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
errStr := strings.ToLower(err.Error())
|
|
||||||
indicators := []string{
|
|
||||||
"429",
|
|
||||||
"rate",
|
|
||||||
"limit",
|
|
||||||
"403",
|
|
||||||
"forbidden",
|
|
||||||
"401",
|
|
||||||
"unauthorized",
|
|
||||||
"timeout",
|
|
||||||
"connection",
|
|
||||||
"spotify error",
|
|
||||||
"access token",
|
|
||||||
"client token",
|
|
||||||
"eof",
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, indicator := range indicators {
|
|
||||||
if strings.Contains(errStr, indicator) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func CheckAvailabilityFromDeezerID(deezerTrackID string) (string, error) {
|
func CheckAvailabilityFromDeezerID(deezerTrackID string) (string, error) {
|
||||||
client := NewSongLinkClient()
|
client := NewSongLinkClient()
|
||||||
availability, err := client.CheckAvailabilityFromDeezer(deezerTrackID)
|
availability, err := client.CheckAvailabilityFromDeezer(deezerTrackID)
|
||||||
@@ -1670,62 +1853,6 @@ func errorResponse(msg string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
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 {
|
func DownloadCoverToFile(coverURL string, outputPath string, maxQuality bool) error {
|
||||||
if coverURL == "" {
|
if coverURL == "" {
|
||||||
return fmt.Errorf("no cover URL provided")
|
return fmt.Errorf("no cover URL provided")
|
||||||
@@ -1853,26 +1980,7 @@ func GetLyricsFetchOptionsJSON() (string, error) {
|
|||||||
// When search_online is true, searches Spotify/Deezer by track name + artist to fetch
|
// When search_online is true, searches Spotify/Deezer by track name + artist to fetch
|
||||||
// complete metadata from the internet before embedding.
|
// complete metadata from the internet before embedding.
|
||||||
func ReEnrichFile(requestJSON string) (string, error) {
|
func ReEnrichFile(requestJSON string) (string, error) {
|
||||||
var req struct {
|
var req reEnrichRequest
|
||||||
FilePath string `json:"file_path"`
|
|
||||||
CoverURL string `json:"cover_url"`
|
|
||||||
MaxQuality bool `json:"max_quality"`
|
|
||||||
EmbedLyrics bool `json:"embed_lyrics"`
|
|
||||||
SpotifyID string `json:"spotify_id"`
|
|
||||||
TrackName string `json:"track_name"`
|
|
||||||
ArtistName string `json:"artist_name"`
|
|
||||||
AlbumName string `json:"album_name"`
|
|
||||||
AlbumArtist string `json:"album_artist"`
|
|
||||||
TrackNumber int `json:"track_number"`
|
|
||||||
DiscNumber int `json:"disc_number"`
|
|
||||||
ReleaseDate string `json:"release_date"`
|
|
||||||
ISRC string `json:"isrc"`
|
|
||||||
Genre string `json:"genre"`
|
|
||||||
Label string `json:"label"`
|
|
||||||
Copyright string `json:"copyright"`
|
|
||||||
DurationMs int64 `json:"duration_ms"`
|
|
||||||
SearchOnline bool `json:"search_online"`
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
||||||
return "", fmt.Errorf("failed to parse request: %w", err)
|
return "", fmt.Errorf("failed to parse request: %w", err)
|
||||||
@@ -1894,42 +2002,22 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
|||||||
deezerClient := GetDeezerClient()
|
deezerClient := GetDeezerClient()
|
||||||
GoLog("[ReEnrich] Trying metadata providers in configured priority...\n")
|
GoLog("[ReEnrich] Trying metadata providers in configured priority...\n")
|
||||||
manager := GetExtensionManager()
|
manager := GetExtensionManager()
|
||||||
|
if identifierTrack, err := resolveReEnrichTrackFromIdentifiers(req); err == nil && identifierTrack != nil {
|
||||||
|
GoLog("[ReEnrich] Identifier-first metadata match (%s): %s - %s (album: %s, date: %s)\n",
|
||||||
|
identifierTrack.ProviderID, identifierTrack.Name, identifierTrack.Artists, identifierTrack.AlbumName, identifierTrack.ReleaseDate)
|
||||||
|
applyReEnrichTrackMetadata(&req, *identifierTrack)
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
|
||||||
tracks, searchErr := manager.SearchTracksWithMetadataProviders(searchQuery, 5, true)
|
tracks, searchErr := manager.SearchTracksWithMetadataProviders(searchQuery, 5, true)
|
||||||
if searchErr == nil && len(tracks) > 0 {
|
if searchErr == nil && len(tracks) > 0 {
|
||||||
track := tracks[0]
|
track := selectBestReEnrichTrack(req, tracks)
|
||||||
GoLog("[ReEnrich] Metadata match (%s): %s - %s (album: %s)\n", track.ProviderID, track.Name, track.Artists, track.AlbumName)
|
if track != nil {
|
||||||
if track.SpotifyID != "" {
|
GoLog("[ReEnrich] Metadata match (%s): %s - %s (album: %s, date: %s)\n",
|
||||||
req.SpotifyID = track.SpotifyID
|
track.ProviderID, track.Name, track.Artists, track.AlbumName, track.ReleaseDate)
|
||||||
} else if track.DeezerID != "" {
|
applyReEnrichTrackMetadata(&req, *track)
|
||||||
req.SpotifyID = "deezer:" + track.DeezerID
|
found = true
|
||||||
} else if track.QobuzID != "" {
|
|
||||||
req.SpotifyID = "qobuz:" + track.QobuzID
|
|
||||||
} else if track.TidalID != "" {
|
|
||||||
req.SpotifyID = "tidal:" + track.TidalID
|
|
||||||
} else {
|
|
||||||
req.SpotifyID = track.ID
|
|
||||||
}
|
}
|
||||||
req.AlbumName = track.AlbumName
|
|
||||||
req.AlbumArtist = track.AlbumArtist
|
|
||||||
req.TrackNumber = track.TrackNumber
|
|
||||||
req.DiscNumber = track.DiscNumber
|
|
||||||
req.ReleaseDate = track.ReleaseDate
|
|
||||||
req.ISRC = track.ISRC
|
|
||||||
coverURL := track.ResolvedCoverURL()
|
|
||||||
if coverURL != "" {
|
|
||||||
req.CoverURL = coverURL
|
|
||||||
}
|
|
||||||
req.DurationMs = int64(track.DurationMS)
|
|
||||||
if track.Genre != "" {
|
|
||||||
req.Genre = track.Genre
|
|
||||||
}
|
|
||||||
if track.Label != "" {
|
|
||||||
req.Label = track.Label
|
|
||||||
}
|
|
||||||
if track.Copyright != "" {
|
|
||||||
req.Copyright = track.Copyright
|
|
||||||
}
|
|
||||||
found = true
|
|
||||||
} else if searchErr != nil {
|
} else if searchErr != nil {
|
||||||
GoLog("[ReEnrich] Metadata provider search failed: %v\n", searchErr)
|
GoLog("[ReEnrich] Metadata provider search failed: %v\n", searchErr)
|
||||||
}
|
}
|
||||||
@@ -1958,7 +2046,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",
|
GoLog("[ReEnrich] Metadata to embed: title=%s, artist=%s, album=%s, albumArtist=%s\n",
|
||||||
req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist)
|
req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist)
|
||||||
GoLog("[ReEnrich] track=%d, disc=%d, date=%s, isrc=%s, genre=%s, label=%s\n",
|
GoLog("[ReEnrich] track=%d, disc=%d, date=%s, isrc=%s, genre=%s, label=%s\n",
|
||||||
@@ -2041,7 +2128,6 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build enriched metadata response for Dart (includes online search results)
|
|
||||||
enrichedMeta := map[string]interface{}{
|
enrichedMeta := map[string]interface{}{
|
||||||
"track_name": req.TrackName,
|
"track_name": req.TrackName,
|
||||||
"artist_name": req.ArtistName,
|
"artist_name": req.ArtistName,
|
||||||
@@ -2187,12 +2273,6 @@ func LoadExtensionFromPath(filePath string) (string, error) {
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
settingsStore := GetExtensionSettingsStore()
|
|
||||||
settings := settingsStore.GetAll(ext.ID)
|
|
||||||
if len(settings) > 0 {
|
|
||||||
manager.InitializeExtension(ext.ID, settings)
|
|
||||||
}
|
|
||||||
|
|
||||||
result := map[string]interface{}{
|
result := map[string]interface{}{
|
||||||
"id": ext.ID,
|
"id": ext.ID,
|
||||||
"name": ext.Manifest.Name,
|
"name": ext.Manifest.Name,
|
||||||
@@ -2226,12 +2306,6 @@ func UpgradeExtensionFromPath(filePath string) (string, error) {
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
settingsStore := GetExtensionSettingsStore()
|
|
||||||
settings := settingsStore.GetAll(ext.ID)
|
|
||||||
if len(settings) > 0 {
|
|
||||||
manager.InitializeExtension(ext.ID, settings)
|
|
||||||
}
|
|
||||||
|
|
||||||
result := map[string]interface{}{
|
result := map[string]interface{}{
|
||||||
"id": ext.ID,
|
"id": ext.ID,
|
||||||
"display_name": ext.Manifest.DisplayName,
|
"display_name": ext.Manifest.DisplayName,
|
||||||
@@ -3177,17 +3251,17 @@ func GetPostProcessingProvidersJSON() (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func InitExtensionStoreJSON(cacheDir string) error {
|
func InitExtensionStoreJSON(cacheDir string) error {
|
||||||
InitExtensionStore(cacheDir)
|
initExtensionStore(cacheDir)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func SetStoreRegistryURLJSON(registryURL string) error {
|
func SetStoreRegistryURLJSON(registryURL string) error {
|
||||||
store := GetExtensionStore()
|
store := getExtensionStore()
|
||||||
if store == nil {
|
if store == nil {
|
||||||
return fmt.Errorf("extension store not initialized")
|
return fmt.Errorf("extension store not initialized")
|
||||||
}
|
}
|
||||||
|
|
||||||
resolved, err := ResolveRegistryURL(registryURL)
|
resolved, err := resolveRegistryURL(registryURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -3196,41 +3270,37 @@ func SetStoreRegistryURLJSON(registryURL string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
store.SetRegistryURL(resolved)
|
store.setRegistryURL(resolved)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func ClearStoreRegistryURLJSON() error {
|
func ClearStoreRegistryURLJSON() error {
|
||||||
store := GetExtensionStore()
|
store := getExtensionStore()
|
||||||
if store == nil {
|
if store == nil {
|
||||||
return fmt.Errorf("extension store not initialized")
|
return fmt.Errorf("extension store not initialized")
|
||||||
}
|
}
|
||||||
|
|
||||||
store.SetRegistryURL("")
|
store.setRegistryURL("")
|
||||||
store.ClearCache()
|
store.clearCache()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetStoreRegistryURLJSON() (string, error) {
|
func GetStoreRegistryURLJSON() (string, error) {
|
||||||
store := GetExtensionStore()
|
store := getExtensionStore()
|
||||||
if store == nil {
|
if store == nil {
|
||||||
return "", fmt.Errorf("extension store not initialized")
|
return "", fmt.Errorf("extension store not initialized")
|
||||||
}
|
}
|
||||||
|
|
||||||
return store.GetRegistryURL(), nil
|
return store.getRegistryURL(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetStoreExtensionsJSON(forceRefresh bool) (string, error) {
|
func GetStoreExtensionsJSON(forceRefresh bool) (string, error) {
|
||||||
store := GetExtensionStore()
|
store := getExtensionStore()
|
||||||
if store == nil {
|
if store == nil {
|
||||||
return "", fmt.Errorf("extension store not initialized")
|
return "", fmt.Errorf("extension store not initialized")
|
||||||
}
|
}
|
||||||
|
|
||||||
if forceRefresh {
|
extensions, err := store.getExtensionsWithStatus(forceRefresh)
|
||||||
store.FetchRegistry(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
extensions, err := store.GetExtensionsWithStatus()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -3244,12 +3314,12 @@ func GetStoreExtensionsJSON(forceRefresh bool) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func SearchStoreExtensionsJSON(query, category string) (string, error) {
|
func SearchStoreExtensionsJSON(query, category string) (string, error) {
|
||||||
store := GetExtensionStore()
|
store := getExtensionStore()
|
||||||
if store == nil {
|
if store == nil {
|
||||||
return "", fmt.Errorf("extension store not initialized")
|
return "", fmt.Errorf("extension store not initialized")
|
||||||
}
|
}
|
||||||
|
|
||||||
extensions, err := store.SearchExtensions(query, category)
|
extensions, err := store.searchExtensions(query, category)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -3263,12 +3333,12 @@ func SearchStoreExtensionsJSON(query, category string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func GetStoreCategoriesJSON() (string, error) {
|
func GetStoreCategoriesJSON() (string, error) {
|
||||||
store := GetExtensionStore()
|
store := getExtensionStore()
|
||||||
if store == nil {
|
if store == nil {
|
||||||
return "", fmt.Errorf("extension store not initialized")
|
return "", fmt.Errorf("extension store not initialized")
|
||||||
}
|
}
|
||||||
|
|
||||||
categories := store.GetCategories()
|
categories := store.getCategories()
|
||||||
jsonBytes, err := json.Marshal(categories)
|
jsonBytes, err := json.Marshal(categories)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -3287,7 +3357,7 @@ func buildStoreExtensionDestPath(destDir, extensionID string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func DownloadStoreExtensionJSON(extensionID, destDir string) (string, error) {
|
func DownloadStoreExtensionJSON(extensionID, destDir string) (string, error) {
|
||||||
store := GetExtensionStore()
|
store := getExtensionStore()
|
||||||
if store == nil {
|
if store == nil {
|
||||||
return "", fmt.Errorf("extension store not initialized")
|
return "", fmt.Errorf("extension store not initialized")
|
||||||
}
|
}
|
||||||
@@ -3296,7 +3366,7 @@ func DownloadStoreExtensionJSON(extensionID, destDir string) (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
err = store.DownloadExtension(extensionID, destPath)
|
err = store.downloadExtension(extensionID, destPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -3305,12 +3375,12 @@ func DownloadStoreExtensionJSON(extensionID, destDir string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ClearStoreCacheJSON() error {
|
func ClearStoreCacheJSON() error {
|
||||||
store := GetExtensionStore()
|
store := getExtensionStore()
|
||||||
if store == nil {
|
if store == nil {
|
||||||
return fmt.Errorf("extension store not initialized")
|
return fmt.Errorf("extension store not initialized")
|
||||||
}
|
}
|
||||||
|
|
||||||
store.ClearCache()
|
store.clearCache()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3324,12 +3394,14 @@ func callExtensionFunctionJSON(extensionID, functionName string, timeout time.Du
|
|||||||
if !ext.Enabled {
|
if !ext.Enabled {
|
||||||
return "", fmt.Errorf("extension '%s' is disabled", extensionID)
|
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
|
// Goja runtime is not thread-safe; guard direct extension.*() calls with VMMu
|
||||||
// to avoid races with other provider calls (e.g. getAlbum/getPlaylist).
|
// to avoid races with other provider calls (e.g. getAlbum/getPlaylist).
|
||||||
ext.VMMu.Lock()
|
|
||||||
defer ext.VMMu.Unlock()
|
|
||||||
|
|
||||||
script := fmt.Sprintf(`
|
script := fmt.Sprintf(`
|
||||||
(function() {
|
(function() {
|
||||||
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
|
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
|
||||||
@@ -3339,7 +3411,7 @@ func callExtensionFunctionJSON(extensionID, functionName string, timeout time.Du
|
|||||||
})()
|
})()
|
||||||
`, functionName, functionName)
|
`, functionName, functionName)
|
||||||
|
|
||||||
result, err := RunWithTimeoutAndRecover(ext.VM, script, timeout)
|
result, err := RunWithTimeoutAndRecover(vm, script, timeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("%s failed: %w", functionName, err)
|
return "", fmt.Errorf("%s failed: %w", functionName, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,3 +113,67 @@ func TestBuildDownloadSuccessResponsePrefersProviderCoverURL(t *testing.T) {
|
|||||||
t.Fatalf("cover url = %q, want %q", resp.CoverURL, result.CoverURL)
|
t.Fatalf("cover url = %q, want %q", resp.CoverURL, result.CoverURL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestApplyReEnrichTrackMetadataPreservesExistingReleaseDateWhenCandidateMissing(t *testing.T) {
|
||||||
|
req := reEnrichRequest{
|
||||||
|
SpotifyID: "spotify-track-id",
|
||||||
|
AlbumName: "Original Album",
|
||||||
|
ReleaseDate: "2024-01-01",
|
||||||
|
ISRC: "REQ123",
|
||||||
|
}
|
||||||
|
|
||||||
|
applyReEnrichTrackMetadata(&req, ExtTrackMetadata{
|
||||||
|
AlbumName: "Resolved Album",
|
||||||
|
ReleaseDate: "",
|
||||||
|
ISRC: "",
|
||||||
|
})
|
||||||
|
|
||||||
|
if req.ReleaseDate != "2024-01-01" {
|
||||||
|
t.Fatalf("release date = %q, want existing value preserved", req.ReleaseDate)
|
||||||
|
}
|
||||||
|
if req.AlbumName != "Resolved Album" {
|
||||||
|
t.Fatalf("album = %q, want updated album", req.AlbumName)
|
||||||
|
}
|
||||||
|
if req.ISRC != "REQ123" {
|
||||||
|
t.Fatalf("isrc = %q, want existing value preserved", req.ISRC)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectBestReEnrichTrackPrefersCandidateWithReleaseDate(t *testing.T) {
|
||||||
|
req := reEnrichRequest{
|
||||||
|
TrackName: "Song Title",
|
||||||
|
ArtistName: "Artist Name",
|
||||||
|
AlbumName: "Album Name",
|
||||||
|
ReleaseDate: "",
|
||||||
|
DurationMs: 180000,
|
||||||
|
}
|
||||||
|
|
||||||
|
tracks := []ExtTrackMetadata{
|
||||||
|
{
|
||||||
|
ID: "first",
|
||||||
|
Name: "Song Title",
|
||||||
|
Artists: "Artist Name",
|
||||||
|
AlbumName: "Album Name",
|
||||||
|
DurationMS: 180000,
|
||||||
|
ReleaseDate: "",
|
||||||
|
ProviderID: "spotify",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "second",
|
||||||
|
Name: "Song Title",
|
||||||
|
Artists: "Artist Name",
|
||||||
|
AlbumName: "Album Name",
|
||||||
|
DurationMS: 180000,
|
||||||
|
ReleaseDate: "2024-03-09",
|
||||||
|
ProviderID: "deezer",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
best := selectBestReEnrichTrack(req, tracks)
|
||||||
|
if best == nil {
|
||||||
|
t.Fatal("expected a selected track")
|
||||||
|
}
|
||||||
|
if best.ID != "second" {
|
||||||
|
t.Fatalf("selected track = %q, want candidate with release date", best.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+241
-114
@@ -44,16 +44,76 @@ func compareVersions(v1, v2 string) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type LoadedExtension struct {
|
type LoadedExtension struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Manifest *ExtensionManifest `json:"manifest"`
|
Manifest *ExtensionManifest `json:"manifest"`
|
||||||
VM *goja.Runtime `json:"-"`
|
VM *goja.Runtime `json:"-"`
|
||||||
VMMu sync.Mutex `json:"-"`
|
VMMu sync.Mutex `json:"-"`
|
||||||
runtime *ExtensionRuntime
|
runtime *ExtensionRuntime
|
||||||
Enabled bool `json:"enabled"`
|
initialized bool
|
||||||
Error string `json:"error,omitempty"`
|
Enabled bool `json:"enabled"`
|
||||||
DataDir string `json:"data_dir"`
|
Error string `json:"error,omitempty"`
|
||||||
SourceDir string `json:"source_dir"`
|
DataDir string `json:"data_dir"`
|
||||||
IconPath string `json:"icon_path"`
|
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 {
|
type ExtensionManager struct {
|
||||||
@@ -220,10 +280,10 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
|||||||
SourceDir: extDir,
|
SourceDir: extDir,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := m.initializeVM(ext); err != nil {
|
if err := validateExtensionLoad(ext); err != nil {
|
||||||
ext.Error = err.Error()
|
ext.Error = err.Error()
|
||||||
ext.Enabled = false
|
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
|
m.extensions[manifest.Name] = ext
|
||||||
@@ -232,7 +292,10 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
|||||||
return ext, nil
|
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()
|
vm := goja.New()
|
||||||
ext.VM = vm
|
ext.VM = vm
|
||||||
|
|
||||||
@@ -279,6 +342,136 @@ func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
|
|||||||
return nil
|
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 {
|
func (m *ExtensionManager) UnloadExtension(extensionID string) error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
@@ -288,21 +481,9 @@ func (m *ExtensionManager) UnloadExtension(extensionID string) error {
|
|||||||
return fmt.Errorf("Extension not found")
|
return fmt.Errorf("Extension not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
if ext.VM != nil {
|
ext.VMMu.Lock()
|
||||||
cleanup, err := ext.VM.RunString("typeof extension !== 'undefined' && typeof extension.cleanup === 'function' ? extension.cleanup() : null")
|
teardownVMLocked(ext)
|
||||||
if err != nil {
|
ext.VMMu.Unlock()
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
delete(m.extensions, extensionID)
|
delete(m.extensions, extensionID)
|
||||||
GoLog("[Extension] Unloaded extension: %s\n", 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")
|
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])
|
GoLog("[Extension] %s %s\n", extensionID, map[bool]string{true: "enabled", false: "disabled"}[enabled])
|
||||||
|
|
||||||
store := GetExtensionSettingsStore()
|
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.Error = err.Error()
|
||||||
ext.Enabled = false
|
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
|
m.extensions[manifest.Name] = ext
|
||||||
@@ -590,10 +785,14 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
|
|||||||
SourceDir: extDir,
|
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.Error = err.Error()
|
||||||
ext.Enabled = false
|
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()
|
m.mu.Lock()
|
||||||
@@ -790,56 +989,13 @@ func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[
|
|||||||
return fmt.Errorf("Extension not found")
|
return fmt.Errorf("Extension not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
if ext.VM == nil {
|
ext.VMMu.Lock()
|
||||||
return fmt.Errorf("Extension failed to load. Please reinstall the extension")
|
defer ext.VMMu.Unlock()
|
||||||
}
|
|
||||||
|
|
||||||
settingsJSON, err := json.Marshal(settings)
|
if err := ensureRuntimeReadyLocked(ext, false); err != nil {
|
||||||
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)
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
return initializeExtensionWithSettingsLocked(ext, settings)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) CleanupExtension(extensionID string) error {
|
func (m *ExtensionManager) CleanupExtension(extensionID string) error {
|
||||||
@@ -854,41 +1010,12 @@ func (m *ExtensionManager) CleanupExtension(extensionID string) error {
|
|||||||
if ext.VM == nil {
|
if ext.VM == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
ext.VMMu.Lock()
|
||||||
script := `
|
defer ext.VMMu.Unlock()
|
||||||
(function() {
|
if err := runCleanupLocked(ext); err != nil {
|
||||||
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 {
|
|
||||||
GoLog("[Extension] Cleanup error for %s: %v\n", extensionID, err)
|
GoLog("[Extension] Cleanup error for %s: %v\n", extensionID, err)
|
||||||
return 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)
|
GoLog("[Extension] Cleaned up %s\n", extensionID)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -917,8 +1044,8 @@ func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) (
|
|||||||
return nil, fmt.Errorf("extension not found: %s", extensionID)
|
return nil, fmt.Errorf("extension not found: %s", extensionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
if ext.VM == nil {
|
if err := ext.ensureRuntimeReady(); err != nil {
|
||||||
return nil, fmt.Errorf("extension VM not initialized")
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if !ext.Enabled {
|
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) {
|
func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSearchResult, error) {
|
||||||
if !p.extension.Manifest.IsMetadataProvider() {
|
if !p.extension.Manifest.IsMetadataProvider() {
|
||||||
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
|
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 {
|
if !p.extension.Enabled {
|
||||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||||
}
|
}
|
||||||
|
if err := p.lockReadyVM(); err != nil {
|
||||||
p.extension.VMMu.Lock()
|
return nil, err
|
||||||
|
}
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
script := fmt.Sprintf(`
|
script := fmt.Sprintf(`
|
||||||
@@ -192,8 +202,9 @@ func (p *ExtensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata,
|
|||||||
if !p.extension.Enabled {
|
if !p.extension.Enabled {
|
||||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||||
}
|
}
|
||||||
|
if err := p.lockReadyVM(); err != nil {
|
||||||
p.extension.VMMu.Lock()
|
return nil, err
|
||||||
|
}
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
script := fmt.Sprintf(`
|
script := fmt.Sprintf(`
|
||||||
@@ -240,8 +251,9 @@ func (p *ExtensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata,
|
|||||||
if !p.extension.Enabled {
|
if !p.extension.Enabled {
|
||||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||||
}
|
}
|
||||||
|
if err := p.lockReadyVM(); err != nil {
|
||||||
p.extension.VMMu.Lock()
|
return nil, err
|
||||||
|
}
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
script := fmt.Sprintf(`
|
script := fmt.Sprintf(`
|
||||||
@@ -291,8 +303,9 @@ func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadat
|
|||||||
if !p.extension.Enabled {
|
if !p.extension.Enabled {
|
||||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||||
}
|
}
|
||||||
|
if err := p.lockReadyVM(); err != nil {
|
||||||
p.extension.VMMu.Lock()
|
return nil, err
|
||||||
|
}
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
script := fmt.Sprintf(`
|
script := fmt.Sprintf(`
|
||||||
@@ -345,8 +358,10 @@ func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTra
|
|||||||
if !p.extension.Enabled {
|
if !p.extension.Enabled {
|
||||||
return track, nil
|
return track, nil
|
||||||
}
|
}
|
||||||
|
if err := p.lockReadyVM(); err != nil {
|
||||||
p.extension.VMMu.Lock()
|
GoLog("[Extension] EnrichTrack init error for %s: %v\n", p.extension.ID, err)
|
||||||
|
return track, nil
|
||||||
|
}
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
trackJSON, err := json.Marshal(track)
|
trackJSON, err := json.Marshal(track)
|
||||||
@@ -405,8 +420,9 @@ func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName
|
|||||||
if !p.extension.Enabled {
|
if !p.extension.Enabled {
|
||||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||||
}
|
}
|
||||||
|
if err := p.lockReadyVM(); err != nil {
|
||||||
p.extension.VMMu.Lock()
|
return nil, err
|
||||||
|
}
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
script := fmt.Sprintf(`
|
script := fmt.Sprintf(`
|
||||||
@@ -452,8 +468,9 @@ func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*Ext
|
|||||||
if !p.extension.Enabled {
|
if !p.extension.Enabled {
|
||||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||||
}
|
}
|
||||||
|
if err := p.lockReadyVM(); err != nil {
|
||||||
p.extension.VMMu.Lock()
|
return nil, err
|
||||||
|
}
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
script := fmt.Sprintf(`
|
script := fmt.Sprintf(`
|
||||||
@@ -493,7 +510,7 @@ func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*Ext
|
|||||||
|
|
||||||
const ExtDownloadTimeout = DownloadTimeout
|
const ExtDownloadTimeout = DownloadTimeout
|
||||||
|
|
||||||
func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string, onProgress func(percent int)) (*ExtDownloadResult, error) {
|
func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath, itemID string, onProgress func(percent int)) (*ExtDownloadResult, error) {
|
||||||
if !p.extension.Manifest.IsDownloadProvider() {
|
if !p.extension.Manifest.IsDownloadProvider() {
|
||||||
return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID)
|
||||||
}
|
}
|
||||||
@@ -501,9 +518,18 @@ func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string,
|
|||||||
if !p.extension.Enabled {
|
if !p.extension.Enabled {
|
||||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||||
}
|
}
|
||||||
|
if err := p.lockReadyVM(); err != nil {
|
||||||
p.extension.VMMu.Lock()
|
return &ExtDownloadResult{
|
||||||
|
Success: false,
|
||||||
|
ErrorMessage: err.Error(),
|
||||||
|
ErrorType: "init_error",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
if p.extension.runtime != nil {
|
||||||
|
p.extension.runtime.setActiveDownloadItemID(itemID)
|
||||||
|
defer p.extension.runtime.clearActiveDownloadItemID()
|
||||||
|
}
|
||||||
|
|
||||||
p.vm.Set("__onProgress", func(call goja.FunctionCall) goja.Value {
|
p.vm.Set("__onProgress", func(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) > 0 {
|
if len(call.Arguments) > 0 {
|
||||||
@@ -1106,7 +1132,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
StartItemProgress(req.ItemID)
|
StartItemProgress(req.ItemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := provider.Download(trackID, req.Quality, outputPath, func(percent int) {
|
result, err := provider.Download(trackID, req.Quality, outputPath, req.ItemID, func(percent int) {
|
||||||
if req.ItemID != "" {
|
if req.ItemID != "" {
|
||||||
normalized := float64(percent) / 100.0
|
normalized := float64(percent) / 100.0
|
||||||
if normalized < 0 {
|
if normalized < 0 {
|
||||||
@@ -1334,7 +1360,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
StartItemProgress(req.ItemID)
|
StartItemProgress(req.ItemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := provider.Download(availability.TrackID, req.Quality, outputPath, func(percent int) {
|
result, err := provider.Download(availability.TrackID, req.Quality, outputPath, req.ItemID, func(percent int) {
|
||||||
if req.ItemID != "" {
|
if req.ItemID != "" {
|
||||||
normalized := float64(percent) / 100.0
|
normalized := float64(percent) / 100.0
|
||||||
if normalized < 0 {
|
if normalized < 0 {
|
||||||
@@ -1626,8 +1652,9 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string
|
|||||||
if !p.extension.Enabled {
|
if !p.extension.Enabled {
|
||||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||||
}
|
}
|
||||||
|
if err := p.lockReadyVM(); err != nil {
|
||||||
p.extension.VMMu.Lock()
|
return nil, err
|
||||||
|
}
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
if options == nil {
|
if options == nil {
|
||||||
@@ -1707,8 +1734,9 @@ func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e
|
|||||||
if !p.extension.Enabled {
|
if !p.extension.Enabled {
|
||||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||||
}
|
}
|
||||||
|
if err := p.lockReadyVM(); err != nil {
|
||||||
p.extension.VMMu.Lock()
|
return nil, err
|
||||||
|
}
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
script := fmt.Sprintf(`
|
script := fmt.Sprintf(`
|
||||||
@@ -1792,8 +1820,9 @@ func (p *ExtensionProviderWrapper) MatchTrack(sourceTrack map[string]interface{}
|
|||||||
if !p.extension.Enabled {
|
if !p.extension.Enabled {
|
||||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||||
}
|
}
|
||||||
|
if err := p.lockReadyVM(); err != nil {
|
||||||
p.extension.VMMu.Lock()
|
return nil, err
|
||||||
|
}
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
sourceJSON, _ := json.Marshal(sourceTrack)
|
sourceJSON, _ := json.Marshal(sourceTrack)
|
||||||
@@ -1862,8 +1891,9 @@ func (p *ExtensionProviderWrapper) PostProcess(filePath string, metadata map[str
|
|||||||
if !p.extension.Enabled {
|
if !p.extension.Enabled {
|
||||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||||
}
|
}
|
||||||
|
if err := p.lockReadyVM(); err != nil {
|
||||||
p.extension.VMMu.Lock()
|
return &PostProcessResult{Success: false, Error: err.Error()}, nil
|
||||||
|
}
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
metadataJSON, _ := json.Marshal(metadata)
|
metadataJSON, _ := json.Marshal(metadata)
|
||||||
@@ -1924,8 +1954,9 @@ func (p *ExtensionProviderWrapper) PostProcessV2(input PostProcessInput, metadat
|
|||||||
if !p.extension.Enabled {
|
if !p.extension.Enabled {
|
||||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||||
}
|
}
|
||||||
|
if err := p.lockReadyVM(); err != nil {
|
||||||
p.extension.VMMu.Lock()
|
return &PostProcessResult{Success: false, Error: err.Error()}, nil
|
||||||
|
}
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
metadataJSON, _ := json.Marshal(metadata)
|
metadataJSON, _ := json.Marshal(metadata)
|
||||||
@@ -2182,8 +2213,9 @@ func (p *ExtensionProviderWrapper) FetchLyrics(trackName, artistName, albumName
|
|||||||
if !p.extension.Enabled {
|
if !p.extension.Enabled {
|
||||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||||
}
|
}
|
||||||
|
if err := p.lockReadyVM(); err != nil {
|
||||||
p.extension.VMMu.Lock()
|
return nil, err
|
||||||
|
}
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
// Use global variables to avoid JS injection issues with special characters in track/artist names
|
// Use global variables to avoid JS injection issues with special characters in track/artist names
|
||||||
|
|||||||
@@ -90,6 +90,9 @@ type ExtensionRuntime struct {
|
|||||||
dataDir string
|
dataDir string
|
||||||
vm *goja.Runtime
|
vm *goja.Runtime
|
||||||
|
|
||||||
|
activeDownloadMu sync.RWMutex
|
||||||
|
activeDownloadItemID string
|
||||||
|
|
||||||
storageMu sync.RWMutex
|
storageMu sync.RWMutex
|
||||||
storageCache map[string]interface{}
|
storageCache map[string]interface{}
|
||||||
storageLoaded bool
|
storageLoaded bool
|
||||||
@@ -139,6 +142,24 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
|
|||||||
return runtime
|
return runtime
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) setActiveDownloadItemID(itemID string) {
|
||||||
|
r.activeDownloadMu.Lock()
|
||||||
|
defer r.activeDownloadMu.Unlock()
|
||||||
|
r.activeDownloadItemID = strings.TrimSpace(itemID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) clearActiveDownloadItemID() {
|
||||||
|
r.activeDownloadMu.Lock()
|
||||||
|
defer r.activeDownloadMu.Unlock()
|
||||||
|
r.activeDownloadItemID = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) getActiveDownloadItemID() string {
|
||||||
|
r.activeDownloadMu.RLock()
|
||||||
|
defer r.activeDownloadMu.RUnlock()
|
||||||
|
return r.activeDownloadItemID
|
||||||
|
}
|
||||||
|
|
||||||
func newExtensionHTTPClient(ext *LoadedExtension, jar http.CookieJar, timeout time.Duration) *http.Client {
|
func newExtensionHTTPClient(ext *LoadedExtension, jar http.CookieJar, timeout time.Duration) *http.Client {
|
||||||
// Extension sandbox enforces HTTPS-only domains. Do not apply global
|
// Extension sandbox enforces HTTPS-only domains. Do not apply global
|
||||||
// allow_http scheme downgrade here, because some extension APIs (e.g.
|
// allow_http scheme downgrade here, because some extension APIs (e.g.
|
||||||
|
|||||||
@@ -205,13 +205,22 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
|||||||
defer out.Close()
|
defer out.Close()
|
||||||
|
|
||||||
contentLength := resp.ContentLength
|
contentLength := resp.ContentLength
|
||||||
|
activeItemID := r.getActiveDownloadItemID()
|
||||||
|
if activeItemID != "" && contentLength > 0 {
|
||||||
|
SetItemBytesTotal(activeItemID, contentLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
var progressWriter interface{ Write([]byte) (int, error) } = out
|
||||||
|
if activeItemID != "" {
|
||||||
|
progressWriter = NewItemProgressWriter(out, activeItemID)
|
||||||
|
}
|
||||||
|
|
||||||
var written int64
|
var written int64
|
||||||
buf := make([]byte, 32*1024)
|
buf := make([]byte, 32*1024)
|
||||||
for {
|
for {
|
||||||
nr, er := resp.Body.Read(buf)
|
nr, er := resp.Body.Read(buf)
|
||||||
if nr > 0 {
|
if nr > 0 {
|
||||||
nw, ew := out.Write(buf[0:nr])
|
nw, ew := progressWriter.Write(buf[0:nr])
|
||||||
if nw < 0 || nr < nw {
|
if nw < 0 || nr < nw {
|
||||||
nw = 0
|
nw = 0
|
||||||
if ew == nil {
|
if ew == nil {
|
||||||
@@ -220,6 +229,12 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
|||||||
}
|
}
|
||||||
written += int64(nw)
|
written += int64(nw)
|
||||||
if ew != nil {
|
if ew != nil {
|
||||||
|
if ew == ErrDownloadCancelled {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "download cancelled",
|
||||||
|
})
|
||||||
|
}
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
"error": fmt.Sprintf("failed to write file: %v", ew),
|
"error": fmt.Sprintf("failed to write file: %v", ew),
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const (
|
|||||||
CategoryIntegration = "integration"
|
CategoryIntegration = "integration"
|
||||||
)
|
)
|
||||||
|
|
||||||
type StoreExtension struct {
|
type storeExtension struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
DisplayName string `json:"display_name,omitempty"`
|
DisplayName string `json:"display_name,omitempty"`
|
||||||
@@ -41,7 +41,7 @@ type StoreExtension struct {
|
|||||||
MinAppVersionAlt string `json:"minAppVersion,omitempty"`
|
MinAppVersionAlt string `json:"minAppVersion,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *StoreExtension) getDisplayName() string {
|
func (e *storeExtension) getDisplayName() string {
|
||||||
if e.DisplayName != "" {
|
if e.DisplayName != "" {
|
||||||
return e.DisplayName
|
return e.DisplayName
|
||||||
}
|
}
|
||||||
@@ -51,34 +51,34 @@ func (e *StoreExtension) getDisplayName() string {
|
|||||||
return e.Name
|
return e.Name
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *StoreExtension) getDownloadURL() string {
|
func (e *storeExtension) getDownloadURL() string {
|
||||||
if e.DownloadURL != "" {
|
if e.DownloadURL != "" {
|
||||||
return e.DownloadURL
|
return e.DownloadURL
|
||||||
}
|
}
|
||||||
return e.DownloadURLAlt
|
return e.DownloadURLAlt
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *StoreExtension) getIconURL() string {
|
func (e *storeExtension) getIconURL() string {
|
||||||
if e.IconURL != "" {
|
if e.IconURL != "" {
|
||||||
return e.IconURL
|
return e.IconURL
|
||||||
}
|
}
|
||||||
return e.IconURLAlt
|
return e.IconURLAlt
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *StoreExtension) getMinAppVersion() string {
|
func (e *storeExtension) getMinAppVersion() string {
|
||||||
if e.MinAppVersion != "" {
|
if e.MinAppVersion != "" {
|
||||||
return e.MinAppVersion
|
return e.MinAppVersion
|
||||||
}
|
}
|
||||||
return e.MinAppVersionAlt
|
return e.MinAppVersionAlt
|
||||||
}
|
}
|
||||||
|
|
||||||
type StoreRegistry struct {
|
type storeRegistry struct {
|
||||||
Version int `json:"version"`
|
Version int `json:"version"`
|
||||||
UpdatedAt string `json:"updated_at"`
|
UpdatedAt string `json:"updated_at"`
|
||||||
Extensions []StoreExtension `json:"extensions"`
|
Extensions []storeExtension `json:"extensions"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type StoreExtensionResponse struct {
|
type storeExtensionResponse struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
DisplayName string `json:"display_name"`
|
DisplayName string `json:"display_name"`
|
||||||
@@ -97,8 +97,8 @@ type StoreExtensionResponse struct {
|
|||||||
HasUpdate bool `json:"has_update"`
|
HasUpdate bool `json:"has_update"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *StoreExtension) ToResponse() StoreExtensionResponse {
|
func (e *storeExtension) toResponse() storeExtensionResponse {
|
||||||
return StoreExtensionResponse{
|
resp := storeExtensionResponse{
|
||||||
ID: e.ID,
|
ID: e.ID,
|
||||||
Name: e.Name,
|
Name: e.Name,
|
||||||
DisplayName: e.getDisplayName(),
|
DisplayName: e.getDisplayName(),
|
||||||
@@ -108,25 +108,30 @@ func (e *StoreExtension) ToResponse() StoreExtensionResponse {
|
|||||||
DownloadURL: e.getDownloadURL(),
|
DownloadURL: e.getDownloadURL(),
|
||||||
IconURL: e.getIconURL(),
|
IconURL: e.getIconURL(),
|
||||||
Category: e.Category,
|
Category: e.Category,
|
||||||
Tags: e.Tags,
|
|
||||||
Downloads: e.Downloads,
|
Downloads: e.Downloads,
|
||||||
UpdatedAt: e.UpdatedAt,
|
UpdatedAt: e.UpdatedAt,
|
||||||
MinAppVersion: e.getMinAppVersion(),
|
MinAppVersion: e.getMinAppVersion(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(e.Tags) > 0 {
|
||||||
|
resp.Tags = append([]string(nil), e.Tags...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExtensionStore struct {
|
type extensionStore struct {
|
||||||
registryURL string
|
registryURL string
|
||||||
cacheDir string
|
cacheDir string
|
||||||
cache *StoreRegistry
|
cache *storeRegistry
|
||||||
cacheMu sync.RWMutex
|
cacheMu sync.RWMutex
|
||||||
cacheTime time.Time
|
cacheTime time.Time
|
||||||
cacheTTL time.Duration
|
cacheTTL time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
extensionStore *ExtensionStore
|
globalExtensionStore *extensionStore
|
||||||
extensionStoreMu sync.Mutex
|
extensionStoreMu sync.Mutex
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -134,24 +139,24 @@ const (
|
|||||||
cacheFileName = "store_cache.json"
|
cacheFileName = "store_cache.json"
|
||||||
)
|
)
|
||||||
|
|
||||||
func InitExtensionStore(cacheDir string) *ExtensionStore {
|
func initExtensionStore(cacheDir string) *extensionStore {
|
||||||
extensionStoreMu.Lock()
|
extensionStoreMu.Lock()
|
||||||
defer extensionStoreMu.Unlock()
|
defer extensionStoreMu.Unlock()
|
||||||
|
|
||||||
if extensionStore == nil {
|
if globalExtensionStore == nil {
|
||||||
extensionStore = &ExtensionStore{
|
globalExtensionStore = &extensionStore{
|
||||||
registryURL: "", // No default - user must provide a registry URL
|
registryURL: "", // No default - user must provide a registry URL
|
||||||
cacheDir: cacheDir,
|
cacheDir: cacheDir,
|
||||||
cacheTTL: cacheTTL,
|
cacheTTL: cacheTTL,
|
||||||
}
|
}
|
||||||
extensionStore.loadDiskCache()
|
globalExtensionStore.loadDiskCache()
|
||||||
}
|
}
|
||||||
return extensionStore
|
return globalExtensionStore
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetRegistryURL updates the registry URL and clears the in-memory cache
|
// SetRegistryURL updates the registry URL and clears the in-memory cache
|
||||||
// so the next fetch will use the new URL. Disk cache is also cleared.
|
// so the next fetch will use the new URL. Disk cache is also cleared.
|
||||||
func (s *ExtensionStore) SetRegistryURL(registryURL string) {
|
func (s *extensionStore) setRegistryURL(registryURL string) {
|
||||||
s.cacheMu.Lock()
|
s.cacheMu.Lock()
|
||||||
defer s.cacheMu.Unlock()
|
defer s.cacheMu.Unlock()
|
||||||
|
|
||||||
@@ -173,19 +178,19 @@ func (s *ExtensionStore) SetRegistryURL(registryURL string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetRegistryURL returns the currently configured registry URL.
|
// GetRegistryURL returns the currently configured registry URL.
|
||||||
func (s *ExtensionStore) GetRegistryURL() string {
|
func (s *extensionStore) getRegistryURL() string {
|
||||||
s.cacheMu.RLock()
|
s.cacheMu.RLock()
|
||||||
defer s.cacheMu.RUnlock()
|
defer s.cacheMu.RUnlock()
|
||||||
return s.registryURL
|
return s.registryURL
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetExtensionStore() *ExtensionStore {
|
func getExtensionStore() *extensionStore {
|
||||||
extensionStoreMu.Lock()
|
extensionStoreMu.Lock()
|
||||||
defer extensionStoreMu.Unlock()
|
defer extensionStoreMu.Unlock()
|
||||||
return extensionStore
|
return globalExtensionStore
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ExtensionStore) loadDiskCache() {
|
func (s *extensionStore) loadDiskCache() {
|
||||||
if s.cacheDir == "" {
|
if s.cacheDir == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -197,7 +202,7 @@ func (s *ExtensionStore) loadDiskCache() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var cacheData struct {
|
var cacheData struct {
|
||||||
Registry StoreRegistry `json:"registry"`
|
Registry storeRegistry `json:"registry"`
|
||||||
CacheTime int64 `json:"cache_time"`
|
CacheTime int64 `json:"cache_time"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,13 +215,13 @@ func (s *ExtensionStore) loadDiskCache() {
|
|||||||
LogDebug("ExtensionStore", "Loaded %d extensions from disk cache", len(s.cache.Extensions))
|
LogDebug("ExtensionStore", "Loaded %d extensions from disk cache", len(s.cache.Extensions))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ExtensionStore) saveDiskCache() {
|
func (s *extensionStore) saveDiskCache() {
|
||||||
if s.cacheDir == "" || s.cache == nil {
|
if s.cacheDir == "" || s.cache == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
cacheData := struct {
|
cacheData := struct {
|
||||||
Registry StoreRegistry `json:"registry"`
|
Registry storeRegistry `json:"registry"`
|
||||||
CacheTime int64 `json:"cache_time"`
|
CacheTime int64 `json:"cache_time"`
|
||||||
}{
|
}{
|
||||||
Registry: *s.cache,
|
Registry: *s.cache,
|
||||||
@@ -232,11 +237,10 @@ func (s *ExtensionStore) saveDiskCache() {
|
|||||||
os.WriteFile(cachePath, data, 0644)
|
os.WriteFile(cachePath, data, 0644)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error) {
|
func (s *extensionStore) fetchRegistry(forceRefresh bool) (*storeRegistry, error) {
|
||||||
s.cacheMu.Lock()
|
s.cacheMu.Lock()
|
||||||
defer s.cacheMu.Unlock()
|
defer s.cacheMu.Unlock()
|
||||||
|
|
||||||
// Check if a registry URL has been configured
|
|
||||||
if s.registryURL == "" {
|
if s.registryURL == "" {
|
||||||
return nil, fmt.Errorf("no registry URL configured. Please add a repository URL first")
|
return nil, fmt.Errorf("no registry URL configured. Please add a repository URL first")
|
||||||
}
|
}
|
||||||
@@ -276,7 +280,7 @@ func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error
|
|||||||
return nil, fmt.Errorf("failed to read registry: %w", err)
|
return nil, fmt.Errorf("failed to read registry: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var registry StoreRegistry
|
var registry storeRegistry
|
||||||
if err := json.Unmarshal(body, ®istry); err != nil {
|
if err := json.Unmarshal(body, ®istry); err != nil {
|
||||||
return nil, fmt.Errorf("failed to parse registry: %w", err)
|
return nil, fmt.Errorf("failed to parse registry: %w", err)
|
||||||
}
|
}
|
||||||
@@ -289,8 +293,8 @@ func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error
|
|||||||
return ®istry, nil
|
return ®istry, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionResponse, error) {
|
func (s *extensionStore) getExtensionsWithStatus(forceRefresh bool) ([]storeExtensionResponse, error) {
|
||||||
registry, err := s.FetchRegistry(false)
|
registry, err := s.fetchRegistry(forceRefresh)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -304,29 +308,32 @@ func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionResponse, er
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
result := make([]StoreExtensionResponse, len(registry.Extensions))
|
LogDebug("ExtensionStore", "Building store response for %d registry extensions (%d installed)", len(registry.Extensions), len(installed))
|
||||||
for i, ext := range registry.Extensions {
|
|
||||||
resp := ext.ToResponse()
|
|
||||||
|
|
||||||
|
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 {
|
if installedVersion, ok := installed[ext.ID]; ok {
|
||||||
resp.IsInstalled = true
|
resp.IsInstalled = true
|
||||||
resp.InstalledVersion = installedVersion
|
resp.InstalledVersion = installedVersion
|
||||||
resp.HasUpdate = compareVersions(ext.Version, installedVersion) > 0
|
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
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string) error {
|
func (s *extensionStore) downloadExtension(extensionID string, destPath string) error {
|
||||||
registry, err := s.FetchRegistry(false)
|
registry, err := s.fetchRegistry(false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
var ext *StoreExtension
|
var ext *storeExtension
|
||||||
for _, e := range registry.Extensions {
|
for _, e := range registry.Extensions {
|
||||||
if e.ID == extensionID {
|
if e.ID == extensionID {
|
||||||
ext = &e
|
ext = &e
|
||||||
@@ -378,7 +385,7 @@ func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string)
|
|||||||
// - https://github.com/owner/repo (with optional trailing path / .git) → resolved via
|
// - https://github.com/owner/repo (with optional trailing path / .git) → resolved via
|
||||||
// the GitHub API to discover the default branch, then converted to the raw URL
|
// the GitHub API to discover the default branch, then converted to the raw URL
|
||||||
// - Any other HTTPS URL → returned as-is (assumed to be a direct link)
|
// - Any other HTTPS URL → returned as-is (assumed to be a direct link)
|
||||||
func ResolveRegistryURL(input string) (string, error) {
|
func resolveRegistryURL(input string) (string, error) {
|
||||||
input = strings.TrimSpace(input)
|
input = strings.TrimSpace(input)
|
||||||
if input == "" {
|
if input == "" {
|
||||||
return "", fmt.Errorf("registry URL is empty")
|
return "", fmt.Errorf("registry URL is empty")
|
||||||
@@ -389,7 +396,6 @@ func ResolveRegistryURL(input string) (string, error) {
|
|||||||
return input, nil
|
return input, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to match https://github.com/<owner>/<repo>[/...]
|
|
||||||
const ghPrefix = "https://github.com/"
|
const ghPrefix = "https://github.com/"
|
||||||
if !strings.HasPrefix(input, ghPrefix) {
|
if !strings.HasPrefix(input, ghPrefix) {
|
||||||
// Also accept http:// and upgrade silently.
|
// Also accept http:// and upgrade silently.
|
||||||
@@ -460,7 +466,7 @@ func requireHTTPSURL(rawURL string, context string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ExtensionStore) GetCategories() []string {
|
func (s *extensionStore) getCategories() []string {
|
||||||
return []string{
|
return []string{
|
||||||
CategoryMetadata,
|
CategoryMetadata,
|
||||||
CategoryDownload,
|
CategoryDownload,
|
||||||
@@ -470,8 +476,8 @@ 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()
|
extensions, err := s.getExtensionsWithStatus(false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -480,7 +486,7 @@ func (s *ExtensionStore) SearchExtensions(query string, category string) ([]Stor
|
|||||||
return extensions, nil
|
return extensions, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var result []StoreExtensionResponse
|
result := make([]storeExtensionResponse, 0, len(extensions))
|
||||||
queryLower := toLower(query)
|
queryLower := toLower(query)
|
||||||
|
|
||||||
for _, ext := range extensions {
|
for _, ext := range extensions {
|
||||||
@@ -493,7 +499,6 @@ func (s *ExtensionStore) SearchExtensions(query string, category string) ([]Stor
|
|||||||
!containsIgnoreCase(ext.DisplayName, queryLower) &&
|
!containsIgnoreCase(ext.DisplayName, queryLower) &&
|
||||||
!containsIgnoreCase(ext.Description, queryLower) &&
|
!containsIgnoreCase(ext.Description, queryLower) &&
|
||||||
!containsIgnoreCase(ext.Author, queryLower) {
|
!containsIgnoreCase(ext.Author, queryLower) {
|
||||||
// Check tags
|
|
||||||
found := false
|
found := false
|
||||||
for _, tag := range ext.Tags {
|
for _, tag := range ext.Tags {
|
||||||
if containsIgnoreCase(tag, queryLower) {
|
if containsIgnoreCase(tag, queryLower) {
|
||||||
@@ -513,7 +518,7 @@ func (s *ExtensionStore) SearchExtensions(query string, category string) ([]Stor
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ExtensionStore) ClearCache() {
|
func (s *extensionStore) clearCache() {
|
||||||
s.cacheMu.Lock()
|
s.cacheMu.Lock()
|
||||||
defer s.cacheMu.Unlock()
|
defer s.cacheMu.Unlock()
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -12,6 +12,7 @@ require (
|
|||||||
github.com/refraction-networking/utls v1.8.2
|
github.com/refraction-networking/utls v1.8.2
|
||||||
golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864
|
golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864
|
||||||
golang.org/x/net v0.50.0
|
golang.org/x/net v0.50.0
|
||||||
|
golang.org/x/text v0.34.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -24,6 +25,5 @@ require (
|
|||||||
golang.org/x/mod v0.33.0 // indirect
|
golang.org/x/mod v0.33.0 // indirect
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.41.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
|
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/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 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
|
||||||
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
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 h1:QckvTXtu55YMopmVeDrPQ/r+T6xjw8KMCmE3UgUldkw=
|
||||||
github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
||||||
github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE=
|
github.com/go-flac/flacpicture/v2 v2.0.2 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/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 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
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 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
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 h1:cTVynMSsMYgbUrtia2HB1jrhdUwQNtQti91vUCyjMp4=
|
||||||
golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864/go.mod h1:4OGHIUSBiIqyFAQDaX1tpY0BVnO20DvNDeATBu8aeFQ=
|
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 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
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 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
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 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
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 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
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 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
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 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
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
|
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 {
|
if resp.StatusCode == 403 || resp.StatusCode == 451 {
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, _ := io.ReadAll(resp.Body)
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
bodyStr := strings.ToLower(string(body))
|
bodyStr := strings.ToLower(string(body))
|
||||||
|
|
||||||
// Check if response looks like ISP blocking page
|
|
||||||
ispBlockingIndicators := []string{
|
ispBlockingIndicators := []string{
|
||||||
"blocked", "forbidden", "access denied", "not available in your",
|
"blocked", "forbidden", "access denied", "not available in your",
|
||||||
"restricted", "censored", "unavailable for legal", "blocked by",
|
"restricted", "censored", "unavailable for legal", "blocked by",
|
||||||
@@ -518,7 +515,6 @@ func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns true if ISP blocking was detected
|
|
||||||
func CheckAndLogISPBlocking(err error, requestURL string, tag string) bool {
|
func CheckAndLogISPBlocking(err error, requestURL string, tag string) bool {
|
||||||
ispErr := IsISPBlocking(err, requestURL)
|
ispErr := IsISPBlocking(err, requestURL)
|
||||||
if ispErr != nil {
|
if ispErr != nil {
|
||||||
@@ -553,7 +549,6 @@ func extractDomain(rawURL string) string {
|
|||||||
return "unknown"
|
return "unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
// If ISP blocking is detected, returns a more descriptive error
|
|
||||||
func WrapErrorWithISPCheck(err error, requestURL string, tag string) error {
|
func WrapErrorWithISPCheck(err error, requestURL string, tag string) error {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -112,7 +112,6 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
|
|||||||
|
|
||||||
resp, err := sharedClient.Do(req)
|
resp, err := sharedClient.Do(req)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
// Check for Cloudflare challenge page (403 with specific markers)
|
|
||||||
if resp.StatusCode == 403 || resp.StatusCode == 503 {
|
if resp.StatusCode == 403 || resp.StatusCode == 503 {
|
||||||
body, readErr := io.ReadAll(resp.Body)
|
body, readErr := io.ReadAll(resp.Body)
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
@@ -154,7 +153,6 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
|
|||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if error might be TLS-related (Cloudflare blocking)
|
|
||||||
errStr := strings.ToLower(err.Error())
|
errStr := strings.ToLower(err.Error())
|
||||||
tlsRelated := strings.Contains(errStr, "tls") ||
|
tlsRelated := strings.Contains(errStr, "tls") ||
|
||||||
strings.Contains(errStr, "handshake") ||
|
strings.Contains(errStr, "handshake") ||
|
||||||
|
|||||||
@@ -234,8 +234,6 @@ func ScanLibraryFolder(folderPath string) (string, error) {
|
|||||||
continue
|
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] {
|
if cueReferencedAudioFiles[filePath] {
|
||||||
GoLog("[LibraryScan] Skipping %s (referenced by .cue sheet)\n", filepath.Base(filePath))
|
GoLog("[LibraryScan] Skipping %s (referenced by .cue sheet)\n", filepath.Base(filePath))
|
||||||
continue
|
continue
|
||||||
@@ -557,9 +555,6 @@ func ReadAudioMetadataWithDisplayName(filePath, displayNameHint string) (string,
|
|||||||
return string(jsonBytes), nil
|
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) {
|
func loadExistingFilesSnapshot(snapshotPath string) (map[string]int64, error) {
|
||||||
existingFiles := make(map[string]int64)
|
existingFiles := make(map[string]int64)
|
||||||
if snapshotPath == "" {
|
if snapshotPath == "" {
|
||||||
@@ -637,7 +632,6 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
|
|||||||
libraryScanProgress.TotalFiles = totalFiles
|
libraryScanProgress.TotalFiles = totalFiles
|
||||||
libraryScanProgressMu.Unlock()
|
libraryScanProgressMu.Unlock()
|
||||||
|
|
||||||
// Find files to scan (new or modified)
|
|
||||||
var filesToScan []libraryAudioFileInfo
|
var filesToScan []libraryAudioFileInfo
|
||||||
skippedCount := 0
|
skippedCount := 0
|
||||||
existingCueTrackModTimes := make(map[string]int64)
|
existingCueTrackModTimes := make(map[string]int64)
|
||||||
@@ -653,10 +647,8 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
|
|||||||
for _, f := range currentFiles {
|
for _, f := range currentFiles {
|
||||||
existingModTime, exists := existingFiles[f.path]
|
existingModTime, exists := existingFiles[f.path]
|
||||||
if !exists {
|
if !exists {
|
||||||
// For .cue files, also check if any virtual path entries exist
|
|
||||||
if strings.ToLower(filepath.Ext(f.path)) == ".cue" {
|
if strings.ToLower(filepath.Ext(f.path)) == ".cue" {
|
||||||
if cueTrackModTime, hasCueTracks := existingCueTrackModTimes[f.path]; hasCueTracks {
|
if cueTrackModTime, hasCueTracks := existingCueTrackModTimes[f.path]; hasCueTracks {
|
||||||
// CUE file exists in DB via virtual paths; check if modTime changed
|
|
||||||
if f.modTime == cueTrackModTime {
|
if f.modTime == cueTrackModTime {
|
||||||
skippedCount++
|
skippedCount++
|
||||||
} else {
|
} else {
|
||||||
@@ -675,14 +667,11 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
|
|||||||
|
|
||||||
var deletedPaths []string
|
var deletedPaths []string
|
||||||
for existingPath := range existingFiles {
|
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 {
|
if idx := strings.LastIndex(existingPath, "#track"); idx > 0 {
|
||||||
baseCuePath := existingPath[:idx]
|
baseCuePath := existingPath[:idx]
|
||||||
if currentPathSet[baseCuePath] {
|
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)
|
deletedPaths = append(deletedPaths, existingPath)
|
||||||
} else if !currentPathSet[existingPath] {
|
} else if !currentPathSet[existingPath] {
|
||||||
deletedPaths = append(deletedPaths, existingPath)
|
deletedPaths = append(deletedPaths, existingPath)
|
||||||
@@ -713,7 +702,6 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
|
|||||||
scanTime := time.Now().UTC().Format(time.RFC3339)
|
scanTime := time.Now().UTC().Format(time.RFC3339)
|
||||||
errorCount := 0
|
errorCount := 0
|
||||||
|
|
||||||
// Track audio files referenced by .cue sheets to avoid duplicates (incremental)
|
|
||||||
cueReferencedAudioFilesInc := make(map[string]bool)
|
cueReferencedAudioFilesInc := make(map[string]bool)
|
||||||
parsedCueFiles := make(map[string]scannedCueFileInfo)
|
parsedCueFiles := make(map[string]scannedCueFileInfo)
|
||||||
for _, f := range filesToScan {
|
for _, f := range filesToScan {
|
||||||
@@ -748,7 +736,6 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
|
|||||||
|
|
||||||
ext := strings.ToLower(filepath.Ext(f.path))
|
ext := strings.ToLower(filepath.Ext(f.path))
|
||||||
|
|
||||||
// Handle .cue files: produce multiple track results
|
|
||||||
if ext == ".cue" {
|
if ext == ".cue" {
|
||||||
var cueResults []LibraryScanResult
|
var cueResults []LibraryScanResult
|
||||||
cueInfo, ok := parsedCueFiles[f.path]
|
cueInfo, ok := parsedCueFiles[f.path]
|
||||||
@@ -773,7 +760,6 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip audio files referenced by .cue sheets
|
|
||||||
if cueReferencedAudioFilesInc[f.path] {
|
if cueReferencedAudioFilesInc[f.path] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,7 +83,6 @@ func SetLyricsProviderOrder(providers []string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate provider names
|
|
||||||
validNames := map[string]bool{
|
validNames := map[string]bool{
|
||||||
LyricsProviderSpotifyAPI: true,
|
LyricsProviderSpotifyAPI: true,
|
||||||
LyricsProviderLRCLIB: true,
|
LyricsProviderLRCLIB: true,
|
||||||
@@ -105,7 +104,6 @@ func SetLyricsProviderOrder(providers []string) {
|
|||||||
GoLog("[Lyrics] Provider order set to: %v\n", valid)
|
GoLog("[Lyrics] Provider order set to: %v\n", valid)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLyricsProviderOrder returns the current lyrics provider order.
|
|
||||||
func GetLyricsProviderOrder() []string {
|
func GetLyricsProviderOrder() []string {
|
||||||
lyricsProvidersMu.RLock()
|
lyricsProvidersMu.RLock()
|
||||||
defer lyricsProvidersMu.RUnlock()
|
defer lyricsProvidersMu.RUnlock()
|
||||||
@@ -119,7 +117,6 @@ func GetLyricsProviderOrder() []string {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAvailableLyricsProviders returns metadata about all available providers.
|
|
||||||
func GetAvailableLyricsProviders() []map[string]interface{} {
|
func GetAvailableLyricsProviders() []map[string]interface{} {
|
||||||
return []map[string]interface{}{
|
return []map[string]interface{}{
|
||||||
{"id": LyricsProviderSpotifyAPI, "name": "Spotify Lyrics API", "has_proxy_dependency": true, "description": "Spotify-sourced lyrics via Paxsenix"},
|
{"id": LyricsProviderSpotifyAPI, "name": "Spotify Lyrics API", "has_proxy_dependency": true, "description": "Spotify-sourced lyrics via Paxsenix"},
|
||||||
@@ -140,7 +137,6 @@ func normalizeLyricsFetchOptions(opts LyricsFetchOptions) LyricsFetchOptions {
|
|||||||
return opts
|
return opts
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetLyricsFetchOptions sets provider-specific lyric fetch behavior.
|
|
||||||
func SetLyricsFetchOptions(opts LyricsFetchOptions) {
|
func SetLyricsFetchOptions(opts LyricsFetchOptions) {
|
||||||
normalized := normalizeLyricsFetchOptions(opts)
|
normalized := normalizeLyricsFetchOptions(opts)
|
||||||
|
|
||||||
@@ -156,7 +152,6 @@ func SetLyricsFetchOptions(opts LyricsFetchOptions) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLyricsFetchOptions returns current provider-specific lyric fetch behavior.
|
|
||||||
func GetLyricsFetchOptions() LyricsFetchOptions {
|
func GetLyricsFetchOptions() LyricsFetchOptions {
|
||||||
lyricsFetchOptionsMu.RLock()
|
lyricsFetchOptionsMu.RLock()
|
||||||
defer lyricsFetchOptionsMu.RUnlock()
|
defer lyricsFetchOptionsMu.RUnlock()
|
||||||
@@ -667,7 +662,6 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
|
|||||||
|
|
||||||
GoLog("[Lyrics] Searching for: %s - %s (providers: %v)\n", artistName, trackName, providerOrder)
|
GoLog("[Lyrics] Searching for: %s - %s (providers: %v)\n", artistName, trackName, providerOrder)
|
||||||
|
|
||||||
// Cascade through all configured built-in providers
|
|
||||||
for _, providerName := range providerOrder {
|
for _, providerName := range providerOrder {
|
||||||
GoLog("[Lyrics] Trying provider: %s\n", providerName)
|
GoLog("[Lyrics] Trying provider: %s\n", providerName)
|
||||||
|
|
||||||
|
|||||||
+48
-23
@@ -262,26 +262,35 @@ func qobuzTrackDisplayTitle(track *QobuzTrack) string {
|
|||||||
return fmt.Sprintf("%s (%s)", title, version)
|
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 {
|
func qobuzTrackAlbumImage(track *QobuzTrack) string {
|
||||||
if track == nil {
|
if track == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return qobuzFirstNonEmpty(
|
return qobuzUpscaleImageURL(qobuzFirstNonEmpty(
|
||||||
track.Album.Image.Large,
|
track.Album.Image.Large,
|
||||||
track.Album.Image.Small,
|
track.Album.Image.Small,
|
||||||
track.Album.Image.Thumbnail,
|
track.Album.Image.Thumbnail,
|
||||||
)
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
func qobuzAlbumImage(album *qobuzAlbumDetails) string {
|
func qobuzAlbumImage(album *qobuzAlbumDetails) string {
|
||||||
if album == nil {
|
if album == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return qobuzFirstNonEmpty(
|
return qobuzUpscaleImageURL(qobuzFirstNonEmpty(
|
||||||
album.Image.Large,
|
album.Image.Large,
|
||||||
album.Image.Small,
|
album.Image.Small,
|
||||||
album.Image.Thumbnail,
|
album.Image.Thumbnail,
|
||||||
)
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
func qobuzTrackArtistID(track *QobuzTrack) string {
|
func qobuzTrackArtistID(track *QobuzTrack) string {
|
||||||
@@ -936,7 +945,17 @@ func (q *QobuzDownloader) GetAlbumMetadata(resourceID string) (*AlbumResponsePay
|
|||||||
|
|
||||||
tracks := make([]AlbumTrackMetadata, 0, len(album.Tracks.Items))
|
tracks := make([]AlbumTrackMetadata, 0, len(album.Tracks.Items))
|
||||||
for i := range 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{
|
return &AlbumResponsePayload{
|
||||||
@@ -1578,21 +1597,27 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
|||||||
return nil, fmt.Errorf("no matching track found for: %s - %s", artistName, trackName)
|
return nil, fmt.Errorf("no matching track found for: %s - %s", artistName, trackName)
|
||||||
}
|
}
|
||||||
|
|
||||||
func qobuzTrackMatchesRequest(req DownloadRequest, track *QobuzTrack, logPrefix, source string) bool {
|
func qobuzTrackMatchesRequest(req DownloadRequest, track *QobuzTrack, logPrefix, source string, skipNameVerification bool) bool {
|
||||||
if track == nil {
|
if track == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.ArtistName != "" && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
|
exactISRCMatch := req.ISRC != "" &&
|
||||||
GoLog("[%s] Artist mismatch from %s: expected '%s', got '%s'. Rejecting.\n",
|
track.ISRC != "" &&
|
||||||
logPrefix, source, req.ArtistName, track.Performer.Name)
|
strings.EqualFold(strings.TrimSpace(req.ISRC), strings.TrimSpace(track.ISRC))
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.TrackName != "" && !qobuzTitlesMatch(req.TrackName, track.Title) {
|
if !exactISRCMatch && !skipNameVerification {
|
||||||
GoLog("[%s] Title mismatch from %s: expected '%s', got '%s'. Rejecting.\n",
|
if req.ArtistName != "" && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
|
||||||
logPrefix, source, req.TrackName, track.Title)
|
GoLog("[%s] Artist mismatch from %s: expected '%s', got '%s'. Rejecting.\n",
|
||||||
return false
|
logPrefix, source, req.ArtistName, track.Performer.Name)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.TrackName != "" && !qobuzTitlesMatch(req.TrackName, track.Title) {
|
||||||
|
GoLog("[%s] Title mismatch from %s: expected '%s', got '%s'. Rejecting.\n",
|
||||||
|
logPrefix, source, req.TrackName, track.Title)
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
expectedDurationSec := req.DurationMS / 1000
|
expectedDurationSec := req.DurationMS / 1000
|
||||||
@@ -2106,7 +2131,7 @@ func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloade
|
|||||||
GoLog("[%s] Failed to get track by request Qobuz ID %d: %v\n", logPrefix, trackID, err)
|
GoLog("[%s] Failed to get track by request Qobuz ID %d: %v\n", logPrefix, trackID, err)
|
||||||
track = nil
|
track = nil
|
||||||
} else if track != nil {
|
} else if track != nil {
|
||||||
if qobuzTrackMatchesRequest(req, track, logPrefix, "request Qobuz ID") {
|
if qobuzTrackMatchesRequest(req, track, logPrefix, "request Qobuz ID", false) {
|
||||||
GoLog("[%s] Successfully found track via request Qobuz ID: '%s' by '%s'\n", logPrefix, track.Title, track.Performer.Name)
|
GoLog("[%s] Successfully found track via request Qobuz ID: '%s' by '%s'\n", logPrefix, track.Title, track.Performer.Name)
|
||||||
} else {
|
} else {
|
||||||
track = nil
|
track = nil
|
||||||
@@ -2123,14 +2148,14 @@ func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloade
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
GoLog("[%s] Cache hit but GetTrackByID failed: %v\n", logPrefix, err)
|
GoLog("[%s] Cache hit but GetTrackByID failed: %v\n", logPrefix, err)
|
||||||
track = nil
|
track = nil
|
||||||
} else if track != nil && !qobuzTrackMatchesRequest(req, track, logPrefix, "cached Qobuz ID") {
|
} else if track != nil && !qobuzTrackMatchesRequest(req, track, logPrefix, "cached Qobuz ID", false) {
|
||||||
track = nil
|
track = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 3: Try to get QobuzID from SongLink if we have SpotifyID
|
// Strategy 3: Try to get QobuzID from SongLink if we have SpotifyID but no ISRC
|
||||||
if track == nil && req.SpotifyID != "" && req.QobuzID == "" {
|
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)
|
GoLog("[%s] Trying to get Qobuz ID from SongLink for Spotify ID: %s\n", logPrefix, req.SpotifyID)
|
||||||
songLinkClient := NewSongLinkClient()
|
songLinkClient := NewSongLinkClient()
|
||||||
availability, slErr := songLinkCheckTrackAvailabilityFunc(songLinkClient, req.SpotifyID, req.ISRC)
|
availability, slErr := songLinkCheckTrackAvailabilityFunc(songLinkClient, req.SpotifyID, req.ISRC)
|
||||||
@@ -2143,7 +2168,7 @@ func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloade
|
|||||||
GoLog("[%s] Failed to get track by SongLink ID %d: %v\n", logPrefix, trackID, err)
|
GoLog("[%s] Failed to get track by SongLink ID %d: %v\n", logPrefix, trackID, err)
|
||||||
track = nil
|
track = nil
|
||||||
} else if track != nil {
|
} else if track != nil {
|
||||||
if qobuzTrackMatchesRequest(req, track, logPrefix, "SongLink Qobuz ID") {
|
if qobuzTrackMatchesRequest(req, track, logPrefix, "SongLink Qobuz ID", true) {
|
||||||
GoLog("[%s] Successfully found track via SongLink ID: '%s' by '%s'\n", logPrefix, track.Title, track.Performer.Name)
|
GoLog("[%s] Successfully found track via SongLink ID: '%s' by '%s'\n", logPrefix, track.Title, track.Performer.Name)
|
||||||
if req.ISRC != "" {
|
if req.ISRC != "" {
|
||||||
GetTrackIDCache().SetQobuz(req.ISRC, track.ID)
|
GetTrackIDCache().SetQobuz(req.ISRC, track.ID)
|
||||||
@@ -2160,7 +2185,7 @@ func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloade
|
|||||||
if track == nil && req.ISRC != "" {
|
if track == nil && req.ISRC != "" {
|
||||||
GoLog("[%s] Trying ISRC search: %s\n", logPrefix, req.ISRC)
|
GoLog("[%s] Trying ISRC search: %s\n", logPrefix, req.ISRC)
|
||||||
track, err = qobuzSearchTrackByISRCWithDurationFunc(downloader, req.ISRC, expectedDurationSec)
|
track, err = qobuzSearchTrackByISRCWithDurationFunc(downloader, req.ISRC, expectedDurationSec)
|
||||||
if track != nil && !qobuzTrackMatchesRequest(req, track, logPrefix, "ISRC search") {
|
if track != nil && !qobuzTrackMatchesRequest(req, track, logPrefix, "ISRC search", false) {
|
||||||
track = nil
|
track = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2169,7 +2194,7 @@ func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloade
|
|||||||
if track == nil {
|
if track == nil {
|
||||||
GoLog("[%s] Trying metadata search: '%s' by '%s'\n", logPrefix, req.TrackName, req.ArtistName)
|
GoLog("[%s] Trying metadata search: '%s' by '%s'\n", logPrefix, req.TrackName, req.ArtistName)
|
||||||
track, err = qobuzSearchTrackByMetadataWithDurationFunc(downloader, req.TrackName, req.ArtistName, expectedDurationSec)
|
track, err = qobuzSearchTrackByMetadataWithDurationFunc(downloader, req.TrackName, req.ArtistName, expectedDurationSec)
|
||||||
if track != nil && !qobuzTrackMatchesRequest(req, track, logPrefix, "metadata search") {
|
if track != nil && !qobuzTrackMatchesRequest(req, track, logPrefix, "metadata search", false) {
|
||||||
track = nil
|
track = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2234,7 +2259,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
qobuzQuality = "6"
|
qobuzQuality = "6"
|
||||||
case "HI_RES":
|
case "HI_RES":
|
||||||
qobuzQuality = "7"
|
qobuzQuality = "7"
|
||||||
case "HI_RES_LOSSLESS":
|
case "HI_RES_LOSSLESS", "", "DEFAULT":
|
||||||
qobuzQuality = "27"
|
qobuzQuality = "27"
|
||||||
}
|
}
|
||||||
GoLog("[Qobuz] Using quality: %s (mapped from %s)\n", qobuzQuality, req.Quality)
|
GoLog("[Qobuz] Using quality: %s (mapped from %s)\n", qobuzQuality, req.Quality)
|
||||||
|
|||||||
@@ -436,3 +436,20 @@ func TestResolveQobuzTrackForRequestUsesPrefixedQobuzIDWithoutSongLink(t *testin
|
|||||||
t.Fatalf("unexpected resolved track: %+v", track)
|
t.Fatalf("unexpected resolved track: %+v", track)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestQobuzTrackMatchesRequest_SongLinkBypassesArtistAndTitle(t *testing.T) {
|
||||||
|
req := DownloadRequest{
|
||||||
|
TrackName: "Ringišpil",
|
||||||
|
ArtistName: "Djordje Balasevic",
|
||||||
|
}
|
||||||
|
|
||||||
|
track := &QobuzTrack{
|
||||||
|
Title: "Different Title",
|
||||||
|
Duration: 0,
|
||||||
|
}
|
||||||
|
track.Performer.Name = "Different Artist"
|
||||||
|
|
||||||
|
if !qobuzTrackMatchesRequest(req, track, "Qobuz", "SongLink Qobuz ID", true) {
|
||||||
|
t.Fatal("expected SongLink Qobuz source to bypass artist/title verification")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
package gobackend
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
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.
|
|
||||||
func GetSpotifyDataWithAPI(ctx context.Context, spotifyURL, apiBaseURL string) (interface{}, error) {
|
|
||||||
parsed, err := parseSpotifyURI(spotifyURL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("invalid Spotify URL: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
base := strings.TrimSpace(apiBaseURL)
|
|
||||||
if base == "" {
|
|
||||||
base = DefaultSpotFetchAPIBaseURL
|
|
||||||
}
|
|
||||||
|
|
||||||
endpoint := fmt.Sprintf("%s/%s/%s", strings.TrimSuffix(base, "/"), parsed.Type, parsed.ID)
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create SpotFetch API request: %w", err)
|
|
||||||
}
|
|
||||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
|
||||||
req.Header.Set("Accept", "application/json")
|
|
||||||
|
|
||||||
client := NewHTTPClientWithTimeout(30 * time.Second)
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("SpotFetch API request failed: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return nil, fmt.Errorf("SpotFetch API error: HTTP %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
bodyBytes, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read SpotFetch API response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
switch parsed.Type {
|
|
||||||
case "track":
|
|
||||||
var trackResp TrackResponse
|
|
||||||
if err := json.Unmarshal(bodyBytes, &trackResp); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to decode track response: %w", err)
|
|
||||||
}
|
|
||||||
return trackResp, nil
|
|
||||||
case "album":
|
|
||||||
var albumResp AlbumResponsePayload
|
|
||||||
if err := json.Unmarshal(bodyBytes, &albumResp); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to decode album response: %w", err)
|
|
||||||
}
|
|
||||||
return &albumResp, nil
|
|
||||||
case "playlist":
|
|
||||||
var playlistResp PlaylistResponsePayload
|
|
||||||
if err := json.Unmarshal(bodyBytes, &playlistResp); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to decode playlist response: %w", err)
|
|
||||||
}
|
|
||||||
return playlistResp, nil
|
|
||||||
case "artist":
|
|
||||||
var artistResp ArtistResponsePayload
|
|
||||||
if err := json.Unmarshal(bodyBytes, &artistResp); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to decode artist response: %w", err)
|
|
||||||
}
|
|
||||||
return &artistResp, nil
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("unsupported Spotify type: %s", parsed.Type)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+15
-11
@@ -829,6 +829,7 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, a
|
|||||||
resolved := resolvedTrackInfo{
|
resolved := resolvedTrackInfo{
|
||||||
Title: strings.TrimSpace(track.Title),
|
Title: strings.TrimSpace(track.Title),
|
||||||
ArtistName: tidalTrackArtistsDisplay(track),
|
ArtistName: tidalTrackArtistsDisplay(track),
|
||||||
|
ISRC: strings.TrimSpace(track.ISRC),
|
||||||
Duration: track.Duration,
|
Duration: track.Duration,
|
||||||
}
|
}
|
||||||
if trackMatchesRequest(req, resolved, "Tidal search") {
|
if trackMatchesRequest(req, resolved, "Tidal search") {
|
||||||
@@ -1015,13 +1016,11 @@ func (t *TidalDownloader) GetAlbumMetadata(resourceID string) (*AlbumResponsePay
|
|||||||
tracks := make([]AlbumTrackMetadata, 0, len(itemsModule.PagedList.Items))
|
tracks := make([]AlbumTrackMetadata, 0, len(itemsModule.PagedList.Items))
|
||||||
for _, item := range itemsModule.PagedList.Items {
|
for _, item := range itemsModule.PagedList.Items {
|
||||||
track := item.Item
|
track := item.Item
|
||||||
if track.Album.ID == 0 {
|
track.Album.ID = headerModule.Album.ID
|
||||||
track.Album.ID = headerModule.Album.ID
|
track.Album.Title = headerModule.Album.Title
|
||||||
track.Album.Title = headerModule.Album.Title
|
track.Album.Cover = headerModule.Album.Cover
|
||||||
track.Album.Cover = headerModule.Album.Cover
|
track.Album.ReleaseDate = headerModule.Album.ReleaseDate
|
||||||
track.Album.ReleaseDate = headerModule.Album.ReleaseDate
|
track.Album.URL = headerModule.Album.URL
|
||||||
track.Album.URL = headerModule.Album.URL
|
|
||||||
}
|
|
||||||
tracks = append(tracks, tidalTrackToAlbumTrackMetadata(&track))
|
tracks = append(tracks, tidalTrackToAlbumTrackMetadata(&track))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2037,6 +2036,7 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade
|
|||||||
expectedDurationSec := req.DurationMS / 1000
|
expectedDurationSec := req.DurationMS / 1000
|
||||||
var trackID int64
|
var trackID int64
|
||||||
var gotTidalID bool
|
var gotTidalID bool
|
||||||
|
var resolvedViaSongLink bool
|
||||||
|
|
||||||
if req.TidalID != "" {
|
if req.TidalID != "" {
|
||||||
GoLog("[%s] Using Tidal ID from request payload: %s\n", logPrefix, req.TidalID)
|
GoLog("[%s] Using Tidal ID from request payload: %s\n", logPrefix, req.TidalID)
|
||||||
@@ -2096,6 +2096,7 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade
|
|||||||
trackID = parsedTrackID
|
trackID = parsedTrackID
|
||||||
GoLog("[%s] Got Tidal ID %d directly from SongLink\n", logPrefix, trackID)
|
GoLog("[%s] Got Tidal ID %d directly from SongLink\n", logPrefix, trackID)
|
||||||
gotTidalID = true
|
gotTidalID = true
|
||||||
|
resolvedViaSongLink = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2105,6 +2106,7 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade
|
|||||||
if idErr == nil && trackID > 0 {
|
if idErr == nil && trackID > 0 {
|
||||||
GoLog("[%s] Got Tidal ID %d from URL parsing\n", logPrefix, trackID)
|
GoLog("[%s] Got Tidal ID %d from URL parsing\n", logPrefix, trackID)
|
||||||
gotTidalID = true
|
gotTidalID = true
|
||||||
|
resolvedViaSongLink = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2159,9 +2161,11 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade
|
|||||||
providerArtist = actualTrack.Artists[0].Name
|
providerArtist = actualTrack.Artists[0].Name
|
||||||
}
|
}
|
||||||
resolved := resolvedTrackInfo{
|
resolved := resolvedTrackInfo{
|
||||||
Title: actualTrack.Title,
|
Title: actualTrack.Title,
|
||||||
ArtistName: providerArtist,
|
ArtistName: providerArtist,
|
||||||
Duration: actualTrack.Duration,
|
ISRC: strings.TrimSpace(actualTrack.ISRC),
|
||||||
|
Duration: actualTrack.Duration,
|
||||||
|
SkipNameVerification: resolvedViaSongLink,
|
||||||
}
|
}
|
||||||
if !trackMatchesRequest(req, resolved, logPrefix) {
|
if !trackMatchesRequest(req, resolved, logPrefix) {
|
||||||
// Invalidate the cached ID so future requests don't reuse it.
|
// Invalidate the cached ID so future requests don't reuse it.
|
||||||
@@ -2208,7 +2212,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
quality := req.Quality
|
quality := req.Quality
|
||||||
if quality == "" {
|
if quality == "" || quality == "DEFAULT" {
|
||||||
quality = "LOSSLESS"
|
quality = "LOSSLESS"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,21 @@ import (
|
|||||||
"golang.org/x/text/unicode/norm"
|
"golang.org/x/text/unicode/norm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func writeNormalizedArtistRune(b *strings.Builder, r rune) {
|
||||||
|
switch r {
|
||||||
|
case 'đ':
|
||||||
|
b.WriteString("dj")
|
||||||
|
case 'ß':
|
||||||
|
b.WriteString("ss")
|
||||||
|
case 'æ':
|
||||||
|
b.WriteString("ae")
|
||||||
|
case 'œ':
|
||||||
|
b.WriteString("oe")
|
||||||
|
default:
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// normalizeLooseTitle collapses separators/punctuation so titles like
|
// normalizeLooseTitle collapses separators/punctuation so titles like
|
||||||
// "Doctor / Cops" and "Doctor _ Cops" can still match.
|
// "Doctor / Cops" and "Doctor _ Cops" can still match.
|
||||||
func normalizeLooseTitle(title string) string {
|
func normalizeLooseTitle(title string) string {
|
||||||
@@ -24,11 +39,9 @@ func normalizeLooseTitle(title string) string {
|
|||||||
b.WriteRune(r)
|
b.WriteRune(r)
|
||||||
case unicode.IsSpace(r):
|
case unicode.IsSpace(r):
|
||||||
b.WriteByte(' ')
|
b.WriteByte(' ')
|
||||||
// Treat common separators as spaces.
|
|
||||||
case r == '/', r == '\\', r == '_', r == '-', r == '|', r == '.', r == '&', r == '+':
|
case r == '/', r == '\\', r == '_', r == '-', r == '|', r == '.', r == '&', r == '+':
|
||||||
b.WriteByte(' ')
|
b.WriteByte(' ')
|
||||||
default:
|
default:
|
||||||
// Drop other punctuation/symbols (including emoji) for loose matching.
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,13 +66,12 @@ func normalizeLooseArtistName(name string) string {
|
|||||||
case unicode.Is(unicode.Mn, r), unicode.Is(unicode.Mc, r), unicode.Is(unicode.Me, r):
|
case unicode.Is(unicode.Mn, r), unicode.Is(unicode.Mc, r), unicode.Is(unicode.Me, r):
|
||||||
continue
|
continue
|
||||||
case unicode.IsLetter(r), unicode.IsNumber(r):
|
case unicode.IsLetter(r), unicode.IsNumber(r):
|
||||||
b.WriteRune(r)
|
writeNormalizedArtistRune(&b, r)
|
||||||
case unicode.IsSpace(r):
|
case unicode.IsSpace(r):
|
||||||
b.WriteByte(' ')
|
b.WriteByte(' ')
|
||||||
case r == '/', r == '\\', r == '_', r == '-', r == '|', r == '.', r == '&', r == '+':
|
case r == '/', r == '\\', r == '_', r == '-', r == '|', r == '.', r == '&', r == '+':
|
||||||
b.WriteByte(' ')
|
b.WriteByte(' ')
|
||||||
default:
|
default:
|
||||||
// Drop remaining punctuation/symbols for loose artist matching.
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,30 +114,36 @@ func normalizeSymbolOnlyTitle(title string) string {
|
|||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Shared Track Verification ====================
|
|
||||||
|
|
||||||
// resolvedTrackInfo holds the metadata fetched from a provider for verification.
|
// resolvedTrackInfo holds the metadata fetched from a provider for verification.
|
||||||
type resolvedTrackInfo struct {
|
type resolvedTrackInfo struct {
|
||||||
Title string
|
Title string
|
||||||
ArtistName string
|
ArtistName string
|
||||||
Duration int // seconds
|
ISRC string
|
||||||
|
Duration int
|
||||||
|
SkipNameVerification bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// trackMatchesRequest checks whether a resolved track from a provider matches
|
// trackMatchesRequest checks whether a resolved track from a provider matches
|
||||||
// the original download request. Returns true if the track is a plausible match.
|
// the original download request. Returns true if the track is a plausible match.
|
||||||
func trackMatchesRequest(req DownloadRequest, resolved resolvedTrackInfo, logPrefix string) bool {
|
func trackMatchesRequest(req DownloadRequest, resolved resolvedTrackInfo, logPrefix string) bool {
|
||||||
if req.ArtistName != "" && resolved.ArtistName != "" &&
|
exactISRCMatch := req.ISRC != "" &&
|
||||||
!artistsMatch(req.ArtistName, resolved.ArtistName) {
|
resolved.ISRC != "" &&
|
||||||
GoLog("[%s] Verification failed: artist mismatch — expected '%s', got '%s'\n",
|
strings.EqualFold(strings.TrimSpace(req.ISRC), strings.TrimSpace(resolved.ISRC))
|
||||||
logPrefix, req.ArtistName, resolved.ArtistName)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.TrackName != "" && resolved.Title != "" &&
|
if !exactISRCMatch && !resolved.SkipNameVerification {
|
||||||
!titlesMatch(req.TrackName, resolved.Title) {
|
if req.ArtistName != "" && resolved.ArtistName != "" &&
|
||||||
GoLog("[%s] Verification failed: title mismatch — expected '%s', got '%s'\n",
|
!artistsMatch(req.ArtistName, resolved.ArtistName) {
|
||||||
logPrefix, req.TrackName, resolved.Title)
|
GoLog("[%s] Verification failed: artist mismatch — expected '%s', got '%s'\n",
|
||||||
return false
|
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
|
expectedDurationSec := req.DurationMS / 1000
|
||||||
|
|||||||
@@ -21,6 +21,40 @@ func TestNormalizeLooseTitle_EmojiAndSymbols(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTrackMatchesRequest_SongLinkBypassesArtistAndTitle(t *testing.T) {
|
||||||
|
req := DownloadRequest{
|
||||||
|
TrackName: "Ringišpil",
|
||||||
|
ArtistName: "Djordje Balasevic",
|
||||||
|
}
|
||||||
|
resolved := resolvedTrackInfo{
|
||||||
|
Title: "Completely Different Title",
|
||||||
|
ArtistName: "Totally Different Artist",
|
||||||
|
SkipNameVerification: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if !trackMatchesRequest(req, resolved, "test") {
|
||||||
|
t.Fatal("expected SongLink-resolved track to bypass artist/title verification")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTrackMatchesRequest_SongLinkStillChecksDuration(t *testing.T) {
|
||||||
|
req := DownloadRequest{
|
||||||
|
TrackName: "Ringišpil",
|
||||||
|
ArtistName: "Djordje Balasevic",
|
||||||
|
DurationMS: 180000,
|
||||||
|
}
|
||||||
|
resolved := resolvedTrackInfo{
|
||||||
|
Title: "Completely Different Title",
|
||||||
|
ArtistName: "Totally Different Artist",
|
||||||
|
Duration: 240,
|
||||||
|
SkipNameVerification: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if trackMatchesRequest(req, resolved, "test") {
|
||||||
|
t.Fatal("expected SongLink-resolved track with large duration mismatch to be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestTitlesMatch_SeparatorVariants(t *testing.T) {
|
func TestTitlesMatch_SeparatorVariants(t *testing.T) {
|
||||||
if !titlesMatch("Doctor / Cops", "Doctor _ Cops") {
|
if !titlesMatch("Doctor / Cops", "Doctor _ Cops") {
|
||||||
t.Fatal("expected tidal titlesMatch to accept / vs _ variant")
|
t.Fatal("expected tidal titlesMatch to accept / vs _ variant")
|
||||||
|
|||||||
@@ -1,750 +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 (
|
|
||||||
YouTubeQualityOpus320 YouTubeQuality = "opus_320"
|
|
||||||
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, 320}
|
|
||||||
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_320", "opus320":
|
|
||||||
return "opus", 320, YouTubeQualityOpus320
|
|
||||||
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,54 +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 != 320 {
|
|
||||||
t.Fatalf("expected opus normalization to 320, got %d", opusBitrate)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, mp3Bitrate, _ := parseYouTubeQualityInput("mp3_1")
|
|
||||||
if mp3Bitrate != 128 {
|
|
||||||
t.Fatalf("expected mp3 normalization to 128, got %d", mp3Bitrate)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseYouTubeQualityInput_Opus320(t *testing.T) {
|
|
||||||
format, bitrate, normalized := parseYouTubeQualityInput("opus_320")
|
|
||||||
if format != "opus" {
|
|
||||||
t.Fatalf("expected opus format, got %s", format)
|
|
||||||
}
|
|
||||||
if bitrate != 320 {
|
|
||||||
t.Fatalf("expected 320 bitrate, got %d", bitrate)
|
|
||||||
}
|
|
||||||
if normalized != YouTubeQualityOpus320 {
|
|
||||||
t.Fatalf("expected %s normalized, got %s", YouTubeQualityOpus320, normalized)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -153,13 +153,6 @@ import Gobackend // Import Go framework
|
|||||||
var error: NSError?
|
var error: NSError?
|
||||||
|
|
||||||
switch call.method {
|
switch call.method {
|
||||||
case "parseSpotifyUrl":
|
|
||||||
let args = call.arguments as! [String: Any]
|
|
||||||
let url = args["url"] as! String
|
|
||||||
let response = GobackendParseSpotifyURL(url, &error)
|
|
||||||
if let error = error { throw error }
|
|
||||||
return response
|
|
||||||
|
|
||||||
case "checkAvailability":
|
case "checkAvailability":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let spotifyId = args["spotify_id"] as! String
|
let spotifyId = args["spotify_id"] as! String
|
||||||
@@ -469,13 +462,6 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
case "getSpotifyMetadataWithFallback":
|
|
||||||
let args = call.arguments as! [String: Any]
|
|
||||||
let url = args["url"] as! String
|
|
||||||
let response = GobackendGetSpotifyMetadataWithDeezerFallback(url, &error)
|
|
||||||
if let error = error { throw error }
|
|
||||||
return response
|
|
||||||
|
|
||||||
case "checkAvailabilityFromDeezerID":
|
case "checkAvailabilityFromDeezerID":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let deezerTrackId = args["deezer_track_id"] as! String
|
let deezerTrackId = args["deezer_track_id"] as! String
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ import 'package:flutter/foundation.dart';
|
|||||||
/// App version and info constants
|
/// App version and info constants
|
||||||
/// Update version here only - all other files will reference this
|
/// Update version here only - all other files will reference this
|
||||||
class AppInfo {
|
class AppInfo {
|
||||||
static const String version = '3.9.0';
|
static const String version = '4.1.2';
|
||||||
static const String buildNumber = '115';
|
static const String buildNumber = '119';
|
||||||
static const String fullVersion = '$version+$buildNumber';
|
static const String fullVersion = '$version+$buildNumber';
|
||||||
|
|
||||||
/// Shows "Internal" in debug builds, actual version in release.
|
/// Shows "Internal" in debug builds, actual version in release.
|
||||||
static String get displayVersion => kDebugMode ? 'Internal' : version;
|
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 copyright = '© 2026 SpotiFLAC';
|
||||||
|
|
||||||
static const String mobileAuthor = 'zarzet';
|
static const String mobileAuthor = 'zarzet';
|
||||||
|
|||||||
+533
-25
@@ -151,7 +151,7 @@ abstract class AppLocalizations {
|
|||||||
/// Bottom navigation - Extension store tab
|
/// Bottom navigation - Extension store tab
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Store'**
|
/// **'Repo'**
|
||||||
String get navStore;
|
String get navStore;
|
||||||
|
|
||||||
/// Home screen title
|
/// Home screen title
|
||||||
@@ -163,7 +163,7 @@ abstract class AppLocalizations {
|
|||||||
/// Subtitle shown below search box
|
/// Subtitle shown below search box
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Paste a Spotify link or search by name'**
|
/// **'Paste a supported URL or search by name'**
|
||||||
String get homeSubtitle;
|
String get homeSubtitle;
|
||||||
|
|
||||||
/// Info text about supported URL types
|
/// Info text about supported URL types
|
||||||
@@ -427,13 +427,13 @@ abstract class AppLocalizations {
|
|||||||
/// Show/hide store tab
|
/// Show/hide store tab
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Extension Store'**
|
/// **'Extension Repo'**
|
||||||
String get optionsExtensionStore;
|
String get optionsExtensionStore;
|
||||||
|
|
||||||
/// Subtitle for extension store toggle
|
/// Subtitle for extension store toggle
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Show Store tab in navigation'**
|
/// **'Show Repo tab in navigation'**
|
||||||
String get optionsExtensionStoreSubtitle;
|
String get optionsExtensionStoreSubtitle;
|
||||||
|
|
||||||
/// Auto update check toggle
|
/// Auto update check toggle
|
||||||
@@ -565,7 +565,7 @@ abstract class AppLocalizations {
|
|||||||
/// Store screen title
|
/// Store screen title
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Extension Store'**
|
/// **'Extension Repo'**
|
||||||
String get storeTitle;
|
String get storeTitle;
|
||||||
|
|
||||||
/// Store search placeholder
|
/// Store search placeholder
|
||||||
@@ -1432,6 +1432,66 @@ abstract class AppLocalizations {
|
|||||||
/// **'Playlists'**
|
/// **'Playlists'**
|
||||||
String get searchPlaylists;
|
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
|
/// Tooltip - play button
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -2305,7 +2365,7 @@ abstract class AppLocalizations {
|
|||||||
/// Error heading when the store cannot be loaded
|
/// Error heading when the store cannot be loaded
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Failed to load store'**
|
/// **'Failed to load repository'**
|
||||||
String get storeLoadError;
|
String get storeLoadError;
|
||||||
|
|
||||||
/// Message when store has no extensions
|
/// Message when store has no extensions
|
||||||
@@ -2662,24 +2722,6 @@ abstract class AppLocalizations {
|
|||||||
/// **'Actual quality depends on track availability from the service'**
|
/// **'Actual quality depends on track availability from the service'**
|
||||||
String get qualityNote;
|
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
|
/// Setting - show quality picker
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -2860,6 +2902,18 @@ abstract class AppLocalizations {
|
|||||||
/// **'Artist/Album/ and Artist/Singles/'**
|
/// **'Artist/Album/ and Artist/Singles/'**
|
||||||
String get albumFolderArtistAlbumSinglesSubtitle;
|
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
|
/// Button - delete selected tracks
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -3559,7 +3613,7 @@ abstract class AppLocalizations {
|
|||||||
/// Tutorial extensions tip 1
|
/// Tutorial extensions tip 1
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Browse the Store tab to discover useful extensions'**
|
/// **'Browse the Repo tab to discover useful extensions'**
|
||||||
String get tutorialExtensionsTip1;
|
String get tutorialExtensionsTip1;
|
||||||
|
|
||||||
/// Tutorial extensions tip 2
|
/// Tutorial extensions tip 2
|
||||||
@@ -5084,6 +5138,460 @@ abstract class AppLocalizations {
|
|||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Empty only'**
|
/// **'Empty only'**
|
||||||
String get editMetadataSelectEmpty;
|
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;
|
||||||
|
|
||||||
|
/// Extensions page - subtitle for built-in search provider option
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Search with {providerName}'**
|
||||||
|
String extensionsSearchWith(String providerName);
|
||||||
|
|
||||||
|
/// Extensions page - label for home feed provider selector
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Home Feed Provider'**
|
||||||
|
String get extensionsHomeFeedProvider;
|
||||||
|
|
||||||
|
/// Extensions page - description for home feed provider picker
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Choose which extension provides the home feed on the main screen'**
|
||||||
|
String get extensionsHomeFeedDescription;
|
||||||
|
|
||||||
|
/// Extensions page - home feed provider option: auto
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Auto'**
|
||||||
|
String get extensionsHomeFeedAuto;
|
||||||
|
|
||||||
|
/// Extensions page - subtitle for auto home feed option
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Automatically select the best available'**
|
||||||
|
String get extensionsHomeFeedAutoSubtitle;
|
||||||
|
|
||||||
|
/// Extensions page - subtitle for a specific extension home feed option
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Use {extensionName} home feed'**
|
||||||
|
String extensionsHomeFeedUse(String extensionName);
|
||||||
|
|
||||||
|
/// Extensions page - shown when no installed extension has home feed
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'No extensions with home feed'**
|
||||||
|
String get extensionsNoHomeFeedExtensions;
|
||||||
|
|
||||||
|
/// Sort option - alphabetical ascending
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'A-Z'**
|
||||||
|
String get sortAlphaAsc;
|
||||||
|
|
||||||
|
/// Sort option - alphabetical descending
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Z-A'**
|
||||||
|
String get sortAlphaDesc;
|
||||||
|
|
||||||
|
/// Dialog title when confirming cancellation of an active download
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Cancel download?'**
|
||||||
|
String get cancelDownloadTitle;
|
||||||
|
|
||||||
|
/// Dialog body when confirming cancellation of an active download
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'This will cancel the active download for \"{trackName}\".'**
|
||||||
|
String cancelDownloadContent(String trackName);
|
||||||
|
|
||||||
|
/// Dialog button - keep the active download (do not cancel)
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Keep'**
|
||||||
|
String get cancelDownloadKeep;
|
||||||
|
|
||||||
|
/// Snackbar error when FFmpeg fails to write metadata
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Failed to save metadata via FFmpeg'**
|
||||||
|
String get metadataSaveFailedFfmpeg;
|
||||||
|
|
||||||
|
/// Snackbar error when writing metadata file back to storage fails
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Failed to write metadata back to storage'**
|
||||||
|
String get metadataSaveFailedStorage;
|
||||||
|
|
||||||
|
/// Snackbar shown when folder picker fails to open
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Failed to open folder picker: {error}'**
|
||||||
|
String snackbarFolderPickerFailed(String error);
|
||||||
|
|
||||||
|
/// Error state shown when album fails to load
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Failed to load album'**
|
||||||
|
String get errorLoadAlbum;
|
||||||
|
|
||||||
|
/// Error state shown when playlist fails to load
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Failed to load playlist'**
|
||||||
|
String get errorLoadPlaylist;
|
||||||
|
|
||||||
|
/// Error state shown when artist fails to load
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Failed to load artist'**
|
||||||
|
String get errorLoadArtist;
|
||||||
|
|
||||||
|
/// Android notification channel name for download progress
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Download Progress'**
|
||||||
|
String get notifChannelDownloadName;
|
||||||
|
|
||||||
|
/// Android notification channel description for download progress
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Shows download progress for tracks'**
|
||||||
|
String get notifChannelDownloadDesc;
|
||||||
|
|
||||||
|
/// Android notification channel name for library scan
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Library Scan'**
|
||||||
|
String get notifChannelLibraryScanName;
|
||||||
|
|
||||||
|
/// Android notification channel description for library scan
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Shows local library scan progress'**
|
||||||
|
String get notifChannelLibraryScanDesc;
|
||||||
|
|
||||||
|
/// Notification title while downloading a track
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Downloading {trackName}'**
|
||||||
|
String notifDownloadingTrack(String trackName);
|
||||||
|
|
||||||
|
/// Notification title while finalizing (embedding metadata) a track
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Finalizing {trackName}'**
|
||||||
|
String notifFinalizingTrack(String trackName);
|
||||||
|
|
||||||
|
/// Notification body while embedding metadata into a downloaded track
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Embedding metadata...'**
|
||||||
|
String get notifEmbeddingMetadata;
|
||||||
|
|
||||||
|
/// Notification title when track is already in library, with count
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Already in Library ({completed}/{total})'**
|
||||||
|
String notifAlreadyInLibraryCount(int completed, int total);
|
||||||
|
|
||||||
|
/// Notification title when track is already in library
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Already in Library'**
|
||||||
|
String get notifAlreadyInLibrary;
|
||||||
|
|
||||||
|
/// Notification title when download is complete, with count
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Download Complete ({completed}/{total})'**
|
||||||
|
String notifDownloadCompleteCount(int completed, int total);
|
||||||
|
|
||||||
|
/// Notification title when a single download is complete
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Download Complete'**
|
||||||
|
String get notifDownloadComplete;
|
||||||
|
|
||||||
|
/// Notification title when queue finishes with some failures
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Downloads Finished ({completed} done, {failed} failed)'**
|
||||||
|
String notifDownloadsFinished(int completed, int failed);
|
||||||
|
|
||||||
|
/// Notification title when all downloads finish successfully
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'All Downloads Complete'**
|
||||||
|
String get notifAllDownloadsComplete;
|
||||||
|
|
||||||
|
/// Notification body for queue complete - how many tracks were downloaded
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{count} tracks downloaded successfully'**
|
||||||
|
String notifTracksDownloadedSuccess(int count);
|
||||||
|
|
||||||
|
/// Notification title while scanning local library
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Scanning local library'**
|
||||||
|
String get notifScanningLibrary;
|
||||||
|
|
||||||
|
/// Notification body for library scan progress when total is known
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{scanned}/{total} files • {percentage}%'**
|
||||||
|
String notifLibraryScanProgressWithTotal(
|
||||||
|
int scanned,
|
||||||
|
int total,
|
||||||
|
int percentage,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Notification body for library scan progress when total is unknown
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{scanned} files scanned • {percentage}%'**
|
||||||
|
String notifLibraryScanProgressNoTotal(int scanned, int percentage);
|
||||||
|
|
||||||
|
/// Notification title when library scan finishes
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Library scan complete'**
|
||||||
|
String get notifLibraryScanComplete;
|
||||||
|
|
||||||
|
/// Notification body for library scan complete - number of indexed tracks
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{count} tracks indexed'**
|
||||||
|
String notifLibraryScanCompleteBody(int count);
|
||||||
|
|
||||||
|
/// Library scan complete suffix - excluded track count
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{count} excluded'**
|
||||||
|
String notifLibraryScanExcluded(int count);
|
||||||
|
|
||||||
|
/// Library scan complete suffix - error count
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{count} errors'**
|
||||||
|
String notifLibraryScanErrors(int count);
|
||||||
|
|
||||||
|
/// Notification title when library scan fails
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Library scan failed'**
|
||||||
|
String get notifLibraryScanFailed;
|
||||||
|
|
||||||
|
/// Notification title when library scan is cancelled by the user
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Library scan cancelled'**
|
||||||
|
String get notifLibraryScanCancelled;
|
||||||
|
|
||||||
|
/// Notification body when library scan is cancelled
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Scan stopped before completion.'**
|
||||||
|
String get notifLibraryScanStopped;
|
||||||
|
|
||||||
|
/// Notification title while downloading an app update
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Downloading SpotiFLAC v{version}'**
|
||||||
|
String notifDownloadingUpdate(String version);
|
||||||
|
|
||||||
|
/// Notification body showing update download progress
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{received} / {total} MB • {percentage}%'**
|
||||||
|
String notifUpdateProgress(String received, String total, int percentage);
|
||||||
|
|
||||||
|
/// Notification title when app update download is complete
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Update Ready'**
|
||||||
|
String get notifUpdateReady;
|
||||||
|
|
||||||
|
/// Notification body when app update is ready to install
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'SpotiFLAC v{version} downloaded. Tap to install.'**
|
||||||
|
String notifUpdateReadyBody(String version);
|
||||||
|
|
||||||
|
/// Notification title when app update download fails
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Update Failed'**
|
||||||
|
String get notifUpdateFailed;
|
||||||
|
|
||||||
|
/// Notification body when app update download fails
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Could not download update. Try again later.'**
|
||||||
|
String get notifUpdateFailedBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AppLocalizationsDelegate
|
class _AppLocalizationsDelegate
|
||||||
|
|||||||
@@ -772,6 +772,36 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get searchPlaylists => 'Playlisten';
|
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
|
@override
|
||||||
String get tooltipPlay => 'Abspielen';
|
String get tooltipPlay => 'Abspielen';
|
||||||
|
|
||||||
@@ -1251,7 +1281,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get storeNewRepoUrlLabel => 'New Repository URL';
|
String get storeNewRepoUrlLabel => 'New Repository URL';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeLoadError => 'Failed to load store';
|
String get storeLoadError => 'Failed to load repository';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeEmptyNoExtensions => 'No extensions available';
|
String get storeEmptyNoExtensions => 'No extensions available';
|
||||||
@@ -1449,16 +1479,6 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get qualityNote =>
|
String get qualityNote =>
|
||||||
'Die eigentliche Qualität hängt von der Verfügbarkeit des Dienstes ab';
|
'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
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Qualität vor Download fragen';
|
String get downloadAskBeforeDownload => 'Qualität vor Download fragen';
|
||||||
|
|
||||||
@@ -1558,6 +1578,13 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||||
'Künstler/Album/ und Künstler/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
|
@override
|
||||||
String get downloadedAlbumDeleteSelected => 'Ausgewählte löschen';
|
String get downloadedAlbumDeleteSelected => 'Ausgewählte löschen';
|
||||||
|
|
||||||
@@ -2995,4 +3022,294 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get editMetadataSelectEmpty => 'Empty only';
|
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';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsSearchWith(String providerName) {
|
||||||
|
return 'Search with $providerName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedProvider => 'Home Feed Provider';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedDescription =>
|
||||||
|
'Choose which extension provides the home feed on the main screen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAuto => 'Auto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAutoSubtitle =>
|
||||||
|
'Automatically select the best available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsHomeFeedUse(String extensionName) {
|
||||||
|
return 'Use $extensionName home feed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaAsc => 'A-Z';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaDesc => 'Z-A';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadTitle => 'Cancel download?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cancelDownloadContent(String trackName) {
|
||||||
|
return 'This will cancel the active download for \"$trackName\".';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadKeep => 'Keep';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedStorage =>
|
||||||
|
'Failed to write metadata back to storage';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFolderPickerFailed(String error) {
|
||||||
|
return 'Failed to open folder picker: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadAlbum => 'Failed to load album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadPlaylist => 'Failed to load playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadArtist => 'Failed to load artist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadName => 'Download Progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanName => 'Library Scan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingTrack(String trackName) {
|
||||||
|
return 'Downloading $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifFinalizingTrack(String trackName) {
|
||||||
|
return 'Finalizing $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifEmbeddingMetadata => 'Embedding metadata...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifAlreadyInLibraryCount(int completed, int total) {
|
||||||
|
return 'Already in Library ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAlreadyInLibrary => 'Already in Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadCompleteCount(int completed, int total) {
|
||||||
|
return 'Download Complete ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifDownloadComplete => 'Download Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadsFinished(int completed, int failed) {
|
||||||
|
return 'Downloads Finished ($completed done, $failed failed)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAllDownloadsComplete => 'All Downloads Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifTracksDownloadedSuccess(int count) {
|
||||||
|
return '$count tracks downloaded successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifScanningLibrary => 'Scanning local library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressWithTotal(
|
||||||
|
int scanned,
|
||||||
|
int total,
|
||||||
|
int percentage,
|
||||||
|
) {
|
||||||
|
return '$scanned/$total files • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
|
||||||
|
return '$scanned files scanned • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanComplete => 'Library scan complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanCompleteBody(int count) {
|
||||||
|
return '$count tracks indexed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanExcluded(int count) {
|
||||||
|
return '$count excluded';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanErrors(int count) {
|
||||||
|
return '$count errors';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanFailed => 'Library scan failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanCancelled => 'Library scan cancelled';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanStopped => 'Scan stopped before completion.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingUpdate(String version) {
|
||||||
|
return 'Downloading SpotiFLAC v$version';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateProgress(String received, String total, int percentage) {
|
||||||
|
return '$received / $total MB • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateReady => 'Update Ready';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateReadyBody(String version) {
|
||||||
|
return 'SpotiFLAC v$version downloaded. Tap to install.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailed => 'Update Failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailedBody =>
|
||||||
|
'Could not download update. Try again later.';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,13 +21,13 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get navSettings => 'Settings';
|
String get navSettings => 'Settings';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get navStore => 'Store';
|
String get navStore => 'Repo';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get homeTitle => 'Home';
|
String get homeTitle => 'Home';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get homeSubtitle => 'Paste a Spotify link or search by name';
|
String get homeSubtitle => 'Paste a supported URL or search by name';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs';
|
String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs';
|
||||||
@@ -170,10 +170,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
'Parallel downloads may trigger rate limiting';
|
'Parallel downloads may trigger rate limiting';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsExtensionStore => 'Extension Store';
|
String get optionsExtensionStore => 'Extension Repo';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsExtensionStoreSubtitle => 'Show Store tab in navigation';
|
String get optionsExtensionStoreSubtitle => 'Show Repo tab in navigation';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsCheckUpdates => 'Check for Updates';
|
String get optionsCheckUpdates => 'Check for Updates';
|
||||||
@@ -250,7 +250,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get extensionsUninstall => 'Uninstall';
|
String get extensionsUninstall => 'Uninstall';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeTitle => 'Extension Store';
|
String get storeTitle => 'Extension Repo';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeSearch => 'Search extensions...';
|
String get storeSearch => 'Search extensions...';
|
||||||
@@ -759,6 +759,36 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get searchPlaylists => 'Playlists';
|
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
|
@override
|
||||||
String get tooltipPlay => 'Play';
|
String get tooltipPlay => 'Play';
|
||||||
|
|
||||||
@@ -1231,7 +1261,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get storeNewRepoUrlLabel => 'New Repository URL';
|
String get storeNewRepoUrlLabel => 'New Repository URL';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeLoadError => 'Failed to load store';
|
String get storeLoadError => 'Failed to load repository';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeEmptyNoExtensions => 'No extensions available';
|
String get storeEmptyNoExtensions => 'No extensions available';
|
||||||
@@ -1425,16 +1455,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get qualityNote =>
|
String get qualityNote =>
|
||||||
'Actual quality depends on track availability from the service';
|
'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
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||||
|
|
||||||
@@ -1532,6 +1552,13 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||||
'Artist/Album/ and Artist/Singles/';
|
'Artist/Album/ and Artist/Singles/';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlatSubtitle =>
|
||||||
|
'Artist/Album/ and Artist/song.flac';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||||
|
|
||||||
@@ -1970,7 +1997,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialExtensionsTip1 =>
|
String get tutorialExtensionsTip1 =>
|
||||||
'Browse the Store tab to discover useful extensions';
|
'Browse the Repo tab to discover useful extensions';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialExtensionsTip2 =>
|
String get tutorialExtensionsTip2 =>
|
||||||
@@ -2963,4 +2990,294 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get editMetadataSelectEmpty => 'Empty only';
|
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';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsSearchWith(String providerName) {
|
||||||
|
return 'Search with $providerName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedProvider => 'Home Feed Provider';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedDescription =>
|
||||||
|
'Choose which extension provides the home feed on the main screen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAuto => 'Auto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAutoSubtitle =>
|
||||||
|
'Automatically select the best available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsHomeFeedUse(String extensionName) {
|
||||||
|
return 'Use $extensionName home feed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaAsc => 'A-Z';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaDesc => 'Z-A';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadTitle => 'Cancel download?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cancelDownloadContent(String trackName) {
|
||||||
|
return 'This will cancel the active download for \"$trackName\".';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadKeep => 'Keep';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedStorage =>
|
||||||
|
'Failed to write metadata back to storage';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFolderPickerFailed(String error) {
|
||||||
|
return 'Failed to open folder picker: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadAlbum => 'Failed to load album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadPlaylist => 'Failed to load playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadArtist => 'Failed to load artist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadName => 'Download Progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanName => 'Library Scan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingTrack(String trackName) {
|
||||||
|
return 'Downloading $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifFinalizingTrack(String trackName) {
|
||||||
|
return 'Finalizing $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifEmbeddingMetadata => 'Embedding metadata...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifAlreadyInLibraryCount(int completed, int total) {
|
||||||
|
return 'Already in Library ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAlreadyInLibrary => 'Already in Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadCompleteCount(int completed, int total) {
|
||||||
|
return 'Download Complete ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifDownloadComplete => 'Download Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadsFinished(int completed, int failed) {
|
||||||
|
return 'Downloads Finished ($completed done, $failed failed)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAllDownloadsComplete => 'All Downloads Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifTracksDownloadedSuccess(int count) {
|
||||||
|
return '$count tracks downloaded successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifScanningLibrary => 'Scanning local library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressWithTotal(
|
||||||
|
int scanned,
|
||||||
|
int total,
|
||||||
|
int percentage,
|
||||||
|
) {
|
||||||
|
return '$scanned/$total files • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
|
||||||
|
return '$scanned files scanned • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanComplete => 'Library scan complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanCompleteBody(int count) {
|
||||||
|
return '$count tracks indexed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanExcluded(int count) {
|
||||||
|
return '$count excluded';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanErrors(int count) {
|
||||||
|
return '$count errors';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanFailed => 'Library scan failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanCancelled => 'Library scan cancelled';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanStopped => 'Scan stopped before completion.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingUpdate(String version) {
|
||||||
|
return 'Downloading SpotiFLAC v$version';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateProgress(String received, String total, int percentage) {
|
||||||
|
return '$received / $total MB • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateReady => 'Update Ready';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateReadyBody(String version) {
|
||||||
|
return 'SpotiFLAC v$version downloaded. Tap to install.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailed => 'Update Failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailedBody =>
|
||||||
|
'Could not download update. Try again later.';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -759,6 +759,36 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get searchPlaylists => 'Playlists';
|
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
|
@override
|
||||||
String get tooltipPlay => 'Play';
|
String get tooltipPlay => 'Play';
|
||||||
|
|
||||||
@@ -1231,7 +1261,7 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
String get storeNewRepoUrlLabel => 'New Repository URL';
|
String get storeNewRepoUrlLabel => 'New Repository URL';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeLoadError => 'Failed to load store';
|
String get storeLoadError => 'Failed to load repository';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeEmptyNoExtensions => 'No extensions available';
|
String get storeEmptyNoExtensions => 'No extensions available';
|
||||||
@@ -1425,16 +1455,6 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
String get qualityNote =>
|
String get qualityNote =>
|
||||||
'Actual quality depends on track availability from the service';
|
'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
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||||
|
|
||||||
@@ -1532,6 +1552,13 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||||
'Artist/Album/ and Artist/Singles/';
|
'Artist/Album/ and Artist/Singles/';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlatSubtitle =>
|
||||||
|
'Artist/Album/ and Artist/song.flac';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||||
|
|
||||||
@@ -1970,7 +1997,7 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialExtensionsTip1 =>
|
String get tutorialExtensionsTip1 =>
|
||||||
'Browse the Store tab to discover useful extensions';
|
'Browse the Repo tab to discover useful extensions';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialExtensionsTip2 =>
|
String get tutorialExtensionsTip2 =>
|
||||||
@@ -2963,6 +2990,296 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get editMetadataSelectEmpty => 'Empty only';
|
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';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsSearchWith(String providerName) {
|
||||||
|
return 'Search with $providerName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedProvider => 'Home Feed Provider';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedDescription =>
|
||||||
|
'Choose which extension provides the home feed on the main screen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAuto => 'Auto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAutoSubtitle =>
|
||||||
|
'Automatically select the best available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsHomeFeedUse(String extensionName) {
|
||||||
|
return 'Use $extensionName home feed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaAsc => 'A-Z';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaDesc => 'Z-A';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadTitle => 'Cancel download?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cancelDownloadContent(String trackName) {
|
||||||
|
return 'This will cancel the active download for \"$trackName\".';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadKeep => 'Keep';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedStorage =>
|
||||||
|
'Failed to write metadata back to storage';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFolderPickerFailed(String error) {
|
||||||
|
return 'Failed to open folder picker: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadAlbum => 'Failed to load album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadPlaylist => 'Failed to load playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadArtist => 'Failed to load artist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadName => 'Download Progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanName => 'Library Scan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingTrack(String trackName) {
|
||||||
|
return 'Downloading $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifFinalizingTrack(String trackName) {
|
||||||
|
return 'Finalizing $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifEmbeddingMetadata => 'Embedding metadata...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifAlreadyInLibraryCount(int completed, int total) {
|
||||||
|
return 'Already in Library ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAlreadyInLibrary => 'Already in Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadCompleteCount(int completed, int total) {
|
||||||
|
return 'Download Complete ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifDownloadComplete => 'Download Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadsFinished(int completed, int failed) {
|
||||||
|
return 'Downloads Finished ($completed done, $failed failed)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAllDownloadsComplete => 'All Downloads Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifTracksDownloadedSuccess(int count) {
|
||||||
|
return '$count tracks downloaded successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifScanningLibrary => 'Scanning local library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressWithTotal(
|
||||||
|
int scanned,
|
||||||
|
int total,
|
||||||
|
int percentage,
|
||||||
|
) {
|
||||||
|
return '$scanned/$total files • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
|
||||||
|
return '$scanned files scanned • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanComplete => 'Library scan complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanCompleteBody(int count) {
|
||||||
|
return '$count tracks indexed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanExcluded(int count) {
|
||||||
|
return '$count excluded';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanErrors(int count) {
|
||||||
|
return '$count errors';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanFailed => 'Library scan failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanCancelled => 'Library scan cancelled';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanStopped => 'Scan stopped before completion.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingUpdate(String version) {
|
||||||
|
return 'Downloading SpotiFLAC v$version';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateProgress(String received, String total, int percentage) {
|
||||||
|
return '$received / $total MB • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateReady => 'Update Ready';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateReadyBody(String version) {
|
||||||
|
return 'SpotiFLAC v$version downloaded. Tap to install.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailed => 'Update Failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailedBody =>
|
||||||
|
'Could not download update. Try again later.';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The translations for Spanish Castilian, as used in Spain (`es_ES`).
|
/// The translations for Spanish Castilian, as used in Spain (`es_ES`).
|
||||||
@@ -4334,16 +4651,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs {
|
|||||||
String get qualityNote =>
|
String get qualityNote =>
|
||||||
'La calidad real depende de la disponibilidad de la pista del servicio';
|
'La calidad real depende de la disponibilidad de la pista del servicio';
|
||||||
|
|
||||||
@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
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Preguntar antes de descargar';
|
String get downloadAskBeforeDownload => 'Preguntar antes de descargar';
|
||||||
|
|
||||||
|
|||||||
@@ -761,6 +761,36 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get searchPlaylists => 'Playlists';
|
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
|
@override
|
||||||
String get tooltipPlay => 'Play';
|
String get tooltipPlay => 'Play';
|
||||||
|
|
||||||
@@ -1233,7 +1263,7 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String get storeNewRepoUrlLabel => 'New Repository URL';
|
String get storeNewRepoUrlLabel => 'New Repository URL';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeLoadError => 'Failed to load store';
|
String get storeLoadError => 'Failed to load repository';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeEmptyNoExtensions => 'No extensions available';
|
String get storeEmptyNoExtensions => 'No extensions available';
|
||||||
@@ -1427,16 +1457,6 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String get qualityNote =>
|
String get qualityNote =>
|
||||||
'Actual quality depends on track availability from the service';
|
'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
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||||
|
|
||||||
@@ -1534,6 +1554,13 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||||
'Artist/Album/ and Artist/Singles/';
|
'Artist/Album/ and Artist/Singles/';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlatSubtitle =>
|
||||||
|
'Artist/Album/ and Artist/song.flac';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||||
|
|
||||||
@@ -2964,4 +2991,294 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get editMetadataSelectEmpty => 'Empty only';
|
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';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsSearchWith(String providerName) {
|
||||||
|
return 'Search with $providerName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedProvider => 'Home Feed Provider';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedDescription =>
|
||||||
|
'Choose which extension provides the home feed on the main screen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAuto => 'Auto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAutoSubtitle =>
|
||||||
|
'Automatically select the best available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsHomeFeedUse(String extensionName) {
|
||||||
|
return 'Use $extensionName home feed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaAsc => 'A-Z';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaDesc => 'Z-A';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadTitle => 'Cancel download?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cancelDownloadContent(String trackName) {
|
||||||
|
return 'This will cancel the active download for \"$trackName\".';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadKeep => 'Keep';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedStorage =>
|
||||||
|
'Failed to write metadata back to storage';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFolderPickerFailed(String error) {
|
||||||
|
return 'Failed to open folder picker: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadAlbum => 'Failed to load album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadPlaylist => 'Failed to load playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadArtist => 'Failed to load artist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadName => 'Download Progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanName => 'Library Scan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingTrack(String trackName) {
|
||||||
|
return 'Downloading $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifFinalizingTrack(String trackName) {
|
||||||
|
return 'Finalizing $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifEmbeddingMetadata => 'Embedding metadata...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifAlreadyInLibraryCount(int completed, int total) {
|
||||||
|
return 'Already in Library ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAlreadyInLibrary => 'Already in Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadCompleteCount(int completed, int total) {
|
||||||
|
return 'Download Complete ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifDownloadComplete => 'Download Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadsFinished(int completed, int failed) {
|
||||||
|
return 'Downloads Finished ($completed done, $failed failed)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAllDownloadsComplete => 'All Downloads Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifTracksDownloadedSuccess(int count) {
|
||||||
|
return '$count tracks downloaded successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifScanningLibrary => 'Scanning local library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressWithTotal(
|
||||||
|
int scanned,
|
||||||
|
int total,
|
||||||
|
int percentage,
|
||||||
|
) {
|
||||||
|
return '$scanned/$total files • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
|
||||||
|
return '$scanned files scanned • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanComplete => 'Library scan complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanCompleteBody(int count) {
|
||||||
|
return '$count tracks indexed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanExcluded(int count) {
|
||||||
|
return '$count excluded';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanErrors(int count) {
|
||||||
|
return '$count errors';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanFailed => 'Library scan failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanCancelled => 'Library scan cancelled';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanStopped => 'Scan stopped before completion.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingUpdate(String version) {
|
||||||
|
return 'Downloading SpotiFLAC v$version';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateProgress(String received, String total, int percentage) {
|
||||||
|
return '$received / $total MB • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateReady => 'Update Ready';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateReadyBody(String version) {
|
||||||
|
return 'SpotiFLAC v$version downloaded. Tap to install.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailed => 'Update Failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailedBody =>
|
||||||
|
'Could not download update. Try again later.';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -759,6 +759,36 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get searchPlaylists => 'Playlists';
|
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
|
@override
|
||||||
String get tooltipPlay => 'Play';
|
String get tooltipPlay => 'Play';
|
||||||
|
|
||||||
@@ -1231,7 +1261,7 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
String get storeNewRepoUrlLabel => 'New Repository URL';
|
String get storeNewRepoUrlLabel => 'New Repository URL';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeLoadError => 'Failed to load store';
|
String get storeLoadError => 'Failed to load repository';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeEmptyNoExtensions => 'No extensions available';
|
String get storeEmptyNoExtensions => 'No extensions available';
|
||||||
@@ -1425,16 +1455,6 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
String get qualityNote =>
|
String get qualityNote =>
|
||||||
'Actual quality depends on track availability from the service';
|
'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
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||||
|
|
||||||
@@ -1532,6 +1552,13 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||||
'Artist/Album/ and Artist/Singles/';
|
'Artist/Album/ and Artist/Singles/';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlatSubtitle =>
|
||||||
|
'Artist/Album/ and Artist/song.flac';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||||
|
|
||||||
@@ -2962,4 +2989,294 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get editMetadataSelectEmpty => 'Empty only';
|
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';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsSearchWith(String providerName) {
|
||||||
|
return 'Search with $providerName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedProvider => 'Home Feed Provider';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedDescription =>
|
||||||
|
'Choose which extension provides the home feed on the main screen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAuto => 'Auto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAutoSubtitle =>
|
||||||
|
'Automatically select the best available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsHomeFeedUse(String extensionName) {
|
||||||
|
return 'Use $extensionName home feed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaAsc => 'A-Z';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaDesc => 'Z-A';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadTitle => 'Cancel download?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cancelDownloadContent(String trackName) {
|
||||||
|
return 'This will cancel the active download for \"$trackName\".';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadKeep => 'Keep';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedStorage =>
|
||||||
|
'Failed to write metadata back to storage';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFolderPickerFailed(String error) {
|
||||||
|
return 'Failed to open folder picker: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadAlbum => 'Failed to load album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadPlaylist => 'Failed to load playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadArtist => 'Failed to load artist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadName => 'Download Progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanName => 'Library Scan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingTrack(String trackName) {
|
||||||
|
return 'Downloading $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifFinalizingTrack(String trackName) {
|
||||||
|
return 'Finalizing $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifEmbeddingMetadata => 'Embedding metadata...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifAlreadyInLibraryCount(int completed, int total) {
|
||||||
|
return 'Already in Library ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAlreadyInLibrary => 'Already in Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadCompleteCount(int completed, int total) {
|
||||||
|
return 'Download Complete ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifDownloadComplete => 'Download Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadsFinished(int completed, int failed) {
|
||||||
|
return 'Downloads Finished ($completed done, $failed failed)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAllDownloadsComplete => 'All Downloads Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifTracksDownloadedSuccess(int count) {
|
||||||
|
return '$count tracks downloaded successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifScanningLibrary => 'Scanning local library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressWithTotal(
|
||||||
|
int scanned,
|
||||||
|
int total,
|
||||||
|
int percentage,
|
||||||
|
) {
|
||||||
|
return '$scanned/$total files • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
|
||||||
|
return '$scanned files scanned • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanComplete => 'Library scan complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanCompleteBody(int count) {
|
||||||
|
return '$count tracks indexed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanExcluded(int count) {
|
||||||
|
return '$count excluded';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanErrors(int count) {
|
||||||
|
return '$count errors';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanFailed => 'Library scan failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanCancelled => 'Library scan cancelled';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanStopped => 'Scan stopped before completion.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingUpdate(String version) {
|
||||||
|
return 'Downloading SpotiFLAC v$version';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateProgress(String received, String total, int percentage) {
|
||||||
|
return '$received / $total MB • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateReady => 'Update Ready';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateReadyBody(String version) {
|
||||||
|
return 'SpotiFLAC v$version downloaded. Tap to install.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailed => 'Update Failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailedBody =>
|
||||||
|
'Could not download update. Try again later.';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,13 +21,14 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String get navSettings => 'Pengaturan';
|
String get navSettings => 'Pengaturan';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get navStore => 'Toko';
|
String get navStore => 'Repo';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get homeTitle => 'Beranda';
|
String get homeTitle => 'Beranda';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get homeSubtitle => 'Tempel link Spotify atau cari berdasarkan nama';
|
String get homeSubtitle =>
|
||||||
|
'Tempel URL yang didukung atau cari berdasarkan nama';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get homeSupports => 'Mendukung: URL Track, Album, Playlist, Artis';
|
String get homeSupports => 'Mendukung: URL Track, Album, Playlist, Artis';
|
||||||
@@ -173,10 +174,10 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
'Unduhan paralel dapat memicu pembatasan rate';
|
'Unduhan paralel dapat memicu pembatasan rate';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsExtensionStore => 'Toko Ekstensi';
|
String get optionsExtensionStore => 'Repo Ekstensi';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsExtensionStoreSubtitle => 'Tampilkan tab Toko di navigasi';
|
String get optionsExtensionStoreSubtitle => 'Tampilkan tab Repo di navigasi';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsCheckUpdates => 'Periksa Pembaruan';
|
String get optionsCheckUpdates => 'Periksa Pembaruan';
|
||||||
@@ -252,7 +253,7 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String get extensionsUninstall => 'Copot';
|
String get extensionsUninstall => 'Copot';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeTitle => 'Toko Ekstensi';
|
String get storeTitle => 'Repo Ekstensi';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeSearch => 'Cari ekstensi...';
|
String get storeSearch => 'Cari ekstensi...';
|
||||||
@@ -762,6 +763,36 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get searchPlaylists => 'Playlist';
|
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
|
@override
|
||||||
String get tooltipPlay => 'Putar';
|
String get tooltipPlay => 'Putar';
|
||||||
|
|
||||||
@@ -1237,7 +1268,7 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String get storeNewRepoUrlLabel => 'New Repository URL';
|
String get storeNewRepoUrlLabel => 'New Repository URL';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeLoadError => 'Failed to load store';
|
String get storeLoadError => 'Gagal memuat repo';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeEmptyNoExtensions => 'No extensions available';
|
String get storeEmptyNoExtensions => 'No extensions available';
|
||||||
@@ -1433,16 +1464,6 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String get qualityNote =>
|
String get qualityNote =>
|
||||||
'Kualitas sebenarnya tergantung ketersediaan lagu dari layanan';
|
'Kualitas sebenarnya tergantung ketersediaan lagu dari layanan';
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeQualityNote =>
|
|
||||||
'YouTube hanya menyediakan audio terkompresi (lossy). Bukan bagian dari fallback lossless.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeOpusBitrateTitle => 'Bitrate YouTube Opus';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeMp3BitrateTitle => 'Kecepatan Bit MP3 YouTube';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Tanya Sebelum Unduh';
|
String get downloadAskBeforeDownload => 'Tanya Sebelum Unduh';
|
||||||
|
|
||||||
@@ -1541,6 +1562,13 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||||
'Artis/Album/ dan Artis/Single/';
|
'Artis/Album/ dan Artis/Single/';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlatSubtitle =>
|
||||||
|
'Artist/Album/ and Artist/song.flac';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadedAlbumDeleteSelected => 'Hapus yang Dipilih';
|
String get downloadedAlbumDeleteSelected => 'Hapus yang Dipilih';
|
||||||
|
|
||||||
@@ -1979,7 +2007,7 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialExtensionsTip1 =>
|
String get tutorialExtensionsTip1 =>
|
||||||
'Browse the Store tab to discover useful extensions';
|
'Buka tab Repo untuk menemukan ekstensi yang berguna';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialExtensionsTip2 =>
|
String get tutorialExtensionsTip2 =>
|
||||||
@@ -2972,4 +3000,294 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get editMetadataSelectEmpty => 'Empty only';
|
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';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsSearchWith(String providerName) {
|
||||||
|
return 'Search with $providerName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedProvider => 'Home Feed Provider';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedDescription =>
|
||||||
|
'Choose which extension provides the home feed on the main screen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAuto => 'Auto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAutoSubtitle =>
|
||||||
|
'Automatically select the best available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsHomeFeedUse(String extensionName) {
|
||||||
|
return 'Use $extensionName home feed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaAsc => 'A-Z';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaDesc => 'Z-A';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadTitle => 'Cancel download?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cancelDownloadContent(String trackName) {
|
||||||
|
return 'This will cancel the active download for \"$trackName\".';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadKeep => 'Keep';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedStorage =>
|
||||||
|
'Failed to write metadata back to storage';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFolderPickerFailed(String error) {
|
||||||
|
return 'Failed to open folder picker: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadAlbum => 'Failed to load album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadPlaylist => 'Failed to load playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadArtist => 'Failed to load artist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadName => 'Download Progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanName => 'Library Scan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingTrack(String trackName) {
|
||||||
|
return 'Downloading $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifFinalizingTrack(String trackName) {
|
||||||
|
return 'Finalizing $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifEmbeddingMetadata => 'Embedding metadata...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifAlreadyInLibraryCount(int completed, int total) {
|
||||||
|
return 'Already in Library ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAlreadyInLibrary => 'Already in Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadCompleteCount(int completed, int total) {
|
||||||
|
return 'Download Complete ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifDownloadComplete => 'Download Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadsFinished(int completed, int failed) {
|
||||||
|
return 'Downloads Finished ($completed done, $failed failed)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAllDownloadsComplete => 'All Downloads Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifTracksDownloadedSuccess(int count) {
|
||||||
|
return '$count tracks downloaded successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifScanningLibrary => 'Scanning local library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressWithTotal(
|
||||||
|
int scanned,
|
||||||
|
int total,
|
||||||
|
int percentage,
|
||||||
|
) {
|
||||||
|
return '$scanned/$total files • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
|
||||||
|
return '$scanned files scanned • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanComplete => 'Library scan complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanCompleteBody(int count) {
|
||||||
|
return '$count tracks indexed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanExcluded(int count) {
|
||||||
|
return '$count excluded';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanErrors(int count) {
|
||||||
|
return '$count errors';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanFailed => 'Library scan failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanCancelled => 'Library scan cancelled';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanStopped => 'Scan stopped before completion.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingUpdate(String version) {
|
||||||
|
return 'Downloading SpotiFLAC v$version';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateProgress(String received, String total, int percentage) {
|
||||||
|
return '$received / $total MB • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateReady => 'Update Ready';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateReadyBody(String version) {
|
||||||
|
return 'SpotiFLAC v$version downloaded. Tap to install.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailed => 'Update Failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailedBody =>
|
||||||
|
'Could not download update. Try again later.';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -754,6 +754,36 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get searchPlaylists => 'プレイリスト';
|
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
|
@override
|
||||||
String get tooltipPlay => '再生';
|
String get tooltipPlay => '再生';
|
||||||
|
|
||||||
@@ -1225,7 +1255,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
String get storeNewRepoUrlLabel => 'New Repository URL';
|
String get storeNewRepoUrlLabel => 'New Repository URL';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeLoadError => 'Failed to load store';
|
String get storeLoadError => 'Failed to load repository';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeEmptyNoExtensions => 'No extensions available';
|
String get storeEmptyNoExtensions => 'No extensions available';
|
||||||
@@ -1414,16 +1444,6 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get qualityNote => '実際の品質はサービスからのトラックの可用性に依存します';
|
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
|
@override
|
||||||
String get downloadAskBeforeDownload => 'ダウンロード前に確認する';
|
String get downloadAskBeforeDownload => 'ダウンロード前に確認する';
|
||||||
|
|
||||||
@@ -1519,6 +1539,13 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||||
'Artist/Album/ and Artist/Singles/';
|
'Artist/Album/ and Artist/Singles/';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlatSubtitle =>
|
||||||
|
'Artist/Album/ and Artist/song.flac';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadedAlbumDeleteSelected => '選択済みを削除';
|
String get downloadedAlbumDeleteSelected => '選択済みを削除';
|
||||||
|
|
||||||
@@ -2949,4 +2976,294 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get editMetadataSelectEmpty => 'Empty only';
|
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';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsSearchWith(String providerName) {
|
||||||
|
return 'Search with $providerName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedProvider => 'Home Feed Provider';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedDescription =>
|
||||||
|
'Choose which extension provides the home feed on the main screen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAuto => 'Auto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAutoSubtitle =>
|
||||||
|
'Automatically select the best available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsHomeFeedUse(String extensionName) {
|
||||||
|
return 'Use $extensionName home feed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaAsc => 'A-Z';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaDesc => 'Z-A';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadTitle => 'Cancel download?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cancelDownloadContent(String trackName) {
|
||||||
|
return 'This will cancel the active download for \"$trackName\".';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadKeep => 'Keep';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedStorage =>
|
||||||
|
'Failed to write metadata back to storage';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFolderPickerFailed(String error) {
|
||||||
|
return 'Failed to open folder picker: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadAlbum => 'Failed to load album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadPlaylist => 'Failed to load playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadArtist => 'Failed to load artist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadName => 'Download Progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanName => 'Library Scan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingTrack(String trackName) {
|
||||||
|
return 'Downloading $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifFinalizingTrack(String trackName) {
|
||||||
|
return 'Finalizing $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifEmbeddingMetadata => 'Embedding metadata...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifAlreadyInLibraryCount(int completed, int total) {
|
||||||
|
return 'Already in Library ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAlreadyInLibrary => 'Already in Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadCompleteCount(int completed, int total) {
|
||||||
|
return 'Download Complete ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifDownloadComplete => 'Download Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadsFinished(int completed, int failed) {
|
||||||
|
return 'Downloads Finished ($completed done, $failed failed)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAllDownloadsComplete => 'All Downloads Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifTracksDownloadedSuccess(int count) {
|
||||||
|
return '$count tracks downloaded successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifScanningLibrary => 'Scanning local library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressWithTotal(
|
||||||
|
int scanned,
|
||||||
|
int total,
|
||||||
|
int percentage,
|
||||||
|
) {
|
||||||
|
return '$scanned/$total files • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
|
||||||
|
return '$scanned files scanned • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanComplete => 'Library scan complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanCompleteBody(int count) {
|
||||||
|
return '$count tracks indexed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanExcluded(int count) {
|
||||||
|
return '$count excluded';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanErrors(int count) {
|
||||||
|
return '$count errors';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanFailed => 'Library scan failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanCancelled => 'Library scan cancelled';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanStopped => 'Scan stopped before completion.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingUpdate(String version) {
|
||||||
|
return 'Downloading SpotiFLAC v$version';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateProgress(String received, String total, int percentage) {
|
||||||
|
return '$received / $total MB • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateReady => 'Update Ready';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateReadyBody(String version) {
|
||||||
|
return 'SpotiFLAC v$version downloaded. Tap to install.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailed => 'Update Failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailedBody =>
|
||||||
|
'Could not download update. Try again later.';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -741,6 +741,36 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get searchPlaylists => '재생목록들';
|
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
|
@override
|
||||||
String get tooltipPlay => '재생';
|
String get tooltipPlay => '재생';
|
||||||
|
|
||||||
@@ -1211,7 +1241,7 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
String get storeNewRepoUrlLabel => 'New Repository URL';
|
String get storeNewRepoUrlLabel => 'New Repository URL';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeLoadError => 'Failed to load store';
|
String get storeLoadError => 'Failed to load repository';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeEmptyNoExtensions => 'No extensions available';
|
String get storeEmptyNoExtensions => 'No extensions available';
|
||||||
@@ -1405,16 +1435,6 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
String get qualityNote =>
|
String get qualityNote =>
|
||||||
'Actual quality depends on track availability from the service';
|
'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
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||||
|
|
||||||
@@ -1512,6 +1532,13 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||||
'Artist/Album/ and Artist/Singles/';
|
'Artist/Album/ and Artist/Singles/';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlatSubtitle =>
|
||||||
|
'Artist/Album/ and Artist/song.flac';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||||
|
|
||||||
@@ -2942,4 +2969,294 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get editMetadataSelectEmpty => 'Empty only';
|
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';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsSearchWith(String providerName) {
|
||||||
|
return 'Search with $providerName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedProvider => 'Home Feed Provider';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedDescription =>
|
||||||
|
'Choose which extension provides the home feed on the main screen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAuto => 'Auto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAutoSubtitle =>
|
||||||
|
'Automatically select the best available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsHomeFeedUse(String extensionName) {
|
||||||
|
return 'Use $extensionName home feed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaAsc => 'A-Z';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaDesc => 'Z-A';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadTitle => 'Cancel download?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cancelDownloadContent(String trackName) {
|
||||||
|
return 'This will cancel the active download for \"$trackName\".';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadKeep => 'Keep';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedStorage =>
|
||||||
|
'Failed to write metadata back to storage';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFolderPickerFailed(String error) {
|
||||||
|
return 'Failed to open folder picker: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadAlbum => 'Failed to load album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadPlaylist => 'Failed to load playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadArtist => 'Failed to load artist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadName => 'Download Progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanName => 'Library Scan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingTrack(String trackName) {
|
||||||
|
return 'Downloading $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifFinalizingTrack(String trackName) {
|
||||||
|
return 'Finalizing $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifEmbeddingMetadata => 'Embedding metadata...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifAlreadyInLibraryCount(int completed, int total) {
|
||||||
|
return 'Already in Library ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAlreadyInLibrary => 'Already in Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadCompleteCount(int completed, int total) {
|
||||||
|
return 'Download Complete ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifDownloadComplete => 'Download Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadsFinished(int completed, int failed) {
|
||||||
|
return 'Downloads Finished ($completed done, $failed failed)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAllDownloadsComplete => 'All Downloads Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifTracksDownloadedSuccess(int count) {
|
||||||
|
return '$count tracks downloaded successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifScanningLibrary => 'Scanning local library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressWithTotal(
|
||||||
|
int scanned,
|
||||||
|
int total,
|
||||||
|
int percentage,
|
||||||
|
) {
|
||||||
|
return '$scanned/$total files • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
|
||||||
|
return '$scanned files scanned • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanComplete => 'Library scan complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanCompleteBody(int count) {
|
||||||
|
return '$count tracks indexed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanExcluded(int count) {
|
||||||
|
return '$count excluded';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanErrors(int count) {
|
||||||
|
return '$count errors';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanFailed => 'Library scan failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanCancelled => 'Library scan cancelled';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanStopped => 'Scan stopped before completion.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingUpdate(String version) {
|
||||||
|
return 'Downloading SpotiFLAC v$version';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateProgress(String received, String total, int percentage) {
|
||||||
|
return '$received / $total MB • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateReady => 'Update Ready';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateReadyBody(String version) {
|
||||||
|
return 'SpotiFLAC v$version downloaded. Tap to install.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailed => 'Update Failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailedBody =>
|
||||||
|
'Could not download update. Try again later.';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -759,6 +759,36 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get searchPlaylists => 'Playlists';
|
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
|
@override
|
||||||
String get tooltipPlay => 'Play';
|
String get tooltipPlay => 'Play';
|
||||||
|
|
||||||
@@ -1231,7 +1261,7 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
String get storeNewRepoUrlLabel => 'New Repository URL';
|
String get storeNewRepoUrlLabel => 'New Repository URL';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeLoadError => 'Failed to load store';
|
String get storeLoadError => 'Failed to load repository';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeEmptyNoExtensions => 'No extensions available';
|
String get storeEmptyNoExtensions => 'No extensions available';
|
||||||
@@ -1425,16 +1455,6 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
String get qualityNote =>
|
String get qualityNote =>
|
||||||
'Actual quality depends on track availability from the service';
|
'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
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||||
|
|
||||||
@@ -1532,6 +1552,13 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||||
'Artist/Album/ and Artist/Singles/';
|
'Artist/Album/ and Artist/Singles/';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlatSubtitle =>
|
||||||
|
'Artist/Album/ and Artist/song.flac';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||||
|
|
||||||
@@ -2962,4 +2989,294 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get editMetadataSelectEmpty => 'Empty only';
|
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';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsSearchWith(String providerName) {
|
||||||
|
return 'Search with $providerName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedProvider => 'Home Feed Provider';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedDescription =>
|
||||||
|
'Choose which extension provides the home feed on the main screen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAuto => 'Auto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAutoSubtitle =>
|
||||||
|
'Automatically select the best available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsHomeFeedUse(String extensionName) {
|
||||||
|
return 'Use $extensionName home feed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaAsc => 'A-Z';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaDesc => 'Z-A';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadTitle => 'Cancel download?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cancelDownloadContent(String trackName) {
|
||||||
|
return 'This will cancel the active download for \"$trackName\".';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadKeep => 'Keep';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedStorage =>
|
||||||
|
'Failed to write metadata back to storage';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFolderPickerFailed(String error) {
|
||||||
|
return 'Failed to open folder picker: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadAlbum => 'Failed to load album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadPlaylist => 'Failed to load playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadArtist => 'Failed to load artist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadName => 'Download Progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanName => 'Library Scan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingTrack(String trackName) {
|
||||||
|
return 'Downloading $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifFinalizingTrack(String trackName) {
|
||||||
|
return 'Finalizing $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifEmbeddingMetadata => 'Embedding metadata...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifAlreadyInLibraryCount(int completed, int total) {
|
||||||
|
return 'Already in Library ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAlreadyInLibrary => 'Already in Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadCompleteCount(int completed, int total) {
|
||||||
|
return 'Download Complete ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifDownloadComplete => 'Download Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadsFinished(int completed, int failed) {
|
||||||
|
return 'Downloads Finished ($completed done, $failed failed)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAllDownloadsComplete => 'All Downloads Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifTracksDownloadedSuccess(int count) {
|
||||||
|
return '$count tracks downloaded successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifScanningLibrary => 'Scanning local library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressWithTotal(
|
||||||
|
int scanned,
|
||||||
|
int total,
|
||||||
|
int percentage,
|
||||||
|
) {
|
||||||
|
return '$scanned/$total files • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
|
||||||
|
return '$scanned files scanned • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanComplete => 'Library scan complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanCompleteBody(int count) {
|
||||||
|
return '$count tracks indexed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanExcluded(int count) {
|
||||||
|
return '$count excluded';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanErrors(int count) {
|
||||||
|
return '$count errors';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanFailed => 'Library scan failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanCancelled => 'Library scan cancelled';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanStopped => 'Scan stopped before completion.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingUpdate(String version) {
|
||||||
|
return 'Downloading SpotiFLAC v$version';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateProgress(String received, String total, int percentage) {
|
||||||
|
return '$received / $total MB • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateReady => 'Update Ready';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateReadyBody(String version) {
|
||||||
|
return 'SpotiFLAC v$version downloaded. Tap to install.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailed => 'Update Failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailedBody =>
|
||||||
|
'Could not download update. Try again later.';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -759,6 +759,36 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get searchPlaylists => 'Playlists';
|
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
|
@override
|
||||||
String get tooltipPlay => 'Play';
|
String get tooltipPlay => 'Play';
|
||||||
|
|
||||||
@@ -1231,7 +1261,7 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
String get storeNewRepoUrlLabel => 'New Repository URL';
|
String get storeNewRepoUrlLabel => 'New Repository URL';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeLoadError => 'Failed to load store';
|
String get storeLoadError => 'Failed to load repository';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeEmptyNoExtensions => 'No extensions available';
|
String get storeEmptyNoExtensions => 'No extensions available';
|
||||||
@@ -1425,16 +1455,6 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
String get qualityNote =>
|
String get qualityNote =>
|
||||||
'Actual quality depends on track availability from the service';
|
'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
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||||
|
|
||||||
@@ -1532,6 +1552,13 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||||
'Artist/Album/ and Artist/Singles/';
|
'Artist/Album/ and Artist/Singles/';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlatSubtitle =>
|
||||||
|
'Artist/Album/ and Artist/song.flac';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||||
|
|
||||||
@@ -1970,7 +1997,7 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialExtensionsTip1 =>
|
String get tutorialExtensionsTip1 =>
|
||||||
'Browse the Store tab to discover useful extensions';
|
'Browse the Repo tab to discover useful extensions';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialExtensionsTip2 =>
|
String get tutorialExtensionsTip2 =>
|
||||||
@@ -2963,6 +2990,296 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get editMetadataSelectEmpty => 'Empty only';
|
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';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsSearchWith(String providerName) {
|
||||||
|
return 'Search with $providerName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedProvider => 'Home Feed Provider';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedDescription =>
|
||||||
|
'Choose which extension provides the home feed on the main screen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAuto => 'Auto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAutoSubtitle =>
|
||||||
|
'Automatically select the best available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsHomeFeedUse(String extensionName) {
|
||||||
|
return 'Use $extensionName home feed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaAsc => 'A-Z';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaDesc => 'Z-A';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadTitle => 'Cancel download?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cancelDownloadContent(String trackName) {
|
||||||
|
return 'This will cancel the active download for \"$trackName\".';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadKeep => 'Keep';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedStorage =>
|
||||||
|
'Failed to write metadata back to storage';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFolderPickerFailed(String error) {
|
||||||
|
return 'Failed to open folder picker: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadAlbum => 'Failed to load album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadPlaylist => 'Failed to load playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadArtist => 'Failed to load artist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadName => 'Download Progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanName => 'Library Scan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingTrack(String trackName) {
|
||||||
|
return 'Downloading $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifFinalizingTrack(String trackName) {
|
||||||
|
return 'Finalizing $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifEmbeddingMetadata => 'Embedding metadata...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifAlreadyInLibraryCount(int completed, int total) {
|
||||||
|
return 'Already in Library ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAlreadyInLibrary => 'Already in Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadCompleteCount(int completed, int total) {
|
||||||
|
return 'Download Complete ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifDownloadComplete => 'Download Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadsFinished(int completed, int failed) {
|
||||||
|
return 'Downloads Finished ($completed done, $failed failed)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAllDownloadsComplete => 'All Downloads Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifTracksDownloadedSuccess(int count) {
|
||||||
|
return '$count tracks downloaded successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifScanningLibrary => 'Scanning local library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressWithTotal(
|
||||||
|
int scanned,
|
||||||
|
int total,
|
||||||
|
int percentage,
|
||||||
|
) {
|
||||||
|
return '$scanned/$total files • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
|
||||||
|
return '$scanned files scanned • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanComplete => 'Library scan complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanCompleteBody(int count) {
|
||||||
|
return '$count tracks indexed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanExcluded(int count) {
|
||||||
|
return '$count excluded';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanErrors(int count) {
|
||||||
|
return '$count errors';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanFailed => 'Library scan failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanCancelled => 'Library scan cancelled';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanStopped => 'Scan stopped before completion.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingUpdate(String version) {
|
||||||
|
return 'Downloading SpotiFLAC v$version';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateProgress(String received, String total, int percentage) {
|
||||||
|
return '$received / $total MB • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateReady => 'Update Ready';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateReadyBody(String version) {
|
||||||
|
return 'SpotiFLAC v$version downloaded. Tap to install.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailed => 'Update Failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailedBody =>
|
||||||
|
'Could not download update. Try again later.';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The translations for Portuguese, as used in Portugal (`pt_PT`).
|
/// The translations for Portuguese, as used in Portugal (`pt_PT`).
|
||||||
@@ -4331,16 +4648,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
|
|||||||
String get qualityNote =>
|
String get qualityNote =>
|
||||||
'A qualidade real depende da faixa que estiver disponível no serviço';
|
'A qualidade real depende da faixa que estiver disponível no serviço';
|
||||||
|
|
||||||
@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
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Perguntar qualidade antes de baixar';
|
String get downloadAskBeforeDownload => 'Perguntar qualidade antes de baixar';
|
||||||
|
|
||||||
|
|||||||
@@ -773,6 +773,36 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get searchPlaylists => 'Плейлисты';
|
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
|
@override
|
||||||
String get tooltipPlay => 'Воспроизвести';
|
String get tooltipPlay => 'Воспроизвести';
|
||||||
|
|
||||||
@@ -1252,7 +1282,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
String get storeNewRepoUrlLabel => 'New Repository URL';
|
String get storeNewRepoUrlLabel => 'New Repository URL';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeLoadError => 'Failed to load store';
|
String get storeLoadError => 'Failed to load repository';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeEmptyNoExtensions => 'No extensions available';
|
String get storeEmptyNoExtensions => 'No extensions available';
|
||||||
@@ -1450,16 +1480,6 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
String get qualityNote =>
|
String get qualityNote =>
|
||||||
'Фактическое качество зависит от доступности треков в сервисе';
|
'Фактическое качество зависит от доступности треков в сервисе';
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeQualityNote =>
|
|
||||||
'YouTube обеспечивает только звук с потерями(Lossy).';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeOpusBitrateTitle => 'Битрейт YouTube Opus';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeMp3BitrateTitle => 'Битрейт YouTube MP3';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Спрашивать перед скачиванием';
|
String get downloadAskBeforeDownload => 'Спрашивать перед скачиванием';
|
||||||
|
|
||||||
@@ -1561,6 +1581,13 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||||
'Исполнитель/Альбом и Исполнитель/Сингл/';
|
'Исполнитель/Альбом и Исполнитель/Сингл/';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlatSubtitle =>
|
||||||
|
'Artist/Album/ and Artist/song.flac';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadedAlbumDeleteSelected => 'Удалить выбранные';
|
String get downloadedAlbumDeleteSelected => 'Удалить выбранные';
|
||||||
|
|
||||||
@@ -3022,4 +3049,294 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get editMetadataSelectEmpty => 'Empty only';
|
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';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsSearchWith(String providerName) {
|
||||||
|
return 'Search with $providerName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedProvider => 'Home Feed Provider';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedDescription =>
|
||||||
|
'Choose which extension provides the home feed on the main screen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAuto => 'Auto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAutoSubtitle =>
|
||||||
|
'Automatically select the best available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsHomeFeedUse(String extensionName) {
|
||||||
|
return 'Use $extensionName home feed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaAsc => 'A-Z';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaDesc => 'Z-A';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadTitle => 'Cancel download?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cancelDownloadContent(String trackName) {
|
||||||
|
return 'This will cancel the active download for \"$trackName\".';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadKeep => 'Keep';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedStorage =>
|
||||||
|
'Failed to write metadata back to storage';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFolderPickerFailed(String error) {
|
||||||
|
return 'Failed to open folder picker: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadAlbum => 'Failed to load album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadPlaylist => 'Failed to load playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadArtist => 'Failed to load artist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadName => 'Download Progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanName => 'Library Scan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingTrack(String trackName) {
|
||||||
|
return 'Downloading $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifFinalizingTrack(String trackName) {
|
||||||
|
return 'Finalizing $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifEmbeddingMetadata => 'Embedding metadata...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifAlreadyInLibraryCount(int completed, int total) {
|
||||||
|
return 'Already in Library ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAlreadyInLibrary => 'Already in Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadCompleteCount(int completed, int total) {
|
||||||
|
return 'Download Complete ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifDownloadComplete => 'Download Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadsFinished(int completed, int failed) {
|
||||||
|
return 'Downloads Finished ($completed done, $failed failed)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAllDownloadsComplete => 'All Downloads Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifTracksDownloadedSuccess(int count) {
|
||||||
|
return '$count tracks downloaded successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifScanningLibrary => 'Scanning local library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressWithTotal(
|
||||||
|
int scanned,
|
||||||
|
int total,
|
||||||
|
int percentage,
|
||||||
|
) {
|
||||||
|
return '$scanned/$total files • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
|
||||||
|
return '$scanned files scanned • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanComplete => 'Library scan complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanCompleteBody(int count) {
|
||||||
|
return '$count tracks indexed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanExcluded(int count) {
|
||||||
|
return '$count excluded';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanErrors(int count) {
|
||||||
|
return '$count errors';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanFailed => 'Library scan failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanCancelled => 'Library scan cancelled';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanStopped => 'Scan stopped before completion.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingUpdate(String version) {
|
||||||
|
return 'Downloading SpotiFLAC v$version';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateProgress(String received, String total, int percentage) {
|
||||||
|
return '$received / $total MB • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateReady => 'Update Ready';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateReadyBody(String version) {
|
||||||
|
return 'SpotiFLAC v$version downloaded. Tap to install.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailed => 'Update Failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailedBody =>
|
||||||
|
'Could not download update. Try again later.';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -764,6 +764,36 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get searchPlaylists => 'Çalma Listeleri';
|
String get searchPlaylists => 'Çalma Listeleri';
|
||||||
|
|
||||||
|
@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
|
@override
|
||||||
String get tooltipPlay => 'Oynat';
|
String get tooltipPlay => 'Oynat';
|
||||||
|
|
||||||
@@ -1237,7 +1267,7 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
String get storeNewRepoUrlLabel => 'New Repository URL';
|
String get storeNewRepoUrlLabel => 'New Repository URL';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeLoadError => 'Failed to load store';
|
String get storeLoadError => 'Failed to load repository';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeEmptyNoExtensions => 'No extensions available';
|
String get storeEmptyNoExtensions => 'No extensions available';
|
||||||
@@ -1431,16 +1461,6 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
String get qualityNote =>
|
String get qualityNote =>
|
||||||
'Actual quality depends on track availability from the service';
|
'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
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||||
|
|
||||||
@@ -1538,6 +1558,13 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||||
'Artist/Album/ and Artist/Singles/';
|
'Artist/Album/ and Artist/Singles/';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlatSubtitle =>
|
||||||
|
'Artist/Album/ and Artist/song.flac';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||||
|
|
||||||
@@ -2968,4 +2995,294 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get editMetadataSelectEmpty => 'Empty only';
|
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';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsSearchWith(String providerName) {
|
||||||
|
return 'Search with $providerName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedProvider => 'Home Feed Provider';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedDescription =>
|
||||||
|
'Choose which extension provides the home feed on the main screen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAuto => 'Auto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAutoSubtitle =>
|
||||||
|
'Automatically select the best available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsHomeFeedUse(String extensionName) {
|
||||||
|
return 'Use $extensionName home feed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaAsc => 'A-Z';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaDesc => 'Z-A';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadTitle => 'Cancel download?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cancelDownloadContent(String trackName) {
|
||||||
|
return 'This will cancel the active download for \"$trackName\".';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadKeep => 'Keep';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedStorage =>
|
||||||
|
'Failed to write metadata back to storage';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFolderPickerFailed(String error) {
|
||||||
|
return 'Failed to open folder picker: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadAlbum => 'Failed to load album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadPlaylist => 'Failed to load playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadArtist => 'Failed to load artist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadName => 'Download Progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanName => 'Library Scan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingTrack(String trackName) {
|
||||||
|
return 'Downloading $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifFinalizingTrack(String trackName) {
|
||||||
|
return 'Finalizing $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifEmbeddingMetadata => 'Embedding metadata...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifAlreadyInLibraryCount(int completed, int total) {
|
||||||
|
return 'Already in Library ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAlreadyInLibrary => 'Already in Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadCompleteCount(int completed, int total) {
|
||||||
|
return 'Download Complete ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifDownloadComplete => 'Download Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadsFinished(int completed, int failed) {
|
||||||
|
return 'Downloads Finished ($completed done, $failed failed)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAllDownloadsComplete => 'All Downloads Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifTracksDownloadedSuccess(int count) {
|
||||||
|
return '$count tracks downloaded successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifScanningLibrary => 'Scanning local library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressWithTotal(
|
||||||
|
int scanned,
|
||||||
|
int total,
|
||||||
|
int percentage,
|
||||||
|
) {
|
||||||
|
return '$scanned/$total files • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
|
||||||
|
return '$scanned files scanned • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanComplete => 'Library scan complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanCompleteBody(int count) {
|
||||||
|
return '$count tracks indexed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanExcluded(int count) {
|
||||||
|
return '$count excluded';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanErrors(int count) {
|
||||||
|
return '$count errors';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanFailed => 'Library scan failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanCancelled => 'Library scan cancelled';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanStopped => 'Scan stopped before completion.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingUpdate(String version) {
|
||||||
|
return 'Downloading SpotiFLAC v$version';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateProgress(String received, String total, int percentage) {
|
||||||
|
return '$received / $total MB • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateReady => 'Update Ready';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateReadyBody(String version) {
|
||||||
|
return 'SpotiFLAC v$version downloaded. Tap to install.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailed => 'Update Failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailedBody =>
|
||||||
|
'Could not download update. Try again later.';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -759,6 +759,36 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get searchPlaylists => 'Playlists';
|
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
|
@override
|
||||||
String get tooltipPlay => 'Play';
|
String get tooltipPlay => 'Play';
|
||||||
|
|
||||||
@@ -1231,7 +1261,7 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
String get storeNewRepoUrlLabel => 'New Repository URL';
|
String get storeNewRepoUrlLabel => 'New Repository URL';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeLoadError => 'Failed to load store';
|
String get storeLoadError => 'Failed to load repository';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeEmptyNoExtensions => 'No extensions available';
|
String get storeEmptyNoExtensions => 'No extensions available';
|
||||||
@@ -1425,16 +1455,6 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
String get qualityNote =>
|
String get qualityNote =>
|
||||||
'Actual quality depends on track availability from the service';
|
'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
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||||
|
|
||||||
@@ -1532,6 +1552,13 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||||
'Artist/Album/ and Artist/Singles/';
|
'Artist/Album/ and Artist/Singles/';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlatSubtitle =>
|
||||||
|
'Artist/Album/ and Artist/song.flac';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||||
|
|
||||||
@@ -1970,7 +1997,7 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialExtensionsTip1 =>
|
String get tutorialExtensionsTip1 =>
|
||||||
'Browse the Store tab to discover useful extensions';
|
'Browse the Repo tab to discover useful extensions';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialExtensionsTip2 =>
|
String get tutorialExtensionsTip2 =>
|
||||||
@@ -2963,6 +2990,296 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get editMetadataSelectEmpty => 'Empty only';
|
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';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsSearchWith(String providerName) {
|
||||||
|
return 'Search with $providerName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedProvider => 'Home Feed Provider';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedDescription =>
|
||||||
|
'Choose which extension provides the home feed on the main screen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAuto => 'Auto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAutoSubtitle =>
|
||||||
|
'Automatically select the best available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsHomeFeedUse(String extensionName) {
|
||||||
|
return 'Use $extensionName home feed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaAsc => 'A-Z';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaDesc => 'Z-A';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadTitle => 'Cancel download?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cancelDownloadContent(String trackName) {
|
||||||
|
return 'This will cancel the active download for \"$trackName\".';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadKeep => 'Keep';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedStorage =>
|
||||||
|
'Failed to write metadata back to storage';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFolderPickerFailed(String error) {
|
||||||
|
return 'Failed to open folder picker: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadAlbum => 'Failed to load album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadPlaylist => 'Failed to load playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadArtist => 'Failed to load artist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadName => 'Download Progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanName => 'Library Scan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingTrack(String trackName) {
|
||||||
|
return 'Downloading $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifFinalizingTrack(String trackName) {
|
||||||
|
return 'Finalizing $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifEmbeddingMetadata => 'Embedding metadata...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifAlreadyInLibraryCount(int completed, int total) {
|
||||||
|
return 'Already in Library ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAlreadyInLibrary => 'Already in Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadCompleteCount(int completed, int total) {
|
||||||
|
return 'Download Complete ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifDownloadComplete => 'Download Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadsFinished(int completed, int failed) {
|
||||||
|
return 'Downloads Finished ($completed done, $failed failed)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAllDownloadsComplete => 'All Downloads Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifTracksDownloadedSuccess(int count) {
|
||||||
|
return '$count tracks downloaded successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifScanningLibrary => 'Scanning local library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressWithTotal(
|
||||||
|
int scanned,
|
||||||
|
int total,
|
||||||
|
int percentage,
|
||||||
|
) {
|
||||||
|
return '$scanned/$total files • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
|
||||||
|
return '$scanned files scanned • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanComplete => 'Library scan complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanCompleteBody(int count) {
|
||||||
|
return '$count tracks indexed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanExcluded(int count) {
|
||||||
|
return '$count excluded';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanErrors(int count) {
|
||||||
|
return '$count errors';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanFailed => 'Library scan failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanCancelled => 'Library scan cancelled';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanStopped => 'Scan stopped before completion.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingUpdate(String version) {
|
||||||
|
return 'Downloading SpotiFLAC v$version';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateProgress(String received, String total, int percentage) {
|
||||||
|
return '$received / $total MB • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateReady => 'Update Ready';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateReadyBody(String version) {
|
||||||
|
return 'SpotiFLAC v$version downloaded. Tap to install.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailed => 'Update Failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailedBody =>
|
||||||
|
'Could not download update. Try again later.';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The translations for Chinese, as used in China (`zh_CN`).
|
/// The translations for Chinese, as used in China (`zh_CN`).
|
||||||
@@ -4297,16 +4614,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh {
|
|||||||
String get qualityNote =>
|
String get qualityNote =>
|
||||||
'Actual quality depends on track availability from the service';
|
'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
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||||
|
|
||||||
@@ -6703,16 +7010,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
|
|||||||
String get qualityNote =>
|
String get qualityNote =>
|
||||||
'Actual quality depends on track availability from the service';
|
'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
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||||
|
|
||||||
|
|||||||
@@ -1773,18 +1773,6 @@
|
|||||||
"@qualityNote": {
|
"@qualityNote": {
|
||||||
"description": "Note about quality availability"
|
"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": "Qualität vor Download fragen",
|
||||||
"@downloadAskBeforeDownload": {
|
"@downloadAskBeforeDownload": {
|
||||||
"description": "Setting - show quality picker"
|
"description": "Setting - show quality picker"
|
||||||
|
|||||||
+491
-19
@@ -17,7 +17,7 @@
|
|||||||
"@navSettings": {
|
"@navSettings": {
|
||||||
"description": "Bottom navigation - Settings tab"
|
"description": "Bottom navigation - Settings tab"
|
||||||
},
|
},
|
||||||
"navStore": "Store",
|
"navStore": "Repo",
|
||||||
"@navStore": {
|
"@navStore": {
|
||||||
"description": "Bottom navigation - Extension store tab"
|
"description": "Bottom navigation - Extension store tab"
|
||||||
},
|
},
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
"@homeTitle": {
|
"@homeTitle": {
|
||||||
"description": "Home screen title"
|
"description": "Home screen title"
|
||||||
},
|
},
|
||||||
"homeSubtitle": "Paste a Spotify link or search by name",
|
"homeSubtitle": "Paste a supported URL or search by name",
|
||||||
"@homeSubtitle": {
|
"@homeSubtitle": {
|
||||||
"description": "Subtitle shown below search box"
|
"description": "Subtitle shown below search box"
|
||||||
},
|
},
|
||||||
@@ -211,11 +211,11 @@
|
|||||||
"@optionsConcurrentWarning": {
|
"@optionsConcurrentWarning": {
|
||||||
"description": "Warning about rate limits"
|
"description": "Warning about rate limits"
|
||||||
},
|
},
|
||||||
"optionsExtensionStore": "Extension Store",
|
"optionsExtensionStore": "Extension Repo",
|
||||||
"@optionsExtensionStore": {
|
"@optionsExtensionStore": {
|
||||||
"description": "Show/hide store tab"
|
"description": "Show/hide store tab"
|
||||||
},
|
},
|
||||||
"optionsExtensionStoreSubtitle": "Show Store tab in navigation",
|
"optionsExtensionStoreSubtitle": "Show Repo tab in navigation",
|
||||||
"@optionsExtensionStoreSubtitle": {
|
"@optionsExtensionStoreSubtitle": {
|
||||||
"description": "Subtitle for extension store toggle"
|
"description": "Subtitle for extension store toggle"
|
||||||
},
|
},
|
||||||
@@ -318,7 +318,7 @@
|
|||||||
"@extensionsUninstall": {
|
"@extensionsUninstall": {
|
||||||
"description": "Uninstall extension button"
|
"description": "Uninstall extension button"
|
||||||
},
|
},
|
||||||
"storeTitle": "Extension Store",
|
"storeTitle": "Extension Repo",
|
||||||
"@storeTitle": {
|
"@storeTitle": {
|
||||||
"description": "Store screen title"
|
"description": "Store screen title"
|
||||||
},
|
},
|
||||||
@@ -999,6 +999,46 @@
|
|||||||
"@searchPlaylists": {
|
"@searchPlaylists": {
|
||||||
"description": "Search result category - playlists"
|
"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": "Play",
|
||||||
"@tooltipPlay": {
|
"@tooltipPlay": {
|
||||||
"description": "Tooltip - play button"
|
"description": "Tooltip - play button"
|
||||||
@@ -1614,7 +1654,7 @@
|
|||||||
"@storeNewRepoUrlLabel": {
|
"@storeNewRepoUrlLabel": {
|
||||||
"description": "Label for the new repository URL field inside the dialog"
|
"description": "Label for the new repository URL field inside the dialog"
|
||||||
},
|
},
|
||||||
"storeLoadError": "Failed to load store",
|
"storeLoadError": "Failed to load repository",
|
||||||
"@storeLoadError": {
|
"@storeLoadError": {
|
||||||
"description": "Error heading when the store cannot be loaded"
|
"description": "Error heading when the store cannot be loaded"
|
||||||
},
|
},
|
||||||
@@ -1869,18 +1909,6 @@
|
|||||||
"@qualityNote": {
|
"@qualityNote": {
|
||||||
"description": "Note about quality availability"
|
"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": "Ask Before Download",
|
||||||
"@downloadAskBeforeDownload": {
|
"@downloadAskBeforeDownload": {
|
||||||
"description": "Setting - show quality picker"
|
"description": "Setting - show quality picker"
|
||||||
@@ -2001,6 +2029,14 @@
|
|||||||
"@albumFolderArtistAlbumSinglesSubtitle": {
|
"@albumFolderArtistAlbumSinglesSubtitle": {
|
||||||
"description": "Folder structure example"
|
"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": "Delete Selected",
|
||||||
"@downloadedAlbumDeleteSelected": {
|
"@downloadedAlbumDeleteSelected": {
|
||||||
"description": "Button - delete selected tracks"
|
"description": "Button - delete selected tracks"
|
||||||
@@ -2575,7 +2611,7 @@
|
|||||||
"@tutorialExtensionsDesc": {
|
"@tutorialExtensionsDesc": {
|
||||||
"description": "Tutorial extensions page description"
|
"description": "Tutorial extensions page description"
|
||||||
},
|
},
|
||||||
"tutorialExtensionsTip1": "Browse the Store tab to discover useful extensions",
|
"tutorialExtensionsTip1": "Browse the Repo tab to discover useful extensions",
|
||||||
"@tutorialExtensionsTip1": {
|
"@tutorialExtensionsTip1": {
|
||||||
"description": "Tutorial extensions tip 1"
|
"description": "Tutorial extensions tip 1"
|
||||||
},
|
},
|
||||||
@@ -3903,5 +3939,441 @@
|
|||||||
"editMetadataSelectEmpty": "Empty only",
|
"editMetadataSelectEmpty": "Empty only",
|
||||||
"@editMetadataSelectEmpty": {
|
"@editMetadataSelectEmpty": {
|
||||||
"description": "Button to select only fields that are currently empty"
|
"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"
|
||||||
|
},
|
||||||
|
|
||||||
|
"extensionsSearchWith": "Search with {providerName}",
|
||||||
|
"@extensionsSearchWith": {
|
||||||
|
"description": "Extensions page - subtitle for built-in search provider option",
|
||||||
|
"placeholders": {
|
||||||
|
"providerName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"extensionsHomeFeedProvider": "Home Feed Provider",
|
||||||
|
"@extensionsHomeFeedProvider": {
|
||||||
|
"description": "Extensions page - label for home feed provider selector"
|
||||||
|
},
|
||||||
|
"extensionsHomeFeedDescription": "Choose which extension provides the home feed on the main screen",
|
||||||
|
"@extensionsHomeFeedDescription": {
|
||||||
|
"description": "Extensions page - description for home feed provider picker"
|
||||||
|
},
|
||||||
|
"extensionsHomeFeedAuto": "Auto",
|
||||||
|
"@extensionsHomeFeedAuto": {
|
||||||
|
"description": "Extensions page - home feed provider option: auto"
|
||||||
|
},
|
||||||
|
"extensionsHomeFeedAutoSubtitle": "Automatically select the best available",
|
||||||
|
"@extensionsHomeFeedAutoSubtitle": {
|
||||||
|
"description": "Extensions page - subtitle for auto home feed option"
|
||||||
|
},
|
||||||
|
"extensionsHomeFeedUse": "Use {extensionName} home feed",
|
||||||
|
"@extensionsHomeFeedUse": {
|
||||||
|
"description": "Extensions page - subtitle for a specific extension home feed option",
|
||||||
|
"placeholders": {
|
||||||
|
"extensionName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"extensionsNoHomeFeedExtensions": "No extensions with home feed",
|
||||||
|
"@extensionsNoHomeFeedExtensions": {
|
||||||
|
"description": "Extensions page - shown when no installed extension has home feed"
|
||||||
|
},
|
||||||
|
|
||||||
|
"sortAlphaAsc": "A-Z",
|
||||||
|
"@sortAlphaAsc": {
|
||||||
|
"description": "Sort option - alphabetical ascending"
|
||||||
|
},
|
||||||
|
"sortAlphaDesc": "Z-A",
|
||||||
|
"@sortAlphaDesc": {
|
||||||
|
"description": "Sort option - alphabetical descending"
|
||||||
|
},
|
||||||
|
"cancelDownloadTitle": "Cancel download?",
|
||||||
|
"@cancelDownloadTitle": {
|
||||||
|
"description": "Dialog title when confirming cancellation of an active download"
|
||||||
|
},
|
||||||
|
"cancelDownloadContent": "This will cancel the active download for \"{trackName}\".",
|
||||||
|
"@cancelDownloadContent": {
|
||||||
|
"description": "Dialog body when confirming cancellation of an active download",
|
||||||
|
"placeholders": {
|
||||||
|
"trackName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cancelDownloadKeep": "Keep",
|
||||||
|
"@cancelDownloadKeep": {
|
||||||
|
"description": "Dialog button - keep the active download (do not cancel)"
|
||||||
|
},
|
||||||
|
|
||||||
|
"metadataSaveFailedFfmpeg": "Failed to save metadata via FFmpeg",
|
||||||
|
"@metadataSaveFailedFfmpeg": {
|
||||||
|
"description": "Snackbar error when FFmpeg fails to write metadata"
|
||||||
|
},
|
||||||
|
"metadataSaveFailedStorage": "Failed to write metadata back to storage",
|
||||||
|
"@metadataSaveFailedStorage": {
|
||||||
|
"description": "Snackbar error when writing metadata file back to storage fails"
|
||||||
|
},
|
||||||
|
|
||||||
|
"snackbarFolderPickerFailed": "Failed to open folder picker: {error}",
|
||||||
|
"@snackbarFolderPickerFailed": {
|
||||||
|
"description": "Snackbar shown when folder picker fails to open",
|
||||||
|
"placeholders": {
|
||||||
|
"error": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"errorLoadAlbum": "Failed to load album",
|
||||||
|
"@errorLoadAlbum": {
|
||||||
|
"description": "Error state shown when album fails to load"
|
||||||
|
},
|
||||||
|
"errorLoadPlaylist": "Failed to load playlist",
|
||||||
|
"@errorLoadPlaylist": {
|
||||||
|
"description": "Error state shown when playlist fails to load"
|
||||||
|
},
|
||||||
|
"errorLoadArtist": "Failed to load artist",
|
||||||
|
"@errorLoadArtist": {
|
||||||
|
"description": "Error state shown when artist fails to load"
|
||||||
|
},
|
||||||
|
|
||||||
|
"notifChannelDownloadName": "Download Progress",
|
||||||
|
"@notifChannelDownloadName": {
|
||||||
|
"description": "Android notification channel name for download progress"
|
||||||
|
},
|
||||||
|
"notifChannelDownloadDesc": "Shows download progress for tracks",
|
||||||
|
"@notifChannelDownloadDesc": {
|
||||||
|
"description": "Android notification channel description for download progress"
|
||||||
|
},
|
||||||
|
"notifChannelLibraryScanName": "Library Scan",
|
||||||
|
"@notifChannelLibraryScanName": {
|
||||||
|
"description": "Android notification channel name for library scan"
|
||||||
|
},
|
||||||
|
"notifChannelLibraryScanDesc": "Shows local library scan progress",
|
||||||
|
"@notifChannelLibraryScanDesc": {
|
||||||
|
"description": "Android notification channel description for library scan"
|
||||||
|
},
|
||||||
|
"notifDownloadingTrack": "Downloading {trackName}",
|
||||||
|
"@notifDownloadingTrack": {
|
||||||
|
"description": "Notification title while downloading a track",
|
||||||
|
"placeholders": {
|
||||||
|
"trackName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notifFinalizingTrack": "Finalizing {trackName}",
|
||||||
|
"@notifFinalizingTrack": {
|
||||||
|
"description": "Notification title while finalizing (embedding metadata) a track",
|
||||||
|
"placeholders": {
|
||||||
|
"trackName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notifEmbeddingMetadata": "Embedding metadata...",
|
||||||
|
"@notifEmbeddingMetadata": {
|
||||||
|
"description": "Notification body while embedding metadata into a downloaded track"
|
||||||
|
},
|
||||||
|
"notifAlreadyInLibraryCount": "Already in Library ({completed}/{total})",
|
||||||
|
"@notifAlreadyInLibraryCount": {
|
||||||
|
"description": "Notification title when track is already in library, with count",
|
||||||
|
"placeholders": {
|
||||||
|
"completed": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notifAlreadyInLibrary": "Already in Library",
|
||||||
|
"@notifAlreadyInLibrary": {
|
||||||
|
"description": "Notification title when track is already in library"
|
||||||
|
},
|
||||||
|
"notifDownloadCompleteCount": "Download Complete ({completed}/{total})",
|
||||||
|
"@notifDownloadCompleteCount": {
|
||||||
|
"description": "Notification title when download is complete, with count",
|
||||||
|
"placeholders": {
|
||||||
|
"completed": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notifDownloadComplete": "Download Complete",
|
||||||
|
"@notifDownloadComplete": {
|
||||||
|
"description": "Notification title when a single download is complete"
|
||||||
|
},
|
||||||
|
"notifDownloadsFinished": "Downloads Finished ({completed} done, {failed} failed)",
|
||||||
|
"@notifDownloadsFinished": {
|
||||||
|
"description": "Notification title when queue finishes with some failures",
|
||||||
|
"placeholders": {
|
||||||
|
"completed": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"failed": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notifAllDownloadsComplete": "All Downloads Complete",
|
||||||
|
"@notifAllDownloadsComplete": {
|
||||||
|
"description": "Notification title when all downloads finish successfully"
|
||||||
|
},
|
||||||
|
"notifTracksDownloadedSuccess": "{count} tracks downloaded successfully",
|
||||||
|
"@notifTracksDownloadedSuccess": {
|
||||||
|
"description": "Notification body for queue complete - how many tracks were downloaded",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notifScanningLibrary": "Scanning local library",
|
||||||
|
"@notifScanningLibrary": {
|
||||||
|
"description": "Notification title while scanning local library"
|
||||||
|
},
|
||||||
|
"notifLibraryScanProgressWithTotal": "{scanned}/{total} files • {percentage}%",
|
||||||
|
"@notifLibraryScanProgressWithTotal": {
|
||||||
|
"description": "Notification body for library scan progress when total is known",
|
||||||
|
"placeholders": {
|
||||||
|
"scanned": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"percentage": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notifLibraryScanProgressNoTotal": "{scanned} files scanned • {percentage}%",
|
||||||
|
"@notifLibraryScanProgressNoTotal": {
|
||||||
|
"description": "Notification body for library scan progress when total is unknown",
|
||||||
|
"placeholders": {
|
||||||
|
"scanned": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"percentage": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notifLibraryScanComplete": "Library scan complete",
|
||||||
|
"@notifLibraryScanComplete": {
|
||||||
|
"description": "Notification title when library scan finishes"
|
||||||
|
},
|
||||||
|
"notifLibraryScanCompleteBody": "{count} tracks indexed",
|
||||||
|
"@notifLibraryScanCompleteBody": {
|
||||||
|
"description": "Notification body for library scan complete - number of indexed tracks",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notifLibraryScanExcluded": "{count} excluded",
|
||||||
|
"@notifLibraryScanExcluded": {
|
||||||
|
"description": "Library scan complete suffix - excluded track count",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notifLibraryScanErrors": "{count} errors",
|
||||||
|
"@notifLibraryScanErrors": {
|
||||||
|
"description": "Library scan complete suffix - error count",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notifLibraryScanFailed": "Library scan failed",
|
||||||
|
"@notifLibraryScanFailed": {
|
||||||
|
"description": "Notification title when library scan fails"
|
||||||
|
},
|
||||||
|
"notifLibraryScanCancelled": "Library scan cancelled",
|
||||||
|
"@notifLibraryScanCancelled": {
|
||||||
|
"description": "Notification title when library scan is cancelled by the user"
|
||||||
|
},
|
||||||
|
"notifLibraryScanStopped": "Scan stopped before completion.",
|
||||||
|
"@notifLibraryScanStopped": {
|
||||||
|
"description": "Notification body when library scan is cancelled"
|
||||||
|
},
|
||||||
|
"notifDownloadingUpdate": "Downloading SpotiFLAC v{version}",
|
||||||
|
"@notifDownloadingUpdate": {
|
||||||
|
"description": "Notification title while downloading an app update",
|
||||||
|
"placeholders": {
|
||||||
|
"version": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notifUpdateProgress": "{received} / {total} MB • {percentage}%",
|
||||||
|
"@notifUpdateProgress": {
|
||||||
|
"description": "Notification body showing update download progress",
|
||||||
|
"placeholders": {
|
||||||
|
"received": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"percentage": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notifUpdateReady": "Update Ready",
|
||||||
|
"@notifUpdateReady": {
|
||||||
|
"description": "Notification title when app update download is complete"
|
||||||
|
},
|
||||||
|
"notifUpdateReadyBody": "SpotiFLAC v{version} downloaded. Tap to install.",
|
||||||
|
"@notifUpdateReadyBody": {
|
||||||
|
"description": "Notification body when app update is ready to install",
|
||||||
|
"placeholders": {
|
||||||
|
"version": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notifUpdateFailed": "Update Failed",
|
||||||
|
"@notifUpdateFailed": {
|
||||||
|
"description": "Notification title when app update download fails"
|
||||||
|
},
|
||||||
|
"notifUpdateFailedBody": "Could not download update. Try again later.",
|
||||||
|
"@notifUpdateFailedBody": {
|
||||||
|
"description": "Notification body when app update download fails"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1773,18 +1773,6 @@
|
|||||||
"@qualityNote": {
|
"@qualityNote": {
|
||||||
"description": "Note about quality availability"
|
"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": "Preguntar antes de descargar",
|
"downloadAskBeforeDownload": "Preguntar antes de descargar",
|
||||||
"@downloadAskBeforeDownload": {
|
"@downloadAskBeforeDownload": {
|
||||||
"description": "Setting - show quality picker"
|
"description": "Setting - show quality picker"
|
||||||
|
|||||||
@@ -1773,18 +1773,6 @@
|
|||||||
"@qualityNote": {
|
"@qualityNote": {
|
||||||
"description": "Note about quality availability"
|
"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": "Ask Before Download",
|
||||||
"@downloadAskBeforeDownload": {
|
"@downloadAskBeforeDownload": {
|
||||||
"description": "Setting - show quality picker"
|
"description": "Setting - show quality picker"
|
||||||
|
|||||||
@@ -1773,18 +1773,6 @@
|
|||||||
"@qualityNote": {
|
"@qualityNote": {
|
||||||
"description": "Note about quality availability"
|
"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": "Ask Before Download",
|
||||||
"@downloadAskBeforeDownload": {
|
"@downloadAskBeforeDownload": {
|
||||||
"description": "Setting - show quality picker"
|
"description": "Setting - show quality picker"
|
||||||
|
|||||||
+10
-18
@@ -17,7 +17,7 @@
|
|||||||
"@navSettings": {
|
"@navSettings": {
|
||||||
"description": "Bottom navigation - Settings tab"
|
"description": "Bottom navigation - Settings tab"
|
||||||
},
|
},
|
||||||
"navStore": "Toko",
|
"navStore": "Repo",
|
||||||
"@navStore": {
|
"@navStore": {
|
||||||
"description": "Bottom navigation - Extension store tab"
|
"description": "Bottom navigation - Extension store tab"
|
||||||
},
|
},
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
"@homeTitle": {
|
"@homeTitle": {
|
||||||
"description": "Home screen title"
|
"description": "Home screen title"
|
||||||
},
|
},
|
||||||
"homeSubtitle": "Tempel link Spotify atau cari berdasarkan nama",
|
"homeSubtitle": "Tempel URL yang didukung atau cari berdasarkan nama",
|
||||||
"@homeSubtitle": {
|
"@homeSubtitle": {
|
||||||
"description": "Subtitle shown below search box"
|
"description": "Subtitle shown below search box"
|
||||||
},
|
},
|
||||||
@@ -211,11 +211,11 @@
|
|||||||
"@optionsConcurrentWarning": {
|
"@optionsConcurrentWarning": {
|
||||||
"description": "Warning about rate limits"
|
"description": "Warning about rate limits"
|
||||||
},
|
},
|
||||||
"optionsExtensionStore": "Toko Ekstensi",
|
"optionsExtensionStore": "Repo Ekstensi",
|
||||||
"@optionsExtensionStore": {
|
"@optionsExtensionStore": {
|
||||||
"description": "Show/hide store tab"
|
"description": "Show/hide store tab"
|
||||||
},
|
},
|
||||||
"optionsExtensionStoreSubtitle": "Tampilkan tab Toko di navigasi",
|
"optionsExtensionStoreSubtitle": "Tampilkan tab Repo di navigasi",
|
||||||
"@optionsExtensionStoreSubtitle": {
|
"@optionsExtensionStoreSubtitle": {
|
||||||
"description": "Subtitle for extension store toggle"
|
"description": "Subtitle for extension store toggle"
|
||||||
},
|
},
|
||||||
@@ -318,10 +318,14 @@
|
|||||||
"@extensionsUninstall": {
|
"@extensionsUninstall": {
|
||||||
"description": "Uninstall extension button"
|
"description": "Uninstall extension button"
|
||||||
},
|
},
|
||||||
"storeTitle": "Toko Ekstensi",
|
"storeTitle": "Repo Ekstensi",
|
||||||
"@storeTitle": {
|
"@storeTitle": {
|
||||||
"description": "Store screen title"
|
"description": "Store screen title"
|
||||||
},
|
},
|
||||||
|
"storeLoadError": "Gagal memuat repo",
|
||||||
|
"@storeLoadError": {
|
||||||
|
"description": "Error heading when the store cannot be loaded"
|
||||||
|
},
|
||||||
"storeSearch": "Cari ekstensi...",
|
"storeSearch": "Cari ekstensi...",
|
||||||
"@storeSearch": {
|
"@storeSearch": {
|
||||||
"description": "Store search placeholder"
|
"description": "Store search placeholder"
|
||||||
@@ -1773,18 +1777,6 @@
|
|||||||
"@qualityNote": {
|
"@qualityNote": {
|
||||||
"description": "Note about quality availability"
|
"description": "Note about quality availability"
|
||||||
},
|
},
|
||||||
"youtubeQualityNote": "YouTube hanya menyediakan audio terkompresi (lossy). Bukan bagian dari fallback lossless.",
|
|
||||||
"@youtubeQualityNote": {
|
|
||||||
"description": "Note for YouTube service explaining lossy-only quality"
|
|
||||||
},
|
|
||||||
"youtubeOpusBitrateTitle": "Bitrate YouTube Opus",
|
|
||||||
"@youtubeOpusBitrateTitle": {
|
|
||||||
"description": "Title for YouTube Opus bitrate setting"
|
|
||||||
},
|
|
||||||
"youtubeMp3BitrateTitle": "Kecepatan Bit MP3 YouTube",
|
|
||||||
"@youtubeMp3BitrateTitle": {
|
|
||||||
"description": "Title for YouTube MP3 bitrate setting"
|
|
||||||
},
|
|
||||||
"downloadAskBeforeDownload": "Tanya Sebelum Unduh",
|
"downloadAskBeforeDownload": "Tanya Sebelum Unduh",
|
||||||
"@downloadAskBeforeDownload": {
|
"@downloadAskBeforeDownload": {
|
||||||
"description": "Setting - show quality picker"
|
"description": "Setting - show quality picker"
|
||||||
@@ -2471,7 +2463,7 @@
|
|||||||
"@tutorialExtensionsDesc": {
|
"@tutorialExtensionsDesc": {
|
||||||
"description": "Tutorial extensions page description"
|
"description": "Tutorial extensions page description"
|
||||||
},
|
},
|
||||||
"tutorialExtensionsTip1": "Browse the Store tab to discover useful extensions",
|
"tutorialExtensionsTip1": "Buka tab Repo untuk menemukan ekstensi yang berguna",
|
||||||
"@tutorialExtensionsTip1": {
|
"@tutorialExtensionsTip1": {
|
||||||
"description": "Tutorial extensions tip 1"
|
"description": "Tutorial extensions tip 1"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1773,18 +1773,6 @@
|
|||||||
"@qualityNote": {
|
"@qualityNote": {
|
||||||
"description": "Note about quality availability"
|
"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": "ダウンロード前に確認する",
|
||||||
"@downloadAskBeforeDownload": {
|
"@downloadAskBeforeDownload": {
|
||||||
"description": "Setting - show quality picker"
|
"description": "Setting - show quality picker"
|
||||||
|
|||||||
@@ -1773,18 +1773,6 @@
|
|||||||
"@qualityNote": {
|
"@qualityNote": {
|
||||||
"description": "Note about quality availability"
|
"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": "Ask Before Download",
|
||||||
"@downloadAskBeforeDownload": {
|
"@downloadAskBeforeDownload": {
|
||||||
"description": "Setting - show quality picker"
|
"description": "Setting - show quality picker"
|
||||||
|
|||||||
@@ -1773,18 +1773,6 @@
|
|||||||
"@qualityNote": {
|
"@qualityNote": {
|
||||||
"description": "Note about quality availability"
|
"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": "Ask Before Download",
|
||||||
"@downloadAskBeforeDownload": {
|
"@downloadAskBeforeDownload": {
|
||||||
"description": "Setting - show quality picker"
|
"description": "Setting - show quality picker"
|
||||||
|
|||||||
@@ -1773,18 +1773,6 @@
|
|||||||
"@qualityNote": {
|
"@qualityNote": {
|
||||||
"description": "Note about quality availability"
|
"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": "Perguntar qualidade antes de baixar",
|
"downloadAskBeforeDownload": "Perguntar qualidade antes de baixar",
|
||||||
"@downloadAskBeforeDownload": {
|
"@downloadAskBeforeDownload": {
|
||||||
"description": "Setting - show quality picker"
|
"description": "Setting - show quality picker"
|
||||||
|
|||||||
@@ -1773,18 +1773,6 @@
|
|||||||
"@qualityNote": {
|
"@qualityNote": {
|
||||||
"description": "Note about quality availability"
|
"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": "Спрашивать перед скачиванием",
|
||||||
"@downloadAskBeforeDownload": {
|
"@downloadAskBeforeDownload": {
|
||||||
"description": "Setting - show quality picker"
|
"description": "Setting - show quality picker"
|
||||||
|
|||||||
@@ -1773,18 +1773,6 @@
|
|||||||
"@qualityNote": {
|
"@qualityNote": {
|
||||||
"description": "Note about quality availability"
|
"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": "Ask Before Download",
|
||||||
"@downloadAskBeforeDownload": {
|
"@downloadAskBeforeDownload": {
|
||||||
"description": "Setting - show quality picker"
|
"description": "Setting - show quality picker"
|
||||||
|
|||||||
@@ -1773,18 +1773,6 @@
|
|||||||
"@qualityNote": {
|
"@qualityNote": {
|
||||||
"description": "Note about quality availability"
|
"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": "Ask Before Download",
|
||||||
"@downloadAskBeforeDownload": {
|
"@downloadAskBeforeDownload": {
|
||||||
"description": "Setting - show quality picker"
|
"description": "Setting - show quality picker"
|
||||||
|
|||||||
@@ -1773,18 +1773,6 @@
|
|||||||
"@qualityNote": {
|
"@qualityNote": {
|
||||||
"description": "Note about quality availability"
|
"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": "Ask Before Download",
|
||||||
"@downloadAskBeforeDownload": {
|
"@downloadAskBeforeDownload": {
|
||||||
"description": "Setting - show quality picker"
|
"description": "Setting - show quality picker"
|
||||||
|
|||||||
@@ -192,11 +192,9 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization>
|
|||||||
if (settings.localLibraryPath.isEmpty) return;
|
if (settings.localLibraryPath.isEmpty) return;
|
||||||
if (settings.localLibraryAutoScan == 'off') return;
|
if (settings.localLibraryAutoScan == 'off') return;
|
||||||
|
|
||||||
// Don't start a scan if one is already running.
|
|
||||||
final libraryState = ref.read(localLibraryProvider);
|
final libraryState = ref.read(localLibraryProvider);
|
||||||
if (libraryState.isScanning) return;
|
if (libraryState.isScanning) return;
|
||||||
|
|
||||||
// Determine cooldown based on auto-scan mode.
|
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final lastScanned = readLocalLibraryLastScannedAt(prefs);
|
final lastScanned = readLocalLibraryLastScannedAt(prefs);
|
||||||
@@ -220,7 +218,6 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// All checks passed -- start an incremental scan.
|
|
||||||
final iosBookmark = settings.localLibraryBookmark;
|
final iosBookmark = settings.localLibraryBookmark;
|
||||||
ref
|
ref
|
||||||
.read(localLibraryProvider.notifier)
|
.read(localLibraryProvider.notifier)
|
||||||
|
|||||||
@@ -12,13 +12,7 @@ enum DownloadStatus {
|
|||||||
skipped,
|
skipped,
|
||||||
}
|
}
|
||||||
|
|
||||||
enum DownloadErrorType {
|
enum DownloadErrorType { unknown, notFound, rateLimit, network, permission }
|
||||||
unknown,
|
|
||||||
notFound,
|
|
||||||
rateLimit,
|
|
||||||
network,
|
|
||||||
permission,
|
|
||||||
}
|
|
||||||
|
|
||||||
@JsonSerializable()
|
@JsonSerializable()
|
||||||
class DownloadItem {
|
class DownloadItem {
|
||||||
@@ -28,7 +22,8 @@ class DownloadItem {
|
|||||||
final DownloadStatus status;
|
final DownloadStatus status;
|
||||||
final double progress;
|
final double progress;
|
||||||
final double speedMBps;
|
final double speedMBps;
|
||||||
final int bytesReceived; // Bytes downloaded so far (for unknown size downloads)
|
final int bytesReceived; // Bytes downloaded so far
|
||||||
|
final int bytesTotal; // Total bytes when the server provides content length
|
||||||
final String? filePath;
|
final String? filePath;
|
||||||
final String? error;
|
final String? error;
|
||||||
final DownloadErrorType? errorType;
|
final DownloadErrorType? errorType;
|
||||||
@@ -44,6 +39,7 @@ class DownloadItem {
|
|||||||
this.progress = 0.0,
|
this.progress = 0.0,
|
||||||
this.speedMBps = 0.0,
|
this.speedMBps = 0.0,
|
||||||
this.bytesReceived = 0,
|
this.bytesReceived = 0,
|
||||||
|
this.bytesTotal = 0,
|
||||||
this.filePath,
|
this.filePath,
|
||||||
this.error,
|
this.error,
|
||||||
this.errorType,
|
this.errorType,
|
||||||
@@ -60,6 +56,7 @@ class DownloadItem {
|
|||||||
double? progress,
|
double? progress,
|
||||||
double? speedMBps,
|
double? speedMBps,
|
||||||
int? bytesReceived,
|
int? bytesReceived,
|
||||||
|
int? bytesTotal,
|
||||||
String? filePath,
|
String? filePath,
|
||||||
String? error,
|
String? error,
|
||||||
DownloadErrorType? errorType,
|
DownloadErrorType? errorType,
|
||||||
@@ -75,6 +72,7 @@ class DownloadItem {
|
|||||||
progress: progress ?? this.progress,
|
progress: progress ?? this.progress,
|
||||||
speedMBps: speedMBps ?? this.speedMBps,
|
speedMBps: speedMBps ?? this.speedMBps,
|
||||||
bytesReceived: bytesReceived ?? this.bytesReceived,
|
bytesReceived: bytesReceived ?? this.bytesReceived,
|
||||||
|
bytesTotal: bytesTotal ?? this.bytesTotal,
|
||||||
filePath: filePath ?? this.filePath,
|
filePath: filePath ?? this.filePath,
|
||||||
error: error ?? this.error,
|
error: error ?? this.error,
|
||||||
errorType: errorType ?? this.errorType,
|
errorType: errorType ?? this.errorType,
|
||||||
@@ -86,7 +84,7 @@ class DownloadItem {
|
|||||||
|
|
||||||
String get errorMessage {
|
String get errorMessage {
|
||||||
if (error == null) return '';
|
if (error == null) return '';
|
||||||
|
|
||||||
switch (errorType) {
|
switch (errorType) {
|
||||||
case DownloadErrorType.notFound:
|
case DownloadErrorType.notFound:
|
||||||
return 'Song not found on any service';
|
return 'Song not found on any service';
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ DownloadItem _$DownloadItemFromJson(Map<String, dynamic> json) => DownloadItem(
|
|||||||
progress: (json['progress'] as num?)?.toDouble() ?? 0.0,
|
progress: (json['progress'] as num?)?.toDouble() ?? 0.0,
|
||||||
speedMBps: (json['speedMBps'] as num?)?.toDouble() ?? 0.0,
|
speedMBps: (json['speedMBps'] as num?)?.toDouble() ?? 0.0,
|
||||||
bytesReceived: (json['bytesReceived'] as num?)?.toInt() ?? 0,
|
bytesReceived: (json['bytesReceived'] as num?)?.toInt() ?? 0,
|
||||||
|
bytesTotal: (json['bytesTotal'] as num?)?.toInt() ?? 0,
|
||||||
filePath: json['filePath'] as String?,
|
filePath: json['filePath'] as String?,
|
||||||
error: json['error'] as String?,
|
error: json['error'] as String?,
|
||||||
errorType: $enumDecodeNullable(_$DownloadErrorTypeEnumMap, json['errorType']),
|
errorType: $enumDecodeNullable(_$DownloadErrorTypeEnumMap, json['errorType']),
|
||||||
@@ -33,6 +34,7 @@ Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
|
|||||||
'progress': instance.progress,
|
'progress': instance.progress,
|
||||||
'speedMBps': instance.speedMBps,
|
'speedMBps': instance.speedMBps,
|
||||||
'bytesReceived': instance.bytesReceived,
|
'bytesReceived': instance.bytesReceived,
|
||||||
|
'bytesTotal': instance.bytesTotal,
|
||||||
'filePath': instance.filePath,
|
'filePath': instance.filePath,
|
||||||
'error': instance.error,
|
'error': instance.error,
|
||||||
'errorType': _$DownloadErrorTypeEnumMap[instance.errorType],
|
'errorType': _$DownloadErrorTypeEnumMap[instance.errorType],
|
||||||
|
|||||||
@@ -42,10 +42,6 @@ class AppSettings {
|
|||||||
final String lyricsMode;
|
final String lyricsMode;
|
||||||
final String
|
final String
|
||||||
tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320', 'opus_256', or 'opus_128'
|
tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320', 'opus_256', or 'opus_128'
|
||||||
final int
|
|
||||||
youtubeOpusBitrate; // YouTube Opus bitrate (supported: 128/256/320 kbps)
|
|
||||||
final int
|
|
||||||
youtubeMp3Bitrate; // YouTube MP3 bitrate (supported: 128/256/320 kbps)
|
|
||||||
final bool
|
final bool
|
||||||
useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE
|
useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE
|
||||||
final bool
|
final bool
|
||||||
@@ -121,8 +117,6 @@ class AppSettings {
|
|||||||
this.locale = 'system',
|
this.locale = 'system',
|
||||||
this.lyricsMode = 'embed',
|
this.lyricsMode = 'embed',
|
||||||
this.tidalHighFormat = 'mp3_320',
|
this.tidalHighFormat = 'mp3_320',
|
||||||
this.youtubeOpusBitrate = 256,
|
|
||||||
this.youtubeMp3Bitrate = 320,
|
|
||||||
this.useAllFilesAccess = false,
|
this.useAllFilesAccess = false,
|
||||||
this.autoExportFailedDownloads = false,
|
this.autoExportFailedDownloads = false,
|
||||||
this.downloadNetworkMode = 'any',
|
this.downloadNetworkMode = 'any',
|
||||||
@@ -189,8 +183,6 @@ class AppSettings {
|
|||||||
String? locale,
|
String? locale,
|
||||||
String? lyricsMode,
|
String? lyricsMode,
|
||||||
String? tidalHighFormat,
|
String? tidalHighFormat,
|
||||||
int? youtubeOpusBitrate,
|
|
||||||
int? youtubeMp3Bitrate,
|
|
||||||
bool? useAllFilesAccess,
|
bool? useAllFilesAccess,
|
||||||
bool? autoExportFailedDownloads,
|
bool? autoExportFailedDownloads,
|
||||||
String? downloadNetworkMode,
|
String? downloadNetworkMode,
|
||||||
@@ -257,8 +249,6 @@ class AppSettings {
|
|||||||
locale: locale ?? this.locale,
|
locale: locale ?? this.locale,
|
||||||
lyricsMode: lyricsMode ?? this.lyricsMode,
|
lyricsMode: lyricsMode ?? this.lyricsMode,
|
||||||
tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat,
|
tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat,
|
||||||
youtubeOpusBitrate: youtubeOpusBitrate ?? this.youtubeOpusBitrate,
|
|
||||||
youtubeMp3Bitrate: youtubeMp3Bitrate ?? this.youtubeMp3Bitrate,
|
|
||||||
useAllFilesAccess: useAllFilesAccess ?? this.useAllFilesAccess,
|
useAllFilesAccess: useAllFilesAccess ?? this.useAllFilesAccess,
|
||||||
autoExportFailedDownloads:
|
autoExportFailedDownloads:
|
||||||
autoExportFailedDownloads ?? this.autoExportFailedDownloads,
|
autoExportFailedDownloads ?? this.autoExportFailedDownloads,
|
||||||
|
|||||||
@@ -47,8 +47,6 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
|||||||
locale: json['locale'] as String? ?? 'system',
|
locale: json['locale'] as String? ?? 'system',
|
||||||
lyricsMode: json['lyricsMode'] as String? ?? 'embed',
|
lyricsMode: json['lyricsMode'] as String? ?? 'embed',
|
||||||
tidalHighFormat: json['tidalHighFormat'] as String? ?? 'mp3_320',
|
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,
|
useAllFilesAccess: json['useAllFilesAccess'] as bool? ?? false,
|
||||||
autoExportFailedDownloads:
|
autoExportFailedDownloads:
|
||||||
json['autoExportFailedDownloads'] as bool? ?? false,
|
json['autoExportFailedDownloads'] as bool? ?? false,
|
||||||
@@ -125,8 +123,6 @@ Map<String, dynamic> _$AppSettingsToJson(
|
|||||||
'locale': instance.locale,
|
'locale': instance.locale,
|
||||||
'lyricsMode': instance.lyricsMode,
|
'lyricsMode': instance.lyricsMode,
|
||||||
'tidalHighFormat': instance.tidalHighFormat,
|
'tidalHighFormat': instance.tidalHighFormat,
|
||||||
'youtubeOpusBitrate': instance.youtubeOpusBitrate,
|
|
||||||
'youtubeMp3Bitrate': instance.youtubeMp3Bitrate,
|
|
||||||
'useAllFilesAccess': instance.useAllFilesAccess,
|
'useAllFilesAccess': instance.useAllFilesAccess,
|
||||||
'autoExportFailedDownloads': instance.autoExportFailedDownloads,
|
'autoExportFailedDownloads': instance.autoExportFailedDownloads,
|
||||||
'downloadNetworkMode': instance.downloadNetworkMode,
|
'downloadNetworkMode': instance.downloadNetworkMode,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,7 @@ final _log = AppLogger('ExploreProvider');
|
|||||||
class ExploreItem {
|
class ExploreItem {
|
||||||
final String id;
|
final String id;
|
||||||
final String uri;
|
final String uri;
|
||||||
final String type; // track, album, playlist, artist, station
|
final String type;
|
||||||
final String name;
|
final String name;
|
||||||
final String artists;
|
final String artists;
|
||||||
final String? description;
|
final String? description;
|
||||||
@@ -168,7 +168,6 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
|||||||
return const ExploreState();
|
return const ExploreState();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Restore cached home feed from SharedPreferences immediately on startup
|
|
||||||
Future<void> _restoreFromCache() async {
|
Future<void> _restoreFromCache() async {
|
||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
@@ -199,7 +198,6 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Save home feed to SharedPreferences for instant restore on next launch
|
|
||||||
Future<void> _saveToCache(List<ExploreSection> sections) async {
|
Future<void> _saveToCache(List<ExploreSection> sections) async {
|
||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
@@ -212,11 +210,9 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch home feed from spotify-web extension
|
|
||||||
Future<void> fetchHomeFeed({bool forceRefresh = false}) async {
|
Future<void> fetchHomeFeed({bool forceRefresh = false}) async {
|
||||||
_log.i('fetchHomeFeed called, forceRefresh=$forceRefresh');
|
_log.i('fetchHomeFeed called, forceRefresh=$forceRefresh');
|
||||||
|
|
||||||
// If we have cached content and it's fresh enough, skip network fetch
|
|
||||||
if (!forceRefresh &&
|
if (!forceRefresh &&
|
||||||
state.hasContent &&
|
state.hasContent &&
|
||||||
state.lastFetched != null &&
|
state.lastFetched != null &&
|
||||||
@@ -230,7 +226,6 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only show loading spinner if we have no cached content to display
|
|
||||||
final showLoading = !state.hasContent;
|
final showLoading = !state.hasContent;
|
||||||
state = state.copyWith(isLoading: showLoading, error: null);
|
state = state.copyWith(isLoading: showLoading, error: null);
|
||||||
|
|
||||||
@@ -247,14 +242,12 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
|||||||
if (!extension.enabled || !extension.hasHomeFeed) {
|
if (!extension.enabled || !extension.hasHomeFeed) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// If user has a preference, use that
|
|
||||||
if (preferredId != null &&
|
if (preferredId != null &&
|
||||||
preferredId.isNotEmpty &&
|
preferredId.isNotEmpty &&
|
||||||
extension.id == preferredId) {
|
extension.id == preferredId) {
|
||||||
targetExt = extension;
|
targetExt = extension;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// Otherwise take the first available (fallback to spotify-web if found)
|
|
||||||
if (targetExt == null || extension.id == 'spotify-web') {
|
if (targetExt == null || extension.id == 'spotify-web') {
|
||||||
targetExt = extension;
|
targetExt = extension;
|
||||||
if (preferredId == null && extension.id == 'spotify-web') {
|
if (preferredId == null && extension.id == 'spotify-web') {
|
||||||
@@ -317,7 +310,6 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
|||||||
lastFetched: DateTime.now(),
|
lastFetched: DateTime.now(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Save to disk cache for instant restore on next app launch
|
|
||||||
_saveToCache(sections);
|
_saveToCache(sections);
|
||||||
} catch (e, stack) {
|
} catch (e, stack) {
|
||||||
_log.e('Error fetching home feed: $e', e, stack);
|
_log.e('Error fetching home feed: $e', e, stack);
|
||||||
|
|||||||
@@ -32,14 +32,12 @@ class Extension {
|
|||||||
final bool hasMetadataProvider;
|
final bool hasMetadataProvider;
|
||||||
final bool hasDownloadProvider;
|
final bool hasDownloadProvider;
|
||||||
final bool hasLyricsProvider;
|
final bool hasLyricsProvider;
|
||||||
final bool
|
final bool skipMetadataEnrichment;
|
||||||
skipMetadataEnrichment; // If true, use metadata from extension instead of enriching
|
|
||||||
final SearchBehavior? searchBehavior;
|
final SearchBehavior? searchBehavior;
|
||||||
final URLHandler? urlHandler;
|
final URLHandler? urlHandler;
|
||||||
final TrackMatching? trackMatching;
|
final TrackMatching? trackMatching;
|
||||||
final PostProcessing? postProcessing;
|
final PostProcessing? postProcessing;
|
||||||
final Map<String, dynamic>
|
final Map<String, dynamic> capabilities;
|
||||||
capabilities; // Extension capabilities (homeFeed, browseCategories, etc.)
|
|
||||||
|
|
||||||
const Extension({
|
const Extension({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -198,12 +196,10 @@ class SearchBehavior {
|
|||||||
final String? placeholder;
|
final String? placeholder;
|
||||||
final bool primary;
|
final bool primary;
|
||||||
final String? icon;
|
final String? icon;
|
||||||
final String?
|
final String? thumbnailRatio;
|
||||||
thumbnailRatio; // "square" (1:1), "wide" (16:9), "portrait" (2:3)
|
|
||||||
final int? thumbnailWidth;
|
final int? thumbnailWidth;
|
||||||
final int? thumbnailHeight;
|
final int? thumbnailHeight;
|
||||||
final List<SearchFilter>
|
final List<SearchFilter> filters;
|
||||||
filters; // Available search filters (e.g., track, album, artist, playlist)
|
|
||||||
|
|
||||||
const SearchBehavior({
|
const SearchBehavior({
|
||||||
required this.enabled,
|
required this.enabled,
|
||||||
@@ -239,11 +235,11 @@ class SearchBehavior {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch (thumbnailRatio) {
|
switch (thumbnailRatio) {
|
||||||
case 'wide': // 16:9 - YouTube style
|
case 'wide':
|
||||||
return (defaultSize * 16 / 9, defaultSize);
|
return (defaultSize * 16 / 9, defaultSize);
|
||||||
case 'portrait': // 2:3 - Poster style
|
case 'portrait':
|
||||||
return (defaultSize * 2 / 3, defaultSize);
|
return (defaultSize * 2 / 3, defaultSize);
|
||||||
case 'square': // 1:1 - Album art style
|
case 'square':
|
||||||
default:
|
default:
|
||||||
return (defaultSize, defaultSize);
|
return (defaultSize, defaultSize);
|
||||||
}
|
}
|
||||||
@@ -290,7 +286,6 @@ class PostProcessing {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// URL handler configuration for custom URL patterns
|
|
||||||
class URLHandler {
|
class URLHandler {
|
||||||
final bool enabled;
|
final bool enabled;
|
||||||
final List<String> patterns;
|
final List<String> patterns;
|
||||||
@@ -304,7 +299,6 @@ class URLHandler {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a URL matches any of the patterns
|
|
||||||
bool matchesURL(String url) {
|
bool matchesURL(String url) {
|
||||||
if (!enabled || patterns.isEmpty) return false;
|
if (!enabled || patterns.isEmpty) return false;
|
||||||
final lowerUrl = url.toLowerCase();
|
final lowerUrl = url.toLowerCase();
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ class UserPlaylistCollection {
|
|||||||
createdAt: createdAt,
|
createdAt: createdAt,
|
||||||
updatedAt: updatedAt,
|
updatedAt: updatedAt,
|
||||||
tracks: tracksRaw
|
tracks: tracksRaw
|
||||||
.whereType<Map>()
|
.whereType<Map<Object?, Object?>>()
|
||||||
.map(
|
.map(
|
||||||
(e) => CollectionTrackEntry.fromJson(Map<String, dynamic>.from(e)),
|
(e) => CollectionTrackEntry.fromJson(Map<String, dynamic>.from(e)),
|
||||||
)
|
)
|
||||||
@@ -233,19 +233,19 @@ class LibraryCollectionsState {
|
|||||||
|
|
||||||
return LibraryCollectionsState(
|
return LibraryCollectionsState(
|
||||||
wishlist: wishlistRaw
|
wishlist: wishlistRaw
|
||||||
.whereType<Map>()
|
.whereType<Map<Object?, Object?>>()
|
||||||
.map(
|
.map(
|
||||||
(e) => CollectionTrackEntry.fromJson(Map<String, dynamic>.from(e)),
|
(e) => CollectionTrackEntry.fromJson(Map<String, dynamic>.from(e)),
|
||||||
)
|
)
|
||||||
.toList(growable: false),
|
.toList(growable: false),
|
||||||
loved: lovedRaw
|
loved: lovedRaw
|
||||||
.whereType<Map>()
|
.whereType<Map<Object?, Object?>>()
|
||||||
.map(
|
.map(
|
||||||
(e) => CollectionTrackEntry.fromJson(Map<String, dynamic>.from(e)),
|
(e) => CollectionTrackEntry.fromJson(Map<String, dynamic>.from(e)),
|
||||||
)
|
)
|
||||||
.toList(growable: false),
|
.toList(growable: false),
|
||||||
playlists: playlistsRaw
|
playlists: playlistsRaw
|
||||||
.whereType<Map>()
|
.whereType<Map<Object?, Object?>>()
|
||||||
.map(
|
.map(
|
||||||
(e) =>
|
(e) =>
|
||||||
UserPlaylistCollection.fromJson(Map<String, dynamic>.from(e)),
|
UserPlaylistCollection.fromJson(Map<String, dynamic>.from(e)),
|
||||||
@@ -666,7 +666,6 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
|
|||||||
final destPath = p.join(coversDir.path, '$playlistId$ext');
|
final destPath = p.join(coversDir.path, '$playlistId$ext');
|
||||||
if (playlist.coverImagePath == destPath) return;
|
if (playlist.coverImagePath == destPath) return;
|
||||||
|
|
||||||
// Copy image to persistent location
|
|
||||||
await File(sourceFilePath).copy(destPath);
|
await File(sourceFilePath).copy(destPath);
|
||||||
|
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
@@ -686,7 +685,6 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
|
|||||||
final playlist = state.playlistById(playlistId);
|
final playlist = state.playlistById(playlistId);
|
||||||
if (playlist == null || playlist.coverImagePath == null) return;
|
if (playlist == null || playlist.coverImagePath == null) return;
|
||||||
|
|
||||||
// Delete the file if it exists
|
|
||||||
final path = playlist.coverImagePath;
|
final path = playlist.coverImagePath;
|
||||||
if (path != null) {
|
if (path != null) {
|
||||||
final file = File(path);
|
final file = File(path);
|
||||||
|
|||||||
@@ -252,8 +252,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
|
|
||||||
_startProgressPolling();
|
_startProgressPolling();
|
||||||
|
|
||||||
// On iOS, start accessing the security-scoped bookmark so the Go backend
|
|
||||||
// can read files outside the app sandbox.
|
|
||||||
String? resolvedPath;
|
String? resolvedPath;
|
||||||
bool didStartSecurityAccess = false;
|
bool didStartSecurityAccess = false;
|
||||||
if (Platform.isIOS && iosBookmark != null && iosBookmark.isNotEmpty) {
|
if (Platform.isIOS && iosBookmark != null && iosBookmark.isNotEmpty) {
|
||||||
@@ -275,9 +273,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
try {
|
try {
|
||||||
final isSaf = effectiveFolderPath.startsWith('content://');
|
final isSaf = effectiveFolderPath.startsWith('content://');
|
||||||
|
|
||||||
// Get all file paths from download history to exclude them.
|
|
||||||
// Merge DB + in-memory state to avoid race when a fresh download has not
|
|
||||||
// been flushed to SQLite yet.
|
|
||||||
final downloadedPaths = await _historyDb.getAllFilePaths();
|
final downloadedPaths = await _historyDb.getAllFilePaths();
|
||||||
final inMemoryHistoryPaths = ref
|
final inMemoryHistoryPaths = ref
|
||||||
.read(downloadHistoryProvider)
|
.read(downloadHistoryProvider)
|
||||||
@@ -298,7 +293,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (forceFullScan) {
|
if (forceFullScan) {
|
||||||
// Full scan path - ignores existing data
|
|
||||||
final results = isSaf
|
final results = isSaf
|
||||||
? await PlatformBridge.scanSafTree(effectiveFolderPath)
|
? await PlatformBridge.scanSafTree(effectiveFolderPath)
|
||||||
: await PlatformBridge.scanLibraryFolder(effectiveFolderPath);
|
: await PlatformBridge.scanLibraryFolder(effectiveFolderPath);
|
||||||
@@ -324,16 +318,8 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
_log.i('Skipped $skippedDownloads files already in download history');
|
_log.i('Skipped $skippedDownloads files already in download history');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Full scan should replace library index entirely.
|
await _db.replaceAll(items.map((e) => e.toJson()).toList());
|
||||||
await _db.clearAll();
|
final persistedItems = [...items]..sort(_compareLibraryItems);
|
||||||
if (items.isNotEmpty) {
|
|
||||||
await _db.upsertBatch(items.map((e) => e.toJson()).toList());
|
|
||||||
}
|
|
||||||
final persistedItems =
|
|
||||||
(await _db.getAll())
|
|
||||||
.map(LocalLibraryItem.fromJson)
|
|
||||||
.toList(growable: false)
|
|
||||||
..sort(_compareLibraryItems);
|
|
||||||
|
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
try {
|
try {
|
||||||
@@ -364,7 +350,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
errorCount: state.scanErrorCount,
|
errorCount: state.scanErrorCount,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Incremental scan path - only scans new/modified files
|
|
||||||
final existingFiles = await _db.getFileModTimes();
|
final existingFiles = await _db.getFileModTimes();
|
||||||
_log.i(
|
_log.i(
|
||||||
'Incremental scan: ${existingFiles.length} existing files in database',
|
'Incremental scan: ${existingFiles.length} existing files in database',
|
||||||
@@ -423,7 +408,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// SAF returns 'files' and 'removedUris', non-SAF returns 'scanned' and 'deletedPaths'
|
|
||||||
final scannedList =
|
final scannedList =
|
||||||
(result['files'] as List<dynamic>?) ??
|
(result['files'] as List<dynamic>?) ??
|
||||||
(result['scanned'] as List<dynamic>?) ??
|
(result['scanned'] as List<dynamic>?) ??
|
||||||
@@ -444,10 +428,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
'$skippedCount skipped, ${deletedPaths.length} deleted, $totalFiles total',
|
'$skippedCount skipped, ${deletedPaths.length} deleted, $totalFiles total',
|
||||||
);
|
);
|
||||||
|
|
||||||
// Build the incremental merge base from SQLite, not the current
|
|
||||||
// provider state. Startup auto-scan can fire before `state.items` has
|
|
||||||
// finished loading, which would otherwise drop unchanged rows from the
|
|
||||||
// in-memory library until a manual full rescan.
|
|
||||||
final existingJson = await _db.getAll();
|
final existingJson = await _db.getAll();
|
||||||
final currentByPath = <String, LocalLibraryItem>{
|
final currentByPath = <String, LocalLibraryItem>{
|
||||||
for (final item in existingJson.map(LocalLibraryItem.fromJson))
|
for (final item in existingJson.map(LocalLibraryItem.fromJson))
|
||||||
@@ -468,7 +448,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upsert new/modified items (excluding downloaded files)
|
|
||||||
final updatedItems = <LocalLibraryItem>[];
|
final updatedItems = <LocalLibraryItem>[];
|
||||||
int skippedDownloads = existingDownloadedPaths.length;
|
int skippedDownloads = existingDownloadedPaths.length;
|
||||||
if (scannedList.isNotEmpty) {
|
if (scannedList.isNotEmpty) {
|
||||||
@@ -502,11 +481,8 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
_log.i('Deleted $deleteCount items from database');
|
_log.i('Deleted $deleteCount items from database');
|
||||||
}
|
}
|
||||||
|
|
||||||
final items =
|
final items = currentByPath.values.toList(growable: false)
|
||||||
(await _db.getAll())
|
..sort(_compareLibraryItems);
|
||||||
.map(LocalLibraryItem.fromJson)
|
|
||||||
.toList(growable: false)
|
|
||||||
..sort(_compareLibraryItems);
|
|
||||||
|
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -5,18 +5,16 @@ import 'package:spotiflac_android/services/app_state_database.dart';
|
|||||||
|
|
||||||
const _maxRecentItems = 20;
|
const _maxRecentItems = 20;
|
||||||
|
|
||||||
/// Types of items that can be accessed
|
|
||||||
enum RecentAccessType { artist, album, track, playlist }
|
enum RecentAccessType { artist, album, track, playlist }
|
||||||
|
|
||||||
/// Represents a recently accessed item
|
|
||||||
class RecentAccessItem {
|
class RecentAccessItem {
|
||||||
final String id;
|
final String id;
|
||||||
final String name;
|
final String name;
|
||||||
final String? subtitle; // Artist name for tracks/albums, null for artists
|
final String? subtitle;
|
||||||
final String? imageUrl;
|
final String? imageUrl;
|
||||||
final RecentAccessType type;
|
final RecentAccessType type;
|
||||||
final DateTime accessedAt;
|
final DateTime accessedAt;
|
||||||
final String? providerId; // Extension ID or 'deezer' for built-in
|
final String? providerId;
|
||||||
|
|
||||||
const RecentAccessItem({
|
const RecentAccessItem({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -53,7 +51,6 @@ class RecentAccessItem {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a unique key for deduplication
|
|
||||||
String get uniqueKey => '${type.name}:${providerId ?? 'default'}:$id';
|
String get uniqueKey => '${type.name}:${providerId ?? 'default'}:$id';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -67,7 +64,6 @@ class RecentAccessItem {
|
|||||||
int get hashCode => uniqueKey.hashCode;
|
int get hashCode => uniqueKey.hashCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// State for recent access history
|
|
||||||
class RecentAccessState {
|
class RecentAccessState {
|
||||||
final List<RecentAccessItem> items;
|
final List<RecentAccessItem> items;
|
||||||
final Set<String> hiddenDownloadIds;
|
final Set<String> hiddenDownloadIds;
|
||||||
@@ -92,7 +88,6 @@ class RecentAccessState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Provider for managing recent access history
|
|
||||||
class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
||||||
final AppStateDatabase _appStateDb = AppStateDatabase.instance;
|
final AppStateDatabase _appStateDb = AppStateDatabase.instance;
|
||||||
|
|
||||||
@@ -135,7 +130,6 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Record an access to an artist
|
|
||||||
void recordArtistAccess({
|
void recordArtistAccess({
|
||||||
required String id,
|
required String id,
|
||||||
required String name,
|
required String name,
|
||||||
@@ -154,7 +148,6 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Record an access to an album
|
|
||||||
void recordAlbumAccess({
|
void recordAlbumAccess({
|
||||||
required String id,
|
required String id,
|
||||||
required String name,
|
required String name,
|
||||||
@@ -175,7 +168,6 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Record an access to a track
|
|
||||||
void recordTrackAccess({
|
void recordTrackAccess({
|
||||||
required String id,
|
required String id,
|
||||||
required String name,
|
required String name,
|
||||||
@@ -196,7 +188,6 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Record an access to a playlist
|
|
||||||
void recordPlaylistAccess({
|
void recordPlaylistAccess({
|
||||||
required String id,
|
required String id,
|
||||||
required String name,
|
required String name,
|
||||||
@@ -242,7 +233,6 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove a specific item from history
|
|
||||||
void removeItem(RecentAccessItem item) {
|
void removeItem(RecentAccessItem item) {
|
||||||
final updatedItems = state.items
|
final updatedItems = state.items
|
||||||
.where((e) => e.uniqueKey != item.uniqueKey)
|
.where((e) => e.uniqueKey != item.uniqueKey)
|
||||||
@@ -251,25 +241,21 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
|||||||
unawaited(_appStateDb.deleteRecentAccessRow(item.uniqueKey));
|
unawaited(_appStateDb.deleteRecentAccessRow(item.uniqueKey));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Hide a download item from recents (without deleting the actual download)
|
|
||||||
void hideDownloadFromRecents(String downloadId) {
|
void hideDownloadFromRecents(String downloadId) {
|
||||||
final updatedHidden = {...state.hiddenDownloadIds, downloadId};
|
final updatedHidden = {...state.hiddenDownloadIds, downloadId};
|
||||||
state = state.copyWith(hiddenDownloadIds: updatedHidden);
|
state = state.copyWith(hiddenDownloadIds: updatedHidden);
|
||||||
unawaited(_appStateDb.addHiddenRecentDownloadId(downloadId));
|
unawaited(_appStateDb.addHiddenRecentDownloadId(downloadId));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a download is hidden from recents
|
|
||||||
bool isDownloadHidden(String downloadId) {
|
bool isDownloadHidden(String downloadId) {
|
||||||
return state.hiddenDownloadIds.contains(downloadId);
|
return state.hiddenDownloadIds.contains(downloadId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear all history
|
|
||||||
void clearHistory() {
|
void clearHistory() {
|
||||||
state = state.copyWith(items: []);
|
state = state.copyWith(items: []);
|
||||||
unawaited(_appStateDb.clearRecentAccessRows());
|
unawaited(_appStateDb.clearRecentAccessRows());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear hidden downloads (show all again)
|
|
||||||
void clearHiddenDownloads() {
|
void clearHiddenDownloads() {
|
||||||
state = state.copyWith(hiddenDownloadIds: {});
|
state = state.copyWith(hiddenDownloadIds: {});
|
||||||
unawaited(_appStateDb.clearHiddenRecentDownloadIds());
|
unawaited(_appStateDb.clearHiddenRecentDownloadIds());
|
||||||
|
|||||||
@@ -11,13 +11,11 @@ import 'package:spotiflac_android/utils/logger.dart';
|
|||||||
|
|
||||||
const _settingsKey = 'app_settings';
|
const _settingsKey = 'app_settings';
|
||||||
const _migrationVersionKey = 'settings_migration_version';
|
const _migrationVersionKey = 'settings_migration_version';
|
||||||
const _currentMigrationVersion = 6;
|
const _currentMigrationVersion = 7;
|
||||||
const _spotifyClientSecretKey = 'spotify_client_secret';
|
const _spotifyClientSecretKey = 'spotify_client_secret';
|
||||||
final _log = AppLogger('SettingsProvider');
|
final _log = AppLogger('SettingsProvider');
|
||||||
|
|
||||||
class SettingsNotifier extends Notifier<AppSettings> {
|
class SettingsNotifier extends Notifier<AppSettings> {
|
||||||
static const List<int> _youtubeOpusSupportedBitrates = [128, 256, 320];
|
|
||||||
static const List<int> _youtubeMp3SupportedBitrates = [128, 256, 320];
|
|
||||||
static final RegExp _isoRegionPattern = RegExp(r'^[A-Z]{2}$');
|
static final RegExp _isoRegionPattern = RegExp(r'^[A-Z]{2}$');
|
||||||
|
|
||||||
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
|
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
|
||||||
@@ -36,11 +34,12 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
final prefs = await _prefs;
|
final prefs = await _prefs;
|
||||||
final json = prefs.getString(_settingsKey);
|
final json = prefs.getString(_settingsKey);
|
||||||
if (json != null) {
|
if (json != null) {
|
||||||
state = AppSettings.fromJson(jsonDecode(json));
|
state = AppSettings.fromJson(
|
||||||
|
Map<String, dynamic>.from(jsonDecode(json) as Map),
|
||||||
|
);
|
||||||
|
|
||||||
await _runMigrations(prefs);
|
await _runMigrations(prefs);
|
||||||
await _normalizeIosDownloadDirectoryIfNeeded();
|
await _normalizeIosDownloadDirectoryIfNeeded();
|
||||||
await _normalizeYouTubeBitratesIfNeeded();
|
|
||||||
await _normalizeSongLinkRegionIfNeeded();
|
await _normalizeSongLinkRegionIfNeeded();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +54,9 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
void _syncLyricsSettingsToBackend() {
|
void _syncLyricsSettingsToBackend() {
|
||||||
if (!PlatformBridge.supportsCoreBackend) return;
|
if (!PlatformBridge.supportsCoreBackend) return;
|
||||||
|
|
||||||
PlatformBridge.setLyricsProviders(state.lyricsProviders).catchError((e) {
|
PlatformBridge.setLyricsProviders(state.lyricsProviders).catchError((
|
||||||
|
Object e,
|
||||||
|
) {
|
||||||
_log.w('Failed to sync lyrics providers to backend: $e');
|
_log.w('Failed to sync lyrics providers to backend: $e');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -64,7 +65,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
'include_romanization_netease': state.lyricsIncludeRomanizationNetease,
|
'include_romanization_netease': state.lyricsIncludeRomanizationNetease,
|
||||||
'multi_person_word_by_word': state.lyricsMultiPersonWordByWord,
|
'multi_person_word_by_word': state.lyricsMultiPersonWordByWord,
|
||||||
'musixmatch_language': state.musixmatchLanguage,
|
'musixmatch_language': state.musixmatchLanguage,
|
||||||
}).catchError((e) {
|
}).catchError((Object e) {
|
||||||
_log.w('Failed to sync lyrics fetch options to backend: $e');
|
_log.w('Failed to sync lyrics fetch options to backend: $e');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -76,7 +77,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
PlatformBridge.setNetworkCompatibilityOptions(
|
PlatformBridge.setNetworkCompatibilityOptions(
|
||||||
allowHttp: compatibilityMode,
|
allowHttp: compatibilityMode,
|
||||||
insecureTls: compatibilityMode,
|
insecureTls: compatibilityMode,
|
||||||
).catchError((e) {
|
).catchError((Object e) {
|
||||||
_log.w('Failed to sync network compatibility options to backend: $e');
|
_log.w('Failed to sync network compatibility options to backend: $e');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -122,6 +123,10 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
state = state.copyWith(lastSeenVersion: AppInfo.version);
|
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 prefs.setInt(_migrationVersionKey, _currentMigrationVersion);
|
||||||
await _saveSettings();
|
await _saveSettings();
|
||||||
}
|
}
|
||||||
@@ -153,49 +158,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
int _nearestSupportedBitrate(int value, List<int> supported) {
|
|
||||||
var nearest = supported.first;
|
|
||||||
var nearestDistance = (value - nearest).abs();
|
|
||||||
|
|
||||||
for (final option in supported.skip(1)) {
|
|
||||||
final distance = (value - option).abs();
|
|
||||||
// On tie, prefer higher quality bitrate.
|
|
||||||
if (distance < nearestDistance ||
|
|
||||||
(distance == nearestDistance && option > nearest)) {
|
|
||||||
nearest = option;
|
|
||||||
nearestDistance = distance;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nearest;
|
|
||||||
}
|
|
||||||
|
|
||||||
int _normalizeYouTubeOpusBitrate(int bitrate) {
|
|
||||||
return _nearestSupportedBitrate(bitrate, _youtubeOpusSupportedBitrates);
|
|
||||||
}
|
|
||||||
|
|
||||||
int _normalizeYouTubeMp3Bitrate(int bitrate) {
|
|
||||||
return _nearestSupportedBitrate(bitrate, _youtubeMp3SupportedBitrates);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<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,
|
|
||||||
);
|
|
||||||
await _saveSettings();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _normalizeIosDownloadDirectoryIfNeeded() async {
|
Future<void> _normalizeIosDownloadDirectoryIfNeeded() async {
|
||||||
if (!Platform.isIOS) return;
|
if (!Platform.isIOS) return;
|
||||||
|
|
||||||
@@ -469,18 +431,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
_saveSettings();
|
_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) {
|
void setUseAllFilesAccess(bool enabled) {
|
||||||
state = state.copyWith(useAllFilesAccess: enabled);
|
state = state.copyWith(useAllFilesAccess: enabled);
|
||||||
_saveSettings();
|
_saveSettings();
|
||||||
|
|||||||
@@ -12,13 +12,13 @@ const _registryUrlPrefKey = 'store_registry_url';
|
|||||||
int compareVersions(String v1, String v2) {
|
int compareVersions(String v1, String v2) {
|
||||||
final parts1 = v1.replaceAll(_leadingVersionPrefix, '').split('.');
|
final parts1 = v1.replaceAll(_leadingVersionPrefix, '').split('.');
|
||||||
final parts2 = v2.replaceAll(_leadingVersionPrefix, '').split('.');
|
final parts2 = v2.replaceAll(_leadingVersionPrefix, '').split('.');
|
||||||
|
|
||||||
final maxLen = parts1.length > parts2.length ? parts1.length : parts2.length;
|
final maxLen = parts1.length > parts2.length ? parts1.length : parts2.length;
|
||||||
|
|
||||||
for (var i = 0; i < maxLen; i++) {
|
for (var i = 0; i < maxLen; i++) {
|
||||||
final n1 = i < parts1.length ? (int.tryParse(parts1[i]) ?? 0) : 0;
|
final n1 = i < parts1.length ? (int.tryParse(parts1[i]) ?? 0) : 0;
|
||||||
final n2 = i < parts2.length ? (int.tryParse(parts2[i]) ?? 0) : 0;
|
final n2 = i < parts2.length ? (int.tryParse(parts2[i]) ?? 0) : 0;
|
||||||
|
|
||||||
if (n1 < n2) return -1;
|
if (n1 < n2) return -1;
|
||||||
if (n1 > n2) return 1;
|
if (n1 > n2) return 1;
|
||||||
}
|
}
|
||||||
@@ -26,14 +26,19 @@ int compareVersions(String v1, String v2) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class StoreCategory {
|
class StoreCategory {
|
||||||
|
|
||||||
static const String metadata = 'metadata';
|
static const String metadata = 'metadata';
|
||||||
static const String download = 'download';
|
static const String download = 'download';
|
||||||
static const String utility = 'utility';
|
static const String utility = 'utility';
|
||||||
static const String lyrics = 'lyrics';
|
static const String lyrics = 'lyrics';
|
||||||
static const String integration = 'integration';
|
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) {
|
static String getDisplayName(String category) {
|
||||||
switch (category) {
|
switch (category) {
|
||||||
@@ -94,7 +99,8 @@ class StoreExtension {
|
|||||||
return StoreExtension(
|
return StoreExtension(
|
||||||
id: json['id'] as String? ?? '',
|
id: json['id'] as String? ?? '',
|
||||||
name: json['name'] 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',
|
version: json['version'] as String? ?? '0.0.0',
|
||||||
author: json['author'] as String? ?? 'Unknown',
|
author: json['author'] as String? ?? 'Unknown',
|
||||||
description: json['description'] as String? ?? '',
|
description: json['description'] as String? ?? '',
|
||||||
@@ -117,7 +123,6 @@ class StoreExtension {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class StoreState {
|
class StoreState {
|
||||||
final List<StoreExtension> extensions;
|
final List<StoreExtension> extensions;
|
||||||
final String? selectedCategory;
|
final String? selectedCategory;
|
||||||
@@ -160,11 +165,15 @@ class StoreState {
|
|||||||
}) {
|
}) {
|
||||||
return StoreState(
|
return StoreState(
|
||||||
extensions: extensions ?? this.extensions,
|
extensions: extensions ?? this.extensions,
|
||||||
selectedCategory: clearCategory ? null : (selectedCategory ?? this.selectedCategory),
|
selectedCategory: clearCategory
|
||||||
|
? null
|
||||||
|
: (selectedCategory ?? this.selectedCategory),
|
||||||
searchQuery: searchQuery ?? this.searchQuery,
|
searchQuery: searchQuery ?? this.searchQuery,
|
||||||
isLoading: isLoading ?? this.isLoading,
|
isLoading: isLoading ?? this.isLoading,
|
||||||
isDownloading: isDownloading ?? this.isDownloading,
|
isDownloading: isDownloading ?? this.isDownloading,
|
||||||
downloadingId: clearDownloadingId ? null : (downloadingId ?? this.downloadingId),
|
downloadingId: clearDownloadingId
|
||||||
|
? null
|
||||||
|
: (downloadingId ?? this.downloadingId),
|
||||||
error: clearError ? null : (error ?? this.error),
|
error: clearError ? null : (error ?? this.error),
|
||||||
isInitialized: isInitialized ?? this.isInitialized,
|
isInitialized: isInitialized ?? this.isInitialized,
|
||||||
registryUrl: registryUrl ?? this.registryUrl,
|
registryUrl: registryUrl ?? this.registryUrl,
|
||||||
@@ -180,13 +189,16 @@ class StoreState {
|
|||||||
|
|
||||||
if (searchQuery.isNotEmpty) {
|
if (searchQuery.isNotEmpty) {
|
||||||
final query = searchQuery.toLowerCase();
|
final query = searchQuery.toLowerCase();
|
||||||
result = result.where((e) =>
|
result = result
|
||||||
e.name.toLowerCase().contains(query) ||
|
.where(
|
||||||
e.displayName.toLowerCase().contains(query) ||
|
(e) =>
|
||||||
e.description.toLowerCase().contains(query) ||
|
e.name.toLowerCase().contains(query) ||
|
||||||
e.author.toLowerCase().contains(query) ||
|
e.displayName.toLowerCase().contains(query) ||
|
||||||
e.tags.any((t) => t.toLowerCase().contains(query))
|
e.description.toLowerCase().contains(query) ||
|
||||||
).toList();
|
e.author.toLowerCase().contains(query) ||
|
||||||
|
e.tags.any((t) => t.toLowerCase().contains(query)),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
@@ -206,23 +218,28 @@ class StoreNotifier extends Notifier<StoreState> {
|
|||||||
Future<void> initialize(String cacheDir) async {
|
Future<void> initialize(String cacheDir) async {
|
||||||
if (state.isInitialized) return;
|
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 {
|
try {
|
||||||
await PlatformBridge.initExtensionStore(cacheDir);
|
await PlatformBridge.initExtensionStore(cacheDir);
|
||||||
|
|
||||||
// Load saved registry URL from SharedPreferences
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
final savedUrl = prefs.getString(_registryUrlPrefKey) ?? '';
|
|
||||||
|
|
||||||
if (savedUrl.isNotEmpty) {
|
if (savedUrl.isNotEmpty) {
|
||||||
await PlatformBridge.setStoreRegistryUrl(savedUrl);
|
await PlatformBridge.setStoreRegistryUrl(savedUrl);
|
||||||
state = state.copyWith(registryUrl: savedUrl);
|
|
||||||
await refresh();
|
await refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
state = state.copyWith(isInitialized: true, isLoading: false);
|
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) {
|
} catch (e) {
|
||||||
_log.e('Failed to initialize store: $e');
|
_log.e('Failed to initialize store: $e');
|
||||||
state = state.copyWith(isLoading: false, error: e.toString());
|
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).
|
// Read back the resolved URL (may differ from input after normalisation).
|
||||||
final resolvedUrl = await PlatformBridge.getStoreRegistryUrl();
|
final resolvedUrl = await PlatformBridge.getStoreRegistryUrl();
|
||||||
|
|
||||||
// Persist to SharedPreferences
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setString(_registryUrlPrefKey, resolvedUrl);
|
await prefs.setString(_registryUrlPrefKey, resolvedUrl);
|
||||||
|
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
registryUrl: resolvedUrl,
|
registryUrl: resolvedUrl,
|
||||||
extensions: const [], // Clear old extensions
|
extensions: const [],
|
||||||
);
|
);
|
||||||
|
|
||||||
_log.i('Registry URL set to: $resolvedUrl');
|
_log.i('Registry URL set to: $resolvedUrl');
|
||||||
@@ -292,7 +308,9 @@ class StoreNotifier extends Notifier<StoreState> {
|
|||||||
state = state.copyWith(isLoading: true, clearError: true);
|
state = state.copyWith(isLoading: true, clearError: true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final extensions = await PlatformBridge.getStoreExtensions(forceRefresh: forceRefresh);
|
final extensions = await PlatformBridge.getStoreExtensions(
|
||||||
|
forceRefresh: forceRefresh,
|
||||||
|
);
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
extensions: extensions.map((e) => StoreExtension.fromJson(e)).toList(),
|
extensions: extensions.map((e) => StoreExtension.fromJson(e)).toList(),
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -320,12 +338,23 @@ class StoreNotifier extends Notifier<StoreState> {
|
|||||||
state = state.copyWith(searchQuery: '', clearCategory: true);
|
state = state.copyWith(searchQuery: '', clearCategory: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> installExtension(String extensionId, String tempDir, String extensionsDir) async {
|
Future<bool> installExtension(
|
||||||
state = state.copyWith(isDownloading: true, downloadingId: extensionId, clearError: true);
|
String extensionId,
|
||||||
|
String tempDir,
|
||||||
|
String extensionsDir,
|
||||||
|
) async {
|
||||||
|
state = state.copyWith(
|
||||||
|
isDownloading: true,
|
||||||
|
downloadingId: extensionId,
|
||||||
|
clearError: true,
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
_log.i('Downloading extension: $extensionId');
|
_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');
|
_log.i('Installing extension from: $downloadPath');
|
||||||
final extNotifier = ref.read(extensionProvider.notifier);
|
final extNotifier = ref.read(extensionProvider.notifier);
|
||||||
@@ -340,18 +369,28 @@ class StoreNotifier extends Notifier<StoreState> {
|
|||||||
return success;
|
return success;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_log.e('Failed to install extension: $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;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
Future<bool> updateExtension(String extensionId, String tempDir) async {
|
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 {
|
try {
|
||||||
_log.i('Downloading update for: $extensionId');
|
_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');
|
_log.i('Upgrading extension from: $downloadPath');
|
||||||
final extNotifier = ref.read(extensionProvider.notifier);
|
final extNotifier = ref.read(extensionProvider.notifier);
|
||||||
@@ -366,7 +405,11 @@ class StoreNotifier extends Notifier<StoreState> {
|
|||||||
return success;
|
return success;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_log.e('Failed to update extension: $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;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,6 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
|
|||||||
await _saveToStorage();
|
await _saveToStorage();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set custom seed color (used when dynamic color is disabled)
|
|
||||||
Future<void> setSeedColor(Color color) async {
|
Future<void> setSeedColor(Color color) async {
|
||||||
state = state.copyWith(seedColorValue: color.toARGB32());
|
state = state.copyWith(seedColorValue: color.toARGB32());
|
||||||
await _saveToStorage();
|
await _saveToStorage();
|
||||||
@@ -81,4 +80,3 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,21 +18,18 @@ class TrackState {
|
|||||||
final String? artistId;
|
final String? artistId;
|
||||||
final String? artistName;
|
final String? artistName;
|
||||||
final String? coverUrl;
|
final String? coverUrl;
|
||||||
final String? headerImageUrl; // Artist header image for background
|
final String? headerImageUrl;
|
||||||
final int? monthlyListeners;
|
final int? monthlyListeners;
|
||||||
final List<ArtistAlbum>? artistAlbums; // For artist page
|
final List<ArtistAlbum>? artistAlbums;
|
||||||
final List<Track>? artistTopTracks; // Artist's popular tracks
|
final List<Track>? artistTopTracks;
|
||||||
final List<SearchArtist>? searchArtists; // For search results
|
final List<SearchArtist>? searchArtists;
|
||||||
final List<SearchAlbum>? searchAlbums; // For search results (albums)
|
final List<SearchAlbum>? searchAlbums;
|
||||||
final List<SearchPlaylist>? searchPlaylists; // For search results (playlists)
|
final List<SearchPlaylist>? searchPlaylists;
|
||||||
final bool hasSearchText; // For back button handling
|
final bool hasSearchText;
|
||||||
final bool isShowingRecentAccess; // For recent access mode
|
final bool isShowingRecentAccess;
|
||||||
final String?
|
final String? searchExtensionId;
|
||||||
searchExtensionId; // Extension ID used for current search results
|
final String? selectedSearchFilter;
|
||||||
final String?
|
final String? searchSource;
|
||||||
selectedSearchFilter; // Currently selected search filter (e.g., "track", "album", "artist", "playlist")
|
|
||||||
final String?
|
|
||||||
searchSource; // Built-in search provider used for current results (e.g., "deezer", "tidal", "qobuz")
|
|
||||||
|
|
||||||
const TrackState({
|
const TrackState({
|
||||||
this.tracks = const [],
|
this.tracks = const [],
|
||||||
@@ -127,9 +124,9 @@ class ArtistAlbum {
|
|||||||
final String releaseDate;
|
final String releaseDate;
|
||||||
final int totalTracks;
|
final int totalTracks;
|
||||||
final String? coverUrl;
|
final String? coverUrl;
|
||||||
final String albumType; // album, single, compilation
|
final String albumType;
|
||||||
final String artists;
|
final String artists;
|
||||||
final String? providerId; // Extension ID if from extension
|
final String? providerId;
|
||||||
|
|
||||||
const ArtistAlbum({
|
const ArtistAlbum({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -204,7 +201,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
return const TrackState();
|
return const TrackState();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if request is still valid (not cancelled by newer request)
|
|
||||||
bool _isRequestValid(int requestId) => requestId == _currentRequestId;
|
bool _isRequestValid(int requestId) => requestId == _currentRequestId;
|
||||||
|
|
||||||
Future<void> fetchFromUrl(String url, {bool useDeezerFallback = true}) async {
|
Future<void> fetchFromUrl(String url, {bool useDeezerFallback = true}) async {
|
||||||
@@ -217,7 +213,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
if (extensionHandler != null) {
|
if (extensionHandler != null) {
|
||||||
_log.i('Found extension URL handler: $extensionHandler for URL: $url');
|
_log.i('Found extension URL handler: $extensionHandler for URL: $url');
|
||||||
|
|
||||||
// Retry logic for extension URL handlers (up to 3 attempts)
|
|
||||||
Map<String, dynamic>? result;
|
Map<String, dynamic>? result;
|
||||||
for (int attempt = 1; attempt <= 3; attempt++) {
|
for (int attempt = 1; attempt <= 3; attempt++) {
|
||||||
result = await PlatformBridge.handleURLWithExtension(url);
|
result = await PlatformBridge.handleURLWithExtension(url);
|
||||||
@@ -239,7 +234,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (attempt < 3) {
|
if (attempt < 3) {
|
||||||
await Future.delayed(const Duration(milliseconds: 500));
|
await Future<void>.delayed(const Duration(milliseconds: 500));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,10 +275,12 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
state = TrackState(
|
state = TrackState(
|
||||||
tracks: tracks,
|
tracks: tracks,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
albumId: result['album']?['id'] as String?,
|
albumId:
|
||||||
|
(result['album'] as Map<String, dynamic>?)?['id'] as String?,
|
||||||
albumName:
|
albumName:
|
||||||
result['name'] as String? ??
|
result['name'] as String? ??
|
||||||
result['album']?['name'] as String?,
|
(result['album'] as Map<String, dynamic>?)?['name']
|
||||||
|
as String?,
|
||||||
playlistName: type == 'playlist'
|
playlistName: type == 'playlist'
|
||||||
? result['name'] as String?
|
? result['name'] as String?
|
||||||
: null,
|
: null,
|
||||||
@@ -541,91 +538,11 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If URL doesn't match any known service, it's unrecognized
|
state = TrackState(
|
||||||
final isSpotifyUrl =
|
isLoading: false,
|
||||||
url.contains('open.spotify.com') ||
|
error: 'url_not_recognized',
|
||||||
url.contains('spotify.link') ||
|
hasSearchText: state.hasSearchText,
|
||||||
url.startsWith('spotify:');
|
);
|
||||||
if (!isSpotifyUrl) {
|
|
||||||
state = TrackState(
|
|
||||||
isLoading: false,
|
|
||||||
error: 'url_not_recognized',
|
|
||||||
hasSearchText: state.hasSearchText,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final parsed = await PlatformBridge.parseSpotifyUrl(url);
|
|
||||||
if (!_isRequestValid(requestId)) return;
|
|
||||||
|
|
||||||
final type = parsed['type'] as String;
|
|
||||||
|
|
||||||
Map<String, dynamic> metadata;
|
|
||||||
|
|
||||||
try {
|
|
||||||
metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
|
|
||||||
} catch (e) {
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!_isRequestValid(requestId)) return;
|
|
||||||
|
|
||||||
if (type == 'track') {
|
|
||||||
final trackData = metadata['track'] as Map<String, dynamic>;
|
|
||||||
final track = _parseTrack(trackData);
|
|
||||||
state = TrackState(
|
|
||||||
tracks: [track],
|
|
||||||
isLoading: false,
|
|
||||||
coverUrl: track.coverUrl,
|
|
||||||
);
|
|
||||||
} else if (type == 'album') {
|
|
||||||
final albumInfo = metadata['album_info'] as Map<String, dynamic>;
|
|
||||||
final trackList = metadata['track_list'] as List<dynamic>;
|
|
||||||
final tracks = trackList
|
|
||||||
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
|
||||||
.toList();
|
|
||||||
state = TrackState(
|
|
||||||
tracks: tracks,
|
|
||||||
isLoading: false,
|
|
||||||
albumId: parsed['id'] as String?,
|
|
||||||
albumName: albumInfo['name'] as String?,
|
|
||||||
coverUrl: normalizeRemoteHttpUrl(albumInfo['images']?.toString()),
|
|
||||||
);
|
|
||||||
_preWarmCacheForTracks(tracks);
|
|
||||||
} else if (type == 'playlist') {
|
|
||||||
final playlistInfo = metadata['playlist_info'] as Map<String, dynamic>;
|
|
||||||
final trackList = metadata['track_list'] as List<dynamic>;
|
|
||||||
final tracks = trackList
|
|
||||||
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
|
||||||
.toList();
|
|
||||||
final owner = playlistInfo['owner'] as Map<String, dynamic>?;
|
|
||||||
final playlistName =
|
|
||||||
(playlistInfo['name'] ?? owner?['name']) as String?;
|
|
||||||
final coverUrl = normalizeRemoteHttpUrl(
|
|
||||||
(playlistInfo['images'] ?? owner?['images'])?.toString(),
|
|
||||||
);
|
|
||||||
state = TrackState(
|
|
||||||
tracks: tracks,
|
|
||||||
isLoading: false,
|
|
||||||
playlistName: playlistName,
|
|
||||||
coverUrl: coverUrl,
|
|
||||||
);
|
|
||||||
_preWarmCacheForTracks(tracks);
|
|
||||||
} else if (type == 'artist') {
|
|
||||||
final artistInfo = metadata['artist_info'] as Map<String, dynamic>;
|
|
||||||
final albumsList = metadata['albums'] as List<dynamic>;
|
|
||||||
final albums = albumsList
|
|
||||||
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
|
|
||||||
.toList();
|
|
||||||
state = TrackState(
|
|
||||||
tracks: [],
|
|
||||||
isLoading: false,
|
|
||||||
artistId: artistInfo['id'] as String?,
|
|
||||||
artistName: artistInfo['name'] as String?,
|
|
||||||
coverUrl: normalizeRemoteHttpUrl(artistInfo['images']?.toString()),
|
|
||||||
artistAlbums: albums,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!_isRequestValid(requestId)) return;
|
if (!_isRequestValid(requestId)) return;
|
||||||
state = TrackState(
|
state = TrackState(
|
||||||
@@ -643,7 +560,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
}) async {
|
}) async {
|
||||||
final requestId = ++_currentRequestId;
|
final requestId = ++_currentRequestId;
|
||||||
|
|
||||||
// Preserve selected filter during loading
|
|
||||||
final currentFilter = filterOverride ?? state.selectedSearchFilter;
|
final currentFilter = filterOverride ?? state.selectedSearchFilter;
|
||||||
|
|
||||||
state = TrackState(
|
state = TrackState(
|
||||||
@@ -662,7 +578,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
final includeExtensions =
|
final includeExtensions =
|
||||||
settings.useExtensionProviders && hasActiveMetadataExtensions;
|
settings.useExtensionProviders && hasActiveMetadataExtensions;
|
||||||
|
|
||||||
// Determine the effective search provider
|
|
||||||
final effectiveProvider = builtInSearchProvider ?? 'deezer';
|
final effectiveProvider = builtInSearchProvider ?? 'deezer';
|
||||||
|
|
||||||
_log.i(
|
_log.i(
|
||||||
@@ -672,7 +587,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
Map<String, dynamic> results;
|
Map<String, dynamic> results;
|
||||||
List<Map<String, dynamic>> metadataTrackResults = [];
|
List<Map<String, dynamic>> metadataTrackResults = [];
|
||||||
|
|
||||||
// Only use metadata providers for Deezer search (default behavior)
|
|
||||||
if (effectiveProvider == 'deezer') {
|
if (effectiveProvider == 'deezer') {
|
||||||
try {
|
try {
|
||||||
_log.d('Calling metadata provider search API...');
|
_log.d('Calling metadata provider search API...');
|
||||||
@@ -692,7 +606,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call the appropriate search API
|
|
||||||
switch (effectiveProvider) {
|
switch (effectiveProvider) {
|
||||||
case 'tidal':
|
case 'tidal':
|
||||||
_log.d('Calling Tidal search API...');
|
_log.d('Calling Tidal search API...');
|
||||||
@@ -808,9 +721,8 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
hasSearchText: state.hasSearchText,
|
hasSearchText: state.hasSearchText,
|
||||||
isShowingRecentAccess: state.isShowingRecentAccess,
|
isShowingRecentAccess: state.isShowingRecentAccess,
|
||||||
selectedSearchFilter: currentFilter, // Preserve filter in results
|
selectedSearchFilter: currentFilter,
|
||||||
searchSource:
|
searchSource: effectiveProvider,
|
||||||
effectiveProvider, // Track which service was used for search
|
|
||||||
);
|
);
|
||||||
} catch (e, stackTrace) {
|
} catch (e, stackTrace) {
|
||||||
if (!_isRequestValid(requestId)) return;
|
if (!_isRequestValid(requestId)) return;
|
||||||
@@ -836,8 +748,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
isLoading: true,
|
isLoading: true,
|
||||||
hasSearchText: state.hasSearchText,
|
hasSearchText: state.hasSearchText,
|
||||||
isShowingRecentAccess: state.isShowingRecentAccess,
|
isShowingRecentAccess: state.isShowingRecentAccess,
|
||||||
selectedSearchFilter:
|
selectedSearchFilter: state.selectedSearchFilter,
|
||||||
state.selectedSearchFilter, // Preserve filter during loading
|
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -876,9 +787,8 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
hasSearchText: state.hasSearchText,
|
hasSearchText: state.hasSearchText,
|
||||||
isShowingRecentAccess: state.isShowingRecentAccess,
|
isShowingRecentAccess: state.isShowingRecentAccess,
|
||||||
searchExtensionId: extensionId, // Store which extension was used
|
searchExtensionId: extensionId,
|
||||||
selectedSearchFilter:
|
selectedSearchFilter: state.selectedSearchFilter,
|
||||||
state.selectedSearchFilter, // Preserve selected filter
|
|
||||||
);
|
);
|
||||||
} catch (e, stackTrace) {
|
} catch (e, stackTrace) {
|
||||||
if (!_isRequestValid(requestId)) return;
|
if (!_isRequestValid(requestId)) return;
|
||||||
@@ -933,16 +843,13 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
final tracks = List<Track>.from(state.tracks);
|
final tracks = List<Track>.from(state.tracks);
|
||||||
tracks[index] = updatedTrack;
|
tracks[index] = updatedTrack;
|
||||||
state = state.copyWith(tracks: tracks);
|
state = state.copyWith(tracks: tracks);
|
||||||
} catch (_) {
|
} catch (_) {}
|
||||||
// Silently ignore update failures - track may have been removed
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void clear() {
|
void clear() {
|
||||||
state = const TrackState();
|
state = const TrackState();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set selected search filter for extension search
|
|
||||||
void setSearchFilter(String? filter) {
|
void setSearchFilter(String? filter) {
|
||||||
if (state.selectedSearchFilter == filter) return;
|
if (state.selectedSearchFilter == filter) return;
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
@@ -951,7 +858,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set search text state for back button handling
|
|
||||||
void setSearchText(bool hasText) {
|
void setSearchText(bool hasText) {
|
||||||
if (state.hasSearchText == hasText) {
|
if (state.hasSearchText == hasText) {
|
||||||
return;
|
return;
|
||||||
@@ -966,7 +872,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
state = state.copyWith(isShowingRecentAccess: showing);
|
state = state.copyWith(isShowingRecentAccess: showing);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set tracks from a collection (album/playlist) opened from search results
|
|
||||||
void setTracksFromCollection({
|
void setTracksFromCollection({
|
||||||
required List<Track> tracks,
|
required List<Track> tracks,
|
||||||
String? albumName,
|
String? albumName,
|
||||||
@@ -1127,7 +1032,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
'isrc': isrc,
|
'isrc': isrc,
|
||||||
'track_name': track.name,
|
'track_name': track.name,
|
||||||
'artist_name': track.artistName,
|
'artist_name': track.artistName,
|
||||||
'spotify_id': track.id, // Include Spotify ID for Amazon lookup
|
'spotify_id': track.id,
|
||||||
'service': 'tidal',
|
'service': 'tidal',
|
||||||
});
|
});
|
||||||
if (cacheRequests.length >= _maxPreWarmTracksPerRequest) {
|
if (cacheRequests.length >= _maxPreWarmTracksPerRequest) {
|
||||||
|
|||||||
+107
-27
@@ -14,6 +14,7 @@ import 'package:spotiflac_android/utils/file_access.dart';
|
|||||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||||
import 'package:spotiflac_android/widgets/track_collection_quick_actions.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/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/providers/library_collections_provider.dart';
|
||||||
import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart';
|
import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart';
|
||||||
import 'package:spotiflac_android/utils/clickable_metadata.dart';
|
import 'package:spotiflac_android/utils/clickable_metadata.dart';
|
||||||
@@ -173,42 +174,107 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
Future<void> _fetchTracks() async {
|
Future<void> _fetchTracks() async {
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
try {
|
try {
|
||||||
Map<String, dynamic> metadata;
|
|
||||||
|
|
||||||
if (widget.albumId.startsWith('deezer:')) {
|
if (widget.albumId.startsWith('deezer:')) {
|
||||||
final deezerAlbumId = widget.albumId.replaceFirst('deezer:', '');
|
final deezerAlbumId = widget.albumId.replaceFirst('deezer:', '');
|
||||||
metadata = await PlatformBridge.getDeezerMetadata(
|
final metadata = await PlatformBridge.getDeezerMetadata(
|
||||||
'album',
|
'album',
|
||||||
deezerAlbumId,
|
deezerAlbumId,
|
||||||
);
|
);
|
||||||
|
final trackList = metadata['track_list'] as List<dynamic>;
|
||||||
|
final tracks = trackList
|
||||||
|
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
final albumInfo = metadata['album_info'] as Map<String, dynamic>?;
|
||||||
|
final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId'])
|
||||||
|
?.toString();
|
||||||
|
|
||||||
|
_AlbumCache.set(widget.albumId, tracks);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_tracks = tracks;
|
||||||
|
_artistId = artistId;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
} else if (widget.albumId.startsWith('qobuz:')) {
|
} else if (widget.albumId.startsWith('qobuz:')) {
|
||||||
final qobuzAlbumId = widget.albumId.replaceFirst('qobuz:', '');
|
final qobuzAlbumId = widget.albumId.replaceFirst('qobuz:', '');
|
||||||
metadata = await PlatformBridge.getQobuzMetadata('album', qobuzAlbumId);
|
final metadata = await PlatformBridge.getQobuzMetadata(
|
||||||
|
'album',
|
||||||
|
qobuzAlbumId,
|
||||||
|
);
|
||||||
|
final trackList = metadata['track_list'] as List<dynamic>;
|
||||||
|
final tracks = trackList
|
||||||
|
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
final albumInfo = metadata['album_info'] as Map<String, dynamic>?;
|
||||||
|
final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId'])
|
||||||
|
?.toString();
|
||||||
|
|
||||||
|
_AlbumCache.set(widget.albumId, tracks);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_tracks = tracks;
|
||||||
|
_artistId = artistId;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
} else if (widget.albumId.startsWith('tidal:')) {
|
} else if (widget.albumId.startsWith('tidal:')) {
|
||||||
final tidalAlbumId = widget.albumId.replaceFirst('tidal:', '');
|
final tidalAlbumId = widget.albumId.replaceFirst('tidal:', '');
|
||||||
metadata = await PlatformBridge.getTidalMetadata('album', tidalAlbumId);
|
final metadata = await PlatformBridge.getTidalMetadata(
|
||||||
|
'album',
|
||||||
|
tidalAlbumId,
|
||||||
|
);
|
||||||
|
final trackList = metadata['track_list'] as List<dynamic>;
|
||||||
|
final tracks = trackList
|
||||||
|
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
final albumInfo = metadata['album_info'] as Map<String, dynamic>?;
|
||||||
|
final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId'])
|
||||||
|
?.toString();
|
||||||
|
|
||||||
|
_AlbumCache.set(widget.albumId, tracks);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_tracks = tracks;
|
||||||
|
_artistId = artistId;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
} else {
|
} else {
|
||||||
final url = 'https://open.spotify.com/album/${widget.albumId}';
|
final url = 'https://open.spotify.com/album/${widget.albumId}';
|
||||||
metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
|
final result = await PlatformBridge.handleURLWithExtension(url);
|
||||||
}
|
if (result == null || result['tracks'] == null) {
|
||||||
|
throw StateError('Failed to load album metadata from extension');
|
||||||
|
}
|
||||||
|
|
||||||
final trackList = metadata['track_list'] as List<dynamic>;
|
final trackList = result['tracks'] as List<dynamic>;
|
||||||
final tracks = trackList
|
final tracks = trackList
|
||||||
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
final albumInfo = metadata['album_info'] as Map<String, dynamic>?;
|
final albumInfo = result['album'] as Map<String, dynamic>?;
|
||||||
final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId'])
|
final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId'])
|
||||||
?.toString();
|
?.toString();
|
||||||
|
|
||||||
_AlbumCache.set(widget.albumId, tracks);
|
_AlbumCache.set(widget.albumId, tracks);
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_tracks = tracks;
|
_tracks = tracks;
|
||||||
_artistId = artistId;
|
_artistId = artistId;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -241,6 +307,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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
@@ -257,8 +333,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
if (_isLoading)
|
if (_isLoading)
|
||||||
const SliverToBoxAdapter(
|
const SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.all(32),
|
padding: EdgeInsets.all(16),
|
||||||
child: Center(child: CircularProgressIndicator()),
|
child: AlbumTrackListSkeleton(itemCount: 10),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (_error != null)
|
if (_error != null)
|
||||||
@@ -534,9 +610,12 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
final track = tracks[index];
|
final track = tracks[index];
|
||||||
return KeyedSubtree(
|
return KeyedSubtree(
|
||||||
key: ValueKey(track.id),
|
key: ValueKey(track.id),
|
||||||
child: _AlbumTrackItem(
|
child: StaggeredListItem(
|
||||||
track: track,
|
index: index,
|
||||||
onDownload: () => _downloadTrack(context, track),
|
child: _AlbumTrackItem(
|
||||||
|
track: track,
|
||||||
|
onDownload: () => _downloadTrack(context, track),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}, childCount: tracks.length),
|
}, childCount: tracks.length),
|
||||||
@@ -551,6 +630,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
trackName: track.name,
|
trackName: track.name,
|
||||||
artistName: track.artistName,
|
artistName: track.artistName,
|
||||||
coverUrl: track.coverUrl,
|
coverUrl: track.coverUrl,
|
||||||
|
recommendedService: _recommendedDownloadService(),
|
||||||
onSelect: (quality, service) {
|
onSelect: (quality, service) {
|
||||||
ref
|
ref
|
||||||
.read(downloadQueueProvider.notifier)
|
.read(downloadQueueProvider.notifier)
|
||||||
@@ -576,7 +656,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
final tracks = _tracks;
|
final tracks = _tracks;
|
||||||
if (tracks == null || tracks.isEmpty) return;
|
if (tracks == null || tracks.isEmpty) return;
|
||||||
|
|
||||||
// Skip already-downloaded tracks
|
|
||||||
final historyState = ref.read(downloadHistoryProvider);
|
final historyState = ref.read(downloadHistoryProvider);
|
||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
final localLibState =
|
final localLibState =
|
||||||
@@ -623,6 +702,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
context,
|
context,
|
||||||
trackName: '${tracksToQueue.length} tracks',
|
trackName: '${tracksToQueue.length} tracks',
|
||||||
artistName: widget.albumName,
|
artistName: widget.albumName,
|
||||||
|
recommendedService: _recommendedDownloadService(),
|
||||||
onSelect: (quality, service) {
|
onSelect: (quality, service) {
|
||||||
ref
|
ref
|
||||||
.read(downloadQueueProvider.notifier)
|
.read(downloadQueueProvider.notifier)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import 'package:spotiflac_android/screens/home_tab.dart'
|
|||||||
show ExtensionAlbumScreen;
|
show ExtensionAlbumScreen;
|
||||||
import 'package:spotiflac_android/widgets/download_service_picker.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/track_collection_quick_actions.dart';
|
||||||
|
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||||
import 'package:spotiflac_android/utils/clickable_metadata.dart';
|
import 'package:spotiflac_android/utils/clickable_metadata.dart';
|
||||||
|
|
||||||
class _ArtistCache {
|
class _ArtistCache {
|
||||||
@@ -152,6 +153,16 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
return tileSize + 64 + ((textScale - 1) * 14);
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -332,13 +343,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
headerImage = artistData['header_image'] as String?;
|
headerImage = artistData['header_image'] as String?;
|
||||||
listeners = artistData['listeners'] as int?;
|
listeners = artistData['listeners'] as int?;
|
||||||
} else {
|
} else {
|
||||||
final metadata = await PlatformBridge.getSpotifyMetadataWithFallback(
|
throw StateError('Failed to load artist metadata from extension');
|
||||||
url,
|
|
||||||
);
|
|
||||||
final albumsList = metadata['albums'] as List<dynamic>;
|
|
||||||
albums = albumsList
|
|
||||||
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
|
|
||||||
.toList();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -481,10 +486,17 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
hasDiscography: hasDiscography,
|
hasDiscography: hasDiscography,
|
||||||
),
|
),
|
||||||
if (_isLoadingDiscography)
|
if (_isLoadingDiscography)
|
||||||
const SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: ArtistScreenSkeleton(
|
||||||
padding: EdgeInsets.all(32),
|
showCoverHeader:
|
||||||
child: Center(child: CircularProgressIndicator()),
|
(_headerImageUrl ??
|
||||||
|
widget.headerImageUrl ??
|
||||||
|
widget.coverUrl) ==
|
||||||
|
null,
|
||||||
|
showPopularSection:
|
||||||
|
!widget.artistId.startsWith('deezer:') &&
|
||||||
|
!widget.artistId.startsWith('qobuz:') &&
|
||||||
|
!widget.artistId.startsWith('tidal:'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (_error != null)
|
if (_error != null)
|
||||||
@@ -787,7 +799,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
);
|
);
|
||||||
final singleTracks = singles.fold<int>(0, (sum, a) => sum + a.totalTracks);
|
final singleTracks = singles.fold<int>(0, (sum, a) => sum + a.totalTracks);
|
||||||
|
|
||||||
showModalBottomSheet(
|
showModalBottomSheet<void>(
|
||||||
context: context,
|
context: context,
|
||||||
useRootNavigator: true,
|
useRootNavigator: true,
|
||||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||||
@@ -889,6 +901,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
if (settings.askQualityBeforeDownload) {
|
if (settings.askQualityBeforeDownload) {
|
||||||
DownloadServicePicker.show(
|
DownloadServicePicker.show(
|
||||||
context,
|
context,
|
||||||
|
recommendedService: _recommendedDownloadService(),
|
||||||
onSelect: (quality, service) {
|
onSelect: (quality, service) {
|
||||||
_fetchAndQueueAlbums(albums, service, quality);
|
_fetchAndQueueAlbums(albums, service, quality);
|
||||||
},
|
},
|
||||||
@@ -920,7 +933,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
showDialog(
|
showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
builder: (ctx) => _FetchingProgressDialog(
|
builder: (ctx) => _FetchingProgressDialog(
|
||||||
@@ -948,7 +961,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
|
|
||||||
fetchedCount++;
|
fetchedCount++;
|
||||||
|
|
||||||
// Update progress dialog
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
_FetchingProgressDialog.updateProgress(
|
_FetchingProgressDialog.updateProgress(
|
||||||
context,
|
context,
|
||||||
@@ -979,7 +991,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check which tracks are already downloaded
|
|
||||||
final historyState = ref.read(downloadHistoryProvider);
|
final historyState = ref.read(downloadHistoryProvider);
|
||||||
final tracksToQueue = <Track>[];
|
final tracksToQueue = <Track>[];
|
||||||
int skippedCount = 0;
|
int skippedCount = 0;
|
||||||
@@ -1030,10 +1041,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
content: Text(message),
|
content: Text(message),
|
||||||
action: SnackBarAction(
|
action: SnackBarAction(
|
||||||
label: context.l10n.snackbarViewQueue,
|
label: context.l10n.snackbarViewQueue,
|
||||||
onPressed: () {
|
onPressed: () {},
|
||||||
// Navigate to queue tab (index 1)
|
|
||||||
// This will be handled by the navigation system
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -1091,15 +1099,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
.map((t) => _parseTrack(t as Map<String, dynamic>, album: album))
|
.map((t) => _parseTrack(t as Map<String, dynamic>, album: album))
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to direct Spotify metadata
|
|
||||||
final metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
|
|
||||||
if (metadata['tracks'] != null) {
|
|
||||||
final tracksList = metadata['tracks'] as List<dynamic>;
|
|
||||||
return tracksList
|
|
||||||
.map((t) => _parseTrack(t as Map<String, dynamic>, album: album))
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -1107,6 +1106,10 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
Track _parseTrackFromDeezer(Map<String, dynamic> data, ArtistAlbum album) {
|
Track _parseTrackFromDeezer(Map<String, dynamic> data, ArtistAlbum album) {
|
||||||
int durationMs = 0;
|
int durationMs = 0;
|
||||||
final durationValue = data['duration'];
|
final durationValue = data['duration'];
|
||||||
|
final artistData = data['artist'];
|
||||||
|
final artistName = artistData is Map<String, dynamic>
|
||||||
|
? (artistData['name'] as String? ?? widget.artistName)
|
||||||
|
: (artistData?.toString() ?? widget.artistName);
|
||||||
if (durationValue is int) {
|
if (durationValue is int) {
|
||||||
durationMs = durationValue * 1000; // Deezer returns seconds
|
durationMs = durationValue * 1000; // Deezer returns seconds
|
||||||
} else if (durationValue is double) {
|
} else if (durationValue is double) {
|
||||||
@@ -1116,9 +1119,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
return Track(
|
return Track(
|
||||||
id: 'deezer:${data['id']}',
|
id: 'deezer:${data['id']}',
|
||||||
name: (data['title'] ?? data['name'] ?? '').toString(),
|
name: (data['title'] ?? data['name'] ?? '').toString(),
|
||||||
artistName:
|
artistName: artistName,
|
||||||
(data['artist']?['name'] ?? data['artist'] ?? widget.artistName)
|
|
||||||
.toString(),
|
|
||||||
albumName: album.name,
|
albumName: album.name,
|
||||||
albumArtist: widget.artistName,
|
albumArtist: widget.artistName,
|
||||||
artistId: widget.artistId,
|
artistId: widget.artistId,
|
||||||
@@ -1154,6 +1155,8 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
imageUrl.isNotEmpty &&
|
imageUrl.isNotEmpty &&
|
||||||
Uri.tryParse(imageUrl)?.hasAuthority == true;
|
Uri.tryParse(imageUrl)?.hasAuthority == true;
|
||||||
|
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
String? listenersText;
|
String? listenersText;
|
||||||
final listeners = _monthlyListeners ?? widget.monthlyListeners;
|
final listeners = _monthlyListeners ?? widget.monthlyListeners;
|
||||||
if (listeners != null && listeners > 0) {
|
if (listeners != null && listeners > 0) {
|
||||||
@@ -1224,7 +1227,9 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
Colors.transparent,
|
Colors.transparent,
|
||||||
Colors.black.withValues(alpha: 0.3),
|
Colors.black.withValues(alpha: 0.3),
|
||||||
Colors.black.withValues(alpha: 0.7),
|
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],
|
stops: const [0.0, 0.5, 0.75, 1.0],
|
||||||
),
|
),
|
||||||
@@ -1265,7 +1270,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
listenersText,
|
listenersText,
|
||||||
style: Theme.of(context).textTheme.bodyMedium
|
style: Theme.of(context).textTheme.bodyMedium
|
||||||
?.copyWith(
|
?.copyWith(
|
||||||
color: Colors.white.withValues(alpha: 0.8),
|
color: Colors.white,
|
||||||
shadows: [
|
shadows: [
|
||||||
Shadow(
|
Shadow(
|
||||||
offset: const Offset(0, 1),
|
offset: const Offset(0, 1),
|
||||||
@@ -1689,6 +1694,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
if (settings.askQualityBeforeDownload) {
|
if (settings.askQualityBeforeDownload) {
|
||||||
DownloadServicePicker.show(
|
DownloadServicePicker.show(
|
||||||
context,
|
context,
|
||||||
|
recommendedService: _recommendedDownloadService(),
|
||||||
onSelect: (quality, service) {
|
onSelect: (quality, service) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
enqueue(service, quality: quality);
|
enqueue(service, quality: quality);
|
||||||
@@ -1839,29 +1845,14 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
Positioned(
|
Positioned(
|
||||||
top: 8,
|
top: 8,
|
||||||
right: 8,
|
right: 8,
|
||||||
child: AnimatedContainer(
|
child: AnimatedSelectionCheckbox(
|
||||||
duration: const Duration(milliseconds: 200),
|
visible: true,
|
||||||
width: 28,
|
selected: isSelected,
|
||||||
height: 28,
|
colorScheme: colorScheme,
|
||||||
decoration: BoxDecoration(
|
size: 28,
|
||||||
color: isSelected
|
unselectedColor: colorScheme.surface.withValues(
|
||||||
? colorScheme.primary
|
alpha: 0.9,
|
||||||
: colorScheme.surface.withValues(alpha: 0.9),
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
border: Border.all(
|
|
||||||
color: isSelected
|
|
||||||
? colorScheme.primary
|
|
||||||
: colorScheme.outline,
|
|
||||||
width: 2,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
child: isSelected
|
|
||||||
? Icon(
|
|
||||||
Icons.check,
|
|
||||||
color: colorScheme.onPrimary,
|
|
||||||
size: 18,
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (showTypeBadge)
|
if (showTypeBadge)
|
||||||
@@ -1934,7 +1925,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
if (album.providerId != null && album.providerId!.isNotEmpty) {
|
if (album.providerId != null && album.providerId!.isNotEmpty) {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute<void>(
|
||||||
builder: (context) => ExtensionAlbumScreen(
|
builder: (context) => ExtensionAlbumScreen(
|
||||||
extensionId: album.providerId!,
|
extensionId: album.providerId!,
|
||||||
albumId: album.id,
|
albumId: album.id,
|
||||||
@@ -1946,7 +1937,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
} else {
|
} else {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute<void>(
|
||||||
builder: (context) => AlbumScreen(
|
builder: (context) => AlbumScreen(
|
||||||
albumId: album.id,
|
albumId: album.id,
|
||||||
albumName: album.name,
|
albumName: album.name,
|
||||||
@@ -2070,7 +2061,6 @@ class _FetchingProgressDialog extends StatefulWidget {
|
|||||||
required this.onCancel,
|
required this.onCancel,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Static method to update progress from outside
|
|
||||||
static void updateProgress(BuildContext context, int current, int total) {
|
static void updateProgress(BuildContext context, int current, int total) {
|
||||||
final state = context
|
final state = context
|
||||||
.findAncestorStateOfType<_FetchingProgressDialogState>();
|
.findAncestorStateOfType<_FetchingProgressDialogState>();
|
||||||
@@ -2143,7 +2133,6 @@ class _FetchingProgressDialogState extends State<_FetchingProgressDialog> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
// Progress bar
|
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
child: LinearProgressIndicator(
|
child: LinearProgressIndicator(
|
||||||
|
|||||||
@@ -13,10 +13,12 @@ import 'package:spotiflac_android/l10n/l10n.dart';
|
|||||||
import 'package:spotiflac_android/utils/file_access.dart';
|
import 'package:spotiflac_android/utils/file_access.dart';
|
||||||
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
|
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
|
||||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
|
import 'package:spotiflac_android/widgets/batch_progress_dialog.dart';
|
||||||
import 'package:spotiflac_android/providers/playback_provider.dart';
|
import 'package:spotiflac_android/providers/playback_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
||||||
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
|
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
|
||||||
|
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||||
|
|
||||||
class DownloadedAlbumScreen extends ConsumerStatefulWidget {
|
class DownloadedAlbumScreen extends ConsumerStatefulWidget {
|
||||||
final String albumName;
|
final String albumName;
|
||||||
@@ -120,7 +122,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
|
|
||||||
final tracks =
|
final tracks =
|
||||||
allItems.where((item) {
|
allItems.where((item) {
|
||||||
// Use albumArtist if available and not empty, otherwise artistName
|
|
||||||
final itemArtist =
|
final itemArtist =
|
||||||
(item.albumArtist != null && item.albumArtist!.isNotEmpty)
|
(item.albumArtist != null && item.albumArtist!.isNotEmpty)
|
||||||
? item.albumArtist!
|
? item.albumArtist!
|
||||||
@@ -129,7 +130,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
'${item.albumName.toLowerCase()}|${itemArtist.toLowerCase()}';
|
'${item.albumName.toLowerCase()}|${itemArtist.toLowerCase()}';
|
||||||
return itemKey == _albumLookupKey;
|
return itemKey == _albumLookupKey;
|
||||||
}).toList()..sort((a, b) {
|
}).toList()..sort((a, b) {
|
||||||
// Sort by disc number first, then by track number
|
|
||||||
final aDisc = a.discNumber ?? 1;
|
final aDisc = a.discNumber ?? 1;
|
||||||
final bDisc = b.discNumber ?? 1;
|
final bDisc = b.discNumber ?? 1;
|
||||||
if (aDisc != bDisc) return aDisc.compareTo(bDisc);
|
if (aDisc != bDisc) return aDisc.compareTo(bDisc);
|
||||||
@@ -310,14 +310,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
final result = await navigator.push(
|
final result = await navigator.push(
|
||||||
PageRouteBuilder(
|
slidePageRoute<bool>(page: TrackMetadataScreen(item: item)),
|
||||||
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),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath(
|
await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath(
|
||||||
item.filePath,
|
item.filePath,
|
||||||
@@ -693,7 +686,10 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
final track = tracks[index];
|
final track = tracks[index];
|
||||||
return KeyedSubtree(
|
return KeyedSubtree(
|
||||||
key: ValueKey(track.id),
|
key: ValueKey(track.id),
|
||||||
child: _buildTrackItem(context, colorScheme, track),
|
child: StaggeredListItem(
|
||||||
|
index: index,
|
||||||
|
child: _buildTrackItem(context, colorScheme, track),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}, childCount: tracks.length),
|
}, childCount: tracks.length),
|
||||||
);
|
);
|
||||||
@@ -701,6 +697,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
|
|
||||||
final discNumbers = _getSortedDiscNumbers(tracks);
|
final discNumbers = _getSortedDiscNumbers(tracks);
|
||||||
final List<Widget> children = [];
|
final List<Widget> children = [];
|
||||||
|
var revealIndex = 0;
|
||||||
|
|
||||||
for (final discNumber in discNumbers) {
|
for (final discNumber in discNumbers) {
|
||||||
final discTracks = discMap[discNumber];
|
final discTracks = discMap[discNumber];
|
||||||
@@ -712,7 +709,10 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
children.add(
|
children.add(
|
||||||
KeyedSubtree(
|
KeyedSubtree(
|
||||||
key: ValueKey(track.id),
|
key: ValueKey(track.id),
|
||||||
child: _buildTrackItem(context, colorScheme, track),
|
child: StaggeredListItem(
|
||||||
|
index: revealIndex++,
|
||||||
|
child: _buildTrackItem(context, colorScheme, track),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -796,28 +796,11 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
if (_isSelectionMode) ...[
|
if (_isSelectionMode) ...[
|
||||||
Container(
|
AnimatedSelectionCheckbox(
|
||||||
width: 24,
|
visible: true,
|
||||||
height: 24,
|
selected: isSelected,
|
||||||
decoration: BoxDecoration(
|
colorScheme: colorScheme,
|
||||||
color: isSelected
|
size: 24,
|
||||||
? colorScheme.primary
|
|
||||||
: Colors.transparent,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
border: Border.all(
|
|
||||||
color: isSelected
|
|
||||||
? colorScheme.primary
|
|
||||||
: colorScheme.outline,
|
|
||||||
width: 2,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: isSelected
|
|
||||||
? Icon(
|
|
||||||
Icons.check,
|
|
||||||
color: colorScheme.onPrimary,
|
|
||||||
size: 16,
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
],
|
],
|
||||||
@@ -950,7 +933,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
? '320k'
|
? '320k'
|
||||||
: (selectedFormat == 'Opus' ? '128k' : '320k');
|
: (selectedFormat == 'Opus' ? '128k' : '320k');
|
||||||
|
|
||||||
showModalBottomSheet(
|
showModalBottomSheet<void>(
|
||||||
context: context,
|
context: context,
|
||||||
useRootNavigator: true,
|
useRootNavigator: true,
|
||||||
shape: const RoundedRectangleBorder(
|
shape: const RoundedRectangleBorder(
|
||||||
@@ -1123,7 +1106,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
? 'Opus'
|
? 'Opus'
|
||||||
: null;
|
: null;
|
||||||
if (ext == null || ext == targetFormat) continue;
|
if (ext == null || ext == targetFormat) continue;
|
||||||
// Skip lossy sources when target is lossless (pointless re-encoding)
|
|
||||||
final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC';
|
final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC';
|
||||||
final isLosslessSource = ext == 'FLAC' || ext == 'M4A';
|
final isLosslessSource = ext == 'FLAC' || ext == 'M4A';
|
||||||
if (isLosslessTarget && !isLosslessSource) continue;
|
if (isLosslessTarget && !isLosslessSource) continue;
|
||||||
@@ -1183,19 +1165,23 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
final shouldEmbedLyrics =
|
final shouldEmbedLyrics =
|
||||||
settings.embedLyrics && settings.lyricsMode != 'external';
|
settings.embedLyrics && settings.lyricsMode != 'external';
|
||||||
|
|
||||||
|
var cancelled = false;
|
||||||
|
BatchProgressDialog.show(
|
||||||
|
context: context,
|
||||||
|
title: context.l10n.trackConvertConverting,
|
||||||
|
total: total,
|
||||||
|
icon: Icons.transform,
|
||||||
|
onCancel: () {
|
||||||
|
cancelled = true;
|
||||||
|
BatchProgressDialog.dismiss(context);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
for (int i = 0; i < total; i++) {
|
for (int i = 0; i < total; i++) {
|
||||||
if (!mounted) break;
|
if (!mounted || cancelled) break;
|
||||||
final item = selected[i];
|
final item = selected[i];
|
||||||
|
|
||||||
ScaffoldMessenger.of(context).clearSnackBars();
|
BatchProgressDialog.update(current: i + 1, detail: item.trackName);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(
|
|
||||||
context.l10n.selectionBatchConvertProgress(i + 1, total),
|
|
||||||
),
|
|
||||||
duration: const Duration(seconds: 30),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final metadata = <String, String>{
|
final metadata = <String, String>{
|
||||||
@@ -1354,6 +1340,9 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
_exitSelectionMode();
|
_exitSelectionMode();
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
if (!cancelled) {
|
||||||
|
BatchProgressDialog.dismiss(context);
|
||||||
|
}
|
||||||
ScaffoldMessenger.of(context).clearSnackBars();
|
ScaffoldMessenger.of(context).clearSnackBars();
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
|
|||||||
+409
-141
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@ import 'package:spotiflac_android/l10n/l10n.dart';
|
|||||||
import 'package:spotiflac_android/providers/library_collections_provider.dart';
|
import 'package:spotiflac_android/providers/library_collections_provider.dart';
|
||||||
import 'package:spotiflac_android/screens/library_tracks_folder_screen.dart';
|
import 'package:spotiflac_android/screens/library_tracks_folder_screen.dart';
|
||||||
import 'package:spotiflac_android/services/cover_cache_manager.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';
|
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||||
|
|
||||||
class LibraryPlaylistsScreen extends ConsumerWidget {
|
class LibraryPlaylistsScreen extends ConsumerWidget {
|
||||||
@@ -118,7 +119,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute<void>(
|
||||||
builder: (_) => LibraryTracksFolderScreen(
|
builder: (_) => LibraryTracksFolderScreen(
|
||||||
mode: LibraryTracksFolderMode.playlist,
|
mode: LibraryTracksFolderMode.playlist,
|
||||||
playlistId: playlist.id,
|
playlistId: playlist.id,
|
||||||
@@ -148,7 +149,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
|
|||||||
) {
|
) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
showModalBottomSheet(
|
showModalBottomSheet<void>(
|
||||||
context: context,
|
context: context,
|
||||||
useRootNavigator: true,
|
useRootNavigator: true,
|
||||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||||
@@ -210,7 +211,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
|
|||||||
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||||
),
|
),
|
||||||
|
|
||||||
_PlaylistOptionTile(
|
BottomSheetOptionTile(
|
||||||
icon: Icons.edit_outlined,
|
icon: Icons.edit_outlined,
|
||||||
title: context.l10n.collectionRenamePlaylist,
|
title: context.l10n.collectionRenamePlaylist,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
@@ -224,7 +225,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
_PlaylistOptionTile(
|
BottomSheetOptionTile(
|
||||||
icon: Icons.image_outlined,
|
icon: Icons.image_outlined,
|
||||||
title: context.l10n.collectionPlaylistChangeCover,
|
title: context.l10n.collectionPlaylistChangeCover,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
@@ -233,7 +234,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
_PlaylistOptionTile(
|
BottomSheetOptionTile(
|
||||||
icon: Icons.delete_outline,
|
icon: Icons.delete_outline,
|
||||||
iconColor: colorScheme.error,
|
iconColor: colorScheme.error,
|
||||||
title: context.l10n.collectionDeletePlaylist,
|
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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -9,13 +9,16 @@ import 'package:spotiflac_android/l10n/l10n.dart';
|
|||||||
import 'package:spotiflac_android/models/track.dart';
|
import 'package:spotiflac_android/models/track.dart';
|
||||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/library_collections_provider.dart';
|
import 'package:spotiflac_android/providers/library_collections_provider.dart';
|
||||||
|
import 'package:spotiflac_android/providers/playback_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||||
import 'package:spotiflac_android/services/library_database.dart';
|
import 'package:spotiflac_android/services/library_database.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||||
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
||||||
import 'package:spotiflac_android/widgets/download_service_picker.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/playlist_picker_sheet.dart';
|
||||||
|
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||||
|
|
||||||
class LibraryTracksFolderScreen extends ConsumerStatefulWidget {
|
class LibraryTracksFolderScreen extends ConsumerStatefulWidget {
|
||||||
final LibraryTracksFolderMode mode;
|
final LibraryTracksFolderMode mode;
|
||||||
@@ -272,7 +275,6 @@ class _LibraryTracksFolderScreenState
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stale selection cleanup
|
|
||||||
if (_isSelectionMode) {
|
if (_isSelectionMode) {
|
||||||
final validKeys = entries.map((e) => e.key).toSet();
|
final validKeys = entries.map((e) => e.key).toSet();
|
||||||
_selectedKeys.removeWhere((key) => !validKeys.contains(key));
|
_selectedKeys.removeWhere((key) => !validKeys.contains(key));
|
||||||
@@ -348,20 +350,23 @@ class _LibraryTracksFolderScreenState
|
|||||||
final isSelected = _selectedKeys.contains(entry.key);
|
final isSelected = _selectedKeys.contains(entry.key);
|
||||||
return KeyedSubtree(
|
return KeyedSubtree(
|
||||||
key: ValueKey(entry.key),
|
key: ValueKey(entry.key),
|
||||||
child: _CollectionTrackTile(
|
child: StaggeredListItem(
|
||||||
entry: entry,
|
index: index,
|
||||||
mode: widget.mode,
|
child: _CollectionTrackTile(
|
||||||
playlistId: widget.playlistId,
|
entry: entry,
|
||||||
localLibraryState: localState,
|
mode: widget.mode,
|
||||||
folderTracks: folderTracks,
|
playlistId: widget.playlistId,
|
||||||
isSelectionMode: _isSelectionMode,
|
localLibraryState: localState,
|
||||||
isSelected: isSelected,
|
folderTracks: folderTracks,
|
||||||
onTap: _isSelectionMode
|
isSelectionMode: _isSelectionMode,
|
||||||
? () => _toggleSelection(entry.key)
|
isSelected: isSelected,
|
||||||
: null,
|
onTap: _isSelectionMode
|
||||||
onLongPress: _isSelectionMode
|
? () => _toggleSelection(entry.key)
|
||||||
? null
|
: null,
|
||||||
: () => _enterSelectionMode(entry.key),
|
onLongPress: _isSelectionMode
|
||||||
|
? null
|
||||||
|
: () => _enterSelectionMode(entry.key),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}, childCount: entries.length),
|
}, childCount: entries.length),
|
||||||
@@ -372,7 +377,6 @@ class _LibraryTracksFolderScreenState
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
// Selection bottom bar
|
|
||||||
AnimatedPositioned(
|
AnimatedPositioned(
|
||||||
duration: const Duration(milliseconds: 250),
|
duration: const Duration(milliseconds: 250),
|
||||||
curve: Curves.easeOutCubic,
|
curve: Curves.easeOutCubic,
|
||||||
@@ -844,7 +848,7 @@ class _LibraryTracksFolderScreenState
|
|||||||
|
|
||||||
void _confirmDownloadAll(List<Track> tracks) {
|
void _confirmDownloadAll(List<Track> tracks) {
|
||||||
if (tracks.isEmpty) return;
|
if (tracks.isEmpty) return;
|
||||||
showDialog(
|
showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogContext) {
|
builder: (dialogContext) {
|
||||||
final colorScheme = Theme.of(dialogContext).colorScheme;
|
final colorScheme = Theme.of(dialogContext).colorScheme;
|
||||||
@@ -977,7 +981,7 @@ class _LibraryTracksFolderScreenState
|
|||||||
void _showCoverOptionsSheet(BuildContext context, bool hasCustomCover) {
|
void _showCoverOptionsSheet(BuildContext context, bool hasCustomCover) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
showModalBottomSheet(
|
showModalBottomSheet<void>(
|
||||||
context: context,
|
context: context,
|
||||||
useRootNavigator: true,
|
useRootNavigator: true,
|
||||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||||
@@ -1081,14 +1085,19 @@ class _CollectionTrackTile extends ConsumerWidget {
|
|||||||
final track = entry.track;
|
final track = entry.track;
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final effectiveCoverUrl = _resolveCoverUrl(track);
|
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) {
|
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();
|
final isrc = track.isrc?.trim();
|
||||||
if (isrc != null && isrc.isNotEmpty && state.getByIsrc(isrc) != null) {
|
if (isrc != null && isrc.isNotEmpty) {
|
||||||
return true;
|
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(
|
final showLocalLibraryIndicator = ref.watch(
|
||||||
@@ -1096,17 +1105,26 @@ class _CollectionTrackTile extends ConsumerWidget {
|
|||||||
(s) => s.localLibraryEnabled && s.localLibraryShowDuplicates,
|
(s) => s.localLibraryEnabled && s.localLibraryShowDuplicates,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
final isInLocalLibrary = showLocalLibraryIndicator
|
final localItem = showLocalLibraryIndicator
|
||||||
? ref.watch(
|
? ref.watch(
|
||||||
localLibraryProvider.select(
|
localLibraryProvider.select((state) {
|
||||||
(state) => state.existsInLibrary(
|
final isrc = track.isrc?.trim();
|
||||||
isrc: track.isrc,
|
if (isrc != null && isrc.isNotEmpty) {
|
||||||
trackName: track.name,
|
final byIsrc = state.getByIsrc(isrc);
|
||||||
artistName: track.artistName,
|
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(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
@@ -1124,43 +1142,51 @@ class _CollectionTrackTile extends ConsumerWidget {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
if (isSelectionMode) ...[
|
if (isSelectionMode) ...[
|
||||||
Container(
|
AnimatedSelectionCheckbox(
|
||||||
width: 24,
|
visible: true,
|
||||||
height: 24,
|
selected: isSelected,
|
||||||
decoration: BoxDecoration(
|
colorScheme: colorScheme,
|
||||||
color: isSelected
|
size: 24,
|
||||||
? colorScheme.primary
|
|
||||||
: Colors.transparent,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
border: Border.all(
|
|
||||||
color: isSelected
|
|
||||||
? colorScheme.primary
|
|
||||||
: colorScheme.outline,
|
|
||||||
width: 2,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: isSelected
|
|
||||||
? Icon(
|
|
||||||
Icons.check,
|
|
||||||
color: colorScheme.onPrimary,
|
|
||||||
size: 16,
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
],
|
],
|
||||||
ClipRRect(
|
HeroMode(
|
||||||
borderRadius: BorderRadius.circular(8),
|
enabled: heroTag != null,
|
||||||
child: effectiveCoverUrl != null && effectiveCoverUrl.isNotEmpty
|
child: heroTag != null
|
||||||
? _buildTrackCover(context, effectiveCoverUrl, 52)
|
? Hero(
|
||||||
: Container(
|
tag: heroTag,
|
||||||
width: 52,
|
child: ClipRRect(
|
||||||
height: 52,
|
borderRadius: BorderRadius.circular(8),
|
||||||
color: colorScheme.surfaceContainerHighest,
|
child:
|
||||||
child: Icon(
|
effectiveCoverUrl != null &&
|
||||||
Icons.music_note,
|
effectiveCoverUrl.isNotEmpty
|
||||||
color: colorScheme.onSurfaceVariant,
|
? _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,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -1211,15 +1237,24 @@ class _CollectionTrackTile extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
trailing: isSelectionMode
|
trailing: isSelectionMode
|
||||||
? null
|
? null
|
||||||
: IconButton(
|
: historyItem != null || localItem != null
|
||||||
tooltip: MaterialLocalizations.of(context).showMenuTooltip,
|
? IconButton(
|
||||||
icon: Icon(
|
tooltip: context.l10n.tooltipPlay,
|
||||||
Icons.more_vert,
|
onPressed: () {
|
||||||
color: colorScheme.onSurfaceVariant,
|
ref
|
||||||
size: 20,
|
.read(playbackProvider.notifier)
|
||||||
),
|
.playTrackList([track]);
|
||||||
onPressed: () => _showTrackOptionsSheet(context, ref),
|
},
|
||||||
),
|
icon: Icon(
|
||||||
|
Icons.play_arrow,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
),
|
||||||
|
style: IconButton.styleFrom(
|
||||||
|
backgroundColor: colorScheme.primaryContainer
|
||||||
|
.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
onTap: isSelectionMode
|
onTap: isSelectionMode
|
||||||
? onTap
|
? onTap
|
||||||
: () {
|
: () {
|
||||||
@@ -1313,7 +1348,7 @@ class _CollectionTrackTile extends ConsumerWidget {
|
|||||||
final showAddToPlaylist =
|
final showAddToPlaylist =
|
||||||
mode != LibraryTracksFolderMode.wishlist || isDownloaded;
|
mode != LibraryTracksFolderMode.wishlist || isDownloaded;
|
||||||
|
|
||||||
showModalBottomSheet(
|
showModalBottomSheet<void>(
|
||||||
context: context,
|
context: context,
|
||||||
useRootNavigator: true,
|
useRootNavigator: true,
|
||||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||||
@@ -1390,9 +1425,8 @@ class _CollectionTrackTile extends ConsumerWidget {
|
|||||||
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Add to playlist (hidden in wishlist unless already downloaded)
|
|
||||||
if (showAddToPlaylist)
|
if (showAddToPlaylist)
|
||||||
_CollectionOptionTile(
|
BottomSheetOptionTile(
|
||||||
icon: Icons.playlist_add,
|
icon: Icons.playlist_add,
|
||||||
title: context.l10n.collectionAddToPlaylist,
|
title: context.l10n.collectionAddToPlaylist,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
@@ -1401,8 +1435,7 @@ class _CollectionTrackTile extends ConsumerWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
// Remove from folder / playlist
|
BottomSheetOptionTile(
|
||||||
_CollectionOptionTile(
|
|
||||||
icon: Icons.remove_circle_outline,
|
icon: Icons.remove_circle_outline,
|
||||||
iconColor: colorScheme.error,
|
iconColor: colorScheme.error,
|
||||||
title: mode == LibraryTracksFolderMode.playlist
|
title: mode == LibraryTracksFolderMode.playlist
|
||||||
@@ -1501,14 +1534,7 @@ class _CollectionTrackTile extends ConsumerWidget {
|
|||||||
|
|
||||||
if (historyItem != null) {
|
if (historyItem != null) {
|
||||||
await Navigator.of(context).push(
|
await Navigator.of(context).push(
|
||||||
PageRouteBuilder(
|
slidePageRoute<void>(page: TrackMetadataScreen(item: historyItem)),
|
||||||
transitionDuration: const Duration(milliseconds: 300),
|
|
||||||
reverseTransitionDuration: const Duration(milliseconds: 250),
|
|
||||||
pageBuilder: (context, animation, secondaryAnimation) =>
|
|
||||||
TrackMetadataScreen(item: historyItem),
|
|
||||||
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
|
|
||||||
FadeTransition(opacity: animation, child: child),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1525,14 +1551,7 @@ class _CollectionTrackTile extends ConsumerWidget {
|
|||||||
|
|
||||||
if (localItem != null) {
|
if (localItem != null) {
|
||||||
await Navigator.of(context).push(
|
await Navigator.of(context).push(
|
||||||
PageRouteBuilder(
|
slidePageRoute<void>(page: TrackMetadataScreen(localItem: localItem)),
|
||||||
transitionDuration: const Duration(milliseconds: 300),
|
|
||||||
reverseTransitionDuration: const Duration(milliseconds: 250),
|
|
||||||
pageBuilder: (context, animation, secondaryAnimation) =>
|
|
||||||
TrackMetadataScreen(localItem: localItem),
|
|
||||||
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
|
|
||||||
FadeTransition(opacity: animation, child: child),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1542,43 +1561,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 {
|
class _SelectionActionButton extends StatelessWidget {
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final String label;
|
final String label;
|
||||||
|
|||||||
@@ -13,9 +13,11 @@ import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
|
|||||||
import 'package:spotiflac_android/services/library_database.dart';
|
import 'package:spotiflac_android/services/library_database.dart';
|
||||||
import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
||||||
import 'package:spotiflac_android/services/local_track_redownload_service.dart';
|
import 'package:spotiflac_android/services/local_track_redownload_service.dart';
|
||||||
|
import 'package:spotiflac_android/widgets/batch_progress_dialog.dart';
|
||||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||||
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/playback_provider.dart';
|
import 'package:spotiflac_android/providers/playback_provider.dart';
|
||||||
|
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||||
|
|
||||||
class LocalAlbumScreen extends ConsumerStatefulWidget {
|
class LocalAlbumScreen extends ConsumerStatefulWidget {
|
||||||
final String albumName;
|
final String albumName;
|
||||||
@@ -531,7 +533,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
if (tracks.isEmpty) return null;
|
if (tracks.isEmpty) return null;
|
||||||
final first = tracks.first;
|
final first = tracks.first;
|
||||||
|
|
||||||
// For lossy formats, use bitrate
|
|
||||||
if (first.bitrate != null && first.bitrate! > 0) {
|
if (first.bitrate != null && first.bitrate! > 0) {
|
||||||
final fmt = first.format?.toUpperCase() ?? '';
|
final fmt = first.format?.toUpperCase() ?? '';
|
||||||
final firstBitrate = first.bitrate;
|
final firstBitrate = first.bitrate;
|
||||||
@@ -543,7 +544,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
return '$fmt ${firstBitrate}kbps'.trim();
|
return '$fmt ${firstBitrate}kbps'.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
// For lossless formats, use bit depth / sample rate
|
|
||||||
if (first.bitDepth == null ||
|
if (first.bitDepth == null ||
|
||||||
first.bitDepth == 0 ||
|
first.bitDepth == 0 ||
|
||||||
first.sampleRate == null) {
|
first.sampleRate == null) {
|
||||||
@@ -630,7 +630,10 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
final track = discTracks[index];
|
final track = discTracks[index];
|
||||||
return KeyedSubtree(
|
return KeyedSubtree(
|
||||||
key: ValueKey(track.id),
|
key: ValueKey(track.id),
|
||||||
child: _buildTrackItem(context, colorScheme, track),
|
child: StaggeredListItem(
|
||||||
|
index: index,
|
||||||
|
child: _buildTrackItem(context, colorScheme, track),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}, childCount: discTracks.length),
|
}, childCount: discTracks.length),
|
||||||
),
|
),
|
||||||
@@ -669,28 +672,11 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
if (_isSelectionMode) ...[
|
if (_isSelectionMode) ...[
|
||||||
Container(
|
AnimatedSelectionCheckbox(
|
||||||
width: 24,
|
visible: true,
|
||||||
height: 24,
|
selected: isSelected,
|
||||||
decoration: BoxDecoration(
|
colorScheme: colorScheme,
|
||||||
color: isSelected
|
size: 24,
|
||||||
? colorScheme.primary
|
|
||||||
: Colors.transparent,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
border: Border.all(
|
|
||||||
color: isSelected
|
|
||||||
? colorScheme.primary
|
|
||||||
: colorScheme.outline,
|
|
||||||
width: 2,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: isSelected
|
|
||||||
? Icon(
|
|
||||||
Icons.check,
|
|
||||||
color: colorScheme.onPrimary,
|
|
||||||
size: 16,
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
],
|
],
|
||||||
@@ -972,16 +958,22 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
var skippedCount = 0;
|
var skippedCount = 0;
|
||||||
final total = selected.length;
|
final total = selected.length;
|
||||||
|
|
||||||
for (var i = 0; i < total; i++) {
|
var cancelled = false;
|
||||||
if (!mounted) break;
|
BatchProgressDialog.show(
|
||||||
|
context: context,
|
||||||
|
title: context.l10n.queueFlacAction,
|
||||||
|
total: total,
|
||||||
|
icon: Icons.queue_music,
|
||||||
|
onCancel: () {
|
||||||
|
cancelled = true;
|
||||||
|
BatchProgressDialog.dismiss(context);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
ScaffoldMessenger.of(context).clearSnackBars();
|
for (var i = 0; i < total; i++) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
if (!mounted || cancelled) break;
|
||||||
SnackBar(
|
|
||||||
content: Text(context.l10n.queueFlacFindingProgress(i + 1, total)),
|
BatchProgressDialog.update(current: i + 1, detail: selected[i].trackName);
|
||||||
duration: const Duration(seconds: 30),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final resolution = await LocalTrackRedownloadService.resolveBestMatch(
|
final resolution = await LocalTrackRedownloadService.resolveBestMatch(
|
||||||
@@ -1002,7 +994,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ScaffoldMessenger.of(context).clearSnackBars();
|
if (!cancelled) {
|
||||||
|
BatchProgressDialog.dismiss(context);
|
||||||
|
}
|
||||||
|
|
||||||
if (matchedTracks.isEmpty) {
|
if (matchedTracks.isEmpty) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@@ -1078,18 +1072,25 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
var successCount = 0;
|
var successCount = 0;
|
||||||
final total = selected.length;
|
final total = selected.length;
|
||||||
|
|
||||||
|
var cancelled = false;
|
||||||
|
BatchProgressDialog.show(
|
||||||
|
context: context,
|
||||||
|
title: context.l10n.trackReEnrichProgress,
|
||||||
|
total: total,
|
||||||
|
icon: Icons.auto_fix_high,
|
||||||
|
onCancel: () {
|
||||||
|
cancelled = true;
|
||||||
|
BatchProgressDialog.dismiss(context);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
for (var i = 0; i < total; i++) {
|
for (var i = 0; i < total; i++) {
|
||||||
if (!mounted) break;
|
if (!mounted || cancelled) break;
|
||||||
final item = selected[i];
|
final item = selected[i];
|
||||||
|
|
||||||
ScaffoldMessenger.of(context).clearSnackBars();
|
BatchProgressDialog.update(
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
current: i + 1,
|
||||||
SnackBar(
|
detail: '${item.trackName} - ${item.artistName}',
|
||||||
content: Text(
|
|
||||||
'${context.l10n.trackReEnrichProgress} (${i + 1}/$total)',
|
|
||||||
),
|
|
||||||
duration: const Duration(seconds: 30),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1129,6 +1130,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!cancelled) {
|
||||||
|
BatchProgressDialog.dismiss(context);
|
||||||
|
}
|
||||||
ScaffoldMessenger.of(context).clearSnackBars();
|
ScaffoldMessenger.of(context).clearSnackBars();
|
||||||
final failedCount = total - successCount;
|
final failedCount = total - successCount;
|
||||||
final summary = failedCount <= 0
|
final summary = failedCount <= 0
|
||||||
@@ -1195,7 +1199,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
? '320k'
|
? '320k'
|
||||||
: (selectedFormat == 'Opus' ? '128k' : '320k');
|
: (selectedFormat == 'Opus' ? '128k' : '320k');
|
||||||
|
|
||||||
showModalBottomSheet(
|
showModalBottomSheet<void>(
|
||||||
context: context,
|
context: context,
|
||||||
useRootNavigator: true,
|
useRootNavigator: true,
|
||||||
shape: const RoundedRectangleBorder(
|
shape: const RoundedRectangleBorder(
|
||||||
@@ -1382,7 +1386,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (currentFormat == null || currentFormat == targetFormat) continue;
|
if (currentFormat == null || currentFormat == targetFormat) continue;
|
||||||
// Skip lossy sources when target is lossless (pointless re-encoding)
|
|
||||||
final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC';
|
final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC';
|
||||||
final isLosslessSource =
|
final isLosslessSource =
|
||||||
currentFormat == 'FLAC' || currentFormat == 'M4A';
|
currentFormat == 'FLAC' || currentFormat == 'M4A';
|
||||||
@@ -1438,19 +1441,23 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
final shouldEmbedLyrics =
|
final shouldEmbedLyrics =
|
||||||
settings.embedLyrics && settings.lyricsMode != 'external';
|
settings.embedLyrics && settings.lyricsMode != 'external';
|
||||||
|
|
||||||
|
var cancelled = false;
|
||||||
|
BatchProgressDialog.show(
|
||||||
|
context: context,
|
||||||
|
title: context.l10n.trackConvertConverting,
|
||||||
|
total: total,
|
||||||
|
icon: Icons.transform,
|
||||||
|
onCancel: () {
|
||||||
|
cancelled = true;
|
||||||
|
BatchProgressDialog.dismiss(context);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
for (int i = 0; i < total; i++) {
|
for (int i = 0; i < total; i++) {
|
||||||
if (!mounted) break;
|
if (!mounted || cancelled) break;
|
||||||
final item = selected[i];
|
final item = selected[i];
|
||||||
|
|
||||||
ScaffoldMessenger.of(context).clearSnackBars();
|
BatchProgressDialog.update(current: i + 1, detail: item.trackName);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(
|
|
||||||
context.l10n.selectionBatchConvertProgress(i + 1, total),
|
|
||||||
),
|
|
||||||
duration: const Duration(seconds: 30),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final metadata = <String, String>{
|
final metadata = <String, String>{
|
||||||
@@ -1503,7 +1510,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
bitrate: bitrate,
|
bitrate: bitrate,
|
||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
coverPath: coverPath,
|
coverPath: coverPath,
|
||||||
deleteOriginal: !isSaf, // Only delete original for regular files
|
deleteOriginal: !isSaf,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (coverPath != null) {
|
if (coverPath != null) {
|
||||||
@@ -1522,15 +1529,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isSaf) {
|
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 uri = Uri.parse(item.filePath);
|
||||||
final pathSegments = uri.pathSegments;
|
final pathSegments = uri.pathSegments;
|
||||||
|
|
||||||
// Try to find 'tree' and 'document' segments
|
|
||||||
String? treeUri;
|
String? treeUri;
|
||||||
String relativeDir = '';
|
String relativeDir = '';
|
||||||
String oldFileName = '';
|
String oldFileName = '';
|
||||||
@@ -1643,6 +1644,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
_exitSelectionMode();
|
_exitSelectionMode();
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
if (!cancelled) {
|
||||||
|
BatchProgressDialog.dismiss(context);
|
||||||
|
}
|
||||||
ScaffoldMessenger.of(context).clearSnackBars();
|
ScaffoldMessenger.of(context).clearSnackBars();
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
|
|||||||
+104
-62
@@ -12,14 +12,16 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
|
|||||||
import 'package:spotiflac_android/providers/store_provider.dart';
|
import 'package:spotiflac_android/providers/store_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/track_provider.dart';
|
import 'package:spotiflac_android/providers/track_provider.dart';
|
||||||
import 'package:spotiflac_android/screens/home_tab.dart';
|
import 'package:spotiflac_android/screens/home_tab.dart';
|
||||||
import 'package:spotiflac_android/screens/store_tab.dart';
|
import 'package:spotiflac_android/screens/repo_tab.dart';
|
||||||
import 'package:spotiflac_android/screens/queue_tab.dart';
|
import 'package:spotiflac_android/screens/queue_tab.dart';
|
||||||
import 'package:spotiflac_android/screens/settings/settings_tab.dart';
|
import 'package:spotiflac_android/screens/settings/settings_tab.dart';
|
||||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||||
import 'package:spotiflac_android/services/shell_navigation_service.dart';
|
import 'package:spotiflac_android/services/shell_navigation_service.dart';
|
||||||
import 'package:spotiflac_android/services/share_intent_service.dart';
|
import 'package:spotiflac_android/services/share_intent_service.dart';
|
||||||
|
import 'package:spotiflac_android/services/notification_service.dart';
|
||||||
import 'package:spotiflac_android/services/update_checker.dart';
|
import 'package:spotiflac_android/services/update_checker.dart';
|
||||||
import 'package:spotiflac_android/widgets/update_dialog.dart';
|
import 'package:spotiflac_android/widgets/update_dialog.dart';
|
||||||
|
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||||
import 'package:spotiflac_android/utils/logger.dart';
|
import 'package:spotiflac_android/utils/logger.dart';
|
||||||
|
|
||||||
final _log = AppLogger('MainShell');
|
final _log = AppLogger('MainShell');
|
||||||
@@ -31,9 +33,11 @@ class MainShell extends ConsumerStatefulWidget {
|
|||||||
ConsumerState<MainShell> createState() => _MainShellState();
|
ConsumerState<MainShell> createState() => _MainShellState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MainShellState extends ConsumerState<MainShell> {
|
class _MainShellState extends ConsumerState<MainShell>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
int _currentIndex = 0;
|
int _currentIndex = 0;
|
||||||
late final PageController _pageController;
|
late final PageController _pageController;
|
||||||
|
late final AnimationController _tabJumpTransitionController;
|
||||||
bool _hasCheckedUpdate = false;
|
bool _hasCheckedUpdate = false;
|
||||||
StreamSubscription<String>? _shareSubscription;
|
StreamSubscription<String>? _shareSubscription;
|
||||||
DateTime? _lastBackPress;
|
DateTime? _lastBackPress;
|
||||||
@@ -41,16 +45,27 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
ShellNavigationService.homeTabNavigatorKey;
|
ShellNavigationService.homeTabNavigatorKey;
|
||||||
final GlobalKey<NavigatorState> _libraryTabNavigatorKey =
|
final GlobalKey<NavigatorState> _libraryTabNavigatorKey =
|
||||||
ShellNavigationService.libraryTabNavigatorKey;
|
ShellNavigationService.libraryTabNavigatorKey;
|
||||||
final GlobalKey<NavigatorState> _storeTabNavigatorKey =
|
final GlobalKey<NavigatorState> _repoTabNavigatorKey =
|
||||||
ShellNavigationService.storeTabNavigatorKey;
|
ShellNavigationService.repoTabNavigatorKey;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
NotificationService().updateStrings(context.l10n);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_pageController = PageController(initialPage: _currentIndex);
|
_pageController = PageController(initialPage: _currentIndex);
|
||||||
|
_tabJumpTransitionController = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 180),
|
||||||
|
value: 1,
|
||||||
|
);
|
||||||
ShellNavigationService.syncState(
|
ShellNavigationService.syncState(
|
||||||
currentTabIndex: _currentIndex,
|
currentTabIndex: _currentIndex,
|
||||||
showStoreTab: false,
|
showRepoTab: false,
|
||||||
);
|
);
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
_checkForUpdates();
|
_checkForUpdates();
|
||||||
@@ -71,7 +86,7 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
_log.d('Received shared URL from stream: $url');
|
_log.d('Received shared URL from stream: $url');
|
||||||
_handleSharedUrl(url);
|
_handleSharedUrl(url);
|
||||||
},
|
},
|
||||||
onError: (error) {
|
onError: (Object error) {
|
||||||
_log.e('Share stream error: $error');
|
_log.e('Share stream error: $error');
|
||||||
},
|
},
|
||||||
cancelOnError: false,
|
cancelOnError: false,
|
||||||
@@ -84,7 +99,7 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
if (!extState.isInitialized) {
|
if (!extState.isInitialized) {
|
||||||
_log.d('Waiting for extensions to initialize before handling URL...');
|
_log.d('Waiting for extensions to initialize before handling URL...');
|
||||||
for (int i = 0; i < 50; i++) {
|
for (int i = 0; i < 50; i++) {
|
||||||
await Future.delayed(const Duration(milliseconds: 100));
|
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (ref.read(extensionProvider).isInitialized) {
|
if (ref.read(extensionProvider).isInitialized) {
|
||||||
_log.d('Extensions initialized, proceeding with URL handling');
|
_log.d('Extensions initialized, proceeding with URL handling');
|
||||||
@@ -154,7 +169,6 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
if (!Platform.isAndroid) return;
|
if (!Platform.isAndroid) return;
|
||||||
|
|
||||||
final settings = ref.read(settingsProvider);
|
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.storageMode == 'saf') return;
|
||||||
if (settings.downloadDirectory.isEmpty) return;
|
if (settings.downloadDirectory.isEmpty) return;
|
||||||
|
|
||||||
@@ -170,7 +184,7 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
showDialog(
|
showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
@@ -229,6 +243,7 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
_shareSubscription?.cancel();
|
_shareSubscription?.cancel();
|
||||||
_pageController.dispose();
|
_pageController.dispose();
|
||||||
|
_tabJumpTransitionController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,7 +266,8 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (_currentIndex != index) {
|
if (_currentIndex != index) {
|
||||||
final shouldResetHome = index == 0;
|
final previousIndex = _currentIndex;
|
||||||
|
final isNonAdjacentJump = (previousIndex - index).abs() > 1;
|
||||||
HapticFeedback.selectionClick();
|
HapticFeedback.selectionClick();
|
||||||
setState(() => _currentIndex = index);
|
setState(() => _currentIndex = index);
|
||||||
final showStore = ref.read(
|
final showStore = ref.read(
|
||||||
@@ -259,22 +275,26 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
);
|
);
|
||||||
ShellNavigationService.syncState(
|
ShellNavigationService.syncState(
|
||||||
currentTabIndex: _currentIndex,
|
currentTabIndex: _currentIndex,
|
||||||
showStoreTab: showStore,
|
showRepoTab: showStore,
|
||||||
);
|
);
|
||||||
FocusManager.instance.primaryFocus?.unfocus();
|
FocusManager.instance.primaryFocus?.unfocus();
|
||||||
if (shouldResetHome) {
|
// Jump directly when skipping intermediate tabs to avoid
|
||||||
_resetHomeToMain();
|
// 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) {
|
void _onPageChanged(int index) {
|
||||||
final previousIndex = _currentIndex;
|
|
||||||
if (_currentIndex != index) {
|
if (_currentIndex != index) {
|
||||||
setState(() => _currentIndex = index);
|
setState(() => _currentIndex = index);
|
||||||
final showStore = ref.read(
|
final showStore = ref.read(
|
||||||
@@ -282,20 +302,17 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
);
|
);
|
||||||
ShellNavigationService.syncState(
|
ShellNavigationService.syncState(
|
||||||
currentTabIndex: _currentIndex,
|
currentTabIndex: _currentIndex,
|
||||||
showStoreTab: showStore,
|
showRepoTab: showStore,
|
||||||
);
|
);
|
||||||
FocusManager.instance.primaryFocus?.unfocus();
|
FocusManager.instance.primaryFocus?.unfocus();
|
||||||
if (index == 0 && previousIndex != 0) {
|
|
||||||
_resetHomeToMain();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleBackPress() {
|
Future<void> _handleBackPress() async {
|
||||||
final rootNavigator = Navigator.of(context, rootNavigator: true);
|
final rootNavigator = Navigator.of(context, rootNavigator: true);
|
||||||
if (rootNavigator.canPop()) {
|
final handledByRootNavigator = await rootNavigator.maybePop();
|
||||||
_log.i('Back: step 1 - root navigator pop');
|
if (handledByRootNavigator) {
|
||||||
rootNavigator.pop();
|
_log.i('Back: step 1 - root navigator handled back');
|
||||||
_lastBackPress = null;
|
_lastBackPress = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -304,9 +321,10 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
settingsProvider.select((s) => s.showExtensionStore),
|
settingsProvider.select((s) => s.showExtensionStore),
|
||||||
);
|
);
|
||||||
final currentNavigator = _navigatorForTab(_currentIndex, showStore);
|
final currentNavigator = _navigatorForTab(_currentIndex, showStore);
|
||||||
if (currentNavigator != null && currentNavigator.canPop()) {
|
final handledByCurrentNavigator =
|
||||||
_log.i('Back: step 2 - tab navigator pop (tab=$_currentIndex)');
|
await currentNavigator?.maybePop() ?? false;
|
||||||
currentNavigator.pop();
|
if (handledByCurrentNavigator) {
|
||||||
|
_log.i('Back: step 2 - tab navigator handled back (tab=$_currentIndex)');
|
||||||
_lastBackPress = null;
|
_lastBackPress = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -403,7 +421,7 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
NavigatorState? _navigatorForTab(int index, bool showStore) {
|
NavigatorState? _navigatorForTab(int index, bool showStore) {
|
||||||
if (index == 0) return _homeTabNavigatorKey.currentState;
|
if (index == 0) return _homeTabNavigatorKey.currentState;
|
||||||
if (index == 1) return _libraryTabNavigatorKey.currentState;
|
if (index == 1) return _libraryTabNavigatorKey.currentState;
|
||||||
if (showStore && index == 2) return _storeTabNavigatorKey.currentState;
|
if (showStore && index == 2) return _repoTabNavigatorKey.currentState;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -417,9 +435,9 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
);
|
);
|
||||||
ShellNavigationService.syncState(
|
ShellNavigationService.syncState(
|
||||||
currentTabIndex: _currentIndex,
|
currentTabIndex: _currentIndex,
|
||||||
showStoreTab: showStore,
|
showRepoTab: showStore,
|
||||||
);
|
);
|
||||||
final storeUpdatesCount = ref.watch(
|
final repoUpdatesCount = ref.watch(
|
||||||
storeProvider.select((s) => s.updatesAvailableCount),
|
storeProvider.select((s) => s.updatesAvailableCount),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -436,9 +454,9 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
),
|
),
|
||||||
if (showStore)
|
if (showStore)
|
||||||
_TabNavigator(
|
_TabNavigator(
|
||||||
key: const ValueKey('tab-store'),
|
key: const ValueKey('tab-repo'),
|
||||||
navigatorKey: _storeTabNavigatorKey,
|
navigatorKey: _repoTabNavigatorKey,
|
||||||
child: const StoreTab(),
|
child: const RepoTab(),
|
||||||
),
|
),
|
||||||
const SettingsTab(),
|
const SettingsTab(),
|
||||||
];
|
];
|
||||||
@@ -451,32 +469,44 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
label: l10n.navHome,
|
label: l10n.navHome,
|
||||||
),
|
),
|
||||||
NavigationDestination(
|
NavigationDestination(
|
||||||
icon: Badge(
|
icon: AnimatedBadge(
|
||||||
isLabelVisible: queueState > 0,
|
count: queueState,
|
||||||
label: Text('$queueState'),
|
|
||||||
child: const Icon(Icons.library_music_outlined),
|
|
||||||
),
|
|
||||||
selectedIcon: SlidingIcon(
|
|
||||||
child: Badge(
|
child: Badge(
|
||||||
isLabelVisible: queueState > 0,
|
isLabelVisible: queueState > 0,
|
||||||
label: Text('$queueState'),
|
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,
|
label: l10n.navLibrary,
|
||||||
),
|
),
|
||||||
if (showStore)
|
if (showStore)
|
||||||
NavigationDestination(
|
NavigationDestination(
|
||||||
icon: Badge(
|
icon: AnimatedBadge(
|
||||||
isLabelVisible: storeUpdatesCount > 0,
|
count: repoUpdatesCount,
|
||||||
label: Text('$storeUpdatesCount'),
|
|
||||||
child: const Icon(Icons.store_outlined),
|
|
||||||
),
|
|
||||||
selectedIcon: SwingIcon(
|
|
||||||
child: Badge(
|
child: Badge(
|
||||||
isLabelVisible: storeUpdatesCount > 0,
|
isLabelVisible: repoUpdatesCount > 0,
|
||||||
label: Text('$storeUpdatesCount'),
|
label: Text('$repoUpdatesCount'),
|
||||||
child: const Icon(Icons.store),
|
child: const Icon(Icons.extension_outlined),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
selectedIcon: BouncingIcon(
|
||||||
|
child: AnimatedBadge(
|
||||||
|
count: repoUpdatesCount,
|
||||||
|
child: Badge(
|
||||||
|
isLabelVisible: repoUpdatesCount > 0,
|
||||||
|
label: Text('$repoUpdatesCount'),
|
||||||
|
child: const Icon(Icons.extension),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
label: l10n.navStore,
|
label: l10n.navStore,
|
||||||
@@ -500,19 +530,31 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
|
|
||||||
return BackButtonListener(
|
return BackButtonListener(
|
||||||
onBackButtonPressed: () async {
|
onBackButtonPressed: () async {
|
||||||
_handleBackPress();
|
await _handleBackPress();
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
body: PageView.builder(
|
body: AnimatedBuilder(
|
||||||
controller: _pageController,
|
animation: _tabJumpTransitionController,
|
||||||
itemCount: tabs.length,
|
child: PageView.builder(
|
||||||
onPageChanged: _onPageChanged,
|
controller: _pageController,
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
itemCount: tabs.length,
|
||||||
itemBuilder: (context, index) => _KeepAliveTabPage(
|
onPageChanged: _onPageChanged,
|
||||||
key: ValueKey('page-$index'),
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
child: tabs[index],
|
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(
|
bottomNavigationBar: NavigationBar(
|
||||||
selectedIndex: _currentIndex.clamp(0, maxIndex),
|
selectedIndex: _currentIndex.clamp(0, maxIndex),
|
||||||
@@ -707,7 +749,7 @@ class _SwingIconState extends State<SwingIcon>
|
|||||||
TweenSequenceItem(tween: Tween(begin: 0.15, end: -0.1), weight: 20),
|
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.1, end: 0.05), weight: 20),
|
||||||
TweenSequenceItem(tween: Tween(begin: 0.05, end: 0.0), weight: 20),
|
TweenSequenceItem(tween: Tween(begin: 0.05, end: 0.0), weight: 20),
|
||||||
]).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut));
|
]).animate(_controller);
|
||||||
|
|
||||||
_controller.forward();
|
_controller.forward();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,12 +15,14 @@ import 'package:spotiflac_android/providers/playback_provider.dart';
|
|||||||
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||||
import 'package:spotiflac_android/widgets/playlist_picker_sheet.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/track_collection_quick_actions.dart';
|
||||||
|
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||||
|
|
||||||
class PlaylistScreen extends ConsumerStatefulWidget {
|
class PlaylistScreen extends ConsumerStatefulWidget {
|
||||||
final String playlistName;
|
final String playlistName;
|
||||||
final String? coverUrl;
|
final String? coverUrl;
|
||||||
final List<Track> tracks;
|
final List<Track> tracks;
|
||||||
final String? playlistId;
|
final String? playlistId;
|
||||||
|
final String? recommendedService;
|
||||||
|
|
||||||
const PlaylistScreen({
|
const PlaylistScreen({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -28,6 +30,7 @@ class PlaylistScreen extends ConsumerStatefulWidget {
|
|||||||
this.coverUrl,
|
this.coverUrl,
|
||||||
required this.tracks,
|
required this.tracks,
|
||||||
this.playlistId,
|
this.playlistId,
|
||||||
|
this.recommendedService,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -47,6 +50,31 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||||||
String get _playlistName => _resolvedPlaylistName ?? widget.playlistName;
|
String get _playlistName => _resolvedPlaylistName ?? widget.playlistName;
|
||||||
String? get _coverUrl => _resolvedCoverUrl ?? widget.coverUrl;
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -360,8 +388,8 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||||||
if (_isLoading) {
|
if (_isLoading) {
|
||||||
return const SliverToBoxAdapter(
|
return const SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.all(32),
|
padding: EdgeInsets.all(16),
|
||||||
child: Center(child: CircularProgressIndicator()),
|
child: TrackListSkeleton(itemCount: 8),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -411,9 +439,12 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||||||
final track = _tracks[index];
|
final track = _tracks[index];
|
||||||
return KeyedSubtree(
|
return KeyedSubtree(
|
||||||
key: ValueKey(track.id),
|
key: ValueKey(track.id),
|
||||||
child: _PlaylistTrackItem(
|
child: StaggeredListItem(
|
||||||
track: track,
|
index: index,
|
||||||
onDownload: () => _downloadTrack(context, track),
|
child: _PlaylistTrackItem(
|
||||||
|
track: track,
|
||||||
|
onDownload: () => _downloadTrack(context, track),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}, childCount: _tracks.length),
|
}, childCount: _tracks.length),
|
||||||
@@ -429,6 +460,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||||||
trackName: track.name,
|
trackName: track.name,
|
||||||
artistName: track.artistName,
|
artistName: track.artistName,
|
||||||
coverUrl: track.coverUrl,
|
coverUrl: track.coverUrl,
|
||||||
|
recommendedService: _recommendedDownloadService(),
|
||||||
onSelect: (quality, service) {
|
onSelect: (quality, service) {
|
||||||
ref
|
ref
|
||||||
.read(downloadQueueProvider.notifier)
|
.read(downloadQueueProvider.notifier)
|
||||||
@@ -546,7 +578,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||||||
|
|
||||||
void _confirmDownloadAll(BuildContext context) {
|
void _confirmDownloadAll(BuildContext context) {
|
||||||
if (_tracks.isEmpty) return;
|
if (_tracks.isEmpty) return;
|
||||||
showDialog(
|
showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogContext) {
|
builder: (dialogContext) {
|
||||||
final colorScheme = Theme.of(dialogContext).colorScheme;
|
final colorScheme = Theme.of(dialogContext).colorScheme;
|
||||||
@@ -616,7 +648,6 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||||||
void _downloadTracks(BuildContext context, List<Track> tracks) {
|
void _downloadTracks(BuildContext context, List<Track> tracks) {
|
||||||
if (tracks.isEmpty) return;
|
if (tracks.isEmpty) return;
|
||||||
|
|
||||||
// Skip already-downloaded tracks
|
|
||||||
final historyState = ref.read(downloadHistoryProvider);
|
final historyState = ref.read(downloadHistoryProvider);
|
||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
final localLibState =
|
final localLibState =
|
||||||
@@ -663,6 +694,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||||||
context,
|
context,
|
||||||
trackName: '${tracksToQueue.length} tracks',
|
trackName: '${tracksToQueue.length} tracks',
|
||||||
artistName: _playlistName,
|
artistName: _playlistName,
|
||||||
|
recommendedService: _recommendedDownloadService(),
|
||||||
onSelect: (quality, service) {
|
onSelect: (quality, service) {
|
||||||
ref
|
ref
|
||||||
.read(downloadQueueProvider.notifier)
|
.read(downloadQueueProvider.notifier)
|
||||||
@@ -725,7 +757,6 @@ class _PlaylistTrackItem extends ConsumerWidget {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Check local library for duplicate detection
|
|
||||||
final showLocalLibraryIndicator = ref.watch(
|
final showLocalLibraryIndicator = ref.watch(
|
||||||
settingsProvider.select(
|
settingsProvider.select(
|
||||||
(s) => s.localLibraryEnabled && s.localLibraryShowDuplicates,
|
(s) => s.localLibraryEnabled && s.localLibraryShowDuplicates,
|
||||||
|
|||||||
+1066
-1124
File diff suppressed because it is too large
Load Diff
@@ -4,17 +4,18 @@ import 'package:path_provider/path_provider.dart';
|
|||||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||||
import 'package:spotiflac_android/providers/store_provider.dart';
|
import 'package:spotiflac_android/providers/store_provider.dart';
|
||||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||||
|
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||||
import 'package:spotiflac_android/screens/store/extension_details_screen.dart';
|
import 'package:spotiflac_android/screens/store/extension_details_screen.dart';
|
||||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||||
|
|
||||||
class StoreTab extends ConsumerStatefulWidget {
|
class RepoTab extends ConsumerStatefulWidget {
|
||||||
const StoreTab({super.key});
|
const RepoTab({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<StoreTab> createState() => _StoreTabState();
|
ConsumerState<RepoTab> createState() => _RepoTabState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _StoreTabState extends ConsumerState<StoreTab> {
|
class _RepoTabState extends ConsumerState<RepoTab> {
|
||||||
final _searchController = TextEditingController();
|
final _searchController = TextEditingController();
|
||||||
final _repoUrlController = TextEditingController();
|
final _repoUrlController = TextEditingController();
|
||||||
bool _isInitialized = false;
|
bool _isInitialized = false;
|
||||||
@@ -58,7 +59,9 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
|||||||
final downloadingId = ref.watch(
|
final downloadingId = ref.watch(
|
||||||
storeProvider.select((s) => s.downloadingId),
|
storeProvider.select((s) => s.downloadingId),
|
||||||
);
|
);
|
||||||
final hasRegistryUrl = ref.watch(storeProvider.select((s) => s.hasRegistryUrl));
|
final hasRegistryUrl = ref.watch(
|
||||||
|
storeProvider.select((s) => s.hasRegistryUrl),
|
||||||
|
);
|
||||||
final registryUrl = ref.watch(storeProvider.select((s) => s.registryUrl));
|
final registryUrl = ref.watch(storeProvider.select((s) => s.registryUrl));
|
||||||
final filteredExtensions = StoreState(
|
final filteredExtensions = StoreState(
|
||||||
extensions: extensions,
|
extensions: extensions,
|
||||||
@@ -139,7 +142,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
|||||||
prefixIcon: const Icon(Icons.search),
|
prefixIcon: const Icon(Icons.search),
|
||||||
suffixIcon: value.text.isNotEmpty
|
suffixIcon: value.text.isNotEmpty
|
||||||
? IconButton(
|
? IconButton(
|
||||||
tooltip: 'Clear search',
|
tooltip: 'Clear',
|
||||||
icon: const Icon(Icons.clear),
|
icon: const Icon(Icons.clear),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
_searchController.clear();
|
_searchController.clear();
|
||||||
@@ -151,23 +154,37 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
|||||||
: null,
|
: null,
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(28),
|
borderRadius: BorderRadius.circular(28),
|
||||||
borderSide: BorderSide.none,
|
borderSide: BorderSide(
|
||||||
|
color: colorScheme.outlineVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
borderSide: BorderSide(
|
||||||
|
color: colorScheme.outlineVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
borderSide: BorderSide(
|
||||||
|
color: colorScheme.primary,
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor:
|
fillColor: colorScheme.surfaceContainerHighest,
|
||||||
Theme.of(context).brightness == Brightness.dark
|
|
||||||
? Color.alphaBlend(
|
|
||||||
Colors.white.withValues(alpha: 0.08),
|
|
||||||
colorScheme.surface,
|
|
||||||
)
|
|
||||||
: colorScheme.surfaceContainerHighest,
|
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
horizontal: 16,
|
horizontal: 20,
|
||||||
vertical: 12,
|
vertical: 16,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
ref.read(storeProvider.notifier).setSearchQuery(value);
|
ref
|
||||||
|
.read(storeProvider.notifier)
|
||||||
|
.setSearchQuery(value);
|
||||||
|
},
|
||||||
|
onTapOutside: (_) {
|
||||||
|
FocusScope.of(context).unfocus();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -231,7 +248,8 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
|||||||
_CategoryChip(
|
_CategoryChip(
|
||||||
label: context.l10n.storeFilterIntegration,
|
label: context.l10n.storeFilterIntegration,
|
||||||
icon: Icons.link,
|
icon: Icons.link,
|
||||||
isSelected: selectedCategory == StoreCategory.integration,
|
isSelected:
|
||||||
|
selectedCategory == StoreCategory.integration,
|
||||||
onTap: () => ref
|
onTap: () => ref
|
||||||
.read(storeProvider.notifier)
|
.read(storeProvider.notifier)
|
||||||
.setCategory(StoreCategory.integration),
|
.setCategory(StoreCategory.integration),
|
||||||
@@ -242,8 +260,11 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
|||||||
),
|
),
|
||||||
|
|
||||||
if (isLoading && extensions.isEmpty)
|
if (isLoading && extensions.isEmpty)
|
||||||
const SliverFillRemaining(
|
const SliverToBoxAdapter(
|
||||||
child: Center(child: CircularProgressIndicator()),
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(16),
|
||||||
|
child: TrackListSkeleton(itemCount: 6),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
else if (error != null && extensions.isEmpty)
|
else if (error != null && extensions.isEmpty)
|
||||||
SliverFillRemaining(child: _buildErrorState(error, colorScheme))
|
SliverFillRemaining(child: _buildErrorState(error, colorScheme))
|
||||||
@@ -302,16 +323,16 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
Icons.store_outlined,
|
Icons.extension_outlined,
|
||||||
size: 72,
|
size: 72,
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
Text(
|
Text(
|
||||||
context.l10n.storeAddRepoTitle,
|
context.l10n.storeAddRepoTitle,
|
||||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
style: Theme.of(
|
||||||
fontWeight: FontWeight.bold,
|
context,
|
||||||
),
|
).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
@@ -322,16 +343,23 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
|||||||
labelText: context.l10n.storeRepoUrlLabel,
|
labelText: context.l10n.storeRepoUrlLabel,
|
||||||
prefixIcon: const Icon(Icons.link),
|
prefixIcon: const Icon(Icons.link),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
borderSide: BorderSide(color: colorScheme.outlineVariant),
|
||||||
),
|
),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(28),
|
||||||
borderSide: BorderSide(color: colorScheme.outline),
|
borderSide: BorderSide(color: colorScheme.outlineVariant),
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(28),
|
||||||
borderSide: BorderSide(color: colorScheme.primary, width: 2),
|
borderSide: BorderSide(color: colorScheme.primary, width: 2),
|
||||||
),
|
),
|
||||||
|
filled: true,
|
||||||
|
fillColor: colorScheme.surfaceContainerHighest,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 20,
|
||||||
|
vertical: 16,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
keyboardType: TextInputType.url,
|
keyboardType: TextInputType.url,
|
||||||
autocorrect: false,
|
autocorrect: false,
|
||||||
@@ -347,7 +375,11 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
|||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.error_outline, size: 20, color: colorScheme.onErrorContainer),
|
Icon(
|
||||||
|
Icons.error_outline,
|
||||||
|
size: 20,
|
||||||
|
color: colorScheme.onErrorContainer,
|
||||||
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -384,7 +416,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
|||||||
|
|
||||||
void _showChangeRepoDialog(String currentUrl) {
|
void _showChangeRepoDialog(String currentUrl) {
|
||||||
final changeUrlController = TextEditingController(text: currentUrl);
|
final changeUrlController = TextEditingController(text: currentUrl);
|
||||||
showDialog(
|
showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: Text(context.l10n.storeRepoDialogTitle),
|
title: Text(context.l10n.storeRepoDialogTitle),
|
||||||
@@ -416,7 +448,31 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
|||||||
labelText: context.l10n.storeNewRepoUrlLabel,
|
labelText: context.l10n.storeNewRepoUrlLabel,
|
||||||
prefixIcon: const Icon(Icons.link),
|
prefixIcon: const Icon(Icons.link),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
borderSide: BorderSide(
|
||||||
|
color: Theme.of(context).colorScheme.outlineVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
borderSide: BorderSide(
|
||||||
|
color: Theme.of(context).colorScheme.outlineVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
borderSide: BorderSide(
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
filled: true,
|
||||||
|
fillColor: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.surfaceContainerHighest,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 20,
|
||||||
|
vertical: 16,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
keyboardType: TextInputType.url,
|
keyboardType: TextInputType.url,
|
||||||
@@ -503,7 +559,9 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
hasFilters ? context.l10n.storeEmptyNoResults : context.l10n.storeEmptyNoExtensions,
|
hasFilters
|
||||||
|
? context.l10n.storeEmptyNoResults
|
||||||
|
: context.l10n.storeEmptyNoExtensions,
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
@@ -525,7 +583,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
|||||||
|
|
||||||
void _showExtensionDetails(StoreExtension ext) {
|
void _showExtensionDetails(StoreExtension ext) {
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute<void>(
|
||||||
builder: (context) => ExtensionDetailsScreen(extension: ext),
|
builder: (context) => ExtensionDetailsScreen(extension: ext),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -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/download_queue_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_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/track_collection_quick_actions.dart';
|
||||||
|
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||||
import 'package:spotiflac_android/utils/clickable_metadata.dart';
|
import 'package:spotiflac_android/utils/clickable_metadata.dart';
|
||||||
|
|
||||||
class SearchScreen extends ConsumerStatefulWidget {
|
class SearchScreen extends ConsumerStatefulWidget {
|
||||||
@@ -51,9 +52,9 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||||||
ref
|
ref
|
||||||
.read(downloadQueueProvider.notifier)
|
.read(downloadQueueProvider.notifier)
|
||||||
.addToQueue(track, settings.defaultService);
|
.addToQueue(track, settings.defaultService);
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
context,
|
SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))),
|
||||||
).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))));
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -95,13 +96,20 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||||||
child: Text(error, style: TextStyle(color: colorScheme.error)),
|
child: Text(error, style: TextStyle(color: colorScheme.error)),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: tracks.isEmpty
|
child: AnimatedStateSwitcher(
|
||||||
? _buildEmptyState(colorScheme)
|
child: isLoading && tracks.isEmpty
|
||||||
: ListView.builder(
|
? const TrackListSkeleton(key: ValueKey('loading'))
|
||||||
itemCount: tracks.length,
|
: tracks.isEmpty
|
||||||
itemBuilder: (context, index) =>
|
? _buildEmptyState(colorScheme)
|
||||||
_buildTrackTile(tracks[index], 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) {
|
Widget _buildTrackTile(Track track, ColorScheme colorScheme) {
|
||||||
return ListTile(
|
final coverWidget = track.coverUrl != null
|
||||||
leading: track.coverUrl != null
|
? ClipRRect(
|
||||||
? ClipRRect(
|
borderRadius: BorderRadius.circular(8),
|
||||||
borderRadius: BorderRadius.circular(8),
|
child: CachedNetworkImage(
|
||||||
child: CachedNetworkImage(
|
imageUrl: track.coverUrl!,
|
||||||
imageUrl: track.coverUrl!,
|
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
memCacheWidth: 144,
|
|
||||||
memCacheHeight: 144,
|
|
||||||
cacheManager: CoverCacheManager.instance,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: Container(
|
|
||||||
width: 48,
|
width: 48,
|
||||||
height: 48,
|
height: 48,
|
||||||
decoration: BoxDecoration(
|
fit: BoxFit.cover,
|
||||||
color: colorScheme.surfaceContainerHighest,
|
memCacheWidth: 144,
|
||||||
borderRadius: BorderRadius.circular(8),
|
memCacheHeight: 144,
|
||||||
),
|
cacheManager: CoverCacheManager.instance,
|
||||||
child: Icon(
|
|
||||||
Icons.music_note,
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
)
|
||||||
|
: 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),
|
title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||||
subtitle: Column(
|
subtitle: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
|||||||
@@ -770,7 +770,7 @@ class _LanguageSelector extends StatelessWidget {
|
|||||||
|
|
||||||
void _showLanguagePicker(BuildContext context) {
|
void _showLanguagePicker(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
showModalBottomSheet(
|
showModalBottomSheet<void>(
|
||||||
context: context,
|
context: context,
|
||||||
useRootNavigator: true,
|
useRootNavigator: true,
|
||||||
backgroundColor: colorScheme.surface,
|
backgroundColor: colorScheme.surface,
|
||||||
|
|||||||
@@ -477,122 +477,40 @@ class _CryptoWalletItem extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
int _cr(String v) {
|
class _SupporterChip extends StatelessWidget {
|
||||||
int r = 0x1F;
|
|
||||||
for (final c in v.codeUnits) {
|
|
||||||
r = (r * 31 + c) & 0x7FFFFFFF;
|
|
||||||
}
|
|
||||||
return r;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Highlighted supporters (hashes of names).
|
|
||||||
const _cv = <int>{1211573191, 1003219236};
|
|
||||||
|
|
||||||
// Diamond tier supporters ($50+ donors).
|
|
||||||
const _dv = <int>{560908930};
|
|
||||||
|
|
||||||
enum _SupporterTier { normal, gold, diamond }
|
|
||||||
|
|
||||||
_SupporterTier _tierOf(String name) {
|
|
||||||
final h = _cr(name);
|
|
||||||
if (_dv.contains(h)) return _SupporterTier.diamond;
|
|
||||||
if (_cv.contains(h)) return _SupporterTier.gold;
|
|
||||||
return _SupporterTier.normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
class _SupporterChip extends StatefulWidget {
|
|
||||||
final String name;
|
final String name;
|
||||||
final ColorScheme colorScheme;
|
final ColorScheme colorScheme;
|
||||||
|
|
||||||
const _SupporterChip({required this.name, required this.colorScheme});
|
const _SupporterChip({required this.name, required this.colorScheme});
|
||||||
|
|
||||||
@override
|
|
||||||
State<_SupporterChip> createState() => _SupporterChipState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _SupporterChipState extends State<_SupporterChip>
|
|
||||||
with SingleTickerProviderStateMixin {
|
|
||||||
late final _SupporterTier _tier;
|
|
||||||
AnimationController? _shimmerController;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_tier = _tierOf(widget.name);
|
|
||||||
if (_tier == _SupporterTier.diamond) {
|
|
||||||
_shimmerController = AnimationController(
|
|
||||||
vsync: this,
|
|
||||||
duration: const Duration(milliseconds: 2400),
|
|
||||||
)..repeat();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_shimmerController?.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
|
||||||
|
|
||||||
if (_tier == _SupporterTier.diamond) {
|
|
||||||
return _buildDiamondChip(isDark);
|
|
||||||
}
|
|
||||||
|
|
||||||
final isGold = _tier == _SupporterTier.gold;
|
|
||||||
const goldChipColor = Color(0xFFFFF8DC);
|
|
||||||
const goldAccentColor = Color(0xFFB8860B);
|
|
||||||
const goldDarkChipColor = Color(0xFF3A3000);
|
|
||||||
|
|
||||||
final chipColor = isGold
|
|
||||||
? goldChipColor
|
|
||||||
: widget.colorScheme.secondaryContainer;
|
|
||||||
final accentColor = isGold ? goldAccentColor : widget.colorScheme.primary;
|
|
||||||
final effectiveChipColor = isGold && isDark ? goldDarkChipColor : chipColor;
|
|
||||||
|
|
||||||
return Material(
|
return Material(
|
||||||
color: effectiveChipColor,
|
color: colorScheme.secondaryContainer,
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
child: Container(
|
child: Padding(
|
||||||
decoration: isGold
|
|
||||||
? BoxDecoration(
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
border: Border.all(
|
|
||||||
color: accentColor.withValues(alpha: 0.4),
|
|
||||||
width: 1,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
CircleAvatar(
|
CircleAvatar(
|
||||||
radius: 10,
|
radius: 10,
|
||||||
backgroundColor: accentColor.withValues(alpha: 0.2),
|
backgroundColor: colorScheme.primary.withValues(alpha: 0.2),
|
||||||
child: isGold
|
child: Text(
|
||||||
? Icon(Icons.star_rounded, size: 12, color: accentColor)
|
name.isNotEmpty ? name[0].toUpperCase() : '?',
|
||||||
: Text(
|
style: TextStyle(
|
||||||
widget.name.isNotEmpty
|
fontSize: 10,
|
||||||
? widget.name[0].toUpperCase()
|
fontWeight: FontWeight.bold,
|
||||||
: '?',
|
color: colorScheme.primary,
|
||||||
style: TextStyle(
|
),
|
||||||
fontSize: 10,
|
),
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: accentColor,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
widget.name,
|
name,
|
||||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||||
color: isGold
|
color: colorScheme.onSecondaryContainer,
|
||||||
? accentColor
|
fontWeight: FontWeight.w500,
|
||||||
: widget.colorScheme.onSecondaryContainer,
|
|
||||||
fontWeight: isGold ? FontWeight.w600 : FontWeight.w500,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -600,92 +518,6 @@ class _SupporterChipState extends State<_SupporterChip>
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildDiamondChip(bool isDark) {
|
|
||||||
const diamondLight = Color(0xFFE8F4FD);
|
|
||||||
const diamondDark = Color(0xFF0D2B3E);
|
|
||||||
const diamondAccent = Color(0xFF4FC3F7);
|
|
||||||
const diamondHighlight = Color(0xFFB3E5FC);
|
|
||||||
|
|
||||||
final chipBg = isDark ? diamondDark : diamondLight;
|
|
||||||
|
|
||||||
return AnimatedBuilder(
|
|
||||||
animation: _shimmerController!,
|
|
||||||
builder: (context, child) {
|
|
||||||
final t = _shimmerController!.value;
|
|
||||||
return Material(
|
|
||||||
color: Colors.transparent,
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
child: Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
gradient: LinearGradient(
|
|
||||||
begin: Alignment(-2.0 + 4.0 * t, 0.0),
|
|
||||||
end: Alignment(-1.0 + 4.0 * t, 0.0),
|
|
||||||
colors: [
|
|
||||||
chipBg,
|
|
||||||
isDark
|
|
||||||
? diamondAccent.withValues(alpha: 0.18)
|
|
||||||
: diamondHighlight.withValues(alpha: 0.7),
|
|
||||||
chipBg,
|
|
||||||
],
|
|
||||||
stops: const [0.0, 0.5, 1.0],
|
|
||||||
),
|
|
||||||
border: Border.all(
|
|
||||||
color: diamondAccent.withValues(
|
|
||||||
alpha: 0.5 + 0.3 * (0.5 - (t - 0.5).abs()),
|
|
||||||
),
|
|
||||||
width: 1.2,
|
|
||||||
),
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: diamondAccent.withValues(
|
|
||||||
alpha: 0.15 + 0.1 * (0.5 - (t - 0.5).abs()),
|
|
||||||
),
|
|
||||||
blurRadius: 8,
|
|
||||||
spreadRadius: 0,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 20,
|
|
||||||
height: 20,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
gradient: LinearGradient(
|
|
||||||
begin: Alignment.topLeft,
|
|
||||||
end: Alignment.bottomRight,
|
|
||||||
colors: [
|
|
||||||
diamondAccent.withValues(alpha: 0.3),
|
|
||||||
diamondAccent.withValues(alpha: 0.15),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: const Icon(
|
|
||||||
Icons.diamond_rounded,
|
|
||||||
size: 12,
|
|
||||||
color: diamondAccent,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(
|
|
||||||
widget.name,
|
|
||||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
|
||||||
color: isDark ? diamondHighlight : diamondAccent,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class _NoticeLine extends StatelessWidget {
|
class _NoticeLine extends StatelessWidget {
|
||||||
|
|||||||
@@ -465,34 +465,6 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
SettingsItem(
|
|
||||||
title: context.l10n.youtubeOpusBitrateTitle,
|
|
||||||
subtitle:
|
|
||||||
'${settings.youtubeOpusBitrate}kbps (128/256/320)',
|
|
||||||
onTap: () => _showYoutubeBitratePicker(
|
|
||||||
context: context,
|
|
||||||
title: context.l10n.youtubeOpusBitrateTitle,
|
|
||||||
currentValue: settings.youtubeOpusBitrate,
|
|
||||||
options: const [128, 256, 320],
|
|
||||||
onSave: (value) => ref
|
|
||||||
.read(settingsProvider.notifier)
|
|
||||||
.setYoutubeOpusBitrate(value),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SettingsItem(
|
|
||||||
title: context.l10n.youtubeMp3BitrateTitle,
|
|
||||||
subtitle: '${settings.youtubeMp3Bitrate}kbps (128/256/320)',
|
|
||||||
onTap: () => _showYoutubeBitratePicker(
|
|
||||||
context: context,
|
|
||||||
title: context.l10n.youtubeMp3BitrateTitle,
|
|
||||||
currentValue: settings.youtubeMp3Bitrate,
|
|
||||||
options: const [128, 256, 320],
|
|
||||||
onSave: (value) => ref
|
|
||||||
.read(settingsProvider.notifier)
|
|
||||||
.setYoutubeMp3Bitrate(value),
|
|
||||||
),
|
|
||||||
showDivider: false,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -538,7 +510,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
|||||||
),
|
),
|
||||||
onTap: () => Navigator.push(
|
onTap: () => Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute<void>(
|
||||||
builder: (_) => const LyricsProviderPriorityPage(),
|
builder: (_) => const LyricsProviderPriorityPage(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -869,6 +841,8 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
|||||||
return 'Albums/[Year] Album/';
|
return 'Albums/[Year] Album/';
|
||||||
case 'artist_album_singles':
|
case 'artist_album_singles':
|
||||||
return 'Artist/Album/ + Artist/Singles/';
|
return 'Artist/Album/ + Artist/Singles/';
|
||||||
|
case 'artist_album_flat':
|
||||||
|
return 'Artist/Album/ + Artist/song.flac';
|
||||||
default:
|
default:
|
||||||
return 'Albums/Artist/Album Name/';
|
return 'Albums/Artist/Album Name/';
|
||||||
}
|
}
|
||||||
@@ -879,7 +853,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
|||||||
WidgetRef ref,
|
WidgetRef ref,
|
||||||
String current,
|
String current,
|
||||||
) {
|
) {
|
||||||
showModalBottomSheet(
|
showModalBottomSheet<void>(
|
||||||
context: context,
|
context: context,
|
||||||
useRootNavigator: true,
|
useRootNavigator: true,
|
||||||
builder: (context) => SafeArea(
|
builder: (context) => SafeArea(
|
||||||
@@ -958,6 +932,20 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
|||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.person_outline_outlined),
|
||||||
|
title: Text(context.l10n.albumFolderArtistAlbumFlat),
|
||||||
|
subtitle: Text(context.l10n.albumFolderArtistAlbumFlatSubtitle),
|
||||||
|
trailing: current == 'artist_album_flat'
|
||||||
|
? const Icon(Icons.check)
|
||||||
|
: null,
|
||||||
|
onTap: () {
|
||||||
|
ref
|
||||||
|
.read(settingsProvider.notifier)
|
||||||
|
.setAlbumFolderStructure('artist_album_flat');
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1014,7 +1002,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
showModalBottomSheet(
|
showModalBottomSheet<void>(
|
||||||
context: context,
|
context: context,
|
||||||
useRootNavigator: true,
|
useRootNavigator: true,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
@@ -1232,7 +1220,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
|||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
final isSafMode =
|
final isSafMode =
|
||||||
settings.storageMode == 'saf' && settings.downloadTreeUri.isNotEmpty;
|
settings.storageMode == 'saf' && settings.downloadTreeUri.isNotEmpty;
|
||||||
showModalBottomSheet(
|
showModalBottomSheet<void>(
|
||||||
context: context,
|
context: context,
|
||||||
useRootNavigator: true,
|
useRootNavigator: true,
|
||||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||||
@@ -1310,7 +1298,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
|||||||
|
|
||||||
void _showIOSDirectoryOptions(BuildContext context, WidgetRef ref) {
|
void _showIOSDirectoryOptions(BuildContext context, WidgetRef ref) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
showModalBottomSheet(
|
showModalBottomSheet<void>(
|
||||||
context: context,
|
context: context,
|
||||||
useRootNavigator: true,
|
useRootNavigator: true,
|
||||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||||
@@ -1371,7 +1359,9 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
|||||||
if (ctx.mounted) {
|
if (ctx.mounted) {
|
||||||
ScaffoldMessenger.of(ctx).showSnackBar(
|
ScaffoldMessenger.of(ctx).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text('Failed to open folder picker: $e'),
|
content: Text(
|
||||||
|
ctx.l10n.snackbarFolderPickerFailed(e.toString()),
|
||||||
|
),
|
||||||
backgroundColor: Theme.of(ctx).colorScheme.error,
|
backgroundColor: Theme.of(ctx).colorScheme.error,
|
||||||
duration: const Duration(seconds: 4),
|
duration: const Duration(seconds: 4),
|
||||||
),
|
),
|
||||||
@@ -1505,7 +1495,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
|||||||
String current,
|
String current,
|
||||||
) {
|
) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
showModalBottomSheet(
|
showModalBottomSheet<void>(
|
||||||
context: context,
|
context: context,
|
||||||
useRootNavigator: true,
|
useRootNavigator: true,
|
||||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||||
@@ -1610,7 +1600,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
|||||||
String current,
|
String current,
|
||||||
) {
|
) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
showModalBottomSheet(
|
showModalBottomSheet<void>(
|
||||||
context: context,
|
context: context,
|
||||||
useRootNavigator: true,
|
useRootNavigator: true,
|
||||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||||
@@ -1689,68 +1679,6 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showYoutubeBitratePicker({
|
|
||||||
required BuildContext context,
|
|
||||||
required String title,
|
|
||||||
required int currentValue,
|
|
||||||
required List<int> options,
|
|
||||||
required void Function(int value) onSave,
|
|
||||||
}) {
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
|
|
||||||
showModalBottomSheet(
|
|
||||||
context: context,
|
|
||||||
useRootNavigator: true,
|
|
||||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
|
||||||
shape: const RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
|
||||||
),
|
|
||||||
builder: (sheetContext) => SafeArea(
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Container(
|
|
||||||
width: 40,
|
|
||||||
height: 4,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
|
|
||||||
borderRadius: BorderRadius.circular(2),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(24, 12, 24, 8),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
title,
|
|
||||||
style: Theme.of(sheetContext).textTheme.titleMedium
|
|
||||||
?.copyWith(fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
for (final bitrate in options)
|
|
||||||
ListTile(
|
|
||||||
title: Text('$bitrate kbps'),
|
|
||||||
trailing: bitrate == currentValue
|
|
||||||
? Icon(Icons.check, color: colorScheme.primary)
|
|
||||||
: null,
|
|
||||||
onTap: () {
|
|
||||||
onSave(bitrate);
|
|
||||||
Navigator.pop(sheetContext);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showMusixmatchLanguagePicker(
|
void _showMusixmatchLanguagePicker(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
WidgetRef ref,
|
WidgetRef ref,
|
||||||
@@ -1759,7 +1687,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
|||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final controller = TextEditingController(text: currentLanguage);
|
final controller = TextEditingController(text: currentLanguage);
|
||||||
|
|
||||||
showModalBottomSheet(
|
showModalBottomSheet<void>(
|
||||||
context: context,
|
context: context,
|
||||||
useRootNavigator: true,
|
useRootNavigator: true,
|
||||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||||
@@ -1845,7 +1773,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
|||||||
String current,
|
String current,
|
||||||
) {
|
) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
showModalBottomSheet(
|
showModalBottomSheet<void>(
|
||||||
context: context,
|
context: context,
|
||||||
useRootNavigator: true,
|
useRootNavigator: true,
|
||||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||||
@@ -1917,7 +1845,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
|||||||
) {
|
) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final normalizedCurrent = current.trim().toUpperCase();
|
final normalizedCurrent = current.trim().toUpperCase();
|
||||||
showModalBottomSheet(
|
showModalBottomSheet<void>(
|
||||||
context: context,
|
context: context,
|
||||||
useRootNavigator: true,
|
useRootNavigator: true,
|
||||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||||
@@ -1985,7 +1913,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
|||||||
String current,
|
String current,
|
||||||
) {
|
) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
showModalBottomSheet(
|
showModalBottomSheet<void>(
|
||||||
context: context,
|
context: context,
|
||||||
useRootNavigator: true,
|
useRootNavigator: true,
|
||||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||||
@@ -2100,7 +2028,7 @@ class _ServiceSelector extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final extState = ref.watch(extensionProvider);
|
final extState = ref.watch(extensionProvider);
|
||||||
final builtInServiceIds = ['tidal', 'qobuz', 'deezer', 'youtube'];
|
final builtInServiceIds = ['tidal', 'qobuz', 'deezer'];
|
||||||
|
|
||||||
final extensionProviders = extState.extensions
|
final extensionProviders = extState.extensions
|
||||||
.where((e) => e.enabled && e.hasDownloadProvider)
|
.where((e) => e.enabled && e.hasDownloadProvider)
|
||||||
@@ -2136,15 +2064,6 @@ class _ServiceSelector extends ConsumerWidget {
|
|||||||
onTap: () => onChanged('qobuz'),
|
onTap: () => onChanged('qobuz'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
|
||||||
child: _ServiceChip(
|
|
||||||
icon: Icons.smart_display,
|
|
||||||
label: 'YouTube',
|
|
||||||
isSelected: effectiveService == 'youtube',
|
|
||||||
onTap: () => onChanged('youtube'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
if (extensionProviders.isNotEmpty) ...[
|
if (extensionProviders.isNotEmpty) ...[
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
|
|||||||
final hasError = extension.status == 'error';
|
final hasError = extension.status == 'error';
|
||||||
|
|
||||||
return PopScope(
|
return PopScope(
|
||||||
canPop: true, // Always allow back gesture
|
canPop: true,
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
body: CustomScrollView(
|
body: CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
@@ -832,9 +832,9 @@ class _SettingItemState extends State<_SettingItem> {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
context,
|
SnackBar(content: Text(context.l10n.snackbarError(e.toString()))),
|
||||||
).showSnackBar(SnackBar(content: Text(context.l10n.snackbarError(e.toString()))));
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -849,7 +849,7 @@ class _SettingItemState extends State<_SettingItem> {
|
|||||||
);
|
);
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
showDialog(
|
showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: Text(widget.setting.label),
|
title: Text(widget.setting.label),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||||
|
import 'package:spotiflac_android/models/settings.dart';
|
||||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/explore_provider.dart';
|
import 'package:spotiflac_android/providers/explore_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
@@ -61,7 +62,7 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
|||||||
final topPadding = normalizedHeaderTopPadding(context);
|
final topPadding = normalizedHeaderTopPadding(context);
|
||||||
|
|
||||||
return PopScope(
|
return PopScope(
|
||||||
canPop: true, // Always allow back gesture
|
canPop: true,
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
body: CustomScrollView(
|
body: CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
@@ -212,7 +213,7 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
|||||||
showDivider: index < extState.extensions.length - 1,
|
showDivider: index < extState.extensions.length - 1,
|
||||||
onTap: () => Navigator.push(
|
onTap: () => Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute<void>(
|
||||||
builder: (_) =>
|
builder: (_) =>
|
||||||
ExtensionDetailPage(extensionId: ext.id),
|
ExtensionDetailPage(extensionId: ext.id),
|
||||||
),
|
),
|
||||||
@@ -469,7 +470,9 @@ class _DownloadPriorityItem extends ConsumerWidget {
|
|||||||
onTap: hasDownloadExtensions
|
onTap: hasDownloadExtensions
|
||||||
? () => Navigator.push(
|
? () => Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(builder: (_) => const ProviderPriorityPage()),
|
MaterialPageRoute<void>(
|
||||||
|
builder: (_) => const ProviderPriorityPage(),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -534,7 +537,7 @@ class _MetadataPriorityItem extends ConsumerWidget {
|
|||||||
onTap: hasMetadataExtensions
|
onTap: hasMetadataExtensions
|
||||||
? () => Navigator.push(
|
? () => Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute<void>(
|
||||||
builder: (_) => const MetadataProviderPriorityPage(),
|
builder: (_) => const MetadataProviderPriorityPage(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -600,14 +603,12 @@ class _SearchProviderSelector extends ConsumerWidget {
|
|||||||
.where((e) => e.enabled && e.hasCustomSearch)
|
.where((e) => e.enabled && e.hasCustomSearch)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
// Always allow tapping: built-in providers are always available
|
|
||||||
final hasAnyProvider =
|
final hasAnyProvider =
|
||||||
searchProviders.isNotEmpty || _builtInProviders.isNotEmpty;
|
searchProviders.isNotEmpty || _builtInProviders.isNotEmpty;
|
||||||
|
|
||||||
String currentProviderName = context.l10n.extensionDefaultProvider;
|
String currentProviderName = context.l10n.extensionDefaultProvider;
|
||||||
if (settings.searchProvider != null &&
|
if (settings.searchProvider != null &&
|
||||||
settings.searchProvider!.isNotEmpty) {
|
settings.searchProvider!.isNotEmpty) {
|
||||||
// Check built-in first
|
|
||||||
if (_builtInProviders.containsKey(settings.searchProvider)) {
|
if (_builtInProviders.containsKey(settings.searchProvider)) {
|
||||||
currentProviderName = _builtInProviders[settings.searchProvider]!;
|
currentProviderName = _builtInProviders[settings.searchProvider]!;
|
||||||
} else {
|
} else {
|
||||||
@@ -680,12 +681,12 @@ class _SearchProviderSelector extends ConsumerWidget {
|
|||||||
void _showSearchProviderPicker(
|
void _showSearchProviderPicker(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
WidgetRef ref,
|
WidgetRef ref,
|
||||||
dynamic settings,
|
AppSettings settings,
|
||||||
List<Extension> searchProviders,
|
List<Extension> searchProviders,
|
||||||
) {
|
) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
showModalBottomSheet(
|
showModalBottomSheet<void>(
|
||||||
context: context,
|
context: context,
|
||||||
useRootNavigator: true,
|
useRootNavigator: true,
|
||||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||||
@@ -734,7 +735,7 @@ class _SearchProviderSelector extends ConsumerWidget {
|
|||||||
(entry) => ListTile(
|
(entry) => ListTile(
|
||||||
leading: Icon(Icons.search, color: colorScheme.tertiary),
|
leading: Icon(Icons.search, color: colorScheme.tertiary),
|
||||||
title: Text(entry.value),
|
title: Text(entry.value),
|
||||||
subtitle: Text('Search with ${entry.value}'),
|
subtitle: Text(ctx.l10n.extensionsSearchWith(entry.value)),
|
||||||
trailing: settings.searchProvider == entry.key
|
trailing: settings.searchProvider == entry.key
|
||||||
? Icon(Icons.check_circle, color: colorScheme.primary)
|
? Icon(Icons.check_circle, color: colorScheme.primary)
|
||||||
: Icon(Icons.circle_outlined, color: colorScheme.outline),
|
: Icon(Icons.circle_outlined, color: colorScheme.outline),
|
||||||
@@ -790,7 +791,7 @@ class _HomeFeedProviderSelector extends ConsumerWidget {
|
|||||||
|
|
||||||
final hasAnyProvider = homeFeedProviders.isNotEmpty;
|
final hasAnyProvider = homeFeedProviders.isNotEmpty;
|
||||||
|
|
||||||
String currentProviderName = 'Auto';
|
String currentProviderName = context.l10n.extensionsHomeFeedAuto;
|
||||||
if (settings.homeFeedProvider != null &&
|
if (settings.homeFeedProvider != null &&
|
||||||
settings.homeFeedProvider!.isNotEmpty) {
|
settings.homeFeedProvider!.isNotEmpty) {
|
||||||
final ext = homeFeedProviders
|
final ext = homeFeedProviders
|
||||||
@@ -827,7 +828,7 @@ class _HomeFeedProviderSelector extends ConsumerWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Home Feed Provider',
|
context.l10n.extensionsHomeFeedProvider,
|
||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||||
color: !hasAnyProvider ? colorScheme.outline : null,
|
color: !hasAnyProvider ? colorScheme.outline : null,
|
||||||
),
|
),
|
||||||
@@ -835,7 +836,7 @@ class _HomeFeedProviderSelector extends ConsumerWidget {
|
|||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
!hasAnyProvider
|
!hasAnyProvider
|
||||||
? 'No extensions with home feed'
|
? context.l10n.extensionsNoHomeFeedExtensions
|
||||||
: currentProviderName,
|
: currentProviderName,
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
@@ -861,12 +862,12 @@ class _HomeFeedProviderSelector extends ConsumerWidget {
|
|||||||
void _showHomeFeedProviderPicker(
|
void _showHomeFeedProviderPicker(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
WidgetRef ref,
|
WidgetRef ref,
|
||||||
dynamic settings,
|
AppSettings settings,
|
||||||
List<Extension> homeFeedProviders,
|
List<Extension> homeFeedProviders,
|
||||||
) {
|
) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
showModalBottomSheet(
|
showModalBottomSheet<void>(
|
||||||
context: context,
|
context: context,
|
||||||
useRootNavigator: true,
|
useRootNavigator: true,
|
||||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||||
@@ -882,7 +883,7 @@ class _HomeFeedProviderSelector extends ConsumerWidget {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Home Feed Provider',
|
ctx.l10n.extensionsHomeFeedProvider,
|
||||||
style: Theme.of(
|
style: Theme.of(
|
||||||
context,
|
context,
|
||||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||||
@@ -891,7 +892,7 @@ class _HomeFeedProviderSelector extends ConsumerWidget {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Choose which extension provides the home feed on the main screen',
|
ctx.l10n.extensionsHomeFeedDescription,
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
@@ -899,8 +900,8 @@ class _HomeFeedProviderSelector extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: Icon(Icons.auto_awesome, color: colorScheme.primary),
|
leading: Icon(Icons.auto_awesome, color: colorScheme.primary),
|
||||||
title: const Text('Auto'),
|
title: Text(ctx.l10n.extensionsHomeFeedAuto),
|
||||||
subtitle: const Text('Automatically select the best available'),
|
subtitle: Text(ctx.l10n.extensionsHomeFeedAutoSubtitle),
|
||||||
trailing:
|
trailing:
|
||||||
(settings.homeFeedProvider == null ||
|
(settings.homeFeedProvider == null ||
|
||||||
settings.homeFeedProvider!.isEmpty)
|
settings.homeFeedProvider!.isEmpty)
|
||||||
@@ -916,7 +917,9 @@ class _HomeFeedProviderSelector extends ConsumerWidget {
|
|||||||
(ext) => ListTile(
|
(ext) => ListTile(
|
||||||
leading: Icon(Icons.extension, color: colorScheme.secondary),
|
leading: Icon(Icons.extension, color: colorScheme.secondary),
|
||||||
title: Text(ext.displayName),
|
title: Text(ext.displayName),
|
||||||
subtitle: Text('Use ${ext.displayName} home feed'),
|
subtitle: Text(
|
||||||
|
ctx.l10n.extensionsHomeFeedUse(ext.displayName),
|
||||||
|
),
|
||||||
trailing: settings.homeFeedProvider == ext.id
|
trailing: settings.homeFeedProvider == ext.id
|
||||||
? Icon(Icons.check_circle, color: colorScheme.primary)
|
? Icon(Icons.check_circle, color: colorScheme.primary)
|
||||||
: Icon(Icons.circle_outlined, color: colorScheme.outline),
|
: Icon(Icons.circle_outlined, color: colorScheme.outline),
|
||||||
|
|||||||
@@ -23,21 +23,15 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
|||||||
int _androidSdkVersion = 0;
|
int _androidSdkVersion = 0;
|
||||||
bool _hasStoragePermission = false;
|
bool _hasStoragePermission = false;
|
||||||
|
|
||||||
/// Convert SAF content URI to a readable display path
|
|
||||||
String _getDisplayPath(String path) {
|
String _getDisplayPath(String path) {
|
||||||
if (!path.startsWith('content://')) return path;
|
if (!path.startsWith('content://')) return path;
|
||||||
// Extract the path portion from SAF tree URI
|
|
||||||
// e.g. content://com.android.externalstorage.documents/tree/primary%3AMusic
|
|
||||||
// -> /storage/emulated/0/Music
|
|
||||||
try {
|
try {
|
||||||
final uri = Uri.parse(path);
|
final uri = Uri.parse(path);
|
||||||
final treePath =
|
final treePath = uri.pathSegments.last;
|
||||||
uri.pathSegments.last; // e.g. "primary:Music" or "primary%3AMusic"
|
|
||||||
final decoded = Uri.decodeComponent(treePath);
|
final decoded = Uri.decodeComponent(treePath);
|
||||||
if (decoded.startsWith('primary:')) {
|
if (decoded.startsWith('primary:')) {
|
||||||
return '/storage/emulated/0/${decoded.substring('primary:'.length)}';
|
return '/storage/emulated/0/${decoded.substring('primary:'.length)}';
|
||||||
}
|
}
|
||||||
// For SD card or other volumes, just show the decoded path
|
|
||||||
return decoded;
|
return decoded;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return path;
|
return path;
|
||||||
@@ -261,7 +255,7 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
|||||||
|
|
||||||
void _showAutoScanPicker(BuildContext context, String current) {
|
void _showAutoScanPicker(BuildContext context, String current) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
showModalBottomSheet(
|
showModalBottomSheet<void>(
|
||||||
context: context,
|
context: context,
|
||||||
useRootNavigator: true,
|
useRootNavigator: true,
|
||||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ class _LogScreenState extends State<LogScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _clearLogs() {
|
void _clearLogs() {
|
||||||
showDialog(
|
showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: Text(context.l10n.logClearLogsTitle),
|
title: Text(context.l10n.logClearLogsTitle),
|
||||||
@@ -136,7 +136,7 @@ class _LogScreenState extends State<LogScreen> {
|
|||||||
final logs = _filteredLogs;
|
final logs = _filteredLogs;
|
||||||
|
|
||||||
return PopScope(
|
return PopScope(
|
||||||
canPop: true, // Always allow back gesture
|
canPop: true,
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
body: CustomScrollView(
|
body: CustomScrollView(
|
||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
final topPadding = normalizedHeaderTopPadding(context);
|
final topPadding = normalizedHeaderTopPadding(context);
|
||||||
|
|
||||||
return PopScope(
|
return PopScope(
|
||||||
canPop: true, // Always allow back gesture
|
canPop: true,
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
body: CustomScrollView(
|
body: CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
@@ -158,7 +158,7 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
children: [
|
children: [
|
||||||
SettingsSwitchItem(
|
SettingsSwitchItem(
|
||||||
icon: Icons.store,
|
icon: Icons.extension,
|
||||||
title: context.l10n.optionsExtensionStore,
|
title: context.l10n.optionsExtensionStore,
|
||||||
subtitle: context.l10n.optionsExtensionStoreSubtitle,
|
subtitle: context.l10n.optionsExtensionStoreSubtitle,
|
||||||
value: settings.showExtensionStore,
|
value: settings.showExtensionStore,
|
||||||
@@ -241,7 +241,7 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
WidgetRef ref,
|
WidgetRef ref,
|
||||||
ColorScheme colorScheme,
|
ColorScheme colorScheme,
|
||||||
) {
|
) {
|
||||||
showDialog(
|
showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: Text(context.l10n.dialogClearHistoryTitle),
|
title: Text(context.l10n.dialogClearHistoryTitle),
|
||||||
@@ -273,7 +273,7 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
BuildContext context,
|
BuildContext context,
|
||||||
WidgetRef ref,
|
WidgetRef ref,
|
||||||
) async {
|
) async {
|
||||||
showDialog(
|
showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
@@ -307,9 +307,9 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
Navigator.pop(context); // Close loading dialog
|
Navigator.pop(context); // Close loading dialog
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
context,
|
SnackBar(content: Text(context.l10n.snackbarError(e.toString()))),
|
||||||
).showSnackBar(SnackBar(content: Text('Error: $e')));
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -340,12 +340,6 @@ class _ProviderItem extends StatelessWidget {
|
|||||||
icon: Icons.graphic_eq,
|
icon: Icons.graphic_eq,
|
||||||
isBuiltIn: true,
|
isBuiltIn: true,
|
||||||
);
|
);
|
||||||
case 'youtube':
|
|
||||||
return _ProviderInfo(
|
|
||||||
name: 'YouTube',
|
|
||||||
icon: Icons.play_circle_outline,
|
|
||||||
isBuiltIn: true,
|
|
||||||
);
|
|
||||||
default:
|
default:
|
||||||
return _ProviderInfo(
|
return _ProviderInfo(
|
||||||
name: provider,
|
name: provider,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import 'package:spotiflac_android/screens/settings/donate_page.dart';
|
|||||||
import 'package:spotiflac_android/screens/settings/log_screen.dart';
|
import 'package:spotiflac_android/screens/settings/log_screen.dart';
|
||||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||||
|
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||||
|
|
||||||
class SettingsTab extends ConsumerWidget {
|
class SettingsTab extends ConsumerWidget {
|
||||||
const SettingsTab({super.key});
|
const SettingsTab({super.key});
|
||||||
@@ -150,26 +151,6 @@ class SettingsTab extends ConsumerWidget {
|
|||||||
|
|
||||||
void _navigateTo(BuildContext context, Widget page) {
|
void _navigateTo(BuildContext context, Widget page) {
|
||||||
FocusManager.instance.primaryFocus?.unfocus();
|
FocusManager.instance.primaryFocus?.unfocus();
|
||||||
|
Navigator.of(context).push(slidePageRoute<void>(page: page));
|
||||||
Navigator.of(context).push(
|
|
||||||
PageRouteBuilder(
|
|
||||||
pageBuilder: (context, animation, secondaryAnimation) => page,
|
|
||||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
|
||||||
const begin = Offset(1.0, 0.0);
|
|
||||||
const end = Offset.zero;
|
|
||||||
const curve = Curves.easeInOut;
|
|
||||||
var tween = Tween(
|
|
||||||
begin: begin,
|
|
||||||
end: end,
|
|
||||||
).chain(CurveTween(curve: curve));
|
|
||||||
return SlideTransition(
|
|
||||||
position: animation.drive(tween),
|
|
||||||
child: child,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
transitionDuration: const Duration(milliseconds: 300),
|
|
||||||
reverseTransitionDuration: const Duration(milliseconds: 250),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
final shouldOpen = await _showAndroid11StorageDialog();
|
final shouldOpen = await _showAndroid11StorageDialog();
|
||||||
if (shouldOpen == true) {
|
if (shouldOpen == true) {
|
||||||
await Permission.manageExternalStorage.request();
|
await Permission.manageExternalStorage.request();
|
||||||
await Future.delayed(const Duration(milliseconds: 500));
|
await Future<void>.delayed(const Duration(milliseconds: 500));
|
||||||
manageStatus = await Permission.manageExternalStorage.status;
|
manageStatus = await Permission.manageExternalStorage.status;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -203,7 +203,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _showPermissionDeniedDialog(String permissionType) async {
|
Future<void> _showPermissionDeniedDialog(String permissionType) async {
|
||||||
await showDialog(
|
await showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: Text(context.l10n.setupPermissionRequired(permissionType)),
|
title: Text(context.l10n.setupPermissionRequired(permissionType)),
|
||||||
@@ -286,7 +286,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
|
|
||||||
Future<void> _showIOSDirectoryOptions() async {
|
Future<void> _showIOSDirectoryOptions() async {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
await showModalBottomSheet(
|
await showModalBottomSheet<void>(
|
||||||
context: context,
|
context: context,
|
||||||
useRootNavigator: true,
|
useRootNavigator: true,
|
||||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||||
@@ -339,7 +339,9 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text('Failed to open folder picker: $e'),
|
content: Text(
|
||||||
|
context.l10n.snackbarFolderPickerFailed(e.toString()),
|
||||||
|
),
|
||||||
backgroundColor: Theme.of(context).colorScheme.error,
|
backgroundColor: Theme.of(context).colorScheme.error,
|
||||||
duration: const Duration(seconds: 4),
|
duration: const Duration(seconds: 4),
|
||||||
),
|
),
|
||||||
@@ -430,9 +432,9 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
if (mounted) context.go('/tutorial');
|
if (mounted) context.go('/tutorial');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
context,
|
SnackBar(content: Text(context.l10n.snackbarError(e.toString()))),
|
||||||
).showSnackBar(SnackBar(content: Text('Error: $e')));
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setState(() => _isLoading = false);
|
setState(() => _isLoading = false);
|
||||||
@@ -441,14 +443,9 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
|
|
||||||
void _nextPage() {
|
void _nextPage() {
|
||||||
bool canProceed = false;
|
bool canProceed = false;
|
||||||
// Step 0 is Welcome, always can proceed
|
|
||||||
if (_currentStep == 0) {
|
if (_currentStep == 0) {
|
||||||
canProceed = true;
|
canProceed = true;
|
||||||
} else {
|
} else {
|
||||||
// Logic for other steps (offset by 1 because of welcome step)
|
|
||||||
// Step 1: Storage
|
|
||||||
// Step 2: Notification (if android 13+) OR Directory
|
|
||||||
// etc.
|
|
||||||
canProceed = _isStepCompleted(_currentStep);
|
canProceed = _isStepCompleted(_currentStep);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -470,9 +467,8 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool _isStepCompleted(int step) {
|
bool _isStepCompleted(int step) {
|
||||||
if (step == 0) return true; // Welcome
|
if (step == 0) return true;
|
||||||
|
|
||||||
// Adjust step index for logic because we added Welcome at 0
|
|
||||||
final logicStep = step - 1;
|
final logicStep = step - 1;
|
||||||
|
|
||||||
if (_androidSdkVersion >= 33) {
|
if (_androidSdkVersion >= 33) {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user