Compare commits

..

108 Commits

Author SHA1 Message Date
Zarz Eleutherius 637504db41 New translations app_en.arb (Ukrainian) 2026-04-14 08:44:40 +07:00
Zarz Eleutherius 48e499eaeb New translations app_en.arb (Turkish) 2026-04-14 08:44:39 +07:00
Zarz Eleutherius 7372a34d25 New translations app_en.arb (Hindi) 2026-04-14 08:44:38 +07:00
Zarz Eleutherius 4411d80a19 New translations app_en.arb (Indonesian) 2026-04-14 08:44:37 +07:00
Zarz Eleutherius 316d7677c7 New translations app_en.arb (Chinese Traditional) 2026-04-14 08:44:36 +07:00
Zarz Eleutherius fa061fc587 New translations app_en.arb (Chinese Simplified) 2026-04-14 08:44:35 +07:00
Zarz Eleutherius 38605080b7 New translations app_en.arb (Russian) 2026-04-14 08:44:34 +07:00
Zarz Eleutherius 478179169c New translations app_en.arb (Portuguese) 2026-04-14 08:44:32 +07:00
Zarz Eleutherius 83594831a9 New translations app_en.arb (Dutch) 2026-04-14 08:44:31 +07:00
Zarz Eleutherius cec3acfff6 New translations app_en.arb (Korean) 2026-04-14 08:44:30 +07:00
Zarz Eleutherius 18ef5e0aee New translations app_en.arb (Japanese) 2026-04-14 08:44:29 +07:00
Zarz Eleutherius f674eef681 New translations app_en.arb (German) 2026-04-14 08:44:28 +07:00
Zarz Eleutherius 1b95085977 New translations app_en.arb (Spanish) 2026-04-14 08:44:27 +07:00
Zarz Eleutherius 35ab00a7bd New translations app_en.arb (French) 2026-04-14 08:44:26 +07:00
Zarz Eleutherius f2ec276b91 Update source file app_en.arb 2026-04-14 08:44:24 +07:00
Zarz Eleutherius ee797756f7 New translations app_en.arb (Ukrainian) 2026-04-13 08:33:42 +07:00
Zarz Eleutherius 2d54ac1d12 New translations app_en.arb (Ukrainian) 2026-04-12 06:54:47 +07:00
Zarz Eleutherius 87f624c685 New translations app_en.arb (Spanish) 2026-04-11 06:49:41 +07:00
Zarz Eleutherius 48ec563aa1 New translations app_en.arb (Turkish) 2026-04-10 06:27:43 +07:00
Zarz Eleutherius 070e0cd8cf New translations app_en.arb (Ukrainian) 2026-04-08 01:58:38 +07:00
Zarz Eleutherius 948d7aa735 New translations app_en.arb (Turkish) 2026-04-08 01:58:36 +07:00
Zarz Eleutherius 1aaa033dc1 New translations app_en.arb (Ukrainian) 2026-04-07 01:58:46 +07:00
Zarz Eleutherius 56a7ec0763 New translations app_en.arb (Turkish) 2026-04-07 01:58:44 +07:00
Zarz Eleutherius 7da5f69551 New translations app_en.arb (Hindi) 2026-04-07 01:58:43 +07:00
Zarz Eleutherius ace70de9e1 New translations app_en.arb (Indonesian) 2026-04-07 01:58:42 +07:00
Zarz Eleutherius e7369bb4a9 New translations app_en.arb (Chinese Traditional) 2026-04-07 01:58:41 +07:00
Zarz Eleutherius cd6598a866 New translations app_en.arb (Chinese Simplified) 2026-04-07 01:58:39 +07:00
Zarz Eleutherius 93dc95ccc4 New translations app_en.arb (Russian) 2026-04-07 01:58:38 +07:00
Zarz Eleutherius 951518ba81 New translations app_en.arb (Portuguese) 2026-04-07 01:58:37 +07:00
Zarz Eleutherius e3449ded60 New translations app_en.arb (Dutch) 2026-04-07 01:58:36 +07:00
Zarz Eleutherius 913db0c97d New translations app_en.arb (Korean) 2026-04-07 01:58:34 +07:00
Zarz Eleutherius f675c1f223 New translations app_en.arb (Japanese) 2026-04-07 01:58:33 +07:00
Zarz Eleutherius 2d8ee8b04f New translations app_en.arb (German) 2026-04-07 01:58:32 +07:00
Zarz Eleutherius ef1f1b381f New translations app_en.arb (Spanish) 2026-04-07 01:58:31 +07:00
Zarz Eleutherius e2dce6c623 New translations app_en.arb (French) 2026-04-07 01:58:29 +07:00
Zarz Eleutherius 1da8228f89 Update source file app_en.arb 2026-04-07 01:58:27 +07:00
Zarz Eleutherius 67df645ca0 New translations app_en.arb (German) 2026-04-06 01:49:58 +07:00
Zarz Eleutherius 258166c973 New translations app_en.arb (Turkish) 2026-04-05 01:48:02 +07:00
Zarz Eleutherius 780aa8494b New translations app_en.arb (Hindi) 2026-04-05 01:48:01 +07:00
Zarz Eleutherius 0a539bde70 New translations app_en.arb (Indonesian) 2026-04-05 01:48:00 +07:00
Zarz Eleutherius 5232af5a36 New translations app_en.arb (Chinese Traditional) 2026-04-05 01:47:59 +07:00
Zarz Eleutherius 01b4c257ff New translations app_en.arb (Chinese Simplified) 2026-04-05 01:47:58 +07:00
Zarz Eleutherius 914c179a1c New translations app_en.arb (Russian) 2026-04-05 01:47:57 +07:00
Zarz Eleutherius 6d3bea874c New translations app_en.arb (Portuguese) 2026-04-05 01:47:56 +07:00
Zarz Eleutherius 10a3fed592 New translations app_en.arb (Dutch) 2026-04-05 01:47:55 +07:00
Zarz Eleutherius 9245b7fe5d New translations app_en.arb (Korean) 2026-04-05 01:47:54 +07:00
Zarz Eleutherius bca72234be New translations app_en.arb (Japanese) 2026-04-05 01:47:53 +07:00
Zarz Eleutherius d3d77688bf New translations app_en.arb (German) 2026-04-05 01:47:52 +07:00
Zarz Eleutherius a1fb0f1db7 New translations app_en.arb (Spanish) 2026-04-05 01:47:51 +07:00
Zarz Eleutherius 2f58426385 New translations app_en.arb (French) 2026-04-05 01:47:50 +07:00
Zarz Eleutherius f495ce4340 Update source file app_en.arb 2026-04-05 01:47:48 +07:00
Zarz Eleutherius cace5993d2 New translations app_en.arb (German) 2026-04-04 01:51:35 +07:00
Zarz Eleutherius d0da28209e New translations app_en.arb (Spanish) 2026-04-03 00:21:28 +07:00
Zarz Eleutherius ea30ac3eb9 New translations app_en.arb (Turkish) 2026-03-31 16:23:59 +07:00
Zarz Eleutherius 1ff9963209 New translations app_en.arb (Hindi) 2026-03-31 16:23:58 +07:00
Zarz Eleutherius 1e00024ca2 New translations app_en.arb (Indonesian) 2026-03-31 16:23:57 +07:00
Zarz Eleutherius e685bef532 New translations app_en.arb (Chinese Traditional) 2026-03-31 16:23:56 +07:00
Zarz Eleutherius 4b2d61ef2d New translations app_en.arb (Chinese Simplified) 2026-03-31 16:23:54 +07:00
Zarz Eleutherius d79d739200 New translations app_en.arb (Russian) 2026-03-31 16:23:53 +07:00
Zarz Eleutherius 08281b9302 New translations app_en.arb (Portuguese) 2026-03-31 16:23:52 +07:00
Zarz Eleutherius 95b85b9ad4 New translations app_en.arb (Dutch) 2026-03-31 16:23:51 +07:00
Zarz Eleutherius d1ff6b6311 New translations app_en.arb (Korean) 2026-03-31 16:23:50 +07:00
Zarz Eleutherius fe159efc5e New translations app_en.arb (Japanese) 2026-03-31 16:23:48 +07:00
Zarz Eleutherius 92b83fc7ba New translations app_en.arb (German) 2026-03-31 16:23:47 +07:00
Zarz Eleutherius f828e21b39 New translations app_en.arb (Spanish) 2026-03-31 16:23:46 +07:00
Zarz Eleutherius 581b394d46 New translations app_en.arb (French) 2026-03-31 16:23:44 +07:00
Zarz Eleutherius 7f120f3a7e Update source file app_en.arb 2026-03-31 16:23:41 +07:00
Zarz Eleutherius 7c4714db36 New translations app_en.arb (Turkish) 2026-03-30 16:25:37 +07:00
Zarz Eleutherius 7c3f8e6297 New translations app_en.arb (Hindi) 2026-03-30 16:25:36 +07:00
Zarz Eleutherius cb416fffd4 New translations app_en.arb (Indonesian) 2026-03-30 16:25:34 +07:00
Zarz Eleutherius a46644abd3 New translations app_en.arb (Chinese Traditional) 2026-03-30 16:25:33 +07:00
Zarz Eleutherius 660cca6fc4 New translations app_en.arb (Chinese Simplified) 2026-03-30 16:25:31 +07:00
Zarz Eleutherius ef9715f54a New translations app_en.arb (Russian) 2026-03-30 16:25:30 +07:00
Zarz Eleutherius b38132d3b7 New translations app_en.arb (Portuguese) 2026-03-30 16:25:28 +07:00
Zarz Eleutherius 1b00569cb2 New translations app_en.arb (Dutch) 2026-03-30 16:25:27 +07:00
Zarz Eleutherius 4e2539167a New translations app_en.arb (Korean) 2026-03-30 16:25:25 +07:00
Zarz Eleutherius dff7d33461 New translations app_en.arb (Japanese) 2026-03-30 16:25:24 +07:00
Zarz Eleutherius ec228788ca New translations app_en.arb (German) 2026-03-30 16:25:22 +07:00
Zarz Eleutherius 83b6ce7648 New translations app_en.arb (Spanish) 2026-03-30 16:25:21 +07:00
Zarz Eleutherius 7f669680cd New translations app_en.arb (French) 2026-03-30 16:25:19 +07:00
Zarz Eleutherius 1e2e201eff Update source file app_en.arb 2026-03-30 16:25:16 +07:00
Zarz Eleutherius b2fcfe5f18 New translations app_en.arb (Turkish) 2026-03-27 15:58:26 +07:00
Zarz Eleutherius 9d9c3ff1e8 New translations app_en.arb (Hindi) 2026-03-27 15:58:25 +07:00
Zarz Eleutherius 071d096314 New translations app_en.arb (Indonesian) 2026-03-27 15:58:24 +07:00
Zarz Eleutherius 983971ec83 New translations app_en.arb (Chinese Traditional) 2026-03-27 15:58:23 +07:00
Zarz Eleutherius 2adcffd95f New translations app_en.arb (Chinese Simplified) 2026-03-27 15:58:22 +07:00
Zarz Eleutherius bd3734a68c New translations app_en.arb (Russian) 2026-03-27 15:58:20 +07:00
Zarz Eleutherius 0a0eefaf3f New translations app_en.arb (Portuguese) 2026-03-27 15:58:19 +07:00
Zarz Eleutherius 2b65d5aedd New translations app_en.arb (Dutch) 2026-03-27 15:58:18 +07:00
Zarz Eleutherius 77f5fc68c8 New translations app_en.arb (Korean) 2026-03-27 15:58:17 +07:00
Zarz Eleutherius fd79bde4ab New translations app_en.arb (Japanese) 2026-03-27 15:58:16 +07:00
Zarz Eleutherius a99b0230f4 New translations app_en.arb (German) 2026-03-27 15:58:15 +07:00
Zarz Eleutherius 81e41e2f6c New translations app_en.arb (Spanish) 2026-03-27 15:58:14 +07:00
Zarz Eleutherius 97ff250465 New translations app_en.arb (French) 2026-03-27 15:58:13 +07:00
Zarz Eleutherius f8700ee017 Update source file app_en.arb 2026-03-27 15:58:11 +07:00
Zarz Eleutherius d7a009cade New translations app_en.arb (Turkish) 2026-03-26 16:01:55 +07:00
Zarz Eleutherius a2d8feebb3 New translations app_en.arb (Hindi) 2026-03-26 16:01:54 +07:00
Zarz Eleutherius e6f9b4c01d New translations app_en.arb (Indonesian) 2026-03-26 16:01:52 +07:00
Zarz Eleutherius 9682f30fd6 New translations app_en.arb (Chinese Traditional) 2026-03-26 16:01:51 +07:00
Zarz Eleutherius 5c85cb5575 New translations app_en.arb (Chinese Simplified) 2026-03-26 16:01:50 +07:00
Zarz Eleutherius 4bc93381d4 New translations app_en.arb (Russian) 2026-03-26 16:01:49 +07:00
Zarz Eleutherius a41c62548a New translations app_en.arb (Portuguese) 2026-03-26 16:01:47 +07:00
Zarz Eleutherius fd028b6d6c New translations app_en.arb (Dutch) 2026-03-26 16:01:46 +07:00
Zarz Eleutherius 01dd2d52c3 New translations app_en.arb (Korean) 2026-03-26 16:01:44 +07:00
Zarz Eleutherius 3f777eb1cb New translations app_en.arb (Japanese) 2026-03-26 16:01:43 +07:00
Zarz Eleutherius ebfb5150e7 New translations app_en.arb (German) 2026-03-26 16:01:42 +07:00
Zarz Eleutherius aed56e7717 New translations app_en.arb (Spanish) 2026-03-26 16:01:41 +07:00
Zarz Eleutherius 7f4f69620b New translations app_en.arb (French) 2026-03-26 16:01:40 +07:00
121 changed files with 36098 additions and 7936 deletions
+6 -6
View File
@@ -17,7 +17,7 @@
<div align="center">
[![GitHub Release](https://img.shields.io/github/v/release/zarzet/SpotiFLAC-Mobile?style=for-the-badge&logo=github)](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
[![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/31d1bf3c3b2015c13e83c4f909a7c6093a9423e3e702f0c582a3e0035c849424)
[![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/cc11355330c76f97548b8d26452b91746db9d9c1edbcfc4c18250133484d1487)
[![Crowdin](https://img.shields.io/badge/HELP%20TRANSLATE%20ON-CROWDIN-%2321252b?style=for-the-badge&logo=crowdin)](https://crowdin.com/project/spotiflac-mobile)
[![Telegram Channel](https://img.shields.io/badge/CHANNEL-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/spotiflac)
@@ -141,11 +141,6 @@ In AltStore/SideStore, go to **Browse > Sources**, tap **+**, and paste the link
</details>
> [!NOTE]
> If SpotiFLAC is useful to you, consider supporting development:
>
> [![Ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/zarzet)
---
## Contributors
@@ -170,5 +165,10 @@ Interested in contributing? Check out the [Contributing Guide](CONTRIBUTING.md)
| [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) | |
> [!NOTE]
> If SpotiFLAC is useful to you, consider supporting development:
>
> [![Ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/zarzet)
> [!TIP]
> **Star the repo** to get notified about all new releases directly from GitHub.
-20
View File
@@ -9,19 +9,6 @@
# packages, and plugins designed to encourage good coding practices.
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:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
@@ -36,13 +23,6 @@ linter:
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` 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
# https://dart.dev/guides/language/analysis-options
@@ -137,13 +137,14 @@ class DownloadService : Service() {
private fun startForegroundService() {
isRunning = true
// Acquire wake lock to prevent CPU sleep
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
wakeLock = powerManager.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK,
WAKELOCK_TAG
).apply {
acquire(60 * 60 * 1000L)
acquire(60 * 60 * 1000L) // 1 hour max
}
val notification = buildNotification(0, 0)
@@ -27,7 +27,6 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
import org.json.JSONTokener
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
@@ -130,35 +129,39 @@ class MainActivity: FlutterFragmentActivity() {
)
companion object {
// Minimum API level we consider "safe" for Impeller (Android 10+)
private const val SAFE_API_FOR_IMPELLER = 29
// Known problematic GPU patterns (lowercase)
private val PROBLEMATIC_GPU_PATTERNS = listOf(
"adreno (tm) 3",
"adreno (tm) 4",
"mali-4",
"mali-t6",
"mali-t7",
"powervr sgx",
"powervr ge8320",
"gc1000",
"gc2000",
"adreno (tm) 3", // Adreno 300 series (305, 320, 330, etc.) - old Qualcomm
"adreno (tm) 4", // Adreno 400 series - some have issues
"mali-4", // Mali-400 series - old ARM GPUs
"mali-t6", // Mali-T600 series
"mali-t7", // Mali-T700 series (some)
"powervr sgx", // PowerVR SGX series - old Imagination GPUs
"powervr ge8320", // PowerVR GE8320 - known issues
"gc1000", // Vivante GC1000
"gc2000", // Vivante GC2000
)
// Known problematic chipsets/hardware (lowercase)
private val PROBLEMATIC_CHIPSETS = listOf(
"mt6762",
"mt6765",
"mt8768",
"mp0873",
"msm8974",
"msm8226",
"msm8926",
"apq8084",
"mt6762", // MediaTek Helio P22 with PowerVR GE8320
"mt6765", // MediaTek Helio P35 with PowerVR GE8320
"mt8768", // MediaTek tablet chip
"mp0873", // MediaTek variant
"msm8974", // Snapdragon 800/801 with Adreno 330
"msm8226", // Snapdragon 400 with Adreno 305
"msm8926", // Snapdragon 400 with Adreno 305
"apq8084", // Snapdragon 805 (some issues)
)
// Known problematic device models (lowercase)
private val PROBLEMATIC_MODELS = listOf(
"sm-t220",
"sm-t225",
"hammerhead",
"sm-t220", // Samsung Tab A7 Lite
"sm-t225", // Samsung Tab A7 Lite LTE
"hammerhead", // Nexus 5 (Adreno 330)
)
/**
* Check if device should use Skia instead of Impeller.
@@ -170,6 +173,7 @@ class MainActivity: FlutterFragmentActivity() {
val model = Build.MODEL.lowercase(Locale.ROOT)
val device = Build.DEVICE.lowercase(Locale.ROOT)
// 1. Check for explicitly problematic device models
for (problematicModel in PROBLEMATIC_MODELS) {
if (model.contains(problematicModel) || device.contains(problematicModel)) {
android.util.Log.i("SpotiFLAC", "Matched problematic model: $problematicModel")
@@ -177,6 +181,7 @@ class MainActivity: FlutterFragmentActivity() {
}
}
// 2. Check for problematic chipsets
for (chipset in PROBLEMATIC_CHIPSETS) {
if (hardware.contains(chipset) || board.contains(chipset)) {
android.util.Log.i("SpotiFLAC", "Matched problematic chipset: $chipset")
@@ -184,9 +189,12 @@ class MainActivity: FlutterFragmentActivity() {
}
}
// 3. For Android < 10 (API 29), be more aggressive about disabling Impeller
if (Build.VERSION.SDK_INT < SAFE_API_FOR_IMPELLER) {
// For older Android, check GPU renderer if available
val gpuRenderer = getGpuRenderer().lowercase(Locale.ROOT)
// Check for known problematic GPUs
for (pattern in PROBLEMATIC_GPU_PATTERNS) {
if (gpuRenderer.contains(pattern)) {
android.util.Log.i("SpotiFLAC", "Matched problematic GPU on old Android: $pattern")
@@ -194,12 +202,14 @@ class MainActivity: FlutterFragmentActivity() {
}
}
// For very old Android (< 8.0), always use Skia as Vulkan support is spotty
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
android.util.Log.i("SpotiFLAC", "Android < 8.0, using Skia for safety")
return true
}
}
// 4. For Android 10+, still check for known problematic GPUs
val gpuRenderer = getGpuRenderer().lowercase(Locale.ROOT)
for (pattern in PROBLEMATIC_GPU_PATTERNS) {
if (gpuRenderer.contains(pattern)) {
@@ -217,6 +227,8 @@ class MainActivity: FlutterFragmentActivity() {
*/
private fun getGpuRenderer(): String {
return try {
// This might not work before GL context is created,
// but worth trying for additional detection
android.opengl.GLES20.glGetString(android.opengl.GLES20.GL_RENDERER) ?: ""
} catch (e: Exception) {
""
@@ -304,7 +316,6 @@ class MainActivity: FlutterFragmentActivity() {
".mp3" -> "audio/mpeg"
".opus" -> "audio/ogg"
".flac" -> "audio/flac"
".lrc" -> "application/octet-stream"
else -> "application/octet-stream"
}
}
@@ -402,38 +413,6 @@ class MainActivity: FlutterFragmentActivity() {
}
}
private fun parseJsonValue(value: Any?): Any? {
return when (value) {
null, JSONObject.NULL -> null
is JSONObject -> {
val map = LinkedHashMap<String, Any?>()
val keys = value.keys()
while (keys.hasNext()) {
val key = keys.next()
map[key] = parseJsonValue(value.opt(key))
}
map
}
is JSONArray -> {
val list = ArrayList<Any?>()
for (i in 0 until value.length()) {
list.add(parseJsonValue(value.opt(i)))
}
list
}
is Number, is Boolean, is String -> value
else -> value.toString()
}
}
private fun parseJsonPayload(payload: String): Any {
return try {
parseJsonValue(JSONTokener(payload).nextValue()) ?: payload
} catch (_: Exception) {
payload
}
}
private fun startDownloadProgressStream(sink: EventChannel.EventSink) {
stopDownloadProgressStream()
downloadProgressEventSink = sink
@@ -446,7 +425,7 @@ class MainActivity: FlutterFragmentActivity() {
}
if (payload != lastDownloadProgressPayload) {
lastDownloadProgressPayload = payload
sink.success(parseJsonPayload(payload))
sink.success(payload)
}
} catch (e: Exception) {
android.util.Log.w(
@@ -478,7 +457,7 @@ class MainActivity: FlutterFragmentActivity() {
}
if (payload != lastLibraryScanProgressPayload) {
lastLibraryScanProgressPayload = payload
sink.success(parseJsonPayload(payload))
sink.success(payload)
}
} catch (e: Exception) {
android.util.Log.w(
@@ -620,6 +599,7 @@ class MainActivity: FlutterFragmentActivity() {
* Resolve extension from a MediaStore URI by querying DISPLAY_NAME or MIME_TYPE.
*/
private fun resolveMediaStoreExt(uri: Uri, fallbackExt: String?): String {
// Try DISPLAY_NAME first
try {
contentResolver.query(uri, arrayOf(android.provider.MediaStore.MediaColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
@@ -630,6 +610,7 @@ class MainActivity: FlutterFragmentActivity() {
}
} catch (_: Exception) {}
// Try MIME_TYPE
try {
val mime = contentResolver.getType(uri)
val ext = extFromMimeType(mime)
@@ -855,6 +836,8 @@ class MainActivity: FlutterFragmentActivity() {
val mimeType = mimeTypeForExt(outputExt)
val fileName = buildSafFileName(req, outputExt)
// Check for existing file WITHOUT creating the directory first.
// This prevents empty folders from being created for duplicate downloads.
val existingDir = findDocumentDir(treeUri, relativeDir)
if (existingDir != null) {
val existing = existingDir.findFile(fileName)
@@ -869,6 +852,7 @@ class MainActivity: FlutterFragmentActivity() {
}
}
// Only create the directory now that we know we need to download
val targetDir = ensureDocumentDir(treeUri, relativeDir)
?: return errorJson("Failed to access SAF directory")
@@ -891,6 +875,7 @@ class MainActivity: FlutterFragmentActivity() {
val respObj = JSONObject(response)
if (respObj.optBoolean("success", false)) {
// Extension providers write to a local temp path instead of the SAF FD.
// Copy the local file into the SAF document so it is not empty.
val goFilePath = respObj.optString("file_path", "")
if (goFilePath.isNotEmpty() &&
!goFilePath.startsWith("content://") &&
@@ -939,10 +924,15 @@ class MainActivity: FlutterFragmentActivity() {
try {
val docId = android.provider.DocumentsContract.getDocumentId(childUri)
if (docId.isNullOrEmpty()) return null
// Document IDs typically look like "primary:Music/Album/file.cue"
// Parent would be "primary:Music/Album"
val lastSlash = docId.lastIndexOf('/')
if (lastSlash <= 0) return null
val parentDocId = docId.substring(0, lastSlash)
// Build a tree document URI for the parent so it supports listing/findFile
val treeDocId = android.provider.DocumentsContract.getTreeDocumentId(childUri)
if (treeDocId.isNullOrEmpty()) return null
@@ -967,17 +957,21 @@ class MainActivity: FlutterFragmentActivity() {
val lines = File(cueTempPath).readLines()
for (line in lines) {
val trimmed = line.trim().let { l ->
// Strip BOM
if (l.startsWith("\uFEFF")) l.removePrefix("\uFEFF").trim() else l
}
if (trimmed.uppercase(Locale.ROOT).startsWith("FILE ")) {
val rest = trimmed.substring(5).trim()
// Parse: "filename" TYPE or filename TYPE
val filename = if (rest.startsWith("\"")) {
val endQuote = rest.indexOf('"', 1)
if (endQuote > 0) rest.substring(1, endQuote) else rest
} else {
// Last word is the type, everything else is the filename
val parts = rest.split("\\s+".toRegex())
if (parts.size >= 2) parts.dropLast(1).joinToString(" ") else rest
}
// Return just the filename (strip any path separators)
return filename.substringAfterLast("/").substringAfterLast("\\")
}
}
@@ -1062,6 +1056,7 @@ class MainActivity: FlutterFragmentActivity() {
val supportedAudioExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg")
val audioFiles = mutableListOf<Pair<DocumentFile, String>>()
// CUE files: (cueDoc, parentDir) — we need the parent to find sibling audio
val cueFiles = mutableListOf<Pair<DocumentFile, DocumentFile>>()
val visitedDirUris = mutableSetOf<String>()
val safChildLookupCache = mutableMapOf<String, Map<String, DocumentFile>>()
@@ -1146,6 +1141,7 @@ class MainActivity: FlutterFragmentActivity() {
var scanned = 0
var errors = traversalErrors
// --- CUE first pass: parse CUE sheets, expand to tracks, track referenced audio ---
val cueReferencedAudioUris = mutableSetOf<String>()
for ((cueDoc, parentDir) in cueFiles) {
@@ -1184,8 +1180,10 @@ class MainActivity: FlutterFragmentActivity() {
continue
}
// Mark this audio file so we skip it in the regular audio pass
cueReferencedAudioUris.add(audioDoc.uri.toString())
// Copy audio to same temp dir so Go can resolve it
val tempDir = File(tempCuePath).parent ?: cacheDir.absolutePath
val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" }
val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT)
@@ -1199,6 +1197,7 @@ class MainActivity: FlutterFragmentActivity() {
continue
}
// Rename temp audio to its original name so Go can find it by name
val renamedAudio = File(tempDir, audioName)
val tempAudioFile = File(tempAudioPath)
if (renamedAudio.absolutePath != tempAudioFile.absolutePath) {
@@ -1241,12 +1240,14 @@ class MainActivity: FlutterFragmentActivity() {
}
}
// --- Regular audio file pass: skip files referenced by CUE sheets ---
for ((doc, _) in audioFiles) {
if (safScanCancel) {
updateSafScanProgress { it.isComplete = true }
return "[]"
}
// Skip audio files that are represented by CUE track entries
if (cueReferencedAudioUris.contains(doc.uri.toString())) {
scanned++
val pct = scanned.toDouble() / totalItems.toDouble() * 100.0
@@ -1325,6 +1326,7 @@ class MainActivity: FlutterFragmentActivity() {
return result.toString()
}
// Parse existing files map: URI -> lastModified
val existingFiles = mutableMapOf<String, Long>()
try {
val obj = JSONObject(existingFilesJson)
@@ -1343,15 +1345,20 @@ class MainActivity: FlutterFragmentActivity() {
}
val supportedAudioExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg")
val audioFiles = mutableListOf<Triple<DocumentFile, String, Long>>()
val audioFiles = mutableListOf<Triple<DocumentFile, String, Long>>() // doc, path, lastModified
// CUE files to scan: (cueDoc, parentDir, lastModified)
val cueFilesToScan = mutableListOf<Triple<DocumentFile, DocumentFile, Long>>()
// Unchanged CUE files: (cueDoc, parentDir) — need to discover audio siblings for skip set
val unchangedCueFiles = mutableListOf<Pair<DocumentFile, DocumentFile>>()
val currentUris = mutableSetOf<String>()
val visitedDirUris = mutableSetOf<String>()
val safChildLookupCache = mutableMapOf<String, Map<String, DocumentFile>>()
var traversalErrors = 0
val existingCueVirtualPaths = mutableMapOf<String, MutableList<String>>()
// Build a map of CUE base URIs -> existing virtual track URIs from the database.
// Virtual paths look like "content://...album.cue#track01".
// We need this to preserve virtual paths for unchanged CUE files.
val existingCueVirtualPaths = mutableMapOf<String, MutableList<String>>() // cueUri -> [virtualPaths]
for (key in existingFiles.keys) {
val hashIdx = key.indexOf("#track")
if (hashIdx > 0) {
@@ -1360,6 +1367,7 @@ class MainActivity: FlutterFragmentActivity() {
}
}
// Collect all files with lastModified
val queue: ArrayDeque<Pair<DocumentFile, String>> = ArrayDeque()
queue.add(root to "")
@@ -1415,6 +1423,8 @@ class MainActivity: FlutterFragmentActivity() {
}
queue.add(child to childPath)
} else if (child.isFile) {
// Mark file as present first so it cannot be mis-classified as removed
// when provider-specific metadata calls (e.g., lastModified) fail.
val uriStr = child.uri.toString()
currentUris.add(uriStr)
@@ -1426,15 +1436,18 @@ class MainActivity: FlutterFragmentActivity() {
child.lastModified()
} catch (_: Exception) { 0L }
// Check if any virtual track from this CUE exists with matching modTime
val virtualPaths = existingCueVirtualPaths[uriStr]
val existingModified = virtualPaths?.firstOrNull()?.let { existingFiles[it] }
if (existingModified != null && existingModified == lastModified) {
// CUE is unchanged — mark virtual paths as current so they aren't removed
unchangedCueFiles.add(child to dir)
for (vp in virtualPaths) {
currentUris.add(vp)
}
} else {
// CUE is new or modified — needs scanning
cueFilesToScan.add(Triple(child, dir, lastModified))
}
} else if (ext.isNotBlank() && supportedAudioExt.contains(".$ext")) {
@@ -1445,6 +1458,7 @@ class MainActivity: FlutterFragmentActivity() {
existingModified ?: 0L
}
// Check if file is new or modified
if (existingModified == null || existingModified != lastModified) {
audioFiles.add(Triple(child, path, lastModified))
}
@@ -1461,6 +1475,7 @@ class MainActivity: FlutterFragmentActivity() {
}
}
// Find removed files (in existing but not in current)
val removedUris = existingFiles.keys.filter { !currentUris.contains(it) }
val totalFiles = currentUris.size
val filesToProcess = audioFiles.size + cueFilesToScan.size
@@ -1488,6 +1503,7 @@ class MainActivity: FlutterFragmentActivity() {
var scanned = 0
var errors = traversalErrors
// --- CUE first pass: parse new/modified CUE sheets ---
val cueReferencedAudioUris = mutableSetOf<String>()
for ((cueDoc, parentDir, cueLastModified) in cueFilesToScan) {
@@ -1508,6 +1524,7 @@ class MainActivity: FlutterFragmentActivity() {
var tempCuePath: String? = null
var tempAudioPath: String? = null
try {
// Copy CUE to temp
tempCuePath = copyUriToTemp(cueDoc.uri, ".cue")
if (tempCuePath == null) {
errors++
@@ -1516,8 +1533,10 @@ class MainActivity: FlutterFragmentActivity() {
continue
}
// Extract the audio filename from the CUE sheet text
val audioFileName = extractCueAudioFileName(tempCuePath)
// Find the referenced audio file as a sibling in the same SAF directory
val audioDoc = resolveCueAudioSibling(
parentDir = parentDir,
cueName = cueName,
@@ -1532,8 +1551,10 @@ class MainActivity: FlutterFragmentActivity() {
continue
}
// Mark this audio file so we skip it in the regular audio pass
cueReferencedAudioUris.add(audioDoc.uri.toString())
// Copy audio to same temp dir so Go can resolve it
val tempDir = File(tempCuePath).parent ?: cacheDir.absolutePath
val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" }
val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT)
@@ -1547,6 +1568,7 @@ class MainActivity: FlutterFragmentActivity() {
continue
}
// Rename temp audio to its original name so Go can find it by name
val renamedAudio = File(tempDir, audioName)
val tempAudioFile = File(tempAudioPath)
if (renamedAudio.absolutePath != tempAudioFile.absolutePath) {
@@ -1554,6 +1576,7 @@ class MainActivity: FlutterFragmentActivity() {
tempAudioPath = renamedAudio.absolutePath
}
// Call Go to produce library scan entries for each CUE track
val cueResultsJson = Gobackend.scanCueSheetForLibrary(
tempCuePath,
tempDir,
@@ -1565,6 +1588,7 @@ class MainActivity: FlutterFragmentActivity() {
for (j in 0 until cueArray.length()) {
val trackObj = cueArray.getJSONObject(j)
results.put(trackObj)
// Register each virtual path as current so deletion detection works
val virtualPath = trackObj.optString("filePath", "")
if (virtualPath.isNotBlank()) {
currentUris.add(virtualPath)
@@ -1597,6 +1621,9 @@ class MainActivity: FlutterFragmentActivity() {
}
}
// Discover audio siblings for unchanged CUE files so we skip them
// in the regular audio pass. Copy the .cue to temp (tiny file) to extract
// the audio filename, then find the sibling by name.
for ((cueDoc, parentDir) in unchangedCueFiles) {
var tempCue: String? = null
try {
@@ -1621,6 +1648,7 @@ class MainActivity: FlutterFragmentActivity() {
}
}
// --- Regular audio file pass: skip files referenced by CUE sheets ---
for ((doc, _, lastModified) in audioFiles) {
if (safScanCancel) {
updateSafScanProgress { it.isComplete = true }
@@ -1633,6 +1661,7 @@ class MainActivity: FlutterFragmentActivity() {
return result.toString()
}
// Skip audio files that are represented by CUE track entries
if (cueReferencedAudioUris.contains(doc.uri.toString())) {
scanned++
val processed = skippedCount + scanned
@@ -1686,6 +1715,7 @@ class MainActivity: FlutterFragmentActivity() {
}
}
// Recalculate removedUris now that CUE virtual paths have been registered
val finalRemovedUris = existingFiles.keys.filter { !currentUris.contains(it) }
updateSafScanProgress {
@@ -1863,6 +1893,7 @@ class MainActivity: FlutterFragmentActivity() {
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
// Update the intent so receive_sharing_intent can access the new data
setIntent(intent)
}
@@ -1969,13 +2000,13 @@ class MainActivity: FlutterFragmentActivity() {
val response = withContext(Dispatchers.IO) {
Gobackend.getDownloadProgress()
}
result.success(parseJsonPayload(response))
result.success(response)
}
"getAllDownloadProgress" -> {
val response = withContext(Dispatchers.IO) {
Gobackend.getAllDownloadProgress()
}
result.success(parseJsonPayload(response))
result.success(response)
}
"initItemProgress" -> {
val itemId = call.argument<String>("item_id") ?: ""
@@ -2522,6 +2553,7 @@ class MainActivity: FlutterFragmentActivity() {
val tempPath = copyUriToTemp(uri)
?: return@withContext """{"error":"Failed to copy SAF file to temp"}"""
try {
// Replace file_path with temp path for Go
reqObj.put("file_path", tempPath)
val raw = Gobackend.reEnrichFile(reqObj.toString())
val obj = JSONObject(raw)
@@ -2599,6 +2631,7 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(null)
}
// Deezer API methods
"searchDeezerAll" -> {
val query = call.argument<String>("query") ?: ""
val trackLimit = call.argument<Int>("track_limit") ?: 15
@@ -2609,6 +2642,7 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
// Tidal search API
"searchTidalAll" -> {
val query = call.argument<String>("query") ?: ""
val trackLimit = call.argument<Int>("track_limit") ?: 15
@@ -2619,6 +2653,7 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
// Qobuz search API
"searchQobuzAll" -> {
val query = call.argument<String>("query") ?: ""
val trackLimit = call.argument<Int>("track_limit") ?: 15
@@ -2748,6 +2783,7 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
// Log methods
"getLogs" -> {
val response = withContext(Dispatchers.IO) {
Gobackend.getLogs()
@@ -2780,6 +2816,7 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(null)
}
// Extension System methods
"initExtensionSystem" -> {
val extensionsDir = call.argument<String>("extensions_dir") ?: ""
val dataDir = call.argument<String>("data_dir") ?: ""
@@ -2924,6 +2961,7 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(null)
}
// Extension Auth API methods
"getExtensionPendingAuth" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val response = withContext(Dispatchers.IO) {
@@ -2973,6 +3011,7 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
// Extension FFmpeg API
"getPendingFFmpegCommand" -> {
val commandId = call.argument<String>("command_id") ?: ""
val response = withContext(Dispatchers.IO) {
@@ -3000,6 +3039,7 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
// Extension Custom Search API
"customSearchWithExtension" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val query = call.argument<String>("query") ?: ""
@@ -3015,6 +3055,7 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
// Extension URL Handler API
"handleURLWithExtension" -> {
val url = call.argument<String>("url") ?: ""
val response = withContext(Dispatchers.IO) {
@@ -3059,6 +3100,7 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
// Extension Post-Processing API
"runPostProcessing" -> {
val filePath = call.argument<String>("file_path") ?: ""
val metadataJson = call.argument<String>("metadata") ?: ""
@@ -3102,6 +3144,7 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
// Extension Store
"initExtensionStore" -> {
val cacheDir = call.argument<String>("cache_dir") ?: ""
withContext(Dispatchers.IO) {
@@ -3163,6 +3206,7 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(null)
}
// Extension Home Feed (Explore)
"getExtensionHomeFeed" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val response = withContext(Dispatchers.IO) {
@@ -3177,6 +3221,7 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
// Local Library Scanning
"setLibraryCoverCacheDir" -> {
val cacheDir = call.argument<String>("cache_dir") ?: ""
withContext(Dispatchers.IO) {
@@ -3253,7 +3298,7 @@ class MainActivity: FlutterFragmentActivity() {
Gobackend.getLibraryScanProgressJSON()
}
}
result.success(parseJsonPayload(response))
result.success(response)
}
"cancelLibraryScan" -> {
withContext(Dispatchers.IO) {
@@ -3281,6 +3326,7 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
// CUE Sheet Parsing
"parseCueSheet" -> {
val cuePath = call.argument<String>("cue_path") ?: ""
val audioDir = call.argument<String>("audio_dir") ?: ""
@@ -3292,14 +3338,17 @@ class MainActivity: FlutterFragmentActivity() {
?: return@withContext """{"error":"Failed to copy CUE file to temp"}"""
var tempAudioPath: String? = null
try {
// Extract audio filename from CUE text
val audioFileName = extractCueAudioFileName(tempCuePath)
// Try to find the audio sibling in SAF
var audioDoc: DocumentFile? = null
val parentDir = safParentDir(uri)
if (parentDir != null && !audioFileName.isNullOrBlank()) {
audioDoc = try { parentDir.findFile(audioFileName) } catch (_: Exception) { null }
}
// Fallback: try common extensions with the CUE base name
if (audioDoc == null && parentDir != null) {
val cueName = try {
DocumentFile.fromSingleUri(this@MainActivity, uri)?.name ?: ""
@@ -3318,6 +3367,7 @@ class MainActivity: FlutterFragmentActivity() {
val tempDir = File(tempCuePath).parent ?: cacheDir.absolutePath
if (audioDoc != null) {
// Copy audio to same temp dir with original name
val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" }
val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT)
val fallbackExt = if (audioExt.isNotBlank()) ".$audioExt" else null
@@ -3332,11 +3382,15 @@ class MainActivity: FlutterFragmentActivity() {
}
}
// Parse with audio in temp dir; Go will resolve there
val resultJson = Gobackend.parseCueSheet(tempCuePath, tempDir)
// Replace the temp audio_path with the SAF content:// URI
// so Dart knows it's a SAF file and handles it accordingly
if (audioDoc != null) {
val resultObj = JSONObject(resultJson)
resultObj.put("audio_path", audioDoc.uri.toString())
// Also pass the original CUE URI for reference
resultObj.put("cue_path", cuePath)
resultObj.toString()
} else {
+1 -7
View File
@@ -498,13 +498,7 @@ func extractUserTextFrame(data []byte) (string, string) {
func isLyricsDescription(description string) bool {
switch strings.ToLower(strings.TrimSpace(description)) {
case
"lyrics",
"lyric",
"unsyncedlyrics",
"unsynced lyrics",
"uslt",
"lrc":
case "lyrics", "lyric", "unsyncedlyrics", "unsynced lyrics", "lrc":
return true
default:
return false
-133
View File
@@ -1,133 +0,0 @@
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)
}
}
+4 -34
View File
@@ -17,8 +17,6 @@ const (
// Deezer CDN supports these sizes: 56, 250, 500, 1000, 1400, 1800
var deezerSizeRegex = regexp.MustCompile(`/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$`)
var tidalSizeRegex = regexp.MustCompile(`/\d+x\d+\.jpg$`)
func convertSmallToMedium(imageURL string) string {
if strings.Contains(imageURL, spotifySize300) {
return strings.Replace(imageURL, spotifySize300, spotifySize640, 1)
@@ -42,6 +40,7 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
maxURL := upgradeToMaxQuality(downloadURL)
if maxURL != downloadURL {
downloadURL = maxURL
// Log already printed by upgradeToMaxQuality for Deezer
if strings.Contains(coverURL, "scdn.co") || strings.Contains(coverURL, "spotifycdn") {
GoLog("[Cover] Spotify: upgraded to max resolution (~2000x2000)")
}
@@ -87,22 +86,16 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
}
func upgradeToMaxQuality(coverURL string) string {
// Spotify CDN upgrade
if strings.Contains(coverURL, spotifySize640) {
return strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
}
// Deezer CDN upgrade
if strings.Contains(coverURL, "cdn-images.dzcdn.net") {
return upgradeDeezerCover(coverURL)
}
if strings.Contains(coverURL, "resources.tidal.com") {
return upgradeTidalCover(coverURL)
}
if strings.Contains(coverURL, "static.qobuz.com") {
return upgradeQobuzCover(coverURL)
}
return coverURL
}
@@ -118,35 +111,12 @@ func upgradeDeezerCover(coverURL string) string {
return upgraded
}
func upgradeTidalCover(coverURL string) string {
if !strings.Contains(coverURL, "resources.tidal.com") {
return coverURL
}
upgraded := tidalSizeRegex.ReplaceAllString(coverURL, "/origin.jpg")
if upgraded != coverURL {
GoLog("[Cover] Tidal: upgraded to origin resolution")
}
return upgraded
}
func upgradeQobuzCover(coverURL string) string {
if !strings.Contains(coverURL, "static.qobuz.com") {
return coverURL
}
upgraded := qobuzImageSizeRe.ReplaceAllString(coverURL, "_max.jpg")
if upgraded != coverURL {
GoLog("[Cover] Qobuz: upgraded to max resolution")
}
return upgraded
}
func GetCoverFromSpotify(imageURL string, maxQuality bool) string {
if imageURL == "" {
return ""
}
// Always upgrade small to medium first
result := convertSmallToMedium(imageURL)
if maxQuality {
+10
View File
@@ -13,6 +13,7 @@ import (
// CueSheet represents a parsed .cue file
type CueSheet struct {
// Album-level metadata
Performer string `json:"performer"`
Title string `json:"title"`
FileName string `json:"file_name"`
@@ -31,6 +32,7 @@ type CueTrack struct {
Performer string `json:"performer"`
ISRC string `json:"isrc,omitempty"`
Composer string `json:"composer,omitempty"`
// Index positions in seconds (fractional)
StartTime float64 `json:"start_time"` // INDEX 01 in seconds
PreGap float64 `json:"pre_gap"` // INDEX 00 in seconds (or -1 if not present)
}
@@ -80,6 +82,7 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
continue
}
// Handle BOM at start of file
if strings.HasPrefix(line, "\xef\xbb\xbf") {
line = strings.TrimPrefix(line, "\xef\xbb\xbf")
line = strings.TrimSpace(line)
@@ -87,6 +90,7 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
upper := strings.ToUpper(line)
// REM commands (album-level metadata)
if strings.HasPrefix(upper, "REM ") {
matches := reRemCommand.FindStringSubmatch(line)
if len(matches) == 3 {
@@ -132,6 +136,9 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
if strings.HasPrefix(upper, "FILE ") {
rest := line[len("FILE "):]
// Extract filename and type
// Format: FILE "filename.flac" WAVE
// or: FILE filename.flac WAVE
fname, ftype := parseCueFileLine(rest)
sheet.FileName = fname
sheet.FileType = ftype
@@ -139,6 +146,7 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
}
if strings.HasPrefix(upper, "TRACK ") {
// Save previous track
if currentTrack != nil {
sheet.Tracks = append(sheet.Tracks, *currentTrack)
}
@@ -176,6 +184,7 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
continue
}
// SONGWRITER (used as composer sometimes)
if strings.HasPrefix(upper, "SONGWRITER ") {
value := unquoteCue(line[len("SONGWRITER "):])
if currentTrack != nil {
@@ -187,6 +196,7 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
}
}
// Don't forget the last track
if currentTrack != nil {
sheet.Tracks = append(sheet.Tracks, *currentTrack)
}
+2 -1
View File
@@ -1181,7 +1181,7 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa
for attempt := 0; attempt <= deezerMaxRetries; attempt++ {
if attempt > 0 {
delay := deezerRetryDelay * time.Duration(1<<(attempt-1))
delay := deezerRetryDelay * time.Duration(1<<(attempt-1)) // Exponential backoff
GoLog("[Deezer] Retry %d/%d after %v...\n", attempt, deezerMaxRetries, delay)
time.Sleep(delay)
}
@@ -1194,6 +1194,7 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa
lastErr = err
errStr := err.Error()
// Check if error is retryable
isRetryable := strings.Contains(errStr, "timeout") ||
strings.Contains(errStr, "connection reset") ||
strings.Contains(errStr, "connection refused") ||
+1
View File
@@ -319,6 +319,7 @@ func (c *DeezerClient) GetMusicDLDownloadURL(deezerTrackURL string) (string, err
return "", fmt.Errorf("MusicDL error: %s", errMsg)
}
// Try various response fields for download URL
for _, key := range []string{"download_url", "url", "link"} {
if urlVal, ok := raw[key].(string); ok && strings.TrimSpace(urlVal) != "" {
return strings.TrimSpace(urlVal), nil
+144 -76
View File
@@ -48,6 +48,7 @@ func CheckAvailability(spotifyID, isrc string) (string, error) {
}
// SetSongLinkNetworkOptions is kept for backward compatibility.
// It now applies global network compatibility options for all backend API requests.
func SetSongLinkNetworkOptions(allowHTTP, insecureTLS bool) {
SetNetworkCompatibilityOptions(allowHTTP, insecureTLS)
}
@@ -406,6 +407,24 @@ func DownloadTrack(requestJSON string) (string, error) {
}
}
err = deezerErr
case "youtube":
youtubeResult, youtubeErr := downloadFromYouTube(req)
if youtubeErr == nil {
result = DownloadResult{
FilePath: youtubeResult.FilePath,
BitDepth: 0,
SampleRate: 0,
Title: youtubeResult.Title,
Artist: youtubeResult.Artist,
Album: youtubeResult.Album,
ReleaseDate: youtubeResult.ReleaseDate,
TrackNumber: youtubeResult.TrackNumber,
DiscNumber: youtubeResult.DiscNumber,
ISRC: youtubeResult.ISRC,
LyricsLRC: youtubeResult.LyricsLRC,
}
}
err = youtubeErr
default:
return errorResponse("Unknown service: " + req.Service)
}
@@ -457,7 +476,7 @@ func DownloadByStrategy(requestJSON string) (string, error) {
serviceNormalized := strings.ToLower(serviceRaw)
normalizedReq := req
if isBuiltInProvider(serviceNormalized) {
if serviceNormalized == "youtube" || isBuiltInProvider(serviceNormalized) {
normalizedReq.Service = serviceNormalized
}
@@ -467,6 +486,10 @@ func DownloadByStrategy(requestJSON string) (string, error) {
}
normalizedJSON := string(normalizedBytes)
if serviceNormalized == "youtube" {
return DownloadFromYouTube(normalizedJSON)
}
if req.UseExtensions {
// Respect strict mode when auto fallback is disabled:
// for built-in providers, route directly to selected service only.
@@ -698,57 +721,29 @@ func ReadFileMetadata(filePath string) (string, error) {
if isFlac {
metadata, err := ReadMetadata(filePath)
if err != nil {
// File may have wrong extension (e.g. opus saved as .flac).
// Try Ogg/Opus parser as fallback before giving up.
GoLog("[ReadFileMetadata] FLAC parse failed for %s, trying Ogg fallback: %v\n", filePath, err)
oggMeta, oggErr := ReadOggVorbisComments(filePath)
if oggErr == nil && oggMeta != nil {
result["title"] = oggMeta.Title
result["artist"] = oggMeta.Artist
result["album"] = oggMeta.Album
result["album_artist"] = oggMeta.AlbumArtist
result["date"] = oggMeta.Date
if oggMeta.Date == "" {
result["date"] = oggMeta.Year
}
result["track_number"] = oggMeta.TrackNumber
result["disc_number"] = oggMeta.DiscNumber
result["isrc"] = oggMeta.ISRC
result["lyrics"] = oggMeta.Lyrics
result["genre"] = oggMeta.Genre
result["composer"] = oggMeta.Composer
result["comment"] = oggMeta.Comment
quality, qualityErr := GetOggQuality(filePath)
if qualityErr == nil {
result["sample_rate"] = quality.SampleRate
result["duration"] = quality.Duration
}
} else {
return "", fmt.Errorf("failed to read metadata: %w", err)
}
} else {
result["title"] = metadata.Title
result["artist"] = metadata.Artist
result["album"] = metadata.Album
result["album_artist"] = metadata.AlbumArtist
result["date"] = metadata.Date
result["track_number"] = metadata.TrackNumber
result["disc_number"] = metadata.DiscNumber
result["isrc"] = metadata.ISRC
result["lyrics"] = metadata.Lyrics
result["genre"] = metadata.Genre
result["label"] = metadata.Label
result["copyright"] = metadata.Copyright
result["composer"] = metadata.Composer
result["comment"] = metadata.Comment
return "", fmt.Errorf("failed to read metadata: %w", err)
}
result["title"] = metadata.Title
result["artist"] = metadata.Artist
result["album"] = metadata.Album
result["album_artist"] = metadata.AlbumArtist
result["date"] = metadata.Date
result["track_number"] = metadata.TrackNumber
result["disc_number"] = metadata.DiscNumber
result["isrc"] = metadata.ISRC
result["lyrics"] = metadata.Lyrics
result["genre"] = metadata.Genre
result["label"] = metadata.Label
result["copyright"] = metadata.Copyright
result["composer"] = metadata.Composer
result["comment"] = metadata.Comment
quality, qualityErr := GetAudioQuality(filePath)
if qualityErr == nil {
result["bit_depth"] = quality.BitDepth
result["sample_rate"] = quality.SampleRate
if quality.SampleRate > 0 && quality.TotalSamples > 0 {
result["duration"] = int(quality.TotalSamples / int64(quality.SampleRate))
}
quality, qualityErr := GetAudioQuality(filePath)
if qualityErr == nil {
result["bit_depth"] = quality.BitDepth
result["sample_rate"] = quality.SampleRate
if quality.SampleRate > 0 && quality.TotalSamples > 0 {
result["duration"] = int(quality.TotalSamples / int64(quality.SampleRate))
}
}
} else if isM4A {
@@ -915,6 +910,7 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
return string(jsonBytes), nil
}
// MP3/Opus: return metadata for Dart-side FFmpeg embedding
resp := map[string]any{
"success": true,
"method": "ffmpeg",
@@ -1674,6 +1670,62 @@ func errorResponse(msg string) (string, error) {
return string(jsonBytes), nil
}
func DownloadFromYouTube(requestJSON string) (string, error) {
var req DownloadRequest
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
return errorResponse("Invalid request: " + err.Error())
}
applySongLinkRegionFromRequest(&req)
defer closeOwnedOutputFD(req.OutputFD)
req.TrackName = strings.TrimSpace(req.TrackName)
req.ArtistName = strings.TrimSpace(req.ArtistName)
req.AlbumName = strings.TrimSpace(req.AlbumName)
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
req.OutputDir = strings.TrimSpace(req.OutputDir)
req.OutputPath = strings.TrimSpace(req.OutputPath)
req.OutputExt = strings.TrimSpace(req.OutputExt)
if req.OutputPath == "" && req.OutputFD <= 0 && req.OutputDir != "" {
AddAllowedDownloadDir(req.OutputDir)
}
youtubeResult, err := downloadFromYouTube(req)
if err != nil {
return errorResponse(err.Error())
}
resp := DownloadResponse{
Success: true,
Message: "Downloaded from YouTube",
FilePath: youtubeResult.FilePath,
Service: "youtube",
Title: youtubeResult.Title,
Artist: youtubeResult.Artist,
Album: youtubeResult.Album,
ReleaseDate: youtubeResult.ReleaseDate,
TrackNumber: youtubeResult.TrackNumber,
DiscNumber: youtubeResult.DiscNumber,
ISRC: youtubeResult.ISRC,
LyricsLRC: youtubeResult.LyricsLRC,
CoverURL: req.CoverURL,
Genre: req.Genre,
Label: req.Label,
Copyright: req.Copyright,
}
jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil
}
func IsYouTubeURLExport(urlStr string) bool {
return IsYouTubeURL(urlStr)
}
func ExtractYouTubeVideoIDExport(urlStr string) (string, error) {
return ExtractYouTubeVideoID(urlStr)
}
func DownloadCoverToFile(coverURL string, outputPath string, maxQuality bool) error {
if coverURL == "" {
return fmt.Errorf("no cover URL provided")
@@ -1906,6 +1958,7 @@ func ReEnrichFile(requestJSON string) (string, error) {
}
}
// Log metadata summary before embedding
GoLog("[ReEnrich] Metadata to embed: title=%s, artist=%s, album=%s, albumArtist=%s\n",
req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist)
GoLog("[ReEnrich] track=%d, disc=%d, date=%s, isrc=%s, genre=%s, label=%s\n",
@@ -1988,6 +2041,7 @@ func ReEnrichFile(requestJSON string) (string, error) {
}
}
// Build enriched metadata response for Dart (includes online search results)
enrichedMeta := map[string]interface{}{
"track_name": req.TrackName,
"artist_name": req.ArtistName,
@@ -2133,6 +2187,12 @@ func LoadExtensionFromPath(filePath string) (string, error) {
return "", err
}
settingsStore := GetExtensionSettingsStore()
settings := settingsStore.GetAll(ext.ID)
if len(settings) > 0 {
manager.InitializeExtension(ext.ID, settings)
}
result := map[string]interface{}{
"id": ext.ID,
"name": ext.Manifest.Name,
@@ -2166,6 +2226,12 @@ func UpgradeExtensionFromPath(filePath string) (string, error) {
return "", err
}
settingsStore := GetExtensionSettingsStore()
settings := settingsStore.GetAll(ext.ID)
if len(settings) > 0 {
manager.InitializeExtension(ext.ID, settings)
}
result := map[string]interface{}{
"id": ext.ID,
"display_name": ext.Manifest.DisplayName,
@@ -3111,17 +3177,17 @@ func GetPostProcessingProvidersJSON() (string, error) {
}
func InitExtensionStoreJSON(cacheDir string) error {
initExtensionStore(cacheDir)
InitExtensionStore(cacheDir)
return nil
}
func SetStoreRegistryURLJSON(registryURL string) error {
store := getExtensionStore()
store := GetExtensionStore()
if store == nil {
return fmt.Errorf("extension store not initialized")
}
resolved, err := resolveRegistryURL(registryURL)
resolved, err := ResolveRegistryURL(registryURL)
if err != nil {
return err
}
@@ -3130,37 +3196,41 @@ func SetStoreRegistryURLJSON(registryURL string) error {
return err
}
store.setRegistryURL(resolved)
store.SetRegistryURL(resolved)
return nil
}
func ClearStoreRegistryURLJSON() error {
store := getExtensionStore()
store := GetExtensionStore()
if store == nil {
return fmt.Errorf("extension store not initialized")
}
store.setRegistryURL("")
store.clearCache()
store.SetRegistryURL("")
store.ClearCache()
return nil
}
func GetStoreRegistryURLJSON() (string, error) {
store := getExtensionStore()
store := GetExtensionStore()
if store == nil {
return "", fmt.Errorf("extension store not initialized")
}
return store.getRegistryURL(), nil
return store.GetRegistryURL(), nil
}
func GetStoreExtensionsJSON(forceRefresh bool) (string, error) {
store := getExtensionStore()
store := GetExtensionStore()
if store == nil {
return "", fmt.Errorf("extension store not initialized")
}
extensions, err := store.getExtensionsWithStatus(forceRefresh)
if forceRefresh {
store.FetchRegistry(true)
}
extensions, err := store.GetExtensionsWithStatus()
if err != nil {
return "", err
}
@@ -3174,12 +3244,12 @@ func GetStoreExtensionsJSON(forceRefresh bool) (string, error) {
}
func SearchStoreExtensionsJSON(query, category string) (string, error) {
store := getExtensionStore()
store := GetExtensionStore()
if store == nil {
return "", fmt.Errorf("extension store not initialized")
}
extensions, err := store.searchExtensions(query, category)
extensions, err := store.SearchExtensions(query, category)
if err != nil {
return "", err
}
@@ -3193,12 +3263,12 @@ func SearchStoreExtensionsJSON(query, category string) (string, error) {
}
func GetStoreCategoriesJSON() (string, error) {
store := getExtensionStore()
store := GetExtensionStore()
if store == nil {
return "", fmt.Errorf("extension store not initialized")
}
categories := store.getCategories()
categories := store.GetCategories()
jsonBytes, err := json.Marshal(categories)
if err != nil {
return "", err
@@ -3217,7 +3287,7 @@ func buildStoreExtensionDestPath(destDir, extensionID string) (string, error) {
}
func DownloadStoreExtensionJSON(extensionID, destDir string) (string, error) {
store := getExtensionStore()
store := GetExtensionStore()
if store == nil {
return "", fmt.Errorf("extension store not initialized")
}
@@ -3226,7 +3296,7 @@ func DownloadStoreExtensionJSON(extensionID, destDir string) (string, error) {
if err != nil {
return "", err
}
err = store.downloadExtension(extensionID, destPath)
err = store.DownloadExtension(extensionID, destPath)
if err != nil {
return "", err
}
@@ -3235,12 +3305,12 @@ func DownloadStoreExtensionJSON(extensionID, destDir string) (string, error) {
}
func ClearStoreCacheJSON() error {
store := getExtensionStore()
store := GetExtensionStore()
if store == nil {
return fmt.Errorf("extension store not initialized")
}
store.clearCache()
store.ClearCache()
return nil
}
@@ -3254,14 +3324,12 @@ func callExtensionFunctionJSON(extensionID, functionName string, timeout time.Du
if !ext.Enabled {
return "", fmt.Errorf("extension '%s' is disabled", extensionID)
}
vm, err := ext.lockReadyVM()
if err != nil {
return "", err
}
defer ext.VMMu.Unlock()
// Goja runtime is not thread-safe; guard direct extension.*() calls with VMMu
// to avoid races with other provider calls (e.g. getAlbum/getPlaylist).
ext.VMMu.Lock()
defer ext.VMMu.Unlock()
script := fmt.Sprintf(`
(function() {
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
@@ -3271,7 +3339,7 @@ func callExtensionFunctionJSON(extensionID, functionName string, timeout time.Du
})()
`, functionName, functionName)
result, err := RunWithTimeoutAndRecover(vm, script, timeout)
result, err := RunWithTimeoutAndRecover(ext.VM, script, timeout)
if err != nil {
return "", fmt.Errorf("%s failed: %w", functionName, err)
}
+114 -241
View File
@@ -44,76 +44,16 @@ func compareVersions(v1, v2 string) int {
}
type LoadedExtension struct {
ID string `json:"id"`
Manifest *ExtensionManifest `json:"manifest"`
VM *goja.Runtime `json:"-"`
VMMu sync.Mutex `json:"-"`
runtime *ExtensionRuntime
initialized bool
Enabled bool `json:"enabled"`
Error string `json:"error,omitempty"`
DataDir string `json:"data_dir"`
SourceDir string `json:"source_dir"`
IconPath string `json:"icon_path"`
}
func getExtensionInitSettings(extensionID string) map[string]interface{} {
settings := GetExtensionSettingsStore().GetAll(extensionID)
if len(settings) == 0 {
return settings
}
filtered := make(map[string]interface{}, len(settings))
for key, value := range settings {
if strings.HasPrefix(key, "_") {
continue
}
filtered[key] = value
}
return filtered
}
func ensureRuntimeReadyLocked(ext *LoadedExtension, applyStoredSettings bool) error {
if ext.VM == nil || ext.runtime == nil {
if err := initializeVMLocked(ext); err != nil {
ext.Error = err.Error()
ext.Enabled = false
return err
}
}
if applyStoredSettings && !ext.initialized {
settings := getExtensionInitSettings(ext.ID)
if len(settings) > 0 {
if err := initializeExtensionWithSettingsLocked(ext, settings); err != nil {
teardownVMLocked(ext)
ext.Error = err.Error()
ext.Enabled = false
return err
}
} else {
ext.initialized = true
}
}
ext.Error = ""
return nil
}
func (ext *LoadedExtension) ensureRuntimeReady() error {
ext.VMMu.Lock()
defer ext.VMMu.Unlock()
return ensureRuntimeReadyLocked(ext, true)
}
func (ext *LoadedExtension) lockReadyVM() (*goja.Runtime, error) {
ext.VMMu.Lock()
if err := ensureRuntimeReadyLocked(ext, true); err != nil {
ext.VMMu.Unlock()
return nil, err
}
return ext.VM, nil
ID string `json:"id"`
Manifest *ExtensionManifest `json:"manifest"`
VM *goja.Runtime `json:"-"`
VMMu sync.Mutex `json:"-"`
runtime *ExtensionRuntime
Enabled bool `json:"enabled"`
Error string `json:"error,omitempty"`
DataDir string `json:"data_dir"`
SourceDir string `json:"source_dir"`
IconPath string `json:"icon_path"`
}
type ExtensionManager struct {
@@ -280,10 +220,10 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
SourceDir: extDir,
}
if err := validateExtensionLoad(ext); err != nil {
if err := m.initializeVM(ext); err != nil {
ext.Error = err.Error()
ext.Enabled = false
GoLog("[Extension] Failed to validate extension %s: %v\n", manifest.Name, err)
GoLog("[Extension] Failed to initialize VM for %s: %v\n", manifest.Name, err)
}
m.extensions[manifest.Name] = ext
@@ -292,10 +232,7 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
return ext, nil
}
func initializeVMLocked(ext *LoadedExtension) error {
ext.VM = nil
ext.runtime = nil
ext.initialized = false
func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
vm := goja.New()
ext.VM = vm
@@ -342,136 +279,6 @@ func initializeVMLocked(ext *LoadedExtension) error {
return nil
}
func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
ext.VMMu.Lock()
defer ext.VMMu.Unlock()
return initializeVMLocked(ext)
}
func initializeExtensionWithSettingsLocked(
ext *LoadedExtension,
settings map[string]interface{},
) error {
if ext.VM == nil {
return fmt.Errorf("Extension failed to load. Please reinstall the extension")
}
settingsJSON, err := json.Marshal(settings)
if err != nil {
return fmt.Errorf("Failed to save settings")
}
script := fmt.Sprintf(`
(function() {
var settings = %s;
if (typeof extension !== 'undefined' && typeof extension.initialize === 'function') {
try {
extension.initialize(settings);
return { success: true };
} catch (e) {
return { success: false, error: e.toString() };
}
}
return { success: true, message: 'no initialize function' };
})()
`, string(settingsJSON))
result, err := ext.VM.RunString(script)
if err != nil {
ext.Error = fmt.Sprintf("initialize failed: %v", err)
ext.Enabled = false
GoLog("[Extension] Initialize error for %s: %v\n", ext.ID, err)
return err
}
if result != nil && !goja.IsUndefined(result) {
exported := result.Export()
if resultMap, ok := exported.(map[string]interface{}); ok {
if success, ok := resultMap["success"].(bool); ok && !success {
errMsg := "unknown error"
if e, ok := resultMap["error"].(string); ok {
errMsg = e
}
ext.Error = errMsg
ext.Enabled = false
GoLog("[Extension] Initialize failed for %s: %s\n", ext.ID, errMsg)
return fmt.Errorf("initialize failed: %s", errMsg)
}
}
}
ext.initialized = true
GoLog("[Extension] Initialized %s\n", ext.ID)
return nil
}
func runCleanupLocked(ext *LoadedExtension) error {
if ext.VM != nil {
script := `
(function() {
if (typeof extension !== 'undefined' && typeof extension.cleanup === 'function') {
try {
extension.cleanup();
return { success: true };
} catch (e) {
return { success: false, error: e.toString() };
}
}
return { success: true, message: 'no cleanup function' };
})()
`
result, err := ext.VM.RunString(script)
if err != nil {
return err
}
if result != nil && !goja.IsUndefined(result) {
exported := result.Export()
if resultMap, ok := exported.(map[string]interface{}); ok {
if success, ok := resultMap["success"].(bool); ok && !success {
errMsg := "unknown error"
if e, ok := resultMap["error"].(string); ok {
errMsg = e
}
return fmt.Errorf("cleanup failed: %s", errMsg)
}
}
}
if result != nil && !goja.IsUndefined(result) && !goja.IsNull(result) {
GoLog("[Extension] Cleanup called for %s\n", ext.ID)
}
}
return nil
}
func teardownVMLocked(ext *LoadedExtension) {
if err := runCleanupLocked(ext); err != nil {
GoLog("[Extension] Error calling cleanup for %s: %v\n", ext.ID, err)
}
if ext.runtime != nil {
if err := ext.runtime.flushStorageNow(); err != nil {
GoLog("[Extension] Failed to flush storage for %s: %v\n", ext.ID, err)
}
ext.runtime.closeStorageFlusher()
}
ext.runtime = nil
ext.VM = nil
ext.initialized = false
}
func validateExtensionLoad(ext *LoadedExtension) error {
ext.VMMu.Lock()
defer ext.VMMu.Unlock()
if err := initializeVMLocked(ext); err != nil {
return err
}
teardownVMLocked(ext)
return nil
}
func (m *ExtensionManager) UnloadExtension(extensionID string) error {
m.mu.Lock()
defer m.mu.Unlock()
@@ -481,9 +288,21 @@ func (m *ExtensionManager) UnloadExtension(extensionID string) error {
return fmt.Errorf("Extension not found")
}
ext.VMMu.Lock()
teardownVMLocked(ext)
ext.VMMu.Unlock()
if ext.VM != nil {
cleanup, err := ext.VM.RunString("typeof extension !== 'undefined' && typeof extension.cleanup === 'function' ? extension.cleanup() : null")
if err != nil {
GoLog("[Extension] Error calling cleanup for %s: %v\n", extensionID, err)
} else if cleanup != nil && !goja.IsUndefined(cleanup) && !goja.IsNull(cleanup) {
GoLog("[Extension] Cleanup called for %s\n", extensionID)
}
}
if ext.runtime != nil {
if err := ext.runtime.flushStorageNow(); err != nil {
GoLog("[Extension] Failed to flush storage for %s: %v\n", extensionID, err)
}
ext.runtime.closeStorageFlusher()
ext.runtime = nil
}
delete(m.extensions, extensionID)
GoLog("[Extension] Unloaded extension: %s\n", extensionID)
@@ -522,21 +341,7 @@ func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool)
return fmt.Errorf("Extension not found")
}
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()
}
ext.Enabled = enabled
GoLog("[Extension] %s %s\n", extensionID, map[bool]string{true: "enabled", false: "disabled"}[enabled])
store := GetExtensionSettingsStore()
@@ -631,10 +436,10 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
}
}
if err := validateExtensionLoad(ext); err != nil {
if err := m.initializeVM(ext); err != nil {
ext.Error = err.Error()
ext.Enabled = false
GoLog("[Extension] Failed to validate extension %s: %v\n", manifest.Name, err)
GoLog("[Extension] Failed to initialize VM for %s: %v\n", manifest.Name, err)
}
m.extensions[manifest.Name] = ext
@@ -785,14 +590,10 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
SourceDir: extDir,
}
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 {
if err := m.initializeVM(ext); err != nil {
ext.Error = err.Error()
ext.Enabled = false
GoLog("[Extension] Failed to validate upgraded extension %s: %v\n", newManifest.Name, err)
GoLog("[Extension] Failed to initialize VM for %s: %v\n", newManifest.Name, err)
}
m.mu.Lock()
@@ -989,13 +790,56 @@ func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[
return fmt.Errorf("Extension not found")
}
ext.VMMu.Lock()
defer ext.VMMu.Unlock()
if ext.VM == nil {
return fmt.Errorf("Extension failed to load. Please reinstall the extension")
}
if err := ensureRuntimeReadyLocked(ext, false); err != nil {
settingsJSON, err := json.Marshal(settings)
if err != nil {
return fmt.Errorf("Failed to save settings")
}
script := fmt.Sprintf(`
(function() {
var settings = %s;
if (typeof extension !== 'undefined' && typeof extension.initialize === 'function') {
try {
extension.initialize(settings);
return { success: true };
} catch (e) {
return { success: false, error: e.toString() };
}
}
return { success: true, message: 'no initialize function' };
})()
`, string(settingsJSON))
result, err := ext.VM.RunString(script)
if err != nil {
ext.Error = fmt.Sprintf("initialize failed: %v", err)
ext.Enabled = false
GoLog("[Extension] Initialize error for %s: %v\n", extensionID, err)
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 {
@@ -1010,12 +854,41 @@ func (m *ExtensionManager) CleanupExtension(extensionID string) error {
if ext.VM == nil {
return nil
}
ext.VMMu.Lock()
defer ext.VMMu.Unlock()
if err := runCleanupLocked(ext); err != 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 {
GoLog("[Extension] Cleanup error for %s: %v\n", extensionID, err)
return err
}
if result != nil && !goja.IsUndefined(result) {
exported := result.Export()
if resultMap, ok := exported.(map[string]interface{}); ok {
if success, ok := resultMap["success"].(bool); ok && !success {
errMsg := "unknown error"
if e, ok := resultMap["error"].(string); ok {
errMsg = e
}
GoLog("[Extension] Cleanup failed for %s: %s\n", extensionID, errMsg)
return fmt.Errorf("cleanup failed: %s", errMsg)
}
}
}
GoLog("[Extension] Cleaned up %s\n", extensionID)
return nil
}
@@ -1044,8 +917,8 @@ func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) (
return nil, fmt.Errorf("extension not found: %s", extensionID)
}
if err := ext.ensureRuntimeReady(); err != nil {
return nil, err
if ext.VM == nil {
return nil, fmt.Errorf("extension VM not initialized")
}
if !ext.Enabled {
+31 -63
View File
@@ -125,15 +125,6 @@ func NewExtensionProviderWrapper(ext *LoadedExtension) *ExtensionProviderWrapper
}
}
func (p *ExtensionProviderWrapper) lockReadyVM() error {
vm, err := p.extension.lockReadyVM()
if err != nil {
return err
}
p.vm = vm
return nil
}
func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSearchResult, error) {
if !p.extension.Manifest.IsMetadataProvider() {
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
@@ -142,9 +133,8 @@ func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe
if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
if err := p.lockReadyVM(); err != nil {
return nil, err
}
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
script := fmt.Sprintf(`
@@ -202,9 +192,8 @@ func (p *ExtensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata,
if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
if err := p.lockReadyVM(); err != nil {
return nil, err
}
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
script := fmt.Sprintf(`
@@ -251,9 +240,8 @@ func (p *ExtensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata,
if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
if err := p.lockReadyVM(); err != nil {
return nil, err
}
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
script := fmt.Sprintf(`
@@ -303,9 +291,8 @@ func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadat
if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
if err := p.lockReadyVM(); err != nil {
return nil, err
}
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
script := fmt.Sprintf(`
@@ -358,10 +345,8 @@ func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTra
if !p.extension.Enabled {
return track, nil
}
if err := p.lockReadyVM(); err != nil {
GoLog("[Extension] EnrichTrack init error for %s: %v\n", p.extension.ID, err)
return track, nil
}
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
trackJSON, err := json.Marshal(track)
@@ -420,9 +405,8 @@ func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName
if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
if err := p.lockReadyVM(); err != nil {
return nil, err
}
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
script := fmt.Sprintf(`
@@ -468,9 +452,8 @@ func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*Ext
if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
if err := p.lockReadyVM(); err != nil {
return nil, err
}
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
script := fmt.Sprintf(`
@@ -510,7 +493,7 @@ func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*Ext
const ExtDownloadTimeout = DownloadTimeout
func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath, itemID string, onProgress func(percent int)) (*ExtDownloadResult, error) {
func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string, onProgress func(percent int)) (*ExtDownloadResult, error) {
if !p.extension.Manifest.IsDownloadProvider() {
return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID)
}
@@ -518,18 +501,9 @@ func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath, itemID
if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
if err := p.lockReadyVM(); err != nil {
return &ExtDownloadResult{
Success: false,
ErrorMessage: err.Error(),
ErrorType: "init_error",
}, nil
}
p.extension.VMMu.Lock()
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 {
if len(call.Arguments) > 0 {
@@ -1132,7 +1106,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
StartItemProgress(req.ItemID)
}
result, err := provider.Download(trackID, req.Quality, outputPath, req.ItemID, func(percent int) {
result, err := provider.Download(trackID, req.Quality, outputPath, func(percent int) {
if req.ItemID != "" {
normalized := float64(percent) / 100.0
if normalized < 0 {
@@ -1360,7 +1334,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
StartItemProgress(req.ItemID)
}
result, err := provider.Download(availability.TrackID, req.Quality, outputPath, req.ItemID, func(percent int) {
result, err := provider.Download(availability.TrackID, req.Quality, outputPath, func(percent int) {
if req.ItemID != "" {
normalized := float64(percent) / 100.0
if normalized < 0 {
@@ -1652,9 +1626,8 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string
if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
if err := p.lockReadyVM(); err != nil {
return nil, err
}
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
if options == nil {
@@ -1734,9 +1707,8 @@ func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e
if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
if err := p.lockReadyVM(); err != nil {
return nil, err
}
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
script := fmt.Sprintf(`
@@ -1820,9 +1792,8 @@ func (p *ExtensionProviderWrapper) MatchTrack(sourceTrack map[string]interface{}
if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
if err := p.lockReadyVM(); err != nil {
return nil, err
}
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
sourceJSON, _ := json.Marshal(sourceTrack)
@@ -1891,9 +1862,8 @@ func (p *ExtensionProviderWrapper) PostProcess(filePath string, metadata map[str
if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
if err := p.lockReadyVM(); err != nil {
return &PostProcessResult{Success: false, Error: err.Error()}, nil
}
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
metadataJSON, _ := json.Marshal(metadata)
@@ -1954,9 +1924,8 @@ func (p *ExtensionProviderWrapper) PostProcessV2(input PostProcessInput, metadat
if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
if err := p.lockReadyVM(); err != nil {
return &PostProcessResult{Success: false, Error: err.Error()}, nil
}
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
metadataJSON, _ := json.Marshal(metadata)
@@ -2213,9 +2182,8 @@ func (p *ExtensionProviderWrapper) FetchLyrics(trackName, artistName, albumName
if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
if err := p.lockReadyVM(); err != nil {
return nil, err
}
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
// Use global variables to avoid JS injection issues with special characters in track/artist names
-21
View File
@@ -90,9 +90,6 @@ type ExtensionRuntime struct {
dataDir string
vm *goja.Runtime
activeDownloadMu sync.RWMutex
activeDownloadItemID string
storageMu sync.RWMutex
storageCache map[string]interface{}
storageLoaded bool
@@ -142,24 +139,6 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
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 {
// Extension sandbox enforces HTTPS-only domains. Do not apply global
// allow_http scheme downgrade here, because some extension APIs (e.g.
+1 -16
View File
@@ -205,22 +205,13 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
defer out.Close()
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
buf := make([]byte, 32*1024)
for {
nr, er := resp.Body.Read(buf)
if nr > 0 {
nw, ew := progressWriter.Write(buf[0:nr])
nw, ew := out.Write(buf[0:nr])
if nw < 0 || nr < nw {
nw = 0
if ew == nil {
@@ -229,12 +220,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
}
written += int64(nw)
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{}{
"success": false,
"error": fmt.Sprintf("failed to write file: %v", ew),
+48 -53
View File
@@ -21,7 +21,7 @@ const (
CategoryIntegration = "integration"
)
type storeExtension struct {
type StoreExtension struct {
ID string `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name,omitempty"`
@@ -41,7 +41,7 @@ type storeExtension struct {
MinAppVersionAlt string `json:"minAppVersion,omitempty"`
}
func (e *storeExtension) getDisplayName() string {
func (e *StoreExtension) getDisplayName() string {
if e.DisplayName != "" {
return e.DisplayName
}
@@ -51,34 +51,34 @@ func (e *storeExtension) getDisplayName() string {
return e.Name
}
func (e *storeExtension) getDownloadURL() string {
func (e *StoreExtension) getDownloadURL() string {
if e.DownloadURL != "" {
return e.DownloadURL
}
return e.DownloadURLAlt
}
func (e *storeExtension) getIconURL() string {
func (e *StoreExtension) getIconURL() string {
if e.IconURL != "" {
return e.IconURL
}
return e.IconURLAlt
}
func (e *storeExtension) getMinAppVersion() string {
func (e *StoreExtension) getMinAppVersion() string {
if e.MinAppVersion != "" {
return e.MinAppVersion
}
return e.MinAppVersionAlt
}
type storeRegistry struct {
type StoreRegistry struct {
Version int `json:"version"`
UpdatedAt string `json:"updated_at"`
Extensions []storeExtension `json:"extensions"`
Extensions []StoreExtension `json:"extensions"`
}
type storeExtensionResponse struct {
type StoreExtensionResponse struct {
ID string `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
@@ -97,8 +97,8 @@ type storeExtensionResponse struct {
HasUpdate bool `json:"has_update"`
}
func (e *storeExtension) toResponse() storeExtensionResponse {
resp := storeExtensionResponse{
func (e *StoreExtension) ToResponse() StoreExtensionResponse {
return StoreExtensionResponse{
ID: e.ID,
Name: e.Name,
DisplayName: e.getDisplayName(),
@@ -108,30 +108,25 @@ func (e *storeExtension) toResponse() storeExtensionResponse {
DownloadURL: e.getDownloadURL(),
IconURL: e.getIconURL(),
Category: e.Category,
Tags: e.Tags,
Downloads: e.Downloads,
UpdatedAt: e.UpdatedAt,
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
cacheDir string
cache *storeRegistry
cache *StoreRegistry
cacheMu sync.RWMutex
cacheTime time.Time
cacheTTL time.Duration
}
var (
globalExtensionStore *extensionStore
extensionStoreMu sync.Mutex
extensionStore *ExtensionStore
extensionStoreMu sync.Mutex
)
const (
@@ -139,24 +134,24 @@ const (
cacheFileName = "store_cache.json"
)
func initExtensionStore(cacheDir string) *extensionStore {
func InitExtensionStore(cacheDir string) *ExtensionStore {
extensionStoreMu.Lock()
defer extensionStoreMu.Unlock()
if globalExtensionStore == nil {
globalExtensionStore = &extensionStore{
if extensionStore == nil {
extensionStore = &ExtensionStore{
registryURL: "", // No default - user must provide a registry URL
cacheDir: cacheDir,
cacheTTL: cacheTTL,
}
globalExtensionStore.loadDiskCache()
extensionStore.loadDiskCache()
}
return globalExtensionStore
return extensionStore
}
// 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.
func (s *extensionStore) setRegistryURL(registryURL string) {
func (s *ExtensionStore) SetRegistryURL(registryURL string) {
s.cacheMu.Lock()
defer s.cacheMu.Unlock()
@@ -178,19 +173,19 @@ func (s *extensionStore) setRegistryURL(registryURL string) {
}
// GetRegistryURL returns the currently configured registry URL.
func (s *extensionStore) getRegistryURL() string {
func (s *ExtensionStore) GetRegistryURL() string {
s.cacheMu.RLock()
defer s.cacheMu.RUnlock()
return s.registryURL
}
func getExtensionStore() *extensionStore {
func GetExtensionStore() *ExtensionStore {
extensionStoreMu.Lock()
defer extensionStoreMu.Unlock()
return globalExtensionStore
return extensionStore
}
func (s *extensionStore) loadDiskCache() {
func (s *ExtensionStore) loadDiskCache() {
if s.cacheDir == "" {
return
}
@@ -202,7 +197,7 @@ func (s *extensionStore) loadDiskCache() {
}
var cacheData struct {
Registry storeRegistry `json:"registry"`
Registry StoreRegistry `json:"registry"`
CacheTime int64 `json:"cache_time"`
}
@@ -215,13 +210,13 @@ func (s *extensionStore) loadDiskCache() {
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 {
return
}
cacheData := struct {
Registry storeRegistry `json:"registry"`
Registry StoreRegistry `json:"registry"`
CacheTime int64 `json:"cache_time"`
}{
Registry: *s.cache,
@@ -237,10 +232,11 @@ func (s *extensionStore) saveDiskCache() {
os.WriteFile(cachePath, data, 0644)
}
func (s *extensionStore) fetchRegistry(forceRefresh bool) (*storeRegistry, error) {
func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error) {
s.cacheMu.Lock()
defer s.cacheMu.Unlock()
// Check if a registry URL has been configured
if s.registryURL == "" {
return nil, fmt.Errorf("no registry URL configured. Please add a repository URL first")
}
@@ -280,7 +276,7 @@ func (s *extensionStore) fetchRegistry(forceRefresh bool) (*storeRegistry, error
return nil, fmt.Errorf("failed to read registry: %w", err)
}
var registry storeRegistry
var registry StoreRegistry
if err := json.Unmarshal(body, &registry); err != nil {
return nil, fmt.Errorf("failed to parse registry: %w", err)
}
@@ -293,8 +289,8 @@ func (s *extensionStore) fetchRegistry(forceRefresh bool) (*storeRegistry, error
return &registry, nil
}
func (s *extensionStore) getExtensionsWithStatus(forceRefresh bool) ([]storeExtensionResponse, error) {
registry, err := s.fetchRegistry(forceRefresh)
func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionResponse, error) {
registry, err := s.FetchRegistry(false)
if err != nil {
return nil, err
}
@@ -308,32 +304,29 @@ func (s *extensionStore) getExtensionsWithStatus(forceRefresh bool) ([]storeExte
}
}
LogDebug("ExtensionStore", "Building store response for %d registry extensions (%d installed)", len(registry.Extensions), len(installed))
result := make([]StoreExtensionResponse, len(registry.Extensions))
for i, ext := range registry.Extensions {
resp := ext.ToResponse()
result := make([]storeExtensionResponse, 0, len(registry.Extensions))
for i := range registry.Extensions {
ext := &registry.Extensions[i]
resp := ext.toResponse()
if installedVersion, ok := installed[ext.ID]; ok {
resp.IsInstalled = true
resp.InstalledVersion = installedVersion
resp.HasUpdate = compareVersions(ext.Version, installedVersion) > 0
}
result = append(result, resp)
result[i] = resp
}
LogDebug("ExtensionStore", "Built store response payload for %d extensions", len(result))
return result, nil
}
func (s *extensionStore) downloadExtension(extensionID string, destPath string) error {
registry, err := s.fetchRegistry(false)
func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string) error {
registry, err := s.FetchRegistry(false)
if err != nil {
return err
}
var ext *storeExtension
var ext *StoreExtension
for _, e := range registry.Extensions {
if e.ID == extensionID {
ext = &e
@@ -385,7 +378,7 @@ func (s *extensionStore) downloadExtension(extensionID string, destPath string)
// - 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
// - 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)
if input == "" {
return "", fmt.Errorf("registry URL is empty")
@@ -396,6 +389,7 @@ func resolveRegistryURL(input string) (string, error) {
return input, nil
}
// Try to match https://github.com/<owner>/<repo>[/...]
const ghPrefix = "https://github.com/"
if !strings.HasPrefix(input, ghPrefix) {
// Also accept http:// and upgrade silently.
@@ -466,7 +460,7 @@ func requireHTTPSURL(rawURL string, context string) error {
return nil
}
func (s *extensionStore) getCategories() []string {
func (s *ExtensionStore) GetCategories() []string {
return []string{
CategoryMetadata,
CategoryDownload,
@@ -476,8 +470,8 @@ func (s *extensionStore) getCategories() []string {
}
}
func (s *extensionStore) searchExtensions(query string, category string) ([]storeExtensionResponse, error) {
extensions, err := s.getExtensionsWithStatus(false)
func (s *ExtensionStore) SearchExtensions(query string, category string) ([]StoreExtensionResponse, error) {
extensions, err := s.GetExtensionsWithStatus()
if err != nil {
return nil, err
}
@@ -486,7 +480,7 @@ func (s *extensionStore) searchExtensions(query string, category string) ([]stor
return extensions, nil
}
result := make([]storeExtensionResponse, 0, len(extensions))
var result []StoreExtensionResponse
queryLower := toLower(query)
for _, ext := range extensions {
@@ -499,6 +493,7 @@ func (s *extensionStore) searchExtensions(query string, category string) ([]stor
!containsIgnoreCase(ext.DisplayName, queryLower) &&
!containsIgnoreCase(ext.Description, queryLower) &&
!containsIgnoreCase(ext.Author, queryLower) {
// Check tags
found := false
for _, tag := range ext.Tags {
if containsIgnoreCase(tag, queryLower) {
@@ -518,7 +513,7 @@ func (s *extensionStore) searchExtensions(query string, category string) ([]stor
return result, nil
}
func (s *extensionStore) clearCache() {
func (s *ExtensionStore) ClearCache() {
s.cacheMu.Lock()
defer s.cacheMu.Unlock()
+1 -1
View File
@@ -12,7 +12,6 @@ require (
github.com/refraction-networking/utls v1.8.2
golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864
golang.org/x/net v0.50.0
golang.org/x/text v0.34.0
)
require (
@@ -25,5 +24,6 @@ require (
golang.org/x/mod v0.33.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/tools v0.42.0 // indirect
)
+18
View File
@@ -6,6 +6,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM=
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5 h1:QckvTXtu55YMopmVeDrPQ/r+T6xjw8KMCmE3UgUldkw=
github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE=
@@ -28,20 +30,36 @@ github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEv
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3 h1:NiJtT7g4ncNFVjVZMAYNBrPSNhIjFYPj8UKA8MEw2A4=
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3/go.mod h1:wReH3Q1agKmmLapipWFnd4NSs8KPz3fK6mSEZjXLkrg=
golang.org/x/mobile v0.0.0-20260209203831-923679eb55af h1:VqXrZNyqFISxo0rNDFZQlRDRIp7RXSJDeh/LbrK+W1k=
golang.org/x/mobile v0.0.0-20260209203831-923679eb55af/go.mod h1:tbwefIr7RlQD1OpZ0KEZ9nux/uiihAOGdafgZfJkmII=
golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864 h1:cTVynMSsMYgbUrtia2HB1jrhdUwQNtQti91vUCyjMp4=
golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864/go.mod h1:4OGHIUSBiIqyFAQDaX1tpY0BVnO20DvNDeATBu8aeFQ=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
+5
View File
@@ -300,11 +300,14 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
continue
}
// Check for ISP blocking via HTTP status codes
// Some ISPs return 403 or 451 when blocking content
if resp.StatusCode == 403 || resp.StatusCode == 451 {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
bodyStr := strings.ToLower(string(body))
// Check if response looks like ISP blocking page
ispBlockingIndicators := []string{
"blocked", "forbidden", "access denied", "not available in your",
"restricted", "censored", "unavailable for legal", "blocked by",
@@ -515,6 +518,7 @@ func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
return nil
}
// Returns true if ISP blocking was detected
func CheckAndLogISPBlocking(err error, requestURL string, tag string) bool {
ispErr := IsISPBlocking(err, requestURL)
if ispErr != nil {
@@ -549,6 +553,7 @@ func extractDomain(rawURL string) string {
return "unknown"
}
// If ISP blocking is detected, returns a more descriptive error
func WrapErrorWithISPCheck(err error, requestURL string, tag string) error {
if err == nil {
return nil
+2
View File
@@ -112,6 +112,7 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
resp, err := sharedClient.Do(req)
if err == nil {
// Check for Cloudflare challenge page (403 with specific markers)
if resp.StatusCode == 403 || resp.StatusCode == 503 {
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
@@ -153,6 +154,7 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
return resp, nil
}
// Check if error might be TLS-related (Cloudflare blocking)
errStr := strings.ToLower(err.Error())
tlsRelated := strings.Contains(errStr, "tls") ||
strings.Contains(errStr, "handshake") ||
+15 -1
View File
@@ -234,6 +234,8 @@ func ScanLibraryFolder(folderPath string) (string, error) {
continue
}
// Skip audio files that are referenced by a .cue sheet
// (they will be represented by the cue sheet's track entries instead)
if cueReferencedAudioFiles[filePath] {
GoLog("[LibraryScan] Skipping %s (referenced by .cue sheet)\n", filepath.Base(filePath))
continue
@@ -555,6 +557,9 @@ func ReadAudioMetadataWithDisplayName(filePath, displayNameHint string) (string,
return string(jsonBytes), nil
}
// ScanLibraryFolderIncremental performs an incremental scan of the library folder
// existingFilesJSON is a JSON object mapping filePath -> modTime (unix millis)
// Only files that are new or have changed modification time will be scanned
func loadExistingFilesSnapshot(snapshotPath string) (map[string]int64, error) {
existingFiles := make(map[string]int64)
if snapshotPath == "" {
@@ -632,6 +637,7 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
libraryScanProgress.TotalFiles = totalFiles
libraryScanProgressMu.Unlock()
// Find files to scan (new or modified)
var filesToScan []libraryAudioFileInfo
skippedCount := 0
existingCueTrackModTimes := make(map[string]int64)
@@ -647,8 +653,10 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
for _, f := range currentFiles {
existingModTime, exists := existingFiles[f.path]
if !exists {
// For .cue files, also check if any virtual path entries exist
if strings.ToLower(filepath.Ext(f.path)) == ".cue" {
if cueTrackModTime, hasCueTracks := existingCueTrackModTimes[f.path]; hasCueTracks {
// CUE file exists in DB via virtual paths; check if modTime changed
if f.modTime == cueTrackModTime {
skippedCount++
} else {
@@ -667,11 +675,14 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
var deletedPaths []string
for existingPath := range existingFiles {
// For CUE virtual paths (e.g. "/path/album.cue#track01"),
// check if the base .cue file still exists on disk
if idx := strings.LastIndex(existingPath, "#track"); idx > 0 {
baseCuePath := existingPath[:idx]
if currentPathSet[baseCuePath] {
continue
continue // Base .cue file still exists, not deleted
}
// Base CUE file is gone, mark virtual path as deleted
deletedPaths = append(deletedPaths, existingPath)
} else if !currentPathSet[existingPath] {
deletedPaths = append(deletedPaths, existingPath)
@@ -702,6 +713,7 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
scanTime := time.Now().UTC().Format(time.RFC3339)
errorCount := 0
// Track audio files referenced by .cue sheets to avoid duplicates (incremental)
cueReferencedAudioFilesInc := make(map[string]bool)
parsedCueFiles := make(map[string]scannedCueFileInfo)
for _, f := range filesToScan {
@@ -736,6 +748,7 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
ext := strings.ToLower(filepath.Ext(f.path))
// Handle .cue files: produce multiple track results
if ext == ".cue" {
var cueResults []LibraryScanResult
cueInfo, ok := parsedCueFiles[f.path]
@@ -760,6 +773,7 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
continue
}
// Skip audio files referenced by .cue sheets
if cueReferencedAudioFilesInc[f.path] {
continue
}
+6
View File
@@ -83,6 +83,7 @@ func SetLyricsProviderOrder(providers []string) {
return
}
// Validate provider names
validNames := map[string]bool{
LyricsProviderSpotifyAPI: true,
LyricsProviderLRCLIB: true,
@@ -104,6 +105,7 @@ func SetLyricsProviderOrder(providers []string) {
GoLog("[Lyrics] Provider order set to: %v\n", valid)
}
// GetLyricsProviderOrder returns the current lyrics provider order.
func GetLyricsProviderOrder() []string {
lyricsProvidersMu.RLock()
defer lyricsProvidersMu.RUnlock()
@@ -117,6 +119,7 @@ func GetLyricsProviderOrder() []string {
return result
}
// GetAvailableLyricsProviders returns metadata about all available providers.
func GetAvailableLyricsProviders() []map[string]interface{} {
return []map[string]interface{}{
{"id": LyricsProviderSpotifyAPI, "name": "Spotify Lyrics API", "has_proxy_dependency": true, "description": "Spotify-sourced lyrics via Paxsenix"},
@@ -137,6 +140,7 @@ func normalizeLyricsFetchOptions(opts LyricsFetchOptions) LyricsFetchOptions {
return opts
}
// SetLyricsFetchOptions sets provider-specific lyric fetch behavior.
func SetLyricsFetchOptions(opts LyricsFetchOptions) {
normalized := normalizeLyricsFetchOptions(opts)
@@ -152,6 +156,7 @@ func SetLyricsFetchOptions(opts LyricsFetchOptions) {
)
}
// GetLyricsFetchOptions returns current provider-specific lyric fetch behavior.
func GetLyricsFetchOptions() LyricsFetchOptions {
lyricsFetchOptionsMu.RLock()
defer lyricsFetchOptionsMu.RUnlock()
@@ -662,6 +667,7 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
GoLog("[Lyrics] Searching for: %s - %s (providers: %v)\n", artistName, trackName, providerOrder)
// Cascade through all configured built-in providers
for _, providerName := range providerOrder {
GoLog("[Lyrics] Trying provider: %s\n", providerName)
+5 -24
View File
@@ -262,35 +262,26 @@ func qobuzTrackDisplayTitle(track *QobuzTrack) string {
return fmt.Sprintf("%s (%s)", title, version)
}
var qobuzImageSizeRe = regexp.MustCompile(`_\d+\.jpg$`)
func qobuzUpscaleImageURL(url string) string {
if url == "" {
return ""
}
return qobuzImageSizeRe.ReplaceAllString(url, "_max.jpg")
}
func qobuzTrackAlbumImage(track *QobuzTrack) string {
if track == nil {
return ""
}
return qobuzUpscaleImageURL(qobuzFirstNonEmpty(
return qobuzFirstNonEmpty(
track.Album.Image.Large,
track.Album.Image.Small,
track.Album.Image.Thumbnail,
))
)
}
func qobuzAlbumImage(album *qobuzAlbumDetails) string {
if album == nil {
return ""
}
return qobuzUpscaleImageURL(qobuzFirstNonEmpty(
return qobuzFirstNonEmpty(
album.Image.Large,
album.Image.Small,
album.Image.Thumbnail,
))
)
}
func qobuzTrackArtistID(track *QobuzTrack) string {
@@ -945,17 +936,7 @@ func (q *QobuzDownloader) GetAlbumMetadata(resourceID string) (*AlbumResponsePay
tracks := make([]AlbumTrackMetadata, 0, len(album.Tracks.Items))
for i := range album.Tracks.Items {
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))
tracks = append(tracks, qobuzTrackToAlbumTrackMetadata(&album.Tracks.Items[i]))
}
return &AlbumResponsePayload{
+7 -5
View File
@@ -1015,11 +1015,13 @@ func (t *TidalDownloader) GetAlbumMetadata(resourceID string) (*AlbumResponsePay
tracks := make([]AlbumTrackMetadata, 0, len(itemsModule.PagedList.Items))
for _, item := range itemsModule.PagedList.Items {
track := item.Item
track.Album.ID = headerModule.Album.ID
track.Album.Title = headerModule.Album.Title
track.Album.Cover = headerModule.Album.Cover
track.Album.ReleaseDate = headerModule.Album.ReleaseDate
track.Album.URL = headerModule.Album.URL
if track.Album.ID == 0 {
track.Album.ID = headerModule.Album.ID
track.Album.Title = headerModule.Album.Title
track.Album.Cover = headerModule.Album.Cover
track.Album.ReleaseDate = headerModule.Album.ReleaseDate
track.Album.URL = headerModule.Album.URL
}
tracks = append(tracks, tidalTrackToAlbumTrackMetadata(&track))
}
+6 -1
View File
@@ -24,9 +24,11 @@ func normalizeLooseTitle(title string) string {
b.WriteRune(r)
case unicode.IsSpace(r):
b.WriteByte(' ')
// Treat common separators as spaces.
case r == '/', r == '\\', r == '_', r == '-', r == '|', r == '.', r == '&', r == '+':
b.WriteByte(' ')
default:
// Drop other punctuation/symbols (including emoji) for loose matching.
}
}
@@ -57,6 +59,7 @@ func normalizeLooseArtistName(name string) string {
case r == '/', r == '\\', r == '_', r == '-', r == '|', r == '.', r == '&', r == '+':
b.WriteByte(' ')
default:
// Drop remaining punctuation/symbols for loose artist matching.
}
}
@@ -99,11 +102,13 @@ func normalizeSymbolOnlyTitle(title string) string {
return b.String()
}
// ==================== Shared Track Verification ====================
// resolvedTrackInfo holds the metadata fetched from a provider for verification.
type resolvedTrackInfo struct {
Title string
ArtistName string
Duration int
Duration int // seconds
}
// trackMatchesRequest checks whether a resolved track from a provider matches
+750
View File
@@ -0,0 +1,750 @@
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
}
+54
View File
@@ -0,0 +1,54 @@
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)
}
}
+3 -3
View File
@@ -3,14 +3,14 @@ import 'package:flutter/foundation.dart';
/// App version and info constants
/// Update version here only - all other files will reference this
class AppInfo {
static const String version = '4.1.1';
static const String buildNumber = '118';
static const String version = '3.9.0';
static const String buildNumber = '115';
static const String fullVersion = '$version+$buildNumber';
/// Shows "Internal" in debug builds, actual version in release.
static String get displayVersion => kDebugMode ? 'Internal' : version;
static const String appName = 'SpotiFLAC Mobile';
static const String appName = 'SpotiFLAC';
static const String copyright = '© 2026 SpotiFLAC';
static const String mobileAuthor = 'zarzet';
+18 -234
View File
@@ -1432,66 +1432,6 @@ abstract class AppLocalizations {
/// **'Playlists'**
String get searchPlaylists;
/// Bottom sheet title for search sort options
///
/// In en, this message translates to:
/// **'Sort Results'**
String get searchSortTitle;
/// Sort option - default API order
///
/// In en, this message translates to:
/// **'Default'**
String get searchSortDefault;
/// Sort option - title ascending
///
/// In en, this message translates to:
/// **'Title (A-Z)'**
String get searchSortTitleAZ;
/// Sort option - title descending
///
/// In en, this message translates to:
/// **'Title (Z-A)'**
String get searchSortTitleZA;
/// Sort option - artist ascending
///
/// In en, this message translates to:
/// **'Artist (A-Z)'**
String get searchSortArtistAZ;
/// Sort option - artist descending
///
/// In en, this message translates to:
/// **'Artist (Z-A)'**
String get searchSortArtistZA;
/// Sort option - shortest duration first
///
/// In en, this message translates to:
/// **'Duration (Shortest)'**
String get searchSortDurationShort;
/// Sort option - longest duration first
///
/// In en, this message translates to:
/// **'Duration (Longest)'**
String get searchSortDurationLong;
/// Sort option - oldest release first
///
/// In en, this message translates to:
/// **'Release Date (Oldest)'**
String get searchSortDateOldest;
/// Sort option - newest release first
///
/// In en, this message translates to:
/// **'Release Date (Newest)'**
String get searchSortDateNewest;
/// Tooltip - play button
///
/// In en, this message translates to:
@@ -2722,6 +2662,24 @@ abstract class AppLocalizations {
/// **'Actual quality depends on track availability from the service'**
String get qualityNote;
/// Note for YouTube service explaining lossy-only quality
///
/// In en, this message translates to:
/// **'YouTube provides lossy audio only. Not part of lossless fallback.'**
String get youtubeQualityNote;
/// Title for YouTube Opus bitrate setting
///
/// In en, this message translates to:
/// **'YouTube Opus Bitrate'**
String get youtubeOpusBitrateTitle;
/// Title for YouTube MP3 bitrate setting
///
/// In en, this message translates to:
/// **'YouTube MP3 Bitrate'**
String get youtubeMp3BitrateTitle;
/// Setting - show quality picker
///
/// In en, this message translates to:
@@ -2902,18 +2860,6 @@ abstract class AppLocalizations {
/// **'Artist/Album/ and Artist/Singles/'**
String get albumFolderArtistAlbumSinglesSubtitle;
/// Album folder option with singles directly in artist folder
///
/// In en, this message translates to:
/// **'Artist / Album (Singles flat)'**
String get albumFolderArtistAlbumFlat;
/// Folder structure example for flat singles
///
/// In en, this message translates to:
/// **'Artist/Album/ and Artist/song.flac'**
String get albumFolderArtistAlbumFlatSubtitle;
/// Button - delete selected tracks
///
/// In en, this message translates to:
@@ -5138,168 +5084,6 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Empty only'**
String get editMetadataSelectEmpty;
/// Header for active downloads section with count
///
/// In en, this message translates to:
/// **'Downloading ({count})'**
String queueDownloadingCount(int count);
/// Header label for downloaded items section in library
///
/// In en, this message translates to:
/// **'Downloaded'**
String get queueDownloadedHeader;
/// Shown while filter results are being computed
///
/// In en, this message translates to:
/// **'Filtering...'**
String get queueFilteringIndicator;
/// Track count label with plural support
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 track} other{{count} tracks}}'**
String queueTrackCount(int count);
/// Album count label with plural support
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 album} other{{count} albums}}'**
String queueAlbumCount(int count);
/// Empty state title when no album downloads exist
///
/// In en, this message translates to:
/// **'No album downloads'**
String get queueEmptyAlbums;
/// Empty state subtitle for album downloads
///
/// In en, this message translates to:
/// **'Download multiple tracks from an album to see them here'**
String get queueEmptyAlbumsSubtitle;
/// Empty state title when no single track downloads exist
///
/// In en, this message translates to:
/// **'No single downloads'**
String get queueEmptySingles;
/// Empty state subtitle for single track downloads
///
/// In en, this message translates to:
/// **'Single track downloads will appear here'**
String get queueEmptySinglesSubtitle;
/// Empty state title when download history is empty
///
/// In en, this message translates to:
/// **'No download history'**
String get queueEmptyHistory;
/// Empty state subtitle for download history
///
/// In en, this message translates to:
/// **'Downloaded tracks will appear here'**
String get queueEmptyHistorySubtitle;
/// Shown when all playlists are selected in selection mode
///
/// In en, this message translates to:
/// **'All playlists selected'**
String get selectionAllPlaylistsSelected;
/// Hint shown in playlist selection mode
///
/// In en, this message translates to:
/// **'Tap playlists to select'**
String get selectionTapPlaylistsToSelect;
/// Hint shown when no playlists are selected for deletion
///
/// In en, this message translates to:
/// **'Select playlists to delete'**
String get selectionSelectPlaylistsToDelete;
/// Title for audio analysis section
///
/// In en, this message translates to:
/// **'Audio Quality Analysis'**
String get audioAnalysisTitle;
/// Description for audio analysis tap-to-analyze prompt
///
/// In en, this message translates to:
/// **'Verify lossless quality with spectrum analysis'**
String get audioAnalysisDescription;
/// Loading text while analyzing audio
///
/// In en, this message translates to:
/// **'Analyzing audio...'**
String get audioAnalysisAnalyzing;
/// Sample rate metric label
///
/// In en, this message translates to:
/// **'Sample Rate'**
String get audioAnalysisSampleRate;
/// Bit depth metric label
///
/// In en, this message translates to:
/// **'Bit Depth'**
String get audioAnalysisBitDepth;
/// Channels metric label
///
/// In en, this message translates to:
/// **'Channels'**
String get audioAnalysisChannels;
/// Duration metric label
///
/// In en, this message translates to:
/// **'Duration'**
String get audioAnalysisDuration;
/// Nyquist frequency metric label
///
/// In en, this message translates to:
/// **'Nyquist'**
String get audioAnalysisNyquist;
/// File size metric label
///
/// In en, this message translates to:
/// **'Size'**
String get audioAnalysisFileSize;
/// Dynamic range metric label
///
/// In en, this message translates to:
/// **'Dynamic Range'**
String get audioAnalysisDynamicRange;
/// Peak amplitude metric label
///
/// In en, this message translates to:
/// **'Peak'**
String get audioAnalysisPeak;
/// RMS level metric label
///
/// In en, this message translates to:
/// **'RMS'**
String get audioAnalysisRms;
/// Total samples metric label
///
/// In en, this message translates to:
/// **'Samples'**
String get audioAnalysisSamples;
}
class _AppLocalizationsDelegate
+10 -139
View File
@@ -772,36 +772,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get searchPlaylists => 'Playlisten';
@override
String get searchSortTitle => 'Sort Results';
@override
String get searchSortDefault => 'Default';
@override
String get searchSortTitleAZ => 'Title (A-Z)';
@override
String get searchSortTitleZA => 'Title (Z-A)';
@override
String get searchSortArtistAZ => 'Artist (A-Z)';
@override
String get searchSortArtistZA => 'Artist (Z-A)';
@override
String get searchSortDurationShort => 'Duration (Shortest)';
@override
String get searchSortDurationLong => 'Duration (Longest)';
@override
String get searchSortDateOldest => 'Release Date (Oldest)';
@override
String get searchSortDateNewest => 'Release Date (Newest)';
@override
String get tooltipPlay => 'Abspielen';
@@ -1479,6 +1449,16 @@ class AppLocalizationsDe extends AppLocalizations {
String get qualityNote =>
'Die eigentliche Qualität hängt von der Verfügbarkeit des Dienstes ab';
@override
String get youtubeQualityNote =>
'YouTube bietet nur verlustbehaftete Audioqualität. Deswegen ist es kein Teil des verlustfreien Fallbacks.';
@override
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
@override
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
@override
String get downloadAskBeforeDownload => 'Qualität vor Download fragen';
@@ -1578,13 +1558,6 @@ class AppLocalizationsDe extends AppLocalizations {
String get albumFolderArtistAlbumSinglesSubtitle =>
'Künstler/Album/ und Künstler/Singles/';
@override
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
@override
String get albumFolderArtistAlbumFlatSubtitle =>
'Artist/Album/ and Artist/song.flac';
@override
String get downloadedAlbumDeleteSelected => 'Ausgewählte löschen';
@@ -3022,106 +2995,4 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get editMetadataSelectEmpty => 'Empty only';
@override
String queueDownloadingCount(int count) {
return 'Downloading ($count)';
}
@override
String get queueDownloadedHeader => 'Downloaded';
@override
String get queueFilteringIndicator => 'Filtering...';
@override
String queueTrackCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks',
one: '1 track',
);
return '$_temp0';
}
@override
String queueAlbumCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count albums',
one: '1 album',
);
return '$_temp0';
}
@override
String get queueEmptyAlbums => 'No album downloads';
@override
String get queueEmptyAlbumsSubtitle =>
'Download multiple tracks from an album to see them here';
@override
String get queueEmptySingles => 'No single downloads';
@override
String get queueEmptySinglesSubtitle =>
'Single track downloads will appear here';
@override
String get queueEmptyHistory => 'No download history';
@override
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
@override
String get selectionAllPlaylistsSelected => 'All playlists selected';
@override
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
@override
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
@override
String get audioAnalysisTitle => 'Audio Quality Analysis';
@override
String get audioAnalysisDescription =>
'Verify lossless quality with spectrum analysis';
@override
String get audioAnalysisAnalyzing => 'Analyzing audio...';
@override
String get audioAnalysisSampleRate => 'Sample Rate';
@override
String get audioAnalysisBitDepth => 'Bit Depth';
@override
String get audioAnalysisChannels => 'Channels';
@override
String get audioAnalysisDuration => 'Duration';
@override
String get audioAnalysisNyquist => 'Nyquist';
@override
String get audioAnalysisFileSize => 'Size';
@override
String get audioAnalysisDynamicRange => 'Dynamic Range';
@override
String get audioAnalysisPeak => 'Peak';
@override
String get audioAnalysisRms => 'RMS';
@override
String get audioAnalysisSamples => 'Samples';
}
+10 -139
View File
@@ -759,36 +759,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get searchPlaylists => 'Playlists';
@override
String get searchSortTitle => 'Sort Results';
@override
String get searchSortDefault => 'Default';
@override
String get searchSortTitleAZ => 'Title (A-Z)';
@override
String get searchSortTitleZA => 'Title (Z-A)';
@override
String get searchSortArtistAZ => 'Artist (A-Z)';
@override
String get searchSortArtistZA => 'Artist (Z-A)';
@override
String get searchSortDurationShort => 'Duration (Shortest)';
@override
String get searchSortDurationLong => 'Duration (Longest)';
@override
String get searchSortDateOldest => 'Release Date (Oldest)';
@override
String get searchSortDateNewest => 'Release Date (Newest)';
@override
String get tooltipPlay => 'Play';
@@ -1455,6 +1425,16 @@ class AppLocalizationsEn extends AppLocalizations {
String get qualityNote =>
'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
@override
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
@override
String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1552,13 +1532,6 @@ class AppLocalizationsEn extends AppLocalizations {
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
@override
String get albumFolderArtistAlbumFlatSubtitle =>
'Artist/Album/ and Artist/song.flac';
@override
String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2990,106 +2963,4 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get editMetadataSelectEmpty => 'Empty only';
@override
String queueDownloadingCount(int count) {
return 'Downloading ($count)';
}
@override
String get queueDownloadedHeader => 'Downloaded';
@override
String get queueFilteringIndicator => 'Filtering...';
@override
String queueTrackCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks',
one: '1 track',
);
return '$_temp0';
}
@override
String queueAlbumCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count albums',
one: '1 album',
);
return '$_temp0';
}
@override
String get queueEmptyAlbums => 'No album downloads';
@override
String get queueEmptyAlbumsSubtitle =>
'Download multiple tracks from an album to see them here';
@override
String get queueEmptySingles => 'No single downloads';
@override
String get queueEmptySinglesSubtitle =>
'Single track downloads will appear here';
@override
String get queueEmptyHistory => 'No download history';
@override
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
@override
String get selectionAllPlaylistsSelected => 'All playlists selected';
@override
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
@override
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
@override
String get audioAnalysisTitle => 'Audio Quality Analysis';
@override
String get audioAnalysisDescription =>
'Verify lossless quality with spectrum analysis';
@override
String get audioAnalysisAnalyzing => 'Analyzing audio...';
@override
String get audioAnalysisSampleRate => 'Sample Rate';
@override
String get audioAnalysisBitDepth => 'Bit Depth';
@override
String get audioAnalysisChannels => 'Channels';
@override
String get audioAnalysisDuration => 'Duration';
@override
String get audioAnalysisNyquist => 'Nyquist';
@override
String get audioAnalysisFileSize => 'Size';
@override
String get audioAnalysisDynamicRange => 'Dynamic Range';
@override
String get audioAnalysisPeak => 'Peak';
@override
String get audioAnalysisRms => 'RMS';
@override
String get audioAnalysisSamples => 'Samples';
}
+20 -139
View File
@@ -759,36 +759,6 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get searchPlaylists => 'Playlists';
@override
String get searchSortTitle => 'Sort Results';
@override
String get searchSortDefault => 'Default';
@override
String get searchSortTitleAZ => 'Title (A-Z)';
@override
String get searchSortTitleZA => 'Title (Z-A)';
@override
String get searchSortArtistAZ => 'Artist (A-Z)';
@override
String get searchSortArtistZA => 'Artist (Z-A)';
@override
String get searchSortDurationShort => 'Duration (Shortest)';
@override
String get searchSortDurationLong => 'Duration (Longest)';
@override
String get searchSortDateOldest => 'Release Date (Oldest)';
@override
String get searchSortDateNewest => 'Release Date (Newest)';
@override
String get tooltipPlay => 'Play';
@@ -1455,6 +1425,16 @@ class AppLocalizationsEs extends AppLocalizations {
String get qualityNote =>
'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
@override
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
@override
String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1552,13 +1532,6 @@ class AppLocalizationsEs extends AppLocalizations {
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
@override
String get albumFolderArtistAlbumFlatSubtitle =>
'Artist/Album/ and Artist/song.flac';
@override
String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2990,108 +2963,6 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get editMetadataSelectEmpty => 'Empty only';
@override
String queueDownloadingCount(int count) {
return 'Downloading ($count)';
}
@override
String get queueDownloadedHeader => 'Downloaded';
@override
String get queueFilteringIndicator => 'Filtering...';
@override
String queueTrackCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks',
one: '1 track',
);
return '$_temp0';
}
@override
String queueAlbumCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count albums',
one: '1 album',
);
return '$_temp0';
}
@override
String get queueEmptyAlbums => 'No album downloads';
@override
String get queueEmptyAlbumsSubtitle =>
'Download multiple tracks from an album to see them here';
@override
String get queueEmptySingles => 'No single downloads';
@override
String get queueEmptySinglesSubtitle =>
'Single track downloads will appear here';
@override
String get queueEmptyHistory => 'No download history';
@override
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
@override
String get selectionAllPlaylistsSelected => 'All playlists selected';
@override
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
@override
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
@override
String get audioAnalysisTitle => 'Audio Quality Analysis';
@override
String get audioAnalysisDescription =>
'Verify lossless quality with spectrum analysis';
@override
String get audioAnalysisAnalyzing => 'Analyzing audio...';
@override
String get audioAnalysisSampleRate => 'Sample Rate';
@override
String get audioAnalysisBitDepth => 'Bit Depth';
@override
String get audioAnalysisChannels => 'Channels';
@override
String get audioAnalysisDuration => 'Duration';
@override
String get audioAnalysisNyquist => 'Nyquist';
@override
String get audioAnalysisFileSize => 'Size';
@override
String get audioAnalysisDynamicRange => 'Dynamic Range';
@override
String get audioAnalysisPeak => 'Peak';
@override
String get audioAnalysisRms => 'RMS';
@override
String get audioAnalysisSamples => 'Samples';
}
/// The translations for Spanish Castilian, as used in Spain (`es_ES`).
@@ -4463,6 +4334,16 @@ class AppLocalizationsEsEs extends AppLocalizationsEs {
String get qualityNote =>
'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
String get downloadAskBeforeDownload => 'Preguntar antes de descargar';
+10 -139
View File
@@ -761,36 +761,6 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get searchPlaylists => 'Playlists';
@override
String get searchSortTitle => 'Sort Results';
@override
String get searchSortDefault => 'Default';
@override
String get searchSortTitleAZ => 'Title (A-Z)';
@override
String get searchSortTitleZA => 'Title (Z-A)';
@override
String get searchSortArtistAZ => 'Artist (A-Z)';
@override
String get searchSortArtistZA => 'Artist (Z-A)';
@override
String get searchSortDurationShort => 'Duration (Shortest)';
@override
String get searchSortDurationLong => 'Duration (Longest)';
@override
String get searchSortDateOldest => 'Release Date (Oldest)';
@override
String get searchSortDateNewest => 'Release Date (Newest)';
@override
String get tooltipPlay => 'Play';
@@ -1457,6 +1427,16 @@ class AppLocalizationsFr extends AppLocalizations {
String get qualityNote =>
'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
@override
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
@override
String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1554,13 +1534,6 @@ class AppLocalizationsFr extends AppLocalizations {
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
@override
String get albumFolderArtistAlbumFlatSubtitle =>
'Artist/Album/ and Artist/song.flac';
@override
String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2991,106 +2964,4 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get editMetadataSelectEmpty => 'Empty only';
@override
String queueDownloadingCount(int count) {
return 'Downloading ($count)';
}
@override
String get queueDownloadedHeader => 'Downloaded';
@override
String get queueFilteringIndicator => 'Filtering...';
@override
String queueTrackCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks',
one: '1 track',
);
return '$_temp0';
}
@override
String queueAlbumCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count albums',
one: '1 album',
);
return '$_temp0';
}
@override
String get queueEmptyAlbums => 'No album downloads';
@override
String get queueEmptyAlbumsSubtitle =>
'Download multiple tracks from an album to see them here';
@override
String get queueEmptySingles => 'No single downloads';
@override
String get queueEmptySinglesSubtitle =>
'Single track downloads will appear here';
@override
String get queueEmptyHistory => 'No download history';
@override
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
@override
String get selectionAllPlaylistsSelected => 'All playlists selected';
@override
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
@override
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
@override
String get audioAnalysisTitle => 'Audio Quality Analysis';
@override
String get audioAnalysisDescription =>
'Verify lossless quality with spectrum analysis';
@override
String get audioAnalysisAnalyzing => 'Analyzing audio...';
@override
String get audioAnalysisSampleRate => 'Sample Rate';
@override
String get audioAnalysisBitDepth => 'Bit Depth';
@override
String get audioAnalysisChannels => 'Channels';
@override
String get audioAnalysisDuration => 'Duration';
@override
String get audioAnalysisNyquist => 'Nyquist';
@override
String get audioAnalysisFileSize => 'Size';
@override
String get audioAnalysisDynamicRange => 'Dynamic Range';
@override
String get audioAnalysisPeak => 'Peak';
@override
String get audioAnalysisRms => 'RMS';
@override
String get audioAnalysisSamples => 'Samples';
}
+10 -139
View File
@@ -759,36 +759,6 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get searchPlaylists => 'Playlists';
@override
String get searchSortTitle => 'Sort Results';
@override
String get searchSortDefault => 'Default';
@override
String get searchSortTitleAZ => 'Title (A-Z)';
@override
String get searchSortTitleZA => 'Title (Z-A)';
@override
String get searchSortArtistAZ => 'Artist (A-Z)';
@override
String get searchSortArtistZA => 'Artist (Z-A)';
@override
String get searchSortDurationShort => 'Duration (Shortest)';
@override
String get searchSortDurationLong => 'Duration (Longest)';
@override
String get searchSortDateOldest => 'Release Date (Oldest)';
@override
String get searchSortDateNewest => 'Release Date (Newest)';
@override
String get tooltipPlay => 'Play';
@@ -1455,6 +1425,16 @@ class AppLocalizationsHi extends AppLocalizations {
String get qualityNote =>
'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
@override
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
@override
String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1552,13 +1532,6 @@ class AppLocalizationsHi extends AppLocalizations {
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
@override
String get albumFolderArtistAlbumFlatSubtitle =>
'Artist/Album/ and Artist/song.flac';
@override
String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2989,106 +2962,4 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get editMetadataSelectEmpty => 'Empty only';
@override
String queueDownloadingCount(int count) {
return 'Downloading ($count)';
}
@override
String get queueDownloadedHeader => 'Downloaded';
@override
String get queueFilteringIndicator => 'Filtering...';
@override
String queueTrackCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks',
one: '1 track',
);
return '$_temp0';
}
@override
String queueAlbumCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count albums',
one: '1 album',
);
return '$_temp0';
}
@override
String get queueEmptyAlbums => 'No album downloads';
@override
String get queueEmptyAlbumsSubtitle =>
'Download multiple tracks from an album to see them here';
@override
String get queueEmptySingles => 'No single downloads';
@override
String get queueEmptySinglesSubtitle =>
'Single track downloads will appear here';
@override
String get queueEmptyHistory => 'No download history';
@override
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
@override
String get selectionAllPlaylistsSelected => 'All playlists selected';
@override
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
@override
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
@override
String get audioAnalysisTitle => 'Audio Quality Analysis';
@override
String get audioAnalysisDescription =>
'Verify lossless quality with spectrum analysis';
@override
String get audioAnalysisAnalyzing => 'Analyzing audio...';
@override
String get audioAnalysisSampleRate => 'Sample Rate';
@override
String get audioAnalysisBitDepth => 'Bit Depth';
@override
String get audioAnalysisChannels => 'Channels';
@override
String get audioAnalysisDuration => 'Duration';
@override
String get audioAnalysisNyquist => 'Nyquist';
@override
String get audioAnalysisFileSize => 'Size';
@override
String get audioAnalysisDynamicRange => 'Dynamic Range';
@override
String get audioAnalysisPeak => 'Peak';
@override
String get audioAnalysisRms => 'RMS';
@override
String get audioAnalysisSamples => 'Samples';
}
+10 -139
View File
@@ -762,36 +762,6 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get searchPlaylists => 'Playlist';
@override
String get searchSortTitle => 'Sort Results';
@override
String get searchSortDefault => 'Default';
@override
String get searchSortTitleAZ => 'Title (A-Z)';
@override
String get searchSortTitleZA => 'Title (Z-A)';
@override
String get searchSortArtistAZ => 'Artist (A-Z)';
@override
String get searchSortArtistZA => 'Artist (Z-A)';
@override
String get searchSortDurationShort => 'Duration (Shortest)';
@override
String get searchSortDurationLong => 'Duration (Longest)';
@override
String get searchSortDateOldest => 'Release Date (Oldest)';
@override
String get searchSortDateNewest => 'Release Date (Newest)';
@override
String get tooltipPlay => 'Putar';
@@ -1463,6 +1433,16 @@ class AppLocalizationsId extends AppLocalizations {
String get qualityNote =>
'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
String get downloadAskBeforeDownload => 'Tanya Sebelum Unduh';
@@ -1561,13 +1541,6 @@ class AppLocalizationsId extends AppLocalizations {
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artis/Album/ dan Artis/Single/';
@override
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
@override
String get albumFolderArtistAlbumFlatSubtitle =>
'Artist/Album/ and Artist/song.flac';
@override
String get downloadedAlbumDeleteSelected => 'Hapus yang Dipilih';
@@ -2999,106 +2972,4 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get editMetadataSelectEmpty => 'Empty only';
@override
String queueDownloadingCount(int count) {
return 'Downloading ($count)';
}
@override
String get queueDownloadedHeader => 'Downloaded';
@override
String get queueFilteringIndicator => 'Filtering...';
@override
String queueTrackCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks',
one: '1 track',
);
return '$_temp0';
}
@override
String queueAlbumCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count albums',
one: '1 album',
);
return '$_temp0';
}
@override
String get queueEmptyAlbums => 'No album downloads';
@override
String get queueEmptyAlbumsSubtitle =>
'Download multiple tracks from an album to see them here';
@override
String get queueEmptySingles => 'No single downloads';
@override
String get queueEmptySinglesSubtitle =>
'Single track downloads will appear here';
@override
String get queueEmptyHistory => 'No download history';
@override
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
@override
String get selectionAllPlaylistsSelected => 'All playlists selected';
@override
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
@override
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
@override
String get audioAnalysisTitle => 'Audio Quality Analysis';
@override
String get audioAnalysisDescription =>
'Verify lossless quality with spectrum analysis';
@override
String get audioAnalysisAnalyzing => 'Analyzing audio...';
@override
String get audioAnalysisSampleRate => 'Sample Rate';
@override
String get audioAnalysisBitDepth => 'Bit Depth';
@override
String get audioAnalysisChannels => 'Channels';
@override
String get audioAnalysisDuration => 'Duration';
@override
String get audioAnalysisNyquist => 'Nyquist';
@override
String get audioAnalysisFileSize => 'Size';
@override
String get audioAnalysisDynamicRange => 'Dynamic Range';
@override
String get audioAnalysisPeak => 'Peak';
@override
String get audioAnalysisRms => 'RMS';
@override
String get audioAnalysisSamples => 'Samples';
}
+10 -139
View File
@@ -754,36 +754,6 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get searchPlaylists => 'プレイリスト';
@override
String get searchSortTitle => 'Sort Results';
@override
String get searchSortDefault => 'Default';
@override
String get searchSortTitleAZ => 'Title (A-Z)';
@override
String get searchSortTitleZA => 'Title (Z-A)';
@override
String get searchSortArtistAZ => 'Artist (A-Z)';
@override
String get searchSortArtistZA => 'Artist (Z-A)';
@override
String get searchSortDurationShort => 'Duration (Shortest)';
@override
String get searchSortDurationLong => 'Duration (Longest)';
@override
String get searchSortDateOldest => 'Release Date (Oldest)';
@override
String get searchSortDateNewest => 'Release Date (Newest)';
@override
String get tooltipPlay => '再生';
@@ -1444,6 +1414,16 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get qualityNote => '実際の品質はサービスからのトラックの可用性に依存します';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get youtubeOpusBitrateTitle => 'YouTube Opus のビットレート';
@override
String get youtubeMp3BitrateTitle => 'YouTube MP3 のビットレート';
@override
String get downloadAskBeforeDownload => 'ダウンロード前に確認する';
@@ -1539,13 +1519,6 @@ class AppLocalizationsJa extends AppLocalizations {
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
@override
String get albumFolderArtistAlbumFlatSubtitle =>
'Artist/Album/ and Artist/song.flac';
@override
String get downloadedAlbumDeleteSelected => '選択済みを削除';
@@ -2976,106 +2949,4 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get editMetadataSelectEmpty => 'Empty only';
@override
String queueDownloadingCount(int count) {
return 'Downloading ($count)';
}
@override
String get queueDownloadedHeader => 'Downloaded';
@override
String get queueFilteringIndicator => 'Filtering...';
@override
String queueTrackCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks',
one: '1 track',
);
return '$_temp0';
}
@override
String queueAlbumCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count albums',
one: '1 album',
);
return '$_temp0';
}
@override
String get queueEmptyAlbums => 'No album downloads';
@override
String get queueEmptyAlbumsSubtitle =>
'Download multiple tracks from an album to see them here';
@override
String get queueEmptySingles => 'No single downloads';
@override
String get queueEmptySinglesSubtitle =>
'Single track downloads will appear here';
@override
String get queueEmptyHistory => 'No download history';
@override
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
@override
String get selectionAllPlaylistsSelected => 'All playlists selected';
@override
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
@override
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
@override
String get audioAnalysisTitle => 'Audio Quality Analysis';
@override
String get audioAnalysisDescription =>
'Verify lossless quality with spectrum analysis';
@override
String get audioAnalysisAnalyzing => 'Analyzing audio...';
@override
String get audioAnalysisSampleRate => 'Sample Rate';
@override
String get audioAnalysisBitDepth => 'Bit Depth';
@override
String get audioAnalysisChannels => 'Channels';
@override
String get audioAnalysisDuration => 'Duration';
@override
String get audioAnalysisNyquist => 'Nyquist';
@override
String get audioAnalysisFileSize => 'Size';
@override
String get audioAnalysisDynamicRange => 'Dynamic Range';
@override
String get audioAnalysisPeak => 'Peak';
@override
String get audioAnalysisRms => 'RMS';
@override
String get audioAnalysisSamples => 'Samples';
}
+10 -139
View File
@@ -741,36 +741,6 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get searchPlaylists => '재생목록들';
@override
String get searchSortTitle => 'Sort Results';
@override
String get searchSortDefault => 'Default';
@override
String get searchSortTitleAZ => 'Title (A-Z)';
@override
String get searchSortTitleZA => 'Title (Z-A)';
@override
String get searchSortArtistAZ => 'Artist (A-Z)';
@override
String get searchSortArtistZA => 'Artist (Z-A)';
@override
String get searchSortDurationShort => 'Duration (Shortest)';
@override
String get searchSortDurationLong => 'Duration (Longest)';
@override
String get searchSortDateOldest => 'Release Date (Oldest)';
@override
String get searchSortDateNewest => 'Release Date (Newest)';
@override
String get tooltipPlay => '재생';
@@ -1435,6 +1405,16 @@ class AppLocalizationsKo extends AppLocalizations {
String get qualityNote =>
'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
@override
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
@override
String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1532,13 +1512,6 @@ class AppLocalizationsKo extends AppLocalizations {
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
@override
String get albumFolderArtistAlbumFlatSubtitle =>
'Artist/Album/ and Artist/song.flac';
@override
String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2969,106 +2942,4 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get editMetadataSelectEmpty => 'Empty only';
@override
String queueDownloadingCount(int count) {
return 'Downloading ($count)';
}
@override
String get queueDownloadedHeader => 'Downloaded';
@override
String get queueFilteringIndicator => 'Filtering...';
@override
String queueTrackCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks',
one: '1 track',
);
return '$_temp0';
}
@override
String queueAlbumCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count albums',
one: '1 album',
);
return '$_temp0';
}
@override
String get queueEmptyAlbums => 'No album downloads';
@override
String get queueEmptyAlbumsSubtitle =>
'Download multiple tracks from an album to see them here';
@override
String get queueEmptySingles => 'No single downloads';
@override
String get queueEmptySinglesSubtitle =>
'Single track downloads will appear here';
@override
String get queueEmptyHistory => 'No download history';
@override
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
@override
String get selectionAllPlaylistsSelected => 'All playlists selected';
@override
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
@override
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
@override
String get audioAnalysisTitle => 'Audio Quality Analysis';
@override
String get audioAnalysisDescription =>
'Verify lossless quality with spectrum analysis';
@override
String get audioAnalysisAnalyzing => 'Analyzing audio...';
@override
String get audioAnalysisSampleRate => 'Sample Rate';
@override
String get audioAnalysisBitDepth => 'Bit Depth';
@override
String get audioAnalysisChannels => 'Channels';
@override
String get audioAnalysisDuration => 'Duration';
@override
String get audioAnalysisNyquist => 'Nyquist';
@override
String get audioAnalysisFileSize => 'Size';
@override
String get audioAnalysisDynamicRange => 'Dynamic Range';
@override
String get audioAnalysisPeak => 'Peak';
@override
String get audioAnalysisRms => 'RMS';
@override
String get audioAnalysisSamples => 'Samples';
}
+10 -139
View File
@@ -759,36 +759,6 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get searchPlaylists => 'Playlists';
@override
String get searchSortTitle => 'Sort Results';
@override
String get searchSortDefault => 'Default';
@override
String get searchSortTitleAZ => 'Title (A-Z)';
@override
String get searchSortTitleZA => 'Title (Z-A)';
@override
String get searchSortArtistAZ => 'Artist (A-Z)';
@override
String get searchSortArtistZA => 'Artist (Z-A)';
@override
String get searchSortDurationShort => 'Duration (Shortest)';
@override
String get searchSortDurationLong => 'Duration (Longest)';
@override
String get searchSortDateOldest => 'Release Date (Oldest)';
@override
String get searchSortDateNewest => 'Release Date (Newest)';
@override
String get tooltipPlay => 'Play';
@@ -1455,6 +1425,16 @@ class AppLocalizationsNl extends AppLocalizations {
String get qualityNote =>
'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
@override
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
@override
String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1552,13 +1532,6 @@ class AppLocalizationsNl extends AppLocalizations {
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
@override
String get albumFolderArtistAlbumFlatSubtitle =>
'Artist/Album/ and Artist/song.flac';
@override
String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2989,106 +2962,4 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get editMetadataSelectEmpty => 'Empty only';
@override
String queueDownloadingCount(int count) {
return 'Downloading ($count)';
}
@override
String get queueDownloadedHeader => 'Downloaded';
@override
String get queueFilteringIndicator => 'Filtering...';
@override
String queueTrackCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks',
one: '1 track',
);
return '$_temp0';
}
@override
String queueAlbumCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count albums',
one: '1 album',
);
return '$_temp0';
}
@override
String get queueEmptyAlbums => 'No album downloads';
@override
String get queueEmptyAlbumsSubtitle =>
'Download multiple tracks from an album to see them here';
@override
String get queueEmptySingles => 'No single downloads';
@override
String get queueEmptySinglesSubtitle =>
'Single track downloads will appear here';
@override
String get queueEmptyHistory => 'No download history';
@override
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
@override
String get selectionAllPlaylistsSelected => 'All playlists selected';
@override
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
@override
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
@override
String get audioAnalysisTitle => 'Audio Quality Analysis';
@override
String get audioAnalysisDescription =>
'Verify lossless quality with spectrum analysis';
@override
String get audioAnalysisAnalyzing => 'Analyzing audio...';
@override
String get audioAnalysisSampleRate => 'Sample Rate';
@override
String get audioAnalysisBitDepth => 'Bit Depth';
@override
String get audioAnalysisChannels => 'Channels';
@override
String get audioAnalysisDuration => 'Duration';
@override
String get audioAnalysisNyquist => 'Nyquist';
@override
String get audioAnalysisFileSize => 'Size';
@override
String get audioAnalysisDynamicRange => 'Dynamic Range';
@override
String get audioAnalysisPeak => 'Peak';
@override
String get audioAnalysisRms => 'RMS';
@override
String get audioAnalysisSamples => 'Samples';
}
+20 -139
View File
@@ -759,36 +759,6 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get searchPlaylists => 'Playlists';
@override
String get searchSortTitle => 'Sort Results';
@override
String get searchSortDefault => 'Default';
@override
String get searchSortTitleAZ => 'Title (A-Z)';
@override
String get searchSortTitleZA => 'Title (Z-A)';
@override
String get searchSortArtistAZ => 'Artist (A-Z)';
@override
String get searchSortArtistZA => 'Artist (Z-A)';
@override
String get searchSortDurationShort => 'Duration (Shortest)';
@override
String get searchSortDurationLong => 'Duration (Longest)';
@override
String get searchSortDateOldest => 'Release Date (Oldest)';
@override
String get searchSortDateNewest => 'Release Date (Newest)';
@override
String get tooltipPlay => 'Play';
@@ -1455,6 +1425,16 @@ class AppLocalizationsPt extends AppLocalizations {
String get qualityNote =>
'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
@override
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
@override
String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1552,13 +1532,6 @@ class AppLocalizationsPt extends AppLocalizations {
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
@override
String get albumFolderArtistAlbumFlatSubtitle =>
'Artist/Album/ and Artist/song.flac';
@override
String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2990,108 +2963,6 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get editMetadataSelectEmpty => 'Empty only';
@override
String queueDownloadingCount(int count) {
return 'Downloading ($count)';
}
@override
String get queueDownloadedHeader => 'Downloaded';
@override
String get queueFilteringIndicator => 'Filtering...';
@override
String queueTrackCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks',
one: '1 track',
);
return '$_temp0';
}
@override
String queueAlbumCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count albums',
one: '1 album',
);
return '$_temp0';
}
@override
String get queueEmptyAlbums => 'No album downloads';
@override
String get queueEmptyAlbumsSubtitle =>
'Download multiple tracks from an album to see them here';
@override
String get queueEmptySingles => 'No single downloads';
@override
String get queueEmptySinglesSubtitle =>
'Single track downloads will appear here';
@override
String get queueEmptyHistory => 'No download history';
@override
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
@override
String get selectionAllPlaylistsSelected => 'All playlists selected';
@override
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
@override
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
@override
String get audioAnalysisTitle => 'Audio Quality Analysis';
@override
String get audioAnalysisDescription =>
'Verify lossless quality with spectrum analysis';
@override
String get audioAnalysisAnalyzing => 'Analyzing audio...';
@override
String get audioAnalysisSampleRate => 'Sample Rate';
@override
String get audioAnalysisBitDepth => 'Bit Depth';
@override
String get audioAnalysisChannels => 'Channels';
@override
String get audioAnalysisDuration => 'Duration';
@override
String get audioAnalysisNyquist => 'Nyquist';
@override
String get audioAnalysisFileSize => 'Size';
@override
String get audioAnalysisDynamicRange => 'Dynamic Range';
@override
String get audioAnalysisPeak => 'Peak';
@override
String get audioAnalysisRms => 'RMS';
@override
String get audioAnalysisSamples => 'Samples';
}
/// The translations for Portuguese, as used in Portugal (`pt_PT`).
@@ -4460,6 +4331,16 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
String get qualityNote =>
'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
String get downloadAskBeforeDownload => 'Perguntar qualidade antes de baixar';
+10 -139
View File
@@ -773,36 +773,6 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get searchPlaylists => 'Плейлисты';
@override
String get searchSortTitle => 'Sort Results';
@override
String get searchSortDefault => 'Default';
@override
String get searchSortTitleAZ => 'Title (A-Z)';
@override
String get searchSortTitleZA => 'Title (Z-A)';
@override
String get searchSortArtistAZ => 'Artist (A-Z)';
@override
String get searchSortArtistZA => 'Artist (Z-A)';
@override
String get searchSortDurationShort => 'Duration (Shortest)';
@override
String get searchSortDurationLong => 'Duration (Longest)';
@override
String get searchSortDateOldest => 'Release Date (Oldest)';
@override
String get searchSortDateNewest => 'Release Date (Newest)';
@override
String get tooltipPlay => 'Воспроизвести';
@@ -1480,6 +1450,16 @@ class AppLocalizationsRu extends AppLocalizations {
String get qualityNote =>
'Фактическое качество зависит от доступности треков в сервисе';
@override
String get youtubeQualityNote =>
'YouTube обеспечивает только звук с потерями(Lossy).';
@override
String get youtubeOpusBitrateTitle => 'Битрейт YouTube Opus';
@override
String get youtubeMp3BitrateTitle => 'Битрейт YouTube MP3';
@override
String get downloadAskBeforeDownload => 'Спрашивать перед скачиванием';
@@ -1581,13 +1561,6 @@ class AppLocalizationsRu extends AppLocalizations {
String get albumFolderArtistAlbumSinglesSubtitle =>
'Исполнитель/Альбом и Исполнитель/Сингл/';
@override
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
@override
String get albumFolderArtistAlbumFlatSubtitle =>
'Artist/Album/ and Artist/song.flac';
@override
String get downloadedAlbumDeleteSelected => 'Удалить выбранные';
@@ -3049,106 +3022,4 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get editMetadataSelectEmpty => 'Empty only';
@override
String queueDownloadingCount(int count) {
return 'Downloading ($count)';
}
@override
String get queueDownloadedHeader => 'Downloaded';
@override
String get queueFilteringIndicator => 'Filtering...';
@override
String queueTrackCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks',
one: '1 track',
);
return '$_temp0';
}
@override
String queueAlbumCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count albums',
one: '1 album',
);
return '$_temp0';
}
@override
String get queueEmptyAlbums => 'No album downloads';
@override
String get queueEmptyAlbumsSubtitle =>
'Download multiple tracks from an album to see them here';
@override
String get queueEmptySingles => 'No single downloads';
@override
String get queueEmptySinglesSubtitle =>
'Single track downloads will appear here';
@override
String get queueEmptyHistory => 'No download history';
@override
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
@override
String get selectionAllPlaylistsSelected => 'All playlists selected';
@override
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
@override
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
@override
String get audioAnalysisTitle => 'Audio Quality Analysis';
@override
String get audioAnalysisDescription =>
'Verify lossless quality with spectrum analysis';
@override
String get audioAnalysisAnalyzing => 'Analyzing audio...';
@override
String get audioAnalysisSampleRate => 'Sample Rate';
@override
String get audioAnalysisBitDepth => 'Bit Depth';
@override
String get audioAnalysisChannels => 'Channels';
@override
String get audioAnalysisDuration => 'Duration';
@override
String get audioAnalysisNyquist => 'Nyquist';
@override
String get audioAnalysisFileSize => 'Size';
@override
String get audioAnalysisDynamicRange => 'Dynamic Range';
@override
String get audioAnalysisPeak => 'Peak';
@override
String get audioAnalysisRms => 'RMS';
@override
String get audioAnalysisSamples => 'Samples';
}
+10 -139
View File
@@ -764,36 +764,6 @@ class AppLocalizationsTr extends AppLocalizations {
@override
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
String get tooltipPlay => 'Oynat';
@@ -1461,6 +1431,16 @@ class AppLocalizationsTr extends AppLocalizations {
String get qualityNote =>
'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
@override
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
@override
String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1558,13 +1538,6 @@ class AppLocalizationsTr extends AppLocalizations {
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
@override
String get albumFolderArtistAlbumFlatSubtitle =>
'Artist/Album/ and Artist/song.flac';
@override
String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2995,106 +2968,4 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get editMetadataSelectEmpty => 'Empty only';
@override
String queueDownloadingCount(int count) {
return 'Downloading ($count)';
}
@override
String get queueDownloadedHeader => 'Downloaded';
@override
String get queueFilteringIndicator => 'Filtering...';
@override
String queueTrackCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks',
one: '1 track',
);
return '$_temp0';
}
@override
String queueAlbumCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count albums',
one: '1 album',
);
return '$_temp0';
}
@override
String get queueEmptyAlbums => 'No album downloads';
@override
String get queueEmptyAlbumsSubtitle =>
'Download multiple tracks from an album to see them here';
@override
String get queueEmptySingles => 'No single downloads';
@override
String get queueEmptySinglesSubtitle =>
'Single track downloads will appear here';
@override
String get queueEmptyHistory => 'No download history';
@override
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
@override
String get selectionAllPlaylistsSelected => 'All playlists selected';
@override
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
@override
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
@override
String get audioAnalysisTitle => 'Audio Quality Analysis';
@override
String get audioAnalysisDescription =>
'Verify lossless quality with spectrum analysis';
@override
String get audioAnalysisAnalyzing => 'Analyzing audio...';
@override
String get audioAnalysisSampleRate => 'Sample Rate';
@override
String get audioAnalysisBitDepth => 'Bit Depth';
@override
String get audioAnalysisChannels => 'Channels';
@override
String get audioAnalysisDuration => 'Duration';
@override
String get audioAnalysisNyquist => 'Nyquist';
@override
String get audioAnalysisFileSize => 'Size';
@override
String get audioAnalysisDynamicRange => 'Dynamic Range';
@override
String get audioAnalysisPeak => 'Peak';
@override
String get audioAnalysisRms => 'RMS';
@override
String get audioAnalysisSamples => 'Samples';
}
+30 -139
View File
@@ -759,36 +759,6 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get searchPlaylists => 'Playlists';
@override
String get searchSortTitle => 'Sort Results';
@override
String get searchSortDefault => 'Default';
@override
String get searchSortTitleAZ => 'Title (A-Z)';
@override
String get searchSortTitleZA => 'Title (Z-A)';
@override
String get searchSortArtistAZ => 'Artist (A-Z)';
@override
String get searchSortArtistZA => 'Artist (Z-A)';
@override
String get searchSortDurationShort => 'Duration (Shortest)';
@override
String get searchSortDurationLong => 'Duration (Longest)';
@override
String get searchSortDateOldest => 'Release Date (Oldest)';
@override
String get searchSortDateNewest => 'Release Date (Newest)';
@override
String get tooltipPlay => 'Play';
@@ -1455,6 +1425,16 @@ class AppLocalizationsZh extends AppLocalizations {
String get qualityNote =>
'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
@override
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
@override
String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1552,13 +1532,6 @@ class AppLocalizationsZh extends AppLocalizations {
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
@override
String get albumFolderArtistAlbumFlatSubtitle =>
'Artist/Album/ and Artist/song.flac';
@override
String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2990,108 +2963,6 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get editMetadataSelectEmpty => 'Empty only';
@override
String queueDownloadingCount(int count) {
return 'Downloading ($count)';
}
@override
String get queueDownloadedHeader => 'Downloaded';
@override
String get queueFilteringIndicator => 'Filtering...';
@override
String queueTrackCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks',
one: '1 track',
);
return '$_temp0';
}
@override
String queueAlbumCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count albums',
one: '1 album',
);
return '$_temp0';
}
@override
String get queueEmptyAlbums => 'No album downloads';
@override
String get queueEmptyAlbumsSubtitle =>
'Download multiple tracks from an album to see them here';
@override
String get queueEmptySingles => 'No single downloads';
@override
String get queueEmptySinglesSubtitle =>
'Single track downloads will appear here';
@override
String get queueEmptyHistory => 'No download history';
@override
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
@override
String get selectionAllPlaylistsSelected => 'All playlists selected';
@override
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
@override
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
@override
String get audioAnalysisTitle => 'Audio Quality Analysis';
@override
String get audioAnalysisDescription =>
'Verify lossless quality with spectrum analysis';
@override
String get audioAnalysisAnalyzing => 'Analyzing audio...';
@override
String get audioAnalysisSampleRate => 'Sample Rate';
@override
String get audioAnalysisBitDepth => 'Bit Depth';
@override
String get audioAnalysisChannels => 'Channels';
@override
String get audioAnalysisDuration => 'Duration';
@override
String get audioAnalysisNyquist => 'Nyquist';
@override
String get audioAnalysisFileSize => 'Size';
@override
String get audioAnalysisDynamicRange => 'Dynamic Range';
@override
String get audioAnalysisPeak => 'Peak';
@override
String get audioAnalysisRms => 'RMS';
@override
String get audioAnalysisSamples => 'Samples';
}
/// The translations for Chinese, as used in China (`zh_CN`).
@@ -4426,6 +4297,16 @@ class AppLocalizationsZhCn extends AppLocalizationsZh {
String get qualityNote =>
'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
@override
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
@override
String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -6822,6 +6703,16 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
String get qualityNote =>
'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
@override
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
@override
String get downloadAskBeforeDownload => 'Ask Before Download';
+1336 -20
View File
File diff suppressed because it is too large Load Diff
+488 -11
View File
@@ -17,7 +17,7 @@
"@navSettings": {
"description": "Bottom navigation - Settings tab"
},
"navStore": "Store",
"navStore": "Repo",
"@navStore": {
"description": "Bottom navigation - Extension store tab"
},
@@ -25,7 +25,7 @@
"@homeTitle": {
"description": "Home screen title"
},
"homeSubtitle": "Paste a Spotify link or search by name",
"homeSubtitle": "Paste a supported URL or search by name",
"@homeSubtitle": {
"description": "Subtitle shown below search box"
},
@@ -89,6 +89,14 @@
"@downloadFilenameFormat": {
"description": "Setting for output filename pattern"
},
"downloadSingleFilenameFormat": "Single Filename Format",
"@downloadSingleFilenameFormat": {
"description": "Setting for output filename pattern for singles/EPs"
},
"downloadSingleFilenameFormatDescription": "Filename pattern for singles and EPs. Uses the same tags as the album format.",
"@downloadSingleFilenameFormatDescription": {
"description": "Subtitle description for single filename format setting"
},
"downloadFolderOrganization": "Folder Organization",
"@downloadFolderOrganization": {
"description": "Setting for folder structure"
@@ -150,6 +158,14 @@
}
}
},
"optionsDefaultSearchTab": "Default Search Tab",
"@optionsDefaultSearchTab": {
"description": "Title for the preferred default search tab setting"
},
"optionsDefaultSearchTabSubtitle": "Choose which tab opens first for new search results.",
"@optionsDefaultSearchTabSubtitle": {
"description": "Subtitle for the preferred default search tab setting"
},
"optionsSwitchBack": "Tap Deezer or Spotify to switch back from extension",
"@optionsSwitchBack": {
"description": "Hint to switch back to built-in providers"
@@ -190,6 +206,42 @@
"@optionsMaxQualityCoverSubtitle": {
"description": "Subtitle for max quality cover"
},
"optionsReplayGain": "ReplayGain",
"@optionsReplayGain": {
"description": "Title for ReplayGain setting toggle"
},
"optionsReplayGainSubtitleOn": "Scan loudness and embed ReplayGain tags (EBU R128)",
"@optionsReplayGainSubtitleOn": {
"description": "Subtitle when ReplayGain is enabled"
},
"optionsReplayGainSubtitleOff": "Disabled: no loudness normalization tags",
"@optionsReplayGainSubtitleOff": {
"description": "Subtitle when ReplayGain is disabled"
},
"optionsArtistTagMode": "Artist Tag Mode",
"@optionsArtistTagMode": {
"description": "Setting title for how artist metadata is written into files"
},
"optionsArtistTagModeDescription": "Choose how multiple artists are written into embedded tags.",
"@optionsArtistTagModeDescription": {
"description": "Bottom-sheet description for artist tag mode setting"
},
"optionsArtistTagModeJoined": "Single joined value",
"@optionsArtistTagModeJoined": {
"description": "Artist tag mode option that joins multiple artists into one value"
},
"optionsArtistTagModeJoinedSubtitle": "Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.",
"@optionsArtistTagModeJoinedSubtitle": {
"description": "Subtitle for joined artist tag mode"
},
"optionsArtistTagModeSplitVorbis": "Split tags for FLAC/Opus",
"@optionsArtistTagModeSplitVorbis": {
"description": "Artist tag mode option that writes repeated ARTIST tags for Vorbis formats"
},
"optionsArtistTagModeSplitVorbisSubtitle": "Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.",
"@optionsArtistTagModeSplitVorbisSubtitle": {
"description": "Subtitle for split Vorbis artist tag mode"
},
"optionsConcurrentDownloads": "Concurrent Downloads",
"@optionsConcurrentDownloads": {
"description": "Number of parallel downloads"
@@ -211,11 +263,11 @@
"@optionsConcurrentWarning": {
"description": "Warning about rate limits"
},
"optionsExtensionStore": "Extension Store",
"optionsExtensionStore": "Extension Repo",
"@optionsExtensionStore": {
"description": "Show/hide store tab"
},
"optionsExtensionStoreSubtitle": "Show Store tab in navigation",
"optionsExtensionStoreSubtitle": "Show Repo tab in navigation",
"@optionsExtensionStoreSubtitle": {
"description": "Subtitle for extension store toggle"
},
@@ -318,7 +370,7 @@
"@extensionsUninstall": {
"description": "Uninstall extension button"
},
"storeTitle": "Extension Store",
"storeTitle": "Extension Repo",
"@storeTitle": {
"description": "Store screen title"
},
@@ -378,6 +430,10 @@
"@aboutPCSource": {
"description": "Link to PC GitHub repo"
},
"aboutKeepAndroidOpen": "Keep Android Open",
"@aboutKeepAndroidOpen": {
"description": "Link to Keep Android Open campaign website"
},
"aboutReportIssue": "Report an issue",
"@aboutReportIssue": {
"description": "Link to report bugs"
@@ -1159,6 +1215,18 @@
"@providerPriorityInfo": {
"description": "Info tip about fallback behavior"
},
"providerPriorityFallbackExtensionsTitle": "Extension Fallback",
"@providerPriorityFallbackExtensionsTitle": {
"description": "Section title for choosing which download extensions can be used as fallback providers"
},
"providerPriorityFallbackExtensionsDescription": "Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.",
"@providerPriorityFallbackExtensionsDescription": {
"description": "Section description for extension fallback selection"
},
"providerPriorityFallbackExtensionsHint": "Only enabled extensions with download-provider capability are listed here.",
"@providerPriorityFallbackExtensionsHint": {
"description": "Hint below the extension fallback selection list"
},
"providerBuiltIn": "Built-in",
"@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz)"
@@ -1519,6 +1587,14 @@
"@trackLyricsNotAvailable": {
"description": "Message when lyrics not found"
},
"trackLyricsNotInFile": "No lyrics found in this file",
"@trackLyricsNotInFile": {
"description": "Message when no embedded lyrics in audio file"
},
"trackFetchOnlineLyrics": "Fetch from Online",
"@trackFetchOnlineLyrics": {
"description": "Action - fetch lyrics from online providers"
},
"trackLyricsTimeout": "Request timed out. Try again later.",
"@trackLyricsTimeout": {
"description": "Message when lyrics request times out"
@@ -1654,7 +1730,7 @@
"@storeNewRepoUrlLabel": {
"description": "Label for the new repository URL field inside the dialog"
},
"storeLoadError": "Failed to load store",
"storeLoadError": "Failed to load repository",
"@storeLoadError": {
"description": "Error heading when the store cannot be loaded"
},
@@ -1805,6 +1881,14 @@
"@extensionsDownloadPrioritySubtitle": {
"description": "Subtitle for download priority"
},
"extensionsFallbackTitle": "Fallback Extensions",
"@extensionsFallbackTitle": {
"description": "Setting and page title for choosing which download extensions can be used during fallback"
},
"extensionsFallbackSubtitle": "Choose which installed download extensions can be used as fallback",
"@extensionsFallbackSubtitle": {
"description": "Subtitle for download fallback extensions menu"
},
"extensionsNoDownloadProvider": "No extensions with download provider",
"@extensionsNoDownloadProvider": {
"description": "Empty state - no download providers"
@@ -2399,6 +2483,15 @@
}
}
},
"libraryFilesUnit": "{count, plural, =1{file} other{files}}",
"@libraryFilesUnit": {
"description": "Unit label for files count during library scanning",
"placeholders": {
"count": {
"type": "int"
}
}
},
"libraryLastScanned": "Last scanned: {time}",
"@libraryLastScanned": {
"description": "Last scan time display",
@@ -2416,6 +2509,10 @@
"@libraryScanning": {
"description": "Status during scan"
},
"libraryScanFinalizing": "Finalizing library...",
"@libraryScanFinalizing": {
"description": "Status shown after file scanning finishes but library persistence is still running"
},
"libraryScanProgress": "{progress}% of {total} files",
"@libraryScanProgress": {
"description": "Scan progress display",
@@ -2513,6 +2610,30 @@
"@libraryFilterFormat": {
"description": "Filter section - file format"
},
"libraryFilterMetadata": "Metadata",
"@libraryFilterMetadata": {
"description": "Filter section - metadata completeness"
},
"libraryFilterMetadataComplete": "Complete metadata",
"@libraryFilterMetadataComplete": {
"description": "Filter option - items with complete metadata"
},
"libraryFilterMetadataMissingAny": "Missing any metadata",
"@libraryFilterMetadataMissingAny": {
"description": "Filter option - items missing any tracked metadata field"
},
"libraryFilterMetadataMissingYear": "Missing year",
"@libraryFilterMetadataMissingYear": {
"description": "Filter option - items missing release year/date"
},
"libraryFilterMetadataMissingGenre": "Missing genre",
"@libraryFilterMetadataMissingGenre": {
"description": "Filter option - items missing genre"
},
"libraryFilterMetadataMissingAlbumArtist": "Missing album artist",
"@libraryFilterMetadataMissingAlbumArtist": {
"description": "Filter option - items missing album artist"
},
"libraryFilterSort": "Sort",
"@libraryFilterSort": {
"description": "Filter section - sort order"
@@ -2525,6 +2646,22 @@
"@libraryFilterSortOldest": {
"description": "Sort option - oldest first"
},
"libraryFilterSortAlbumAsc": "Album (A-Z)",
"@libraryFilterSortAlbumAsc": {
"description": "Sort option - album ascending"
},
"libraryFilterSortAlbumDesc": "Album (Z-A)",
"@libraryFilterSortAlbumDesc": {
"description": "Sort option - album descending"
},
"libraryFilterSortGenreAsc": "Genre (A-Z)",
"@libraryFilterSortGenreAsc": {
"description": "Sort option - genre ascending"
},
"libraryFilterSortGenreDesc": "Genre (Z-A)",
"@libraryFilterSortGenreDesc": {
"description": "Sort option - genre descending"
},
"timeJustNow": "Just now",
"@timeJustNow": {
"description": "Relative time - less than a minute ago"
@@ -2611,7 +2748,7 @@
"@tutorialExtensionsDesc": {
"description": "Tutorial extensions page description"
},
"tutorialExtensionsTip1": "Browse the Store tab to discover useful extensions",
"tutorialExtensionsTip1": "Browse the Repo tab to discover useful extensions",
"@tutorialExtensionsTip1": {
"description": "Tutorial extensions tip 1"
},
@@ -2877,6 +3014,38 @@
"@trackReEnrichOnlineSubtitle": {
"description": "Subtitle for re-enrich metadata action for local items"
},
"trackReEnrichFieldsTitle": "Fields to update",
"@trackReEnrichFieldsTitle": {
"description": "Section title for field selection in re-enrich dialog"
},
"trackReEnrichFieldCover": "Cover Art",
"@trackReEnrichFieldCover": {
"description": "Checkbox label for cover art field in re-enrich"
},
"trackReEnrichFieldLyrics": "Lyrics",
"@trackReEnrichFieldLyrics": {
"description": "Checkbox label for lyrics field in re-enrich"
},
"trackReEnrichFieldBasicTags": "Album, Album Artist",
"@trackReEnrichFieldBasicTags": {
"description": "Checkbox label for basic tags in re-enrich (title/artist are never overwritten)"
},
"trackReEnrichFieldTrackInfo": "Track & Disc Number",
"@trackReEnrichFieldTrackInfo": {
"description": "Checkbox label for track info in re-enrich"
},
"trackReEnrichFieldReleaseInfo": "Date & ISRC",
"@trackReEnrichFieldReleaseInfo": {
"description": "Checkbox label for release info in re-enrich"
},
"trackReEnrichFieldExtra": "Genre, Label, Copyright",
"@trackReEnrichFieldExtra": {
"description": "Checkbox label for extra metadata in re-enrich"
},
"trackReEnrichSelectAll": "Select All",
"@trackReEnrichSelectAll": {
"description": "Select all fields checkbox in re-enrich"
},
"trackEditMetadata": "Edit Metadata",
"@trackEditMetadata": {
"description": "Menu action - edit embedded metadata"
@@ -3474,10 +3643,6 @@
"@lyricsProvidersDiscardContent": {
"description": "Body text of the discard-changes dialog on lyrics provider page"
},
"lyricsProviderSpotifyApiDesc": "Spotify-sourced synced lyrics via community API",
"@lyricsProviderSpotifyApiDesc": {
"description": "Description for Spotify Lyrics API provider"
},
"lyricsProviderLrclibDesc": "Open-source synced lyrics database",
"@lyricsProviderLrclibDesc": {
"description": "Description for LRCLIB provider"
@@ -4063,5 +4228,317 @@
"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"
}
}
File diff suppressed because it is too large Load Diff
+12
View File
@@ -1773,6 +1773,18 @@
"@qualityNote": {
"description": "Note about quality availability"
},
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
"@youtubeQualityNote": {
"description": "Note for YouTube service explaining lossy-only quality"
},
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
"@youtubeOpusBitrateTitle": {
"description": "Title for YouTube Opus bitrate setting"
},
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
"@youtubeMp3BitrateTitle": {
"description": "Title for YouTube MP3 bitrate setting"
},
"downloadAskBeforeDownload": "Preguntar antes de descargar",
"@downloadAskBeforeDownload": {
"description": "Setting - show quality picker"
+1349 -33
View File
File diff suppressed because it is too large Load Diff
+1325 -9
View File
File diff suppressed because it is too large Load Diff
+1279 -36
View File
File diff suppressed because it is too large Load Diff
+1325 -9
View File
File diff suppressed because it is too large Load Diff
+1325 -9
View File
File diff suppressed because it is too large Load Diff
+1325 -9
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+12
View File
@@ -1773,6 +1773,18 @@
"@qualityNote": {
"description": "Note about quality availability"
},
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
"@youtubeQualityNote": {
"description": "Note for YouTube service explaining lossy-only quality"
},
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
"@youtubeOpusBitrateTitle": {
"description": "Title for YouTube Opus bitrate setting"
},
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
"@youtubeMp3BitrateTitle": {
"description": "Title for YouTube MP3 bitrate setting"
},
"downloadAskBeforeDownload": "Perguntar qualidade antes de baixar",
"@downloadAskBeforeDownload": {
"description": "Setting - show quality picker"
+1336 -20
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+12
View File
@@ -1773,6 +1773,18 @@
"@qualityNote": {
"description": "Note about quality availability"
},
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
"@youtubeQualityNote": {
"description": "Note for YouTube service explaining lossy-only quality"
},
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
"@youtubeOpusBitrateTitle": {
"description": "Title for YouTube Opus bitrate setting"
},
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
"@youtubeMp3BitrateTitle": {
"description": "Title for YouTube MP3 bitrate setting"
},
"downloadAskBeforeDownload": "Ask Before Download",
"@downloadAskBeforeDownload": {
"description": "Setting - show quality picker"
File diff suppressed because it is too large Load Diff
+1326 -10
View File
File diff suppressed because it is too large Load Diff
+1326 -10
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -192,9 +192,11 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization>
if (settings.localLibraryPath.isEmpty) return;
if (settings.localLibraryAutoScan == 'off') return;
// Don't start a scan if one is already running.
final libraryState = ref.read(localLibraryProvider);
if (libraryState.isScanning) return;
// Determine cooldown based on auto-scan mode.
final now = DateTime.now();
final prefs = await SharedPreferences.getInstance();
final lastScanned = readLocalLibraryLastScannedAt(prefs);
@@ -218,6 +220,7 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization>
}
}
// All checks passed -- start an incremental scan.
final iosBookmark = settings.localLibraryBookmark;
ref
.read(localLibraryProvider.notifier)
+9 -7
View File
@@ -12,7 +12,13 @@ enum DownloadStatus {
skipped,
}
enum DownloadErrorType { unknown, notFound, rateLimit, network, permission }
enum DownloadErrorType {
unknown,
notFound,
rateLimit,
network,
permission,
}
@JsonSerializable()
class DownloadItem {
@@ -22,8 +28,7 @@ class DownloadItem {
final DownloadStatus status;
final double progress;
final double speedMBps;
final int bytesReceived; // Bytes downloaded so far
final int bytesTotal; // Total bytes when the server provides content length
final int bytesReceived; // Bytes downloaded so far (for unknown size downloads)
final String? filePath;
final String? error;
final DownloadErrorType? errorType;
@@ -39,7 +44,6 @@ class DownloadItem {
this.progress = 0.0,
this.speedMBps = 0.0,
this.bytesReceived = 0,
this.bytesTotal = 0,
this.filePath,
this.error,
this.errorType,
@@ -56,7 +60,6 @@ class DownloadItem {
double? progress,
double? speedMBps,
int? bytesReceived,
int? bytesTotal,
String? filePath,
String? error,
DownloadErrorType? errorType,
@@ -72,7 +75,6 @@ class DownloadItem {
progress: progress ?? this.progress,
speedMBps: speedMBps ?? this.speedMBps,
bytesReceived: bytesReceived ?? this.bytesReceived,
bytesTotal: bytesTotal ?? this.bytesTotal,
filePath: filePath ?? this.filePath,
error: error ?? this.error,
errorType: errorType ?? this.errorType,
@@ -84,7 +86,7 @@ class DownloadItem {
String get errorMessage {
if (error == null) return '';
switch (errorType) {
case DownloadErrorType.notFound:
return 'Song not found on any service';
-2
View File
@@ -16,7 +16,6 @@ DownloadItem _$DownloadItemFromJson(Map<String, dynamic> json) => DownloadItem(
progress: (json['progress'] as num?)?.toDouble() ?? 0.0,
speedMBps: (json['speedMBps'] as num?)?.toDouble() ?? 0.0,
bytesReceived: (json['bytesReceived'] as num?)?.toInt() ?? 0,
bytesTotal: (json['bytesTotal'] as num?)?.toInt() ?? 0,
filePath: json['filePath'] as String?,
error: json['error'] as String?,
errorType: $enumDecodeNullable(_$DownloadErrorTypeEnumMap, json['errorType']),
@@ -34,7 +33,6 @@ Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
'progress': instance.progress,
'speedMBps': instance.speedMBps,
'bytesReceived': instance.bytesReceived,
'bytesTotal': instance.bytesTotal,
'filePath': instance.filePath,
'error': instance.error,
'errorType': _$DownloadErrorTypeEnumMap[instance.errorType],
+10
View File
@@ -42,6 +42,10 @@ class AppSettings {
final String lyricsMode;
final String
tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320', 'opus_256', or 'opus_128'
final int
youtubeOpusBitrate; // YouTube Opus bitrate (supported: 128/256/320 kbps)
final int
youtubeMp3Bitrate; // YouTube MP3 bitrate (supported: 128/256/320 kbps)
final bool
useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE
final bool
@@ -117,6 +121,8 @@ class AppSettings {
this.locale = 'system',
this.lyricsMode = 'embed',
this.tidalHighFormat = 'mp3_320',
this.youtubeOpusBitrate = 256,
this.youtubeMp3Bitrate = 320,
this.useAllFilesAccess = false,
this.autoExportFailedDownloads = false,
this.downloadNetworkMode = 'any',
@@ -183,6 +189,8 @@ class AppSettings {
String? locale,
String? lyricsMode,
String? tidalHighFormat,
int? youtubeOpusBitrate,
int? youtubeMp3Bitrate,
bool? useAllFilesAccess,
bool? autoExportFailedDownloads,
String? downloadNetworkMode,
@@ -249,6 +257,8 @@ class AppSettings {
locale: locale ?? this.locale,
lyricsMode: lyricsMode ?? this.lyricsMode,
tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat,
youtubeOpusBitrate: youtubeOpusBitrate ?? this.youtubeOpusBitrate,
youtubeMp3Bitrate: youtubeMp3Bitrate ?? this.youtubeMp3Bitrate,
useAllFilesAccess: useAllFilesAccess ?? this.useAllFilesAccess,
autoExportFailedDownloads:
autoExportFailedDownloads ?? this.autoExportFailedDownloads,
+4
View File
@@ -47,6 +47,8 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
locale: json['locale'] as String? ?? 'system',
lyricsMode: json['lyricsMode'] as String? ?? 'embed',
tidalHighFormat: json['tidalHighFormat'] as String? ?? 'mp3_320',
youtubeOpusBitrate: (json['youtubeOpusBitrate'] as num?)?.toInt() ?? 256,
youtubeMp3Bitrate: (json['youtubeMp3Bitrate'] as num?)?.toInt() ?? 320,
useAllFilesAccess: json['useAllFilesAccess'] as bool? ?? false,
autoExportFailedDownloads:
json['autoExportFailedDownloads'] as bool? ?? false,
@@ -123,6 +125,8 @@ Map<String, dynamic> _$AppSettingsToJson(
'locale': instance.locale,
'lyricsMode': instance.lyricsMode,
'tidalHighFormat': instance.tidalHighFormat,
'youtubeOpusBitrate': instance.youtubeOpusBitrate,
'youtubeMp3Bitrate': instance.youtubeMp3Bitrate,
'useAllFilesAccess': instance.useAllFilesAccess,
'autoExportFailedDownloads': instance.autoExportFailedDownloads,
'downloadNetworkMode': instance.downloadNetworkMode,
File diff suppressed because it is too large Load Diff
+9 -1
View File
@@ -11,7 +11,7 @@ final _log = AppLogger('ExploreProvider');
class ExploreItem {
final String id;
final String uri;
final String type;
final String type; // track, album, playlist, artist, station
final String name;
final String artists;
final String? description;
@@ -168,6 +168,7 @@ class ExploreNotifier extends Notifier<ExploreState> {
return const ExploreState();
}
/// Restore cached home feed from SharedPreferences immediately on startup
Future<void> _restoreFromCache() async {
try {
final prefs = await SharedPreferences.getInstance();
@@ -198,6 +199,7 @@ class ExploreNotifier extends Notifier<ExploreState> {
}
}
/// Save home feed to SharedPreferences for instant restore on next launch
Future<void> _saveToCache(List<ExploreSection> sections) async {
try {
final prefs = await SharedPreferences.getInstance();
@@ -210,9 +212,11 @@ class ExploreNotifier extends Notifier<ExploreState> {
}
}
/// Fetch home feed from spotify-web extension
Future<void> fetchHomeFeed({bool forceRefresh = false}) async {
_log.i('fetchHomeFeed called, forceRefresh=$forceRefresh');
// If we have cached content and it's fresh enough, skip network fetch
if (!forceRefresh &&
state.hasContent &&
state.lastFetched != null &&
@@ -226,6 +230,7 @@ class ExploreNotifier extends Notifier<ExploreState> {
return;
}
// Only show loading spinner if we have no cached content to display
final showLoading = !state.hasContent;
state = state.copyWith(isLoading: showLoading, error: null);
@@ -242,12 +247,14 @@ class ExploreNotifier extends Notifier<ExploreState> {
if (!extension.enabled || !extension.hasHomeFeed) {
continue;
}
// If user has a preference, use that
if (preferredId != null &&
preferredId.isNotEmpty &&
extension.id == preferredId) {
targetExt = extension;
break;
}
// Otherwise take the first available (fallback to spotify-web if found)
if (targetExt == null || extension.id == 'spotify-web') {
targetExt = extension;
if (preferredId == null && extension.id == 'spotify-web') {
@@ -310,6 +317,7 @@ class ExploreNotifier extends Notifier<ExploreState> {
lastFetched: DateTime.now(),
);
// Save to disk cache for instant restore on next app launch
_saveToCache(sections);
} catch (e, stack) {
_log.e('Error fetching home feed: $e', e, stack);
+13 -7
View File
@@ -32,12 +32,14 @@ class Extension {
final bool hasMetadataProvider;
final bool hasDownloadProvider;
final bool hasLyricsProvider;
final bool skipMetadataEnrichment;
final bool
skipMetadataEnrichment; // If true, use metadata from extension instead of enriching
final SearchBehavior? searchBehavior;
final URLHandler? urlHandler;
final TrackMatching? trackMatching;
final PostProcessing? postProcessing;
final Map<String, dynamic> capabilities;
final Map<String, dynamic>
capabilities; // Extension capabilities (homeFeed, browseCategories, etc.)
const Extension({
required this.id,
@@ -196,10 +198,12 @@ class SearchBehavior {
final String? placeholder;
final bool primary;
final String? icon;
final String? thumbnailRatio;
final String?
thumbnailRatio; // "square" (1:1), "wide" (16:9), "portrait" (2:3)
final int? thumbnailWidth;
final int? thumbnailHeight;
final List<SearchFilter> filters;
final List<SearchFilter>
filters; // Available search filters (e.g., track, album, artist, playlist)
const SearchBehavior({
required this.enabled,
@@ -235,11 +239,11 @@ class SearchBehavior {
}
switch (thumbnailRatio) {
case 'wide':
case 'wide': // 16:9 - YouTube style
return (defaultSize * 16 / 9, defaultSize);
case 'portrait':
case 'portrait': // 2:3 - Poster style
return (defaultSize * 2 / 3, defaultSize);
case 'square':
case 'square': // 1:1 - Album art style
default:
return (defaultSize, defaultSize);
}
@@ -286,6 +290,7 @@ class PostProcessing {
}
}
/// URL handler configuration for custom URL patterns
class URLHandler {
final bool enabled;
final List<String> patterns;
@@ -299,6 +304,7 @@ class URLHandler {
);
}
/// Check if a URL matches any of the patterns
bool matchesURL(String url) {
if (!enabled || patterns.isEmpty) return false;
final lowerUrl = url.toLowerCase();
@@ -118,7 +118,7 @@ class UserPlaylistCollection {
createdAt: createdAt,
updatedAt: updatedAt,
tracks: tracksRaw
.whereType<Map<Object?, Object?>>()
.whereType<Map>()
.map(
(e) => CollectionTrackEntry.fromJson(Map<String, dynamic>.from(e)),
)
@@ -233,19 +233,19 @@ class LibraryCollectionsState {
return LibraryCollectionsState(
wishlist: wishlistRaw
.whereType<Map<Object?, Object?>>()
.whereType<Map>()
.map(
(e) => CollectionTrackEntry.fromJson(Map<String, dynamic>.from(e)),
)
.toList(growable: false),
loved: lovedRaw
.whereType<Map<Object?, Object?>>()
.whereType<Map>()
.map(
(e) => CollectionTrackEntry.fromJson(Map<String, dynamic>.from(e)),
)
.toList(growable: false),
playlists: playlistsRaw
.whereType<Map<Object?, Object?>>()
.whereType<Map>()
.map(
(e) =>
UserPlaylistCollection.fromJson(Map<String, dynamic>.from(e)),
@@ -666,6 +666,7 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
final destPath = p.join(coversDir.path, '$playlistId$ext');
if (playlist.coverImagePath == destPath) return;
// Copy image to persistent location
await File(sourceFilePath).copy(destPath);
final now = DateTime.now();
@@ -685,6 +686,7 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
final playlist = state.playlistById(playlistId);
if (playlist == null || playlist.coverImagePath == null) return;
// Delete the file if it exists
final path = playlist.coverImagePath;
if (path != null) {
final file = File(path);
+28 -4
View File
@@ -252,6 +252,8 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
_startProgressPolling();
// On iOS, start accessing the security-scoped bookmark so the Go backend
// can read files outside the app sandbox.
String? resolvedPath;
bool didStartSecurityAccess = false;
if (Platform.isIOS && iosBookmark != null && iosBookmark.isNotEmpty) {
@@ -273,6 +275,9 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
try {
final isSaf = effectiveFolderPath.startsWith('content://');
// Get all file paths from download history to exclude them.
// Merge DB + in-memory state to avoid race when a fresh download has not
// been flushed to SQLite yet.
final downloadedPaths = await _historyDb.getAllFilePaths();
final inMemoryHistoryPaths = ref
.read(downloadHistoryProvider)
@@ -293,6 +298,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
);
if (forceFullScan) {
// Full scan path - ignores existing data
final results = isSaf
? await PlatformBridge.scanSafTree(effectiveFolderPath)
: await PlatformBridge.scanLibraryFolder(effectiveFolderPath);
@@ -318,8 +324,16 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
_log.i('Skipped $skippedDownloads files already in download history');
}
await _db.replaceAll(items.map((e) => e.toJson()).toList());
final persistedItems = [...items]..sort(_compareLibraryItems);
// Full scan should replace library index entirely.
await _db.clearAll();
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();
try {
@@ -350,6 +364,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
errorCount: state.scanErrorCount,
);
} else {
// Incremental scan path - only scans new/modified files
final existingFiles = await _db.getFileModTimes();
_log.i(
'Incremental scan: ${existingFiles.length} existing files in database',
@@ -408,6 +423,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
return;
}
// SAF returns 'files' and 'removedUris', non-SAF returns 'scanned' and 'deletedPaths'
final scannedList =
(result['files'] as List<dynamic>?) ??
(result['scanned'] as List<dynamic>?) ??
@@ -428,6 +444,10 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
'$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 currentByPath = <String, LocalLibraryItem>{
for (final item in existingJson.map(LocalLibraryItem.fromJson))
@@ -448,6 +468,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
);
}
// Upsert new/modified items (excluding downloaded files)
final updatedItems = <LocalLibraryItem>[];
int skippedDownloads = existingDownloadedPaths.length;
if (scannedList.isNotEmpty) {
@@ -481,8 +502,11 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
_log.i('Deleted $deleteCount items from database');
}
final items = currentByPath.values.toList(growable: false)
..sort(_compareLibraryItems);
final items =
(await _db.getAll())
.map(LocalLibraryItem.fromJson)
.toList(growable: false)
..sort(_compareLibraryItems);
final now = DateTime.now();
try {
+16 -2
View File
@@ -5,16 +5,18 @@ import 'package:spotiflac_android/services/app_state_database.dart';
const _maxRecentItems = 20;
/// Types of items that can be accessed
enum RecentAccessType { artist, album, track, playlist }
/// Represents a recently accessed item
class RecentAccessItem {
final String id;
final String name;
final String? subtitle;
final String? subtitle; // Artist name for tracks/albums, null for artists
final String? imageUrl;
final RecentAccessType type;
final DateTime accessedAt;
final String? providerId;
final String? providerId; // Extension ID or 'deezer' for built-in
const RecentAccessItem({
required this.id,
@@ -51,6 +53,7 @@ class RecentAccessItem {
);
}
/// Create a unique key for deduplication
String get uniqueKey => '${type.name}:${providerId ?? 'default'}:$id';
@override
@@ -64,6 +67,7 @@ class RecentAccessItem {
int get hashCode => uniqueKey.hashCode;
}
/// State for recent access history
class RecentAccessState {
final List<RecentAccessItem> items;
final Set<String> hiddenDownloadIds;
@@ -88,6 +92,7 @@ class RecentAccessState {
}
}
/// Provider for managing recent access history
class RecentAccessNotifier extends Notifier<RecentAccessState> {
final AppStateDatabase _appStateDb = AppStateDatabase.instance;
@@ -130,6 +135,7 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
}
}
/// Record an access to an artist
void recordArtistAccess({
required String id,
required String name,
@@ -148,6 +154,7 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
);
}
/// Record an access to an album
void recordAlbumAccess({
required String id,
required String name,
@@ -168,6 +175,7 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
);
}
/// Record an access to a track
void recordTrackAccess({
required String id,
required String name,
@@ -188,6 +196,7 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
);
}
/// Record an access to a playlist
void recordPlaylistAccess({
required String id,
required String name,
@@ -233,6 +242,7 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
}
}
/// Remove a specific item from history
void removeItem(RecentAccessItem item) {
final updatedItems = state.items
.where((e) => e.uniqueKey != item.uniqueKey)
@@ -241,21 +251,25 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
unawaited(_appStateDb.deleteRecentAccessRow(item.uniqueKey));
}
/// Hide a download item from recents (without deleting the actual download)
void hideDownloadFromRecents(String downloadId) {
final updatedHidden = {...state.hiddenDownloadIds, downloadId};
state = state.copyWith(hiddenDownloadIds: updatedHidden);
unawaited(_appStateDb.addHiddenRecentDownloadId(downloadId));
}
/// Check if a download is hidden from recents
bool isDownloadHidden(String downloadId) {
return state.hiddenDownloadIds.contains(downloadId);
}
/// Clear all history
void clearHistory() {
state = state.copyWith(items: []);
unawaited(_appStateDb.clearRecentAccessRows());
}
/// Clear hidden downloads (show all again)
void clearHiddenDownloads() {
state = state.copyWith(hiddenDownloadIds: {});
unawaited(_appStateDb.clearHiddenRecentDownloadIds());
+63 -13
View File
@@ -11,11 +11,13 @@ import 'package:spotiflac_android/utils/logger.dart';
const _settingsKey = 'app_settings';
const _migrationVersionKey = 'settings_migration_version';
const _currentMigrationVersion = 7;
const _currentMigrationVersion = 6;
const _spotifyClientSecretKey = 'spotify_client_secret';
final _log = AppLogger('SettingsProvider');
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}$');
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
@@ -34,12 +36,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
final prefs = await _prefs;
final json = prefs.getString(_settingsKey);
if (json != null) {
state = AppSettings.fromJson(
Map<String, dynamic>.from(jsonDecode(json) as Map),
);
state = AppSettings.fromJson(jsonDecode(json));
await _runMigrations(prefs);
await _normalizeIosDownloadDirectoryIfNeeded();
await _normalizeYouTubeBitratesIfNeeded();
await _normalizeSongLinkRegionIfNeeded();
}
@@ -54,9 +55,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
void _syncLyricsSettingsToBackend() {
if (!PlatformBridge.supportsCoreBackend) return;
PlatformBridge.setLyricsProviders(state.lyricsProviders).catchError((
Object e,
) {
PlatformBridge.setLyricsProviders(state.lyricsProviders).catchError((e) {
_log.w('Failed to sync lyrics providers to backend: $e');
});
@@ -65,7 +64,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
'include_romanization_netease': state.lyricsIncludeRomanizationNetease,
'multi_person_word_by_word': state.lyricsMultiPersonWordByWord,
'musixmatch_language': state.musixmatchLanguage,
}).catchError((Object e) {
}).catchError((e) {
_log.w('Failed to sync lyrics fetch options to backend: $e');
});
}
@@ -77,7 +76,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
PlatformBridge.setNetworkCompatibilityOptions(
allowHttp: compatibilityMode,
insecureTls: compatibilityMode,
).catchError((Object e) {
).catchError((e) {
_log.w('Failed to sync network compatibility options to backend: $e');
});
}
@@ -123,10 +122,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
);
}
state = state.copyWith(lastSeenVersion: AppInfo.version);
// Migration 7: YouTube is no longer a built-in service reset to Tidal
if (state.defaultService == 'youtube') {
state = state.copyWith(defaultService: 'tidal');
}
await prefs.setInt(_migrationVersionKey, _currentMigrationVersion);
await _saveSettings();
}
@@ -158,6 +153,49 @@ 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 {
if (!Platform.isIOS) return;
@@ -431,6 +469,18 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setYoutubeOpusBitrate(int bitrate) {
final normalized = _normalizeYouTubeOpusBitrate(bitrate);
state = state.copyWith(youtubeOpusBitrate: normalized);
_saveSettings();
}
void setYoutubeMp3Bitrate(int bitrate) {
final normalized = _normalizeYouTubeMp3Bitrate(bitrate);
state = state.copyWith(youtubeMp3Bitrate: normalized);
_saveSettings();
}
void setUseAllFilesAccess(bool enabled) {
state = state.copyWith(useAllFilesAccess: enabled);
_saveSettings();
+34 -77
View File
@@ -12,13 +12,13 @@ const _registryUrlPrefKey = 'store_registry_url';
int compareVersions(String v1, String v2) {
final parts1 = v1.replaceAll(_leadingVersionPrefix, '').split('.');
final parts2 = v2.replaceAll(_leadingVersionPrefix, '').split('.');
final maxLen = parts1.length > parts2.length ? parts1.length : parts2.length;
for (var i = 0; i < maxLen; i++) {
final n1 = i < parts1.length ? (int.tryParse(parts1[i]) ?? 0) : 0;
final n2 = i < parts2.length ? (int.tryParse(parts2[i]) ?? 0) : 0;
if (n1 < n2) return -1;
if (n1 > n2) return 1;
}
@@ -26,19 +26,14 @@ int compareVersions(String v1, String v2) {
}
class StoreCategory {
static const String metadata = 'metadata';
static const String download = 'download';
static const String utility = 'utility';
static const String lyrics = 'lyrics';
static const String integration = 'integration';
static const List<String> all = [
metadata,
download,
utility,
lyrics,
integration,
];
static const List<String> all = [metadata, download, utility, lyrics, integration];
static String getDisplayName(String category) {
switch (category) {
@@ -99,8 +94,7 @@ class StoreExtension {
return StoreExtension(
id: json['id'] as String? ?? '',
name: json['name'] as String? ?? '',
displayName:
json['display_name'] as String? ?? json['name'] as String? ?? '',
displayName: json['display_name'] as String? ?? json['name'] as String? ?? '',
version: json['version'] as String? ?? '0.0.0',
author: json['author'] as String? ?? 'Unknown',
description: json['description'] as String? ?? '',
@@ -123,6 +117,7 @@ class StoreExtension {
}
}
class StoreState {
final List<StoreExtension> extensions;
final String? selectedCategory;
@@ -165,15 +160,11 @@ class StoreState {
}) {
return StoreState(
extensions: extensions ?? this.extensions,
selectedCategory: clearCategory
? null
: (selectedCategory ?? this.selectedCategory),
selectedCategory: clearCategory ? null : (selectedCategory ?? this.selectedCategory),
searchQuery: searchQuery ?? this.searchQuery,
isLoading: isLoading ?? this.isLoading,
isDownloading: isDownloading ?? this.isDownloading,
downloadingId: clearDownloadingId
? null
: (downloadingId ?? this.downloadingId),
downloadingId: clearDownloadingId ? null : (downloadingId ?? this.downloadingId),
error: clearError ? null : (error ?? this.error),
isInitialized: isInitialized ?? this.isInitialized,
registryUrl: registryUrl ?? this.registryUrl,
@@ -189,16 +180,13 @@ class StoreState {
if (searchQuery.isNotEmpty) {
final query = searchQuery.toLowerCase();
result = result
.where(
(e) =>
e.name.toLowerCase().contains(query) ||
e.displayName.toLowerCase().contains(query) ||
e.description.toLowerCase().contains(query) ||
e.author.toLowerCase().contains(query) ||
e.tags.any((t) => t.toLowerCase().contains(query)),
)
.toList();
result = result.where((e) =>
e.name.toLowerCase().contains(query) ||
e.displayName.toLowerCase().contains(query) ||
e.description.toLowerCase().contains(query) ||
e.author.toLowerCase().contains(query) ||
e.tags.any((t) => t.toLowerCase().contains(query))
).toList();
}
return result;
@@ -218,28 +206,23 @@ class StoreNotifier extends Notifier<StoreState> {
Future<void> initialize(String cacheDir) async {
if (state.isInitialized) return;
// 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,
);
state = state.copyWith(isLoading: true, clearError: true);
try {
await PlatformBridge.initExtensionStore(cacheDir);
// Load saved registry URL from SharedPreferences
final prefs = await SharedPreferences.getInstance();
final savedUrl = prefs.getString(_registryUrlPrefKey) ?? '';
if (savedUrl.isNotEmpty) {
await PlatformBridge.setStoreRegistryUrl(savedUrl);
state = state.copyWith(registryUrl: savedUrl);
await refresh();
}
state = state.copyWith(isInitialized: true, isLoading: false);
_log.i(
'Extension store initialized (registryUrl: ${savedUrl.isEmpty ? "not set" : savedUrl})',
);
_log.i('Extension store initialized (registryUrl: ${savedUrl.isEmpty ? "not set" : savedUrl})');
} catch (e) {
_log.e('Failed to initialize store: $e');
state = state.copyWith(isLoading: false, error: e.toString());
@@ -264,12 +247,13 @@ class StoreNotifier extends Notifier<StoreState> {
// Read back the resolved URL (may differ from input after normalisation).
final resolvedUrl = await PlatformBridge.getStoreRegistryUrl();
// Persist to SharedPreferences
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_registryUrlPrefKey, resolvedUrl);
state = state.copyWith(
registryUrl: resolvedUrl,
extensions: const [],
extensions: const [], // Clear old extensions
);
_log.i('Registry URL set to: $resolvedUrl');
@@ -308,9 +292,7 @@ class StoreNotifier extends Notifier<StoreState> {
state = state.copyWith(isLoading: true, clearError: true);
try {
final extensions = await PlatformBridge.getStoreExtensions(
forceRefresh: forceRefresh,
);
final extensions = await PlatformBridge.getStoreExtensions(forceRefresh: forceRefresh);
state = state.copyWith(
extensions: extensions.map((e) => StoreExtension.fromJson(e)).toList(),
isLoading: false,
@@ -338,23 +320,12 @@ class StoreNotifier extends Notifier<StoreState> {
state = state.copyWith(searchQuery: '', clearCategory: true);
}
Future<bool> installExtension(
String extensionId,
String tempDir,
String extensionsDir,
) async {
state = state.copyWith(
isDownloading: true,
downloadingId: extensionId,
clearError: true,
);
Future<bool> installExtension(String extensionId, String tempDir, String extensionsDir) async {
state = state.copyWith(isDownloading: true, downloadingId: extensionId, clearError: true);
try {
_log.i('Downloading extension: $extensionId');
final downloadPath = await PlatformBridge.downloadStoreExtension(
extensionId,
tempDir,
);
final downloadPath = await PlatformBridge.downloadStoreExtension(extensionId, tempDir);
_log.i('Installing extension from: $downloadPath');
final extNotifier = ref.read(extensionProvider.notifier);
@@ -369,28 +340,18 @@ class StoreNotifier extends Notifier<StoreState> {
return success;
} catch (e) {
_log.e('Failed to install extension: $e');
state = state.copyWith(
isDownloading: false,
clearDownloadingId: true,
error: e.toString(),
);
state = state.copyWith(isDownloading: false, clearDownloadingId: true, error: e.toString());
return false;
}
}
Future<bool> updateExtension(String extensionId, String tempDir) async {
state = state.copyWith(
isDownloading: true,
downloadingId: extensionId,
clearError: true,
);
state = state.copyWith(isDownloading: true, downloadingId: extensionId, clearError: true);
try {
_log.i('Downloading update for: $extensionId');
final downloadPath = await PlatformBridge.downloadStoreExtension(
extensionId,
tempDir,
);
final downloadPath = await PlatformBridge.downloadStoreExtension(extensionId, tempDir);
_log.i('Upgrading extension from: $downloadPath');
final extNotifier = ref.read(extensionProvider.notifier);
@@ -405,11 +366,7 @@ class StoreNotifier extends Notifier<StoreState> {
return success;
} catch (e) {
_log.e('Failed to update extension: $e');
state = state.copyWith(
isDownloading: false,
clearDownloadingId: true,
error: e.toString(),
);
state = state.copyWith(isDownloading: false, clearDownloadingId: true, error: e.toString());
return false;
}
}
+2
View File
@@ -57,6 +57,7 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
await _saveToStorage();
}
/// Set custom seed color (used when dynamic color is disabled)
Future<void> setSeedColor(Color color) async {
state = state.copyWith(seedColorValue: color.toARGB32());
await _saveToStorage();
@@ -80,3 +81,4 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
);
}
}
+41 -25
View File
@@ -18,18 +18,21 @@ class TrackState {
final String? artistId;
final String? artistName;
final String? coverUrl;
final String? headerImageUrl;
final String? headerImageUrl; // Artist header image for background
final int? monthlyListeners;
final List<ArtistAlbum>? artistAlbums;
final List<Track>? artistTopTracks;
final List<SearchArtist>? searchArtists;
final List<SearchAlbum>? searchAlbums;
final List<SearchPlaylist>? searchPlaylists;
final bool hasSearchText;
final bool isShowingRecentAccess;
final String? searchExtensionId;
final String? selectedSearchFilter;
final String? searchSource;
final List<ArtistAlbum>? artistAlbums; // For artist page
final List<Track>? artistTopTracks; // Artist's popular tracks
final List<SearchArtist>? searchArtists; // For search results
final List<SearchAlbum>? searchAlbums; // For search results (albums)
final List<SearchPlaylist>? searchPlaylists; // For search results (playlists)
final bool hasSearchText; // For back button handling
final bool isShowingRecentAccess; // For recent access mode
final String?
searchExtensionId; // Extension ID used for current search results
final String?
selectedSearchFilter; // Currently selected search filter (e.g., "track", "album", "artist", "playlist")
final String?
searchSource; // Built-in search provider used for current results (e.g., "deezer", "tidal", "qobuz")
const TrackState({
this.tracks = const [],
@@ -124,9 +127,9 @@ class ArtistAlbum {
final String releaseDate;
final int totalTracks;
final String? coverUrl;
final String albumType;
final String albumType; // album, single, compilation
final String artists;
final String? providerId;
final String? providerId; // Extension ID if from extension
const ArtistAlbum({
required this.id,
@@ -201,6 +204,7 @@ class TrackNotifier extends Notifier<TrackState> {
return const TrackState();
}
/// Check if request is still valid (not cancelled by newer request)
bool _isRequestValid(int requestId) => requestId == _currentRequestId;
Future<void> fetchFromUrl(String url, {bool useDeezerFallback = true}) async {
@@ -213,6 +217,7 @@ class TrackNotifier extends Notifier<TrackState> {
if (extensionHandler != null) {
_log.i('Found extension URL handler: $extensionHandler for URL: $url');
// Retry logic for extension URL handlers (up to 3 attempts)
Map<String, dynamic>? result;
for (int attempt = 1; attempt <= 3; attempt++) {
result = await PlatformBridge.handleURLWithExtension(url);
@@ -234,7 +239,7 @@ class TrackNotifier extends Notifier<TrackState> {
}
if (attempt < 3) {
await Future<void>.delayed(const Duration(milliseconds: 500));
await Future.delayed(const Duration(milliseconds: 500));
}
}
@@ -275,12 +280,10 @@ class TrackNotifier extends Notifier<TrackState> {
state = TrackState(
tracks: tracks,
isLoading: false,
albumId:
(result['album'] as Map<String, dynamic>?)?['id'] as String?,
albumId: result['album']?['id'] as String?,
albumName:
result['name'] as String? ??
(result['album'] as Map<String, dynamic>?)?['name']
as String?,
result['album']?['name'] as String?,
playlistName: type == 'playlist'
? result['name'] as String?
: null,
@@ -538,6 +541,7 @@ class TrackNotifier extends Notifier<TrackState> {
return;
}
// If URL doesn't match any known service, it's unrecognized
final isSpotifyUrl =
url.contains('open.spotify.com') ||
url.contains('spotify.link') ||
@@ -639,6 +643,7 @@ class TrackNotifier extends Notifier<TrackState> {
}) async {
final requestId = ++_currentRequestId;
// Preserve selected filter during loading
final currentFilter = filterOverride ?? state.selectedSearchFilter;
state = TrackState(
@@ -657,6 +662,7 @@ class TrackNotifier extends Notifier<TrackState> {
final includeExtensions =
settings.useExtensionProviders && hasActiveMetadataExtensions;
// Determine the effective search provider
final effectiveProvider = builtInSearchProvider ?? 'deezer';
_log.i(
@@ -666,6 +672,7 @@ class TrackNotifier extends Notifier<TrackState> {
Map<String, dynamic> results;
List<Map<String, dynamic>> metadataTrackResults = [];
// Only use metadata providers for Deezer search (default behavior)
if (effectiveProvider == 'deezer') {
try {
_log.d('Calling metadata provider search API...');
@@ -685,6 +692,7 @@ class TrackNotifier extends Notifier<TrackState> {
}
}
// Call the appropriate search API
switch (effectiveProvider) {
case 'tidal':
_log.d('Calling Tidal search API...');
@@ -800,8 +808,9 @@ class TrackNotifier extends Notifier<TrackState> {
isLoading: false,
hasSearchText: state.hasSearchText,
isShowingRecentAccess: state.isShowingRecentAccess,
selectedSearchFilter: currentFilter,
searchSource: effectiveProvider,
selectedSearchFilter: currentFilter, // Preserve filter in results
searchSource:
effectiveProvider, // Track which service was used for search
);
} catch (e, stackTrace) {
if (!_isRequestValid(requestId)) return;
@@ -827,7 +836,8 @@ class TrackNotifier extends Notifier<TrackState> {
isLoading: true,
hasSearchText: state.hasSearchText,
isShowingRecentAccess: state.isShowingRecentAccess,
selectedSearchFilter: state.selectedSearchFilter,
selectedSearchFilter:
state.selectedSearchFilter, // Preserve filter during loading
);
try {
@@ -866,8 +876,9 @@ class TrackNotifier extends Notifier<TrackState> {
isLoading: false,
hasSearchText: state.hasSearchText,
isShowingRecentAccess: state.isShowingRecentAccess,
searchExtensionId: extensionId,
selectedSearchFilter: state.selectedSearchFilter,
searchExtensionId: extensionId, // Store which extension was used
selectedSearchFilter:
state.selectedSearchFilter, // Preserve selected filter
);
} catch (e, stackTrace) {
if (!_isRequestValid(requestId)) return;
@@ -922,13 +933,16 @@ class TrackNotifier extends Notifier<TrackState> {
final tracks = List<Track>.from(state.tracks);
tracks[index] = updatedTrack;
state = state.copyWith(tracks: tracks);
} catch (_) {}
} catch (_) {
// Silently ignore update failures - track may have been removed
}
}
void clear() {
state = const TrackState();
}
/// Set selected search filter for extension search
void setSearchFilter(String? filter) {
if (state.selectedSearchFilter == filter) return;
state = state.copyWith(
@@ -937,6 +951,7 @@ class TrackNotifier extends Notifier<TrackState> {
);
}
/// Set search text state for back button handling
void setSearchText(bool hasText) {
if (state.hasSearchText == hasText) {
return;
@@ -951,6 +966,7 @@ class TrackNotifier extends Notifier<TrackState> {
state = state.copyWith(isShowingRecentAccess: showing);
}
/// Set tracks from a collection (album/playlist) opened from search results
void setTracksFromCollection({
required List<Track> tracks,
String? albumName,
@@ -1111,7 +1127,7 @@ class TrackNotifier extends Notifier<TrackState> {
'isrc': isrc,
'track_name': track.name,
'artist_name': track.artistName,
'spotify_id': track.id,
'spotify_id': track.id, // Include Spotify ID for Amazon lookup
'service': 'tidal',
});
if (cacheRequests.length >= _maxPreWarmTracksPerRequest) {
+6 -21
View File
@@ -14,7 +14,6 @@ import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
import 'package:spotiflac_android/widgets/download_service_picker.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
import 'package:spotiflac_android/providers/library_collections_provider.dart';
import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart';
import 'package:spotiflac_android/utils/clickable_metadata.dart';
@@ -242,16 +241,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
);
}
String? _recommendedDownloadService() {
if (widget.extensionId != null && widget.extensionId!.isNotEmpty) {
return widget.extensionId;
}
if (widget.albumId.startsWith('tidal:')) return 'tidal';
if (widget.albumId.startsWith('qobuz:')) return 'qobuz';
if (widget.albumId.startsWith('deezer:')) return 'deezer';
return null;
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
@@ -268,8 +257,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
if (_isLoading)
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(16),
child: AlbumTrackListSkeleton(itemCount: 10),
padding: EdgeInsets.all(32),
child: Center(child: CircularProgressIndicator()),
),
),
if (_error != null)
@@ -545,12 +534,9 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
final track = tracks[index];
return KeyedSubtree(
key: ValueKey(track.id),
child: StaggeredListItem(
index: index,
child: _AlbumTrackItem(
track: track,
onDownload: () => _downloadTrack(context, track),
),
child: _AlbumTrackItem(
track: track,
onDownload: () => _downloadTrack(context, track),
),
);
}, childCount: tracks.length),
@@ -565,7 +551,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
trackName: track.name,
artistName: track.artistName,
coverUrl: track.coverUrl,
recommendedService: _recommendedDownloadService(),
onSelect: (quality, service) {
ref
.read(downloadQueueProvider.notifier)
@@ -591,6 +576,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
final tracks = _tracks;
if (tracks == null || tracks.isEmpty) return;
// Skip already-downloaded tracks
final historyState = ref.read(downloadHistoryProvider);
final settings = ref.read(settingsProvider);
final localLibState =
@@ -637,7 +623,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
context,
trackName: '${tracksToQueue.length} tracks',
artistName: widget.albumName,
recommendedService: _recommendedDownloadService(),
onSelect: (quality, service) {
ref
.read(downloadQueueProvider.notifier)
+43 -47
View File
@@ -20,7 +20,6 @@ import 'package:spotiflac_android/screens/home_tab.dart'
show ExtensionAlbumScreen;
import 'package:spotiflac_android/widgets/download_service_picker.dart';
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
import 'package:spotiflac_android/utils/clickable_metadata.dart';
class _ArtistCache {
@@ -153,16 +152,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
return tileSize + 64 + ((textScale - 1) * 14);
}
String? _recommendedDownloadService() {
if (widget.extensionId != null && widget.extensionId!.isNotEmpty) {
return widget.extensionId;
}
if (widget.artistId.startsWith('tidal:')) return 'tidal';
if (widget.artistId.startsWith('qobuz:')) return 'qobuz';
if (widget.artistId.startsWith('deezer:')) return 'deezer';
return null;
}
@override
void initState() {
super.initState();
@@ -492,17 +481,10 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
hasDiscography: hasDiscography,
),
if (_isLoadingDiscography)
SliverToBoxAdapter(
child: ArtistScreenSkeleton(
showCoverHeader:
(_headerImageUrl ??
widget.headerImageUrl ??
widget.coverUrl) ==
null,
showPopularSection:
!widget.artistId.startsWith('deezer:') &&
!widget.artistId.startsWith('qobuz:') &&
!widget.artistId.startsWith('tidal:'),
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(32),
child: Center(child: CircularProgressIndicator()),
),
),
if (_error != null)
@@ -805,7 +787,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
);
final singleTracks = singles.fold<int>(0, (sum, a) => sum + a.totalTracks);
showModalBottomSheet<void>(
showModalBottomSheet(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
@@ -907,7 +889,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
if (settings.askQualityBeforeDownload) {
DownloadServicePicker.show(
context,
recommendedService: _recommendedDownloadService(),
onSelect: (quality, service) {
_fetchAndQueueAlbums(albums, service, quality);
},
@@ -939,7 +920,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
return;
}
showDialog<void>(
showDialog(
context: context,
barrierDismissible: false,
builder: (ctx) => _FetchingProgressDialog(
@@ -967,6 +948,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
fetchedCount++;
// Update progress dialog
if (mounted) {
_FetchingProgressDialog.updateProgress(
context,
@@ -997,6 +979,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
return;
}
// Check which tracks are already downloaded
final historyState = ref.read(downloadHistoryProvider);
final tracksToQueue = <Track>[];
int skippedCount = 0;
@@ -1047,7 +1030,10 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
content: Text(message),
action: SnackBarAction(
label: context.l10n.snackbarViewQueue,
onPressed: () {},
onPressed: () {
// Navigate to queue tab (index 1)
// This will be handled by the navigation system
},
),
),
);
@@ -1121,10 +1107,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
Track _parseTrackFromDeezer(Map<String, dynamic> data, ArtistAlbum album) {
int durationMs = 0;
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) {
durationMs = durationValue * 1000; // Deezer returns seconds
} else if (durationValue is double) {
@@ -1134,7 +1116,9 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
return Track(
id: 'deezer:${data['id']}',
name: (data['title'] ?? data['name'] ?? '').toString(),
artistName: artistName,
artistName:
(data['artist']?['name'] ?? data['artist'] ?? widget.artistName)
.toString(),
albumName: album.name,
albumArtist: widget.artistName,
artistId: widget.artistId,
@@ -1170,8 +1154,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
imageUrl.isNotEmpty &&
Uri.tryParse(imageUrl)?.hasAuthority == true;
final isDark = Theme.of(context).brightness == Brightness.dark;
String? listenersText;
final listeners = _monthlyListeners ?? widget.monthlyListeners;
if (listeners != null && listeners > 0) {
@@ -1242,9 +1224,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
Colors.transparent,
Colors.black.withValues(alpha: 0.3),
Colors.black.withValues(alpha: 0.7),
isDark
? colorScheme.surface
: Colors.black.withValues(alpha: 0.85),
colorScheme.surface,
],
stops: const [0.0, 0.5, 0.75, 1.0],
),
@@ -1285,7 +1265,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
listenersText,
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(
color: Colors.white,
color: Colors.white.withValues(alpha: 0.8),
shadows: [
Shadow(
offset: const Offset(0, 1),
@@ -1709,7 +1689,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
if (settings.askQualityBeforeDownload) {
DownloadServicePicker.show(
context,
recommendedService: _recommendedDownloadService(),
onSelect: (quality, service) {
if (!mounted) return;
enqueue(service, quality: quality);
@@ -1860,14 +1839,29 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
Positioned(
top: 8,
right: 8,
child: AnimatedSelectionCheckbox(
visible: true,
selected: isSelected,
colorScheme: colorScheme,
size: 28,
unselectedColor: colorScheme.surface.withValues(
alpha: 0.9,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 28,
height: 28,
decoration: BoxDecoration(
color: isSelected
? colorScheme.primary
: colorScheme.surface.withValues(alpha: 0.9),
shape: BoxShape.circle,
border: Border.all(
color: isSelected
? colorScheme.primary
: colorScheme.outline,
width: 2,
),
),
child: isSelected
? Icon(
Icons.check,
color: colorScheme.onPrimary,
size: 18,
)
: null,
),
),
if (showTypeBadge)
@@ -1940,7 +1934,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
if (album.providerId != null && album.providerId!.isNotEmpty) {
Navigator.push(
context,
MaterialPageRoute<void>(
MaterialPageRoute(
builder: (context) => ExtensionAlbumScreen(
extensionId: album.providerId!,
albumId: album.id,
@@ -1952,7 +1946,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
} else {
Navigator.push(
context,
MaterialPageRoute<void>(
MaterialPageRoute(
builder: (context) => AlbumScreen(
albumId: album.id,
albumName: album.name,
@@ -2076,6 +2070,7 @@ class _FetchingProgressDialog extends StatefulWidget {
required this.onCancel,
});
// Static method to update progress from outside
static void updateProgress(BuildContext context, int current, int total) {
final state = context
.findAncestorStateOfType<_FetchingProgressDialogState>();
@@ -2148,6 +2143,7 @@ class _FetchingProgressDialogState extends State<_FetchingProgressDialog> {
),
),
const SizedBox(height: 8),
// Progress bar
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
+36 -17
View File
@@ -17,7 +17,6 @@ import 'package:spotiflac_android/providers/playback_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
class DownloadedAlbumScreen extends ConsumerStatefulWidget {
final String albumName;
@@ -121,6 +120,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
final tracks =
allItems.where((item) {
// Use albumArtist if available and not empty, otherwise artistName
final itemArtist =
(item.albumArtist != null && item.albumArtist!.isNotEmpty)
? item.albumArtist!
@@ -129,6 +129,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
'${item.albumName.toLowerCase()}|${itemArtist.toLowerCase()}';
return itemKey == _albumLookupKey;
}).toList()..sort((a, b) {
// Sort by disc number first, then by track number
final aDisc = a.discNumber ?? 1;
final bDisc = b.discNumber ?? 1;
if (aDisc != bDisc) return aDisc.compareTo(bDisc);
@@ -309,7 +310,14 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
if (!mounted) return;
final result = await navigator.push(
slidePageRoute<bool>(page: TrackMetadataScreen(item: item)),
PageRouteBuilder(
transitionDuration: const Duration(milliseconds: 300),
reverseTransitionDuration: const Duration(milliseconds: 250),
pageBuilder: (context, animation, secondaryAnimation) =>
TrackMetadataScreen(item: item),
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
FadeTransition(opacity: animation, child: child),
),
);
await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath(
item.filePath,
@@ -685,10 +693,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
final track = tracks[index];
return KeyedSubtree(
key: ValueKey(track.id),
child: StaggeredListItem(
index: index,
child: _buildTrackItem(context, colorScheme, track),
),
child: _buildTrackItem(context, colorScheme, track),
);
}, childCount: tracks.length),
);
@@ -696,7 +701,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
final discNumbers = _getSortedDiscNumbers(tracks);
final List<Widget> children = [];
var revealIndex = 0;
for (final discNumber in discNumbers) {
final discTracks = discMap[discNumber];
@@ -708,10 +712,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
children.add(
KeyedSubtree(
key: ValueKey(track.id),
child: StaggeredListItem(
index: revealIndex++,
child: _buildTrackItem(context, colorScheme, track),
),
child: _buildTrackItem(context, colorScheme, track),
),
);
}
@@ -795,11 +796,28 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
mainAxisSize: MainAxisSize.min,
children: [
if (_isSelectionMode) ...[
AnimatedSelectionCheckbox(
visible: true,
selected: isSelected,
colorScheme: colorScheme,
size: 24,
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: isSelected
? colorScheme.primary
: Colors.transparent,
shape: BoxShape.circle,
border: Border.all(
color: isSelected
? colorScheme.primary
: colorScheme.outline,
width: 2,
),
),
child: isSelected
? Icon(
Icons.check,
color: colorScheme.onPrimary,
size: 16,
)
: null,
),
const SizedBox(width: 12),
],
@@ -932,7 +950,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
? '320k'
: (selectedFormat == 'Opus' ? '128k' : '320k');
showModalBottomSheet<void>(
showModalBottomSheet(
context: context,
useRootNavigator: true,
shape: const RoundedRectangleBorder(
@@ -1105,6 +1123,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
? 'Opus'
: null;
if (ext == null || ext == targetFormat) continue;
// Skip lossy sources when target is lossless (pointless re-encoding)
final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC';
final isLosslessSource = ext == 'FLAC' || ext == 'M4A';
if (isLosslessTarget && !isLosslessSource) continue;
+129 -398
View File
@@ -28,7 +28,6 @@ import 'package:spotiflac_android/screens/playlist_screen.dart';
import 'package:spotiflac_android/screens/downloaded_album_screen.dart';
import 'package:spotiflac_android/widgets/download_service_picker.dart';
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
import 'package:spotiflac_android/utils/clickable_metadata.dart';
class HomeTab extends ConsumerStatefulWidget {
@@ -84,18 +83,6 @@ class _SearchResultBuckets {
});
}
enum _SearchSortOption {
defaultOrder,
titleAsc,
titleDesc,
artistAsc,
artistDesc,
durationAsc,
durationDesc,
dateAsc,
dateDesc,
}
const _homeHistoryPreviewLimit = 48;
class _HomeHistoryPreview {
@@ -257,7 +244,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
Map<String, (double, double)>? _thumbnailSizesCache;
List<Track>? _searchBucketsSourceTracks;
_SearchResultBuckets? _searchBucketsCache;
_SearchSortOption _searchSortOption = _SearchSortOption.defaultOrder;
double _responsiveScale({
required BuildContext context,
@@ -294,13 +280,13 @@ class _HomeTabState extends ConsumerState<HomeTab>
double _exploreCardSize(BuildContext context) {
final scale = _responsiveScale(context: context, min: 0.82, max: 1.08);
final textScale = _effectiveTextScale(context);
return 145 * scale * (1 + (textScale - 1) * 0.12);
return 120 * scale * (1 + (textScale - 1) * 0.12);
}
double _exploreSectionHeight(BuildContext context) {
final cardSize = _exploreCardSize(context);
final textScale = _effectiveTextScale(context);
return cardSize + 58 + ((textScale - 1) * 12);
return cardSize + 55 + ((textScale - 1) * 12);
}
@override
@@ -556,7 +542,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
pending != query &&
mounted &&
_urlController.text.trim() == pending) {
await Future<void>.delayed(const Duration(milliseconds: 100));
await Future.delayed(const Duration(milliseconds: 100));
if (mounted && _urlController.text.trim() == pending) {
_executeLiveSearch(pending);
}
@@ -578,7 +564,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
'${searchProvider ?? 'default'}:$query:${selectedFilter ?? 'all'}';
if (_lastSearchQuery == searchKey) return;
_lastSearchQuery = searchKey;
_searchSortOption = _SearchSortOption.defaultOrder;
final isBuiltInProvider =
searchProvider != null &&
@@ -681,7 +666,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
final extensionId = trackState.searchExtensionId;
Navigator.push(
context,
MaterialPageRoute<void>(
MaterialPageRoute(
builder: (context) => AlbumScreen(
albumId: trackState.albumId!,
albumName: trackState.albumName!,
@@ -708,13 +693,11 @@ class _HomeTabState extends ConsumerState<HomeTab>
Navigator.push(
context,
MaterialPageRoute<void>(
MaterialPageRoute(
builder: (context) => PlaylistScreen(
playlistName: trackState.playlistName!,
coverUrl: trackState.coverUrl,
tracks: trackState.tracks,
recommendedService:
trackState.searchExtensionId ?? trackState.searchSource,
),
),
);
@@ -729,7 +712,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
final extensionId = trackState.searchExtensionId;
Navigator.push(
context,
MaterialPageRoute<void>(
MaterialPageRoute(
builder: (context) => ArtistScreen(
artistId: trackState.artistId!,
artistName: trackState.artistName!,
@@ -798,7 +781,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
if (progressDialogInitialized || !mounted) return;
progressDialogInitialized = true;
progressDialogVisible = true;
showDialog<void>(
showDialog(
context: this.context,
useRootNavigator: false,
barrierDismissible: false,
@@ -1298,8 +1281,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
exploreLoading)
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(16),
child: TrackListSkeleton(itemCount: 5),
padding: EdgeInsets.all(32),
child: Center(child: CircularProgressIndicator()),
),
),
@@ -1502,7 +1485,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
delegate: SliverChildBuilderDelegate((context, index) {
if (hasGreeting && index == 0) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Text(
greeting,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
@@ -1517,7 +1500,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
return _buildExploreSection(sections[sectionIndex], colorScheme);
}
return const SizedBox(height: 24);
return const SizedBox(height: 16);
}, childCount: totalCount),
),
];
@@ -1533,7 +1516,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 12),
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
child: Text(
section.title,
style: Theme.of(
@@ -1549,11 +1532,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
itemCount: section.items.length,
itemBuilder: (context, index) {
final item = section.items[index];
return StaggeredListItem(
index: index,
staggerDelay: const Duration(milliseconds: 50),
child: _buildExploreItem(item, colorScheme),
);
return _buildExploreItem(item, colorScheme);
},
),
),
@@ -1600,7 +1579,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
children: [
ClipRRect(
borderRadius: BorderRadius.circular(
isArtist ? cardSize / 2 : 10,
isArtist ? cardSize / 2 : 8,
),
child: item.coverUrl != null && item.coverUrl!.isNotEmpty
? CachedNetworkImage(
@@ -1639,8 +1618,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: isArtist ? TextAlign.center : TextAlign.start,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w500,
color: colorScheme.onSurface,
),
),
@@ -1653,7 +1632,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontSize: 12,
fontSize: 11,
),
),
],
@@ -1691,7 +1670,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
case 'album':
Navigator.push(
context,
MaterialPageRoute<void>(
MaterialPageRoute(
builder: (context) => ExtensionAlbumScreen(
extensionId: extensionId,
albumId: item.id,
@@ -1704,7 +1683,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
case 'playlist':
Navigator.push(
context,
MaterialPageRoute<void>(
MaterialPageRoute(
builder: (context) => ExtensionPlaylistScreen(
extensionId: extensionId,
playlistId: item.id,
@@ -1717,7 +1696,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
case 'artist':
Navigator.push(
context,
MaterialPageRoute<void>(
MaterialPageRoute(
builder: (context) => ExtensionArtistScreen(
extensionId: extensionId,
artistId: item.id,
@@ -1738,7 +1717,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
void _showTrackBottomSheet(ExploreItem item) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet<void>(
showModalBottomSheet(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surface,
@@ -1884,7 +1863,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
if (item.albumId != null && item.albumId!.isNotEmpty) {
Navigator.push(
context,
MaterialPageRoute<void>(
MaterialPageRoute(
builder: (context) => ExtensionAlbumScreen(
extensionId: item.providerId ?? 'spotify-web',
albumId: item.albumId!,
@@ -2143,12 +2122,10 @@ class _HomeTabState extends ConsumerState<HomeTab>
if (item.providerId != null &&
item.providerId!.isNotEmpty &&
item.providerId != 'deezer' &&
item.providerId != 'spotify' &&
item.providerId != 'tidal' &&
item.providerId != 'qobuz') {
item.providerId != 'spotify') {
Navigator.push(
context,
MaterialPageRoute<void>(
MaterialPageRoute(
builder: (context) => ExtensionArtistScreen(
extensionId: item.providerId!,
artistId: item.id,
@@ -2160,7 +2137,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
} else {
Navigator.push(
context,
MaterialPageRoute<void>(
MaterialPageRoute(
builder: (context) => ArtistScreen(
artistId: item.id,
artistName: item.name,
@@ -2174,7 +2151,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
if (item.providerId == 'download') {
Navigator.push(
context,
MaterialPageRoute<void>(
MaterialPageRoute(
builder: (context) => DownloadedAlbumScreen(
albumName: item.name,
artistName: item.subtitle ?? '',
@@ -2185,12 +2162,10 @@ class _HomeTabState extends ConsumerState<HomeTab>
} else if (item.providerId != null &&
item.providerId!.isNotEmpty &&
item.providerId != 'deezer' &&
item.providerId != 'spotify' &&
item.providerId != 'tidal' &&
item.providerId != 'qobuz') {
item.providerId != 'spotify') {
Navigator.push(
context,
MaterialPageRoute<void>(
MaterialPageRoute(
builder: (context) => ExtensionAlbumScreen(
extensionId: item.providerId!,
albumId: item.id,
@@ -2202,7 +2177,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
} else {
Navigator.push(
context,
MaterialPageRoute<void>(
MaterialPageRoute(
builder: (context) => AlbumScreen(
albumId: item.id,
albumName: item.name,
@@ -2235,12 +2210,10 @@ class _HomeTabState extends ConsumerState<HomeTab>
if (item.providerId != null &&
item.providerId!.isNotEmpty &&
item.providerId != 'deezer' &&
item.providerId != 'spotify' &&
item.providerId != 'tidal' &&
item.providerId != 'qobuz') {
item.providerId != 'spotify') {
Navigator.push(
context,
MaterialPageRoute<void>(
MaterialPageRoute(
builder: (context) => ExtensionPlaylistScreen(
extensionId: item.providerId!,
playlistId: item.id,
@@ -2252,7 +2225,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
} else {
Navigator.push(
context,
MaterialPageRoute<void>(
MaterialPageRoute(
builder: (context) => PlaylistScreen(
playlistName: item.name,
coverUrl: item.imageUrl,
@@ -2275,7 +2248,14 @@ class _HomeTabState extends ConsumerState<HomeTab>
);
if (!mounted) return;
final result = await navigator.push(
slidePageRoute<bool>(page: TrackMetadataScreen(item: item)),
PageRouteBuilder(
transitionDuration: const Duration(milliseconds: 300),
reverseTransitionDuration: const Duration(milliseconds: 250),
pageBuilder: (context, animation, secondaryAnimation) =>
TrackMetadataScreen(item: item),
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
FadeTransition(opacity: animation, child: child),
),
);
await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath(
item.filePath,
@@ -2413,168 +2393,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
);
}
// Search result sorting
String _sortOptionLabel(_SearchSortOption option) {
switch (option) {
case _SearchSortOption.defaultOrder:
return context.l10n.searchSortDefault;
case _SearchSortOption.titleAsc:
return context.l10n.searchSortTitleAZ;
case _SearchSortOption.titleDesc:
return context.l10n.searchSortTitleZA;
case _SearchSortOption.artistAsc:
return context.l10n.searchSortArtistAZ;
case _SearchSortOption.artistDesc:
return context.l10n.searchSortArtistZA;
case _SearchSortOption.durationAsc:
return context.l10n.searchSortDurationShort;
case _SearchSortOption.durationDesc:
return context.l10n.searchSortDurationLong;
case _SearchSortOption.dateAsc:
return context.l10n.searchSortDateOldest;
case _SearchSortOption.dateDesc:
return context.l10n.searchSortDateNewest;
}
}
void _showSortOptions(ColorScheme colorScheme) {
var tempSort = _searchSortOption;
showModalBottomSheet<void>(
context: context,
useRootNavigator: true,
isScrollControlled: true,
backgroundColor: colorScheme.surfaceContainerLow,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (ctx) => StatefulBuilder(
builder: (ctx, setSheetState) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 32,
height: 4,
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: colorScheme.outlineVariant,
borderRadius: BorderRadius.circular(2),
),
),
),
Row(
children: [
Text(
context.l10n.searchSortTitle,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const Spacer(),
TextButton(
onPressed: () => setSheetState(
() => tempSort = _SearchSortOption.defaultOrder,
),
child: Text(context.l10n.libraryFilterReset),
),
],
),
const SizedBox(height: 16),
Wrap(
spacing: 8,
runSpacing: 8,
children: _SearchSortOption.values.map((option) {
return FilterChip(
label: Text(_sortOptionLabel(option)),
selected: tempSort == option,
showCheckmark: false,
onSelected: (_) =>
setSheetState(() => tempSort = option),
);
}).toList(),
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: () {
Navigator.pop(ctx);
if (_searchSortOption != tempSort) {
setState(() {
_searchSortOption = tempSort;
});
}
},
child: Text(context.l10n.libraryFilterApply),
),
),
],
),
),
);
},
),
);
}
List<T> _applySortToList<T>(
List<T> items,
String Function(T) getName,
String Function(T) getArtist,
int Function(T) getDuration,
String? Function(T) getDate,
) {
if (_searchSortOption == _SearchSortOption.defaultOrder) return items;
final sorted = List<T>.of(items);
switch (_searchSortOption) {
case _SearchSortOption.defaultOrder:
break;
case _SearchSortOption.titleAsc:
sorted.sort(
(a, b) =>
getName(a).toLowerCase().compareTo(getName(b).toLowerCase()),
);
case _SearchSortOption.titleDesc:
sorted.sort(
(a, b) =>
getName(b).toLowerCase().compareTo(getName(a).toLowerCase()),
);
case _SearchSortOption.artistAsc:
sorted.sort(
(a, b) =>
getArtist(a).toLowerCase().compareTo(getArtist(b).toLowerCase()),
);
case _SearchSortOption.artistDesc:
sorted.sort(
(a, b) =>
getArtist(b).toLowerCase().compareTo(getArtist(a).toLowerCase()),
);
case _SearchSortOption.durationAsc:
sorted.sort((a, b) => getDuration(a).compareTo(getDuration(b)));
case _SearchSortOption.durationDesc:
sorted.sort((a, b) => getDuration(b).compareTo(getDuration(a)));
case _SearchSortOption.dateAsc:
sorted.sort((a, b) {
final da = getDate(a) ?? '';
final db = getDate(b) ?? '';
return da.compareTo(db);
});
case _SearchSortOption.dateDesc:
sorted.sort((a, b) {
final da = getDate(a) ?? '';
final db = getDate(b) ?? '';
return db.compareTo(da);
});
}
return sorted;
}
List<Widget> _buildSearchResults({
required List<Track> tracks,
required List<SearchArtist>? searchArtists,
@@ -2588,15 +2406,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
required bool showLocalLibraryIndicator,
required Map<String, (double, double)> thumbnailSizesByExtensionId,
}) {
final hasActualData =
tracks.isNotEmpty ||
(searchArtists != null && searchArtists.isNotEmpty) ||
(searchAlbums != null && searchAlbums.isNotEmpty) ||
(searchPlaylists != null && searchPlaylists.isNotEmpty);
if (!hasActualData && isLoading) {
return [const SliverToBoxAdapter(child: HomeSearchSkeleton())];
}
if (!hasResults) {
return [const SliverToBoxAdapter(child: SizedBox.shrink())];
}
@@ -2608,59 +2417,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
final playlistItems = buckets.playlistItems;
final artistItems = buckets.artistItems;
final sortedArtists = searchArtists != null && searchArtists.isNotEmpty
? _applySortToList<SearchArtist>(
searchArtists,
(a) => a.name,
(a) => a.name,
(a) => 0,
(a) => null,
)
: searchArtists;
final sortedAlbums = searchAlbums != null && searchAlbums.isNotEmpty
? _applySortToList<SearchAlbum>(
searchAlbums,
(a) => a.name,
(a) => a.artists,
(a) => 0,
(a) => a.releaseDate,
)
: searchAlbums;
final sortedPlaylists =
searchPlaylists != null && searchPlaylists.isNotEmpty
? _applySortToList<SearchPlaylist>(
searchPlaylists,
(p) => p.name,
(p) => p.owner,
(p) => 0,
(p) => null,
)
: searchPlaylists;
List<Track> sortedTracks;
List<int> sortedTrackIndexes;
if (realTracks.isNotEmpty &&
_searchSortOption != _SearchSortOption.defaultOrder) {
final paired = List.generate(
realTracks.length,
(i) => (realTracks[i], realTrackIndexes[i]),
);
final sortedPairs = _applySortToList<(Track, int)>(
paired,
(p) => p.$1.name,
(p) => p.$1.artistName,
(p) => p.$1.duration,
(p) => p.$1.releaseDate,
);
sortedTracks = sortedPairs.map((p) => p.$1).toList();
sortedTrackIndexes = sortedPairs.map((p) => p.$2).toList();
} else {
sortedTracks = realTracks;
sortedTrackIndexes = realTrackIndexes;
}
final slivers = <Widget>[
if (error != null)
SliverToBoxAdapter(
@@ -2678,28 +2434,24 @@ class _HomeTabState extends ConsumerState<HomeTab>
),
];
bool sortButtonShown = false;
if (sortedArtists != null && sortedArtists.isNotEmpty) {
if (searchArtists != null && searchArtists.isNotEmpty) {
slivers.addAll(
_buildVirtualizedResultSection(
title: context.l10n.searchArtists,
itemCount: sortedArtists.length,
itemCount: searchArtists.length,
colorScheme: colorScheme,
showSortButton: !sortButtonShown,
itemBuilder: (index, showDivider) => _SearchArtistItemWidget(
key: ValueKey('search-artist-${sortedArtists[index].id}'),
artist: sortedArtists[index],
key: ValueKey('search-artist-${searchArtists[index].id}'),
artist: searchArtists[index],
showDivider: showDivider,
onTap: () => _navigateToArtist(
sortedArtists[index].id,
sortedArtists[index].name,
sortedArtists[index].imageUrl,
searchArtists[index].id,
searchArtists[index].name,
searchArtists[index].imageUrl,
),
),
),
);
sortButtonShown = true;
}
if (artistItems.isNotEmpty) {
@@ -2708,7 +2460,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
title: context.l10n.searchArtists,
itemCount: artistItems.length,
colorScheme: colorScheme,
showSortButton: !sortButtonShown,
itemBuilder: (index, showDivider) => _CollectionItemWidget(
key: ValueKey('artist-${artistItems[index].id}'),
item: artistItems[index],
@@ -2717,25 +2468,22 @@ class _HomeTabState extends ConsumerState<HomeTab>
),
),
);
sortButtonShown = true;
}
if (sortedAlbums != null && sortedAlbums.isNotEmpty) {
if (searchAlbums != null && searchAlbums.isNotEmpty) {
slivers.addAll(
_buildVirtualizedResultSection(
title: context.l10n.searchAlbums,
itemCount: sortedAlbums.length,
itemCount: searchAlbums.length,
colorScheme: colorScheme,
showSortButton: !sortButtonShown,
itemBuilder: (index, showDivider) => _SearchAlbumItemWidget(
key: ValueKey('search-album-${sortedAlbums[index].id}'),
album: sortedAlbums[index],
key: ValueKey('search-album-${searchAlbums[index].id}'),
album: searchAlbums[index],
showDivider: showDivider,
onTap: () => _navigateToSearchAlbum(sortedAlbums[index]),
onTap: () => _navigateToSearchAlbum(searchAlbums[index]),
),
),
);
sortButtonShown = true;
}
if (albumItems.isNotEmpty) {
@@ -2744,7 +2492,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
title: context.l10n.searchAlbums,
itemCount: albumItems.length,
colorScheme: colorScheme,
showSortButton: !sortButtonShown,
itemBuilder: (index, showDivider) => _CollectionItemWidget(
key: ValueKey('album-${albumItems[index].id}'),
item: albumItems[index],
@@ -2753,25 +2500,22 @@ class _HomeTabState extends ConsumerState<HomeTab>
),
),
);
sortButtonShown = true;
}
if (sortedPlaylists != null && sortedPlaylists.isNotEmpty) {
if (searchPlaylists != null && searchPlaylists.isNotEmpty) {
slivers.addAll(
_buildVirtualizedResultSection(
title: context.l10n.searchPlaylists,
itemCount: sortedPlaylists.length,
itemCount: searchPlaylists.length,
colorScheme: colorScheme,
showSortButton: !sortButtonShown,
itemBuilder: (index, showDivider) => _SearchPlaylistItemWidget(
key: ValueKey('search-playlist-${sortedPlaylists[index].id}'),
playlist: sortedPlaylists[index],
key: ValueKey('search-playlist-${searchPlaylists[index].id}'),
playlist: searchPlaylists[index],
showDivider: showDivider,
onTap: () => _navigateToSearchPlaylist(sortedPlaylists[index]),
onTap: () => _navigateToSearchPlaylist(searchPlaylists[index]),
),
),
);
sortButtonShown = true;
}
if (playlistItems.isNotEmpty) {
@@ -2780,7 +2524,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
title: context.l10n.searchPlaylists,
itemCount: playlistItems.length,
colorScheme: colorScheme,
showSortButton: !sortButtonShown,
itemBuilder: (index, showDivider) => _CollectionItemWidget(
key: ValueKey('playlist-${playlistItems[index].id}'),
item: playlistItems[index],
@@ -2789,22 +2532,20 @@ class _HomeTabState extends ConsumerState<HomeTab>
),
),
);
sortButtonShown = true;
}
if (sortedTracks.isNotEmpty) {
if (realTracks.isNotEmpty) {
slivers.addAll(
_buildVirtualizedResultSection(
title: context.l10n.searchSongs,
itemCount: sortedTracks.length,
itemCount: realTracks.length,
colorScheme: colorScheme,
showSortButton: !sortButtonShown,
itemBuilder: (index, showDivider) => _TrackItemWithStatus(
key: ValueKey(sortedTracks[index].id),
track: sortedTracks[index],
index: sortedTrackIndexes[index],
key: ValueKey(realTracks[index].id),
track: realTracks[index],
index: realTrackIndexes[index],
showDivider: showDivider,
onDownload: () => _downloadTrack(sortedTrackIndexes[index]),
onDownload: () => _downloadTrack(realTrackIndexes[index]),
searchExtensionId: searchExtensionId,
showLocalLibraryIndicator: showLocalLibraryIndicator,
thumbnailSizesByExtensionId: thumbnailSizesByExtensionId,
@@ -2822,7 +2563,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
required int itemCount,
required ColorScheme colorScheme,
required Widget Function(int index, bool showDivider) itemBuilder,
bool showSortButton = false,
}) {
final sectionColor = Theme.of(context).brightness == Brightness.dark
? Color.alphaBlend(
@@ -2834,47 +2574,12 @@ class _HomeTabState extends ConsumerState<HomeTab>
return [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 8, 8),
child: Row(
children: [
Expanded(
child: Text(
title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
if (showSortButton)
SizedBox(
height: 32,
child: TextButton.icon(
onPressed: () => _showSortOptions(colorScheme),
icon: Icon(
Icons.swap_vert,
size: 18,
color: _searchSortOption != _SearchSortOption.defaultOrder
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
label: Text(
_searchSortOption != _SearchSortOption.defaultOrder
? _sortOptionLabel(_searchSortOption)
: context.l10n.libraryFilterSort,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color:
_searchSortOption != _SearchSortOption.defaultOrder
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 8),
visualDensity: VisualDensity.compact,
),
),
),
],
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Text(
title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
),
@@ -2882,23 +2587,20 @@ class _HomeTabState extends ConsumerState<HomeTab>
delegate: SliverChildBuilderDelegate((context, index) {
final isFirst = index == 0;
final isLast = index == itemCount - 1;
return StaggeredListItem(
index: index,
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: sectionColor,
borderRadius: BorderRadius.vertical(
top: isFirst ? const Radius.circular(20) : Radius.zero,
bottom: isLast ? const Radius.circular(20) : Radius.zero,
),
),
clipBehavior: Clip.antiAlias,
child: Material(
color: Colors.transparent,
child: itemBuilder(index, !isLast),
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: sectionColor,
borderRadius: BorderRadius.vertical(
top: isFirst ? const Radius.circular(20) : Radius.zero,
bottom: isLast ? const Radius.circular(20) : Radius.zero,
),
),
clipBehavior: Clip.antiAlias,
child: Material(
color: Colors.transparent,
child: itemBuilder(index, !isLast),
),
);
}, childCount: itemCount),
),
@@ -2910,7 +2612,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
Navigator.push(
context,
MaterialPageRoute<void>(
MaterialPageRoute(
builder: (context) => ArtistScreen(
artistId: artistId,
artistName: artistName,
@@ -2936,7 +2638,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
// Keep the full ID with prefix (e.g., "deezer:123") for AlbumScreen to detect source
Navigator.push(
context,
MaterialPageRoute<void>(
MaterialPageRoute(
builder: (context) => AlbumScreen(
albumId: album.id,
albumName: album.name,
@@ -2963,7 +2665,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
// Keep the full ID with prefix (e.g., "deezer:123") for PlaylistScreen to detect source
Navigator.push(
context,
MaterialPageRoute<void>(
MaterialPageRoute(
builder: (context) => PlaylistScreen(
playlistName: playlist.name,
coverUrl: playlist.imageUrl,
@@ -2999,7 +2701,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
Navigator.push(
context,
MaterialPageRoute<void>(
MaterialPageRoute(
builder: (context) => ExtensionAlbumScreen(
extensionId: extensionId,
albumId: albumItem.id,
@@ -3035,7 +2737,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
Navigator.push(
context,
MaterialPageRoute<void>(
MaterialPageRoute(
builder: (context) => ExtensionPlaylistScreen(
extensionId: extensionId,
playlistId: playlistItem.id,
@@ -3070,7 +2772,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
Navigator.push(
context,
MaterialPageRoute<void>(
MaterialPageRoute(
builder: (context) => ExtensionArtistScreen(
extensionId: extensionId,
artistId: artistItem.id,
@@ -3091,6 +2793,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
}
if (searchProvider != null && searchProvider.isNotEmpty) {
// Check built-in providers first
if (searchProvider == 'tidal') {
return 'Search with Tidal...';
}
@@ -3132,6 +2835,16 @@ class _HomeTabState extends ConsumerState<HomeTab>
_triggerSearchWithFilter(null);
},
showCheckmark: false,
selectedColor: colorScheme.primaryContainer,
backgroundColor: colorScheme.surfaceContainerHighest,
labelStyle: TextStyle(
color: selectedFilter == null
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
fontWeight: selectedFilter == null
? FontWeight.w600
: FontWeight.normal,
),
),
),
...filters.map((filter) {
@@ -3146,8 +2859,24 @@ class _HomeTabState extends ConsumerState<HomeTab>
_triggerSearchWithFilter(filter.id);
},
showCheckmark: false,
selectedColor: colorScheme.primaryContainer,
backgroundColor: colorScheme.surfaceContainerHighest,
labelStyle: TextStyle(
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
fontWeight: isSelected
? FontWeight.w600
: FontWeight.normal,
),
avatar: filter.icon != null
? Icon(_getFilterIcon(filter.icon!), size: 18)
? Icon(
_getFilterIcon(filter.icon!),
size: 18,
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
)
: null,
),
);
@@ -3184,6 +2913,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
if (text.isEmpty || text.length < _minLiveSearchChars) return;
if (text.startsWith('http') || text.startsWith('spotify:')) return;
// Reset last search query to force new search
_lastSearchQuery = null;
_performSearch(text, filterOverride: filter);
}
@@ -3201,11 +2931,15 @@ class _HomeTabState extends ConsumerState<HomeTab>
fillColor: colorScheme.surfaceContainerHighest,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(28),
borderSide: BorderSide(color: colorScheme.outlineVariant),
borderSide: BorderSide(
color: colorScheme.outline.withValues(alpha: 0.5),
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(28),
borderSide: BorderSide(color: colorScheme.outlineVariant),
borderSide: BorderSide(
color: colorScheme.outline.withValues(alpha: 0.5),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(28),
@@ -3253,9 +2987,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
),
),
onSubmitted: (_) => _onSearchSubmitted(),
onTapOutside: (_) {
FocusScope.of(context).unfocus();
},
);
}
@@ -3304,6 +3035,7 @@ class _SearchProviderDropdown extends ConsumerWidget {
.firstOrNull;
}
// Check if current provider is a built-in provider (tidal/qobuz)
const builtInProviders = {'tidal', 'qobuz'};
final isBuiltInProvider =
currentProvider != null && builtInProviders.contains(currentProvider);
@@ -3383,6 +3115,7 @@ class _SearchProviderDropdown extends ConsumerWidget {
],
),
),
// Built-in Tidal search option
PopupMenuItem<String>(
value: 'tidal',
child: Row(
@@ -3410,6 +3143,7 @@ class _SearchProviderDropdown extends ConsumerWidget {
],
),
),
// Built-in Qobuz search option
PopupMenuItem<String>(
value: 'qobuz',
child: Row(
@@ -4232,6 +3966,7 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
// Extract artist info from album response
final artistId = (result['artist_id'] ?? result['artistId'])?.toString();
final artistName = result['artists'] as String?;
@@ -4289,10 +4024,7 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
if (_isLoading) {
return Scaffold(
appBar: AppBar(title: Text(widget.albumName)),
body: const AlbumTrackListSkeleton(
itemCount: 10,
showCoverHeader: true,
),
body: const Center(child: CircularProgressIndicator()),
);
}
@@ -4446,7 +4178,7 @@ class _ExtensionPlaylistScreenState
if (_isLoading) {
return Scaffold(
appBar: AppBar(title: Text(widget.playlistName)),
body: const TrackListSkeleton(itemCount: 8, showCoverHeader: true),
body: const Center(child: CircularProgressIndicator()),
);
}
@@ -4476,7 +4208,6 @@ class _ExtensionPlaylistScreenState
playlistName: widget.playlistName,
coverUrl: widget.coverUrl,
tracks: _tracks!,
recommendedService: widget.extensionId,
);
}
}
@@ -4618,7 +4349,7 @@ class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen> {
if (_isLoading) {
return Scaffold(
appBar: AppBar(title: Text(widget.artistName)),
body: const ArtistScreenSkeleton(),
body: const Center(child: CircularProgressIndicator()),
);
}
+42 -6
View File
@@ -8,7 +8,6 @@ import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/library_collections_provider.dart';
import 'package:spotiflac_android/screens/library_tracks_folder_screen.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/widgets/bottom_sheet_option_tile.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
class LibraryPlaylistsScreen extends ConsumerWidget {
@@ -119,7 +118,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
MaterialPageRoute(
builder: (_) => LibraryTracksFolderScreen(
mode: LibraryTracksFolderMode.playlist,
playlistId: playlist.id,
@@ -149,7 +148,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet<void>(
showModalBottomSheet(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
@@ -211,7 +210,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
),
BottomSheetOptionTile(
_PlaylistOptionTile(
icon: Icons.edit_outlined,
title: context.l10n.collectionRenamePlaylist,
onTap: () {
@@ -225,7 +224,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
},
),
BottomSheetOptionTile(
_PlaylistOptionTile(
icon: Icons.image_outlined,
title: context.l10n.collectionPlaylistChangeCover,
onTap: () {
@@ -234,7 +233,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
},
),
BottomSheetOptionTile(
_PlaylistOptionTile(
icon: Icons.delete_outline,
iconColor: colorScheme.error,
title: context.l10n.collectionDeletePlaylist,
@@ -544,3 +543,40 @@ 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,
);
}
}
+123 -95
View File
@@ -15,9 +15,7 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
import 'package:spotiflac_android/widgets/download_service_picker.dart';
import 'package:spotiflac_android/widgets/bottom_sheet_option_tile.dart';
import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
class LibraryTracksFolderScreen extends ConsumerStatefulWidget {
final LibraryTracksFolderMode mode;
@@ -274,6 +272,7 @@ class _LibraryTracksFolderScreenState
break;
}
// Stale selection cleanup
if (_isSelectionMode) {
final validKeys = entries.map((e) => e.key).toSet();
_selectedKeys.removeWhere((key) => !validKeys.contains(key));
@@ -349,23 +348,20 @@ class _LibraryTracksFolderScreenState
final isSelected = _selectedKeys.contains(entry.key);
return KeyedSubtree(
key: ValueKey(entry.key),
child: StaggeredListItem(
index: index,
child: _CollectionTrackTile(
entry: entry,
mode: widget.mode,
playlistId: widget.playlistId,
localLibraryState: localState,
folderTracks: folderTracks,
isSelectionMode: _isSelectionMode,
isSelected: isSelected,
onTap: _isSelectionMode
? () => _toggleSelection(entry.key)
: null,
onLongPress: _isSelectionMode
? null
: () => _enterSelectionMode(entry.key),
),
child: _CollectionTrackTile(
entry: entry,
mode: widget.mode,
playlistId: widget.playlistId,
localLibraryState: localState,
folderTracks: folderTracks,
isSelectionMode: _isSelectionMode,
isSelected: isSelected,
onTap: _isSelectionMode
? () => _toggleSelection(entry.key)
: null,
onLongPress: _isSelectionMode
? null
: () => _enterSelectionMode(entry.key),
),
);
}, childCount: entries.length),
@@ -376,6 +372,7 @@ class _LibraryTracksFolderScreenState
],
),
// Selection bottom bar
AnimatedPositioned(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
@@ -847,7 +844,7 @@ class _LibraryTracksFolderScreenState
void _confirmDownloadAll(List<Track> tracks) {
if (tracks.isEmpty) return;
showDialog<void>(
showDialog(
context: context,
builder: (dialogContext) {
final colorScheme = Theme.of(dialogContext).colorScheme;
@@ -980,7 +977,7 @@ class _LibraryTracksFolderScreenState
void _showCoverOptionsSheet(BuildContext context, bool hasCustomCover) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet<void>(
showModalBottomSheet(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
@@ -1084,19 +1081,14 @@ class _CollectionTrackTile extends ConsumerWidget {
final track = entry.track;
final colorScheme = Theme.of(context).colorScheme;
final effectiveCoverUrl = _resolveCoverUrl(track);
// Fine-grained provider watches only this tile rebuilds when its own
// history / local-library entry changes.
final historyItem = ref.watch(
final isInHistory = ref.watch(
downloadHistoryProvider.select((state) {
final byId = state.getBySpotifyId(track.id);
if (byId != null) return byId;
if (state.isDownloaded(track.id)) return true;
final isrc = track.isrc?.trim();
if (isrc != null && isrc.isNotEmpty) {
final byIsrc = state.getByIsrc(isrc);
if (byIsrc != null) return byIsrc;
if (isrc != null && isrc.isNotEmpty && state.getByIsrc(isrc) != null) {
return true;
}
return state.findByTrackAndArtist(track.name, track.artistName);
return state.findByTrackAndArtist(track.name, track.artistName) != null;
}),
);
final showLocalLibraryIndicator = ref.watch(
@@ -1104,26 +1096,17 @@ class _CollectionTrackTile extends ConsumerWidget {
(s) => s.localLibraryEnabled && s.localLibraryShowDuplicates,
),
);
final localItem = showLocalLibraryIndicator
final isInLocalLibrary = showLocalLibraryIndicator
? ref.watch(
localLibraryProvider.select((state) {
final isrc = track.isrc?.trim();
if (isrc != null && isrc.isNotEmpty) {
final byIsrc = state.getByIsrc(isrc);
if (byIsrc != null) return byIsrc;
}
return state.findByTrackAndArtist(track.name, track.artistName);
}),
localLibraryProvider.select(
(state) => state.existsInLibrary(
isrc: track.isrc,
trackName: track.name,
artistName: track.artistName,
),
),
)
: null;
final isInHistory = historyItem != null;
final isInLocalLibrary = localItem != null;
final heroTag = historyItem != null
? 'cover_${historyItem.id}'
: localItem != null
? 'cover_lib_${localItem.id}'
: null;
: false;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
@@ -1141,51 +1124,43 @@ class _CollectionTrackTile extends ConsumerWidget {
mainAxisSize: MainAxisSize.min,
children: [
if (isSelectionMode) ...[
AnimatedSelectionCheckbox(
visible: true,
selected: isSelected,
colorScheme: colorScheme,
size: 24,
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: isSelected
? colorScheme.primary
: Colors.transparent,
shape: BoxShape.circle,
border: Border.all(
color: isSelected
? colorScheme.primary
: colorScheme.outline,
width: 2,
),
),
child: isSelected
? Icon(
Icons.check,
color: colorScheme.onPrimary,
size: 16,
)
: null,
),
const SizedBox(width: 12),
],
HeroMode(
enabled: heroTag != null,
child: heroTag != null
? Hero(
tag: heroTag,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child:
effectiveCoverUrl != null &&
effectiveCoverUrl.isNotEmpty
? _buildTrackCover(context, effectiveCoverUrl, 52)
: Container(
width: 52,
height: 52,
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.music_note,
color: colorScheme.onSurfaceVariant,
),
),
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: effectiveCoverUrl != null && effectiveCoverUrl.isNotEmpty
? _buildTrackCover(context, effectiveCoverUrl, 52)
: Container(
width: 52,
height: 52,
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.music_note,
color: colorScheme.onSurfaceVariant,
),
)
: 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,
),
),
),
),
],
@@ -1338,7 +1313,7 @@ class _CollectionTrackTile extends ConsumerWidget {
final showAddToPlaylist =
mode != LibraryTracksFolderMode.wishlist || isDownloaded;
showModalBottomSheet<void>(
showModalBottomSheet(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
@@ -1415,8 +1390,9 @@ class _CollectionTrackTile extends ConsumerWidget {
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
),
// Add to playlist (hidden in wishlist unless already downloaded)
if (showAddToPlaylist)
BottomSheetOptionTile(
_CollectionOptionTile(
icon: Icons.playlist_add,
title: context.l10n.collectionAddToPlaylist,
onTap: () {
@@ -1425,7 +1401,8 @@ class _CollectionTrackTile extends ConsumerWidget {
},
),
BottomSheetOptionTile(
// Remove from folder / playlist
_CollectionOptionTile(
icon: Icons.remove_circle_outline,
iconColor: colorScheme.error,
title: mode == LibraryTracksFolderMode.playlist
@@ -1524,7 +1501,14 @@ class _CollectionTrackTile extends ConsumerWidget {
if (historyItem != null) {
await Navigator.of(context).push(
slidePageRoute<void>(page: TrackMetadataScreen(item: historyItem)),
PageRouteBuilder(
transitionDuration: const Duration(milliseconds: 300),
reverseTransitionDuration: const Duration(milliseconds: 250),
pageBuilder: (context, animation, secondaryAnimation) =>
TrackMetadataScreen(item: historyItem),
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
FadeTransition(opacity: animation, child: child),
),
);
return;
}
@@ -1541,7 +1525,14 @@ class _CollectionTrackTile extends ConsumerWidget {
if (localItem != null) {
await Navigator.of(context).push(
slidePageRoute<void>(page: TrackMetadataScreen(localItem: localItem)),
PageRouteBuilder(
transitionDuration: const Duration(milliseconds: 300),
reverseTransitionDuration: const Duration(milliseconds: 250),
pageBuilder: (context, animation, secondaryAnimation) =>
TrackMetadataScreen(localItem: localItem),
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
FadeTransition(opacity: animation, child: child),
),
);
return;
}
@@ -1551,6 +1542,43 @@ class _CollectionTrackTile extends ConsumerWidget {
}
}
/// Styled like _OptionTile in track_collection_quick_actions.dart
class _CollectionOptionTile extends StatelessWidget {
final IconData icon;
final Color? iconColor;
final String title;
final VoidCallback onTap;
const _CollectionOptionTile({
required this.icon,
this.iconColor,
required this.title,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
leading: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Icon(
icon,
color: iconColor ?? colorScheme.onPrimaryContainer,
size: 20,
),
),
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)),
onTap: onTap,
);
}
}
class _SelectionActionButton extends StatelessWidget {
final IconData icon;
final String label;
+34 -12
View File
@@ -16,7 +16,6 @@ import 'package:spotiflac_android/services/local_track_redownload_service.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/providers/playback_provider.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
class LocalAlbumScreen extends ConsumerStatefulWidget {
final String albumName;
@@ -532,6 +531,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
if (tracks.isEmpty) return null;
final first = tracks.first;
// For lossy formats, use bitrate
if (first.bitrate != null && first.bitrate! > 0) {
final fmt = first.format?.toUpperCase() ?? '';
final firstBitrate = first.bitrate;
@@ -543,6 +543,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
return '$fmt ${firstBitrate}kbps'.trim();
}
// For lossless formats, use bit depth / sample rate
if (first.bitDepth == null ||
first.bitDepth == 0 ||
first.sampleRate == null) {
@@ -629,10 +630,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
final track = discTracks[index];
return KeyedSubtree(
key: ValueKey(track.id),
child: StaggeredListItem(
index: index,
child: _buildTrackItem(context, colorScheme, track),
),
child: _buildTrackItem(context, colorScheme, track),
);
}, childCount: discTracks.length),
),
@@ -671,11 +669,28 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
mainAxisSize: MainAxisSize.min,
children: [
if (_isSelectionMode) ...[
AnimatedSelectionCheckbox(
visible: true,
selected: isSelected,
colorScheme: colorScheme,
size: 24,
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: isSelected
? colorScheme.primary
: Colors.transparent,
shape: BoxShape.circle,
border: Border.all(
color: isSelected
? colorScheme.primary
: colorScheme.outline,
width: 2,
),
),
child: isSelected
? Icon(
Icons.check,
color: colorScheme.onPrimary,
size: 16,
)
: null,
),
const SizedBox(width: 12),
],
@@ -1180,7 +1195,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
? '320k'
: (selectedFormat == 'Opus' ? '128k' : '320k');
showModalBottomSheet<void>(
showModalBottomSheet(
context: context,
useRootNavigator: true,
shape: const RoundedRectangleBorder(
@@ -1367,6 +1382,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
}
}
if (currentFormat == null || currentFormat == targetFormat) continue;
// Skip lossy sources when target is lossless (pointless re-encoding)
final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC';
final isLosslessSource =
currentFormat == 'FLAC' || currentFormat == 'M4A';
@@ -1487,7 +1503,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
bitrate: bitrate,
metadata: metadata,
coverPath: coverPath,
deleteOriginal: !isSaf,
deleteOriginal: !isSaf, // Only delete original for regular files
);
if (coverPath != null) {
@@ -1506,9 +1522,15 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
}
if (isSaf) {
// For SAF: derive the parent tree URI and relative dir from the content URI,
// then create new SAF file and delete old one
// Parse the SAF URI to get the tree document path:
// content://...tree/...document/.../oldName.flac
// We need tree URI and relative dir to create the new file
final uri = Uri.parse(item.filePath);
final pathSegments = uri.pathSegments;
// Try to find 'tree' and 'document' segments
String? treeUri;
String relativeDir = '';
String oldFileName = '';
+40 -74
View File
@@ -20,7 +20,6 @@ import 'package:spotiflac_android/services/shell_navigation_service.dart';
import 'package:spotiflac_android/services/share_intent_service.dart';
import 'package:spotiflac_android/services/update_checker.dart';
import 'package:spotiflac_android/widgets/update_dialog.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('MainShell');
@@ -32,11 +31,9 @@ class MainShell extends ConsumerStatefulWidget {
ConsumerState<MainShell> createState() => _MainShellState();
}
class _MainShellState extends ConsumerState<MainShell>
with SingleTickerProviderStateMixin {
class _MainShellState extends ConsumerState<MainShell> {
int _currentIndex = 0;
late final PageController _pageController;
late final AnimationController _tabJumpTransitionController;
bool _hasCheckedUpdate = false;
StreamSubscription<String>? _shareSubscription;
DateTime? _lastBackPress;
@@ -51,11 +48,6 @@ class _MainShellState extends ConsumerState<MainShell>
void initState() {
super.initState();
_pageController = PageController(initialPage: _currentIndex);
_tabJumpTransitionController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 180),
value: 1,
);
ShellNavigationService.syncState(
currentTabIndex: _currentIndex,
showStoreTab: false,
@@ -79,7 +71,7 @@ class _MainShellState extends ConsumerState<MainShell>
_log.d('Received shared URL from stream: $url');
_handleSharedUrl(url);
},
onError: (Object error) {
onError: (error) {
_log.e('Share stream error: $error');
},
cancelOnError: false,
@@ -92,7 +84,7 @@ class _MainShellState extends ConsumerState<MainShell>
if (!extState.isInitialized) {
_log.d('Waiting for extensions to initialize before handling URL...');
for (int i = 0; i < 50; i++) {
await Future<void>.delayed(const Duration(milliseconds: 100));
await Future.delayed(const Duration(milliseconds: 100));
if (!mounted) return;
if (ref.read(extensionProvider).isInitialized) {
_log.d('Extensions initialized, proceeding with URL handling');
@@ -162,6 +154,7 @@ class _MainShellState extends ConsumerState<MainShell>
if (!Platform.isAndroid) return;
final settings = ref.read(settingsProvider);
// Only show if user is still on legacy storage mode with a download dir set
if (settings.storageMode == 'saf') return;
if (settings.downloadDirectory.isEmpty) return;
@@ -177,7 +170,7 @@ class _MainShellState extends ConsumerState<MainShell>
final colorScheme = Theme.of(context).colorScheme;
showDialog<void>(
showDialog(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
@@ -236,7 +229,6 @@ class _MainShellState extends ConsumerState<MainShell>
void dispose() {
_shareSubscription?.cancel();
_pageController.dispose();
_tabJumpTransitionController.dispose();
super.dispose();
}
@@ -259,8 +251,7 @@ class _MainShellState extends ConsumerState<MainShell>
}
if (_currentIndex != index) {
final previousIndex = _currentIndex;
final isNonAdjacentJump = (previousIndex - index).abs() > 1;
final shouldResetHome = index == 0;
HapticFeedback.selectionClick();
setState(() => _currentIndex = index);
final showStore = ref.read(
@@ -271,23 +262,19 @@ class _MainShellState extends ConsumerState<MainShell>
showStoreTab: showStore,
);
FocusManager.instance.primaryFocus?.unfocus();
// Jump directly when skipping intermediate tabs to avoid
// sliding through them. For those jumps, keep a short fade-in
// so the transition still feels intentional.
if (isNonAdjacentJump) {
_pageController.jumpToPage(index);
_tabJumpTransitionController.forward(from: 0);
} else {
_pageController.animateToPage(
index,
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
);
if (shouldResetHome) {
_resetHomeToMain();
}
_pageController.animateToPage(
index,
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
);
}
}
void _onPageChanged(int index) {
final previousIndex = _currentIndex;
if (_currentIndex != index) {
setState(() => _currentIndex = index);
final showStore = ref.read(
@@ -298,6 +285,9 @@ class _MainShellState extends ConsumerState<MainShell>
showStoreTab: showStore,
);
FocusManager.instance.primaryFocus?.unfocus();
if (index == 0 && previousIndex != 0) {
_resetHomeToMain();
}
}
}
@@ -461,44 +451,32 @@ class _MainShellState extends ConsumerState<MainShell>
label: l10n.navHome,
),
NavigationDestination(
icon: AnimatedBadge(
count: queueState,
icon: Badge(
isLabelVisible: queueState > 0,
label: Text('$queueState'),
child: const Icon(Icons.library_music_outlined),
),
selectedIcon: SlidingIcon(
child: Badge(
isLabelVisible: queueState > 0,
label: Text('$queueState'),
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),
),
child: const Icon(Icons.library_music),
),
),
label: l10n.navLibrary,
),
if (showStore)
NavigationDestination(
icon: AnimatedBadge(
count: storeUpdatesCount,
icon: Badge(
isLabelVisible: storeUpdatesCount > 0,
label: Text('$storeUpdatesCount'),
child: const Icon(Icons.store_outlined),
),
selectedIcon: SwingIcon(
child: Badge(
isLabelVisible: storeUpdatesCount > 0,
label: Text('$storeUpdatesCount'),
child: const Icon(Icons.store_outlined),
),
),
selectedIcon: SwingIcon(
child: AnimatedBadge(
count: storeUpdatesCount,
child: Badge(
isLabelVisible: storeUpdatesCount > 0,
label: Text('$storeUpdatesCount'),
child: const Icon(Icons.store),
),
child: const Icon(Icons.store),
),
),
label: l10n.navStore,
@@ -526,27 +504,15 @@ class _MainShellState extends ConsumerState<MainShell>
return true;
},
child: Scaffold(
body: AnimatedBuilder(
animation: _tabJumpTransitionController,
child: PageView.builder(
controller: _pageController,
itemCount: tabs.length,
onPageChanged: _onPageChanged,
physics: const NeverScrollableScrollPhysics(),
itemBuilder: (context, index) => _KeepAliveTabPage(
key: ValueKey('page-$index'),
child: tabs[index],
),
body: PageView.builder(
controller: _pageController,
itemCount: tabs.length,
onPageChanged: _onPageChanged,
physics: const NeverScrollableScrollPhysics(),
itemBuilder: (context, index) => _KeepAliveTabPage(
key: ValueKey('page-$index'),
child: tabs[index],
),
builder: (context, child) {
final t = Curves.easeOutCubic.transform(
_tabJumpTransitionController.value,
);
return Opacity(
opacity: t,
child: Transform.scale(scale: 0.985 + (0.015 * t), child: child),
);
},
),
bottomNavigationBar: NavigationBar(
selectedIndex: _currentIndex.clamp(0, maxIndex),
@@ -741,7 +707,7 @@ class _SwingIconState extends State<SwingIcon>
TweenSequenceItem(tween: Tween(begin: 0.15, end: -0.1), weight: 20),
TweenSequenceItem(tween: Tween(begin: -0.1, end: 0.05), weight: 20),
TweenSequenceItem(tween: Tween(begin: 0.05, end: 0.0), weight: 20),
]).animate(_controller);
]).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut));
_controller.forward();
}
+8 -39
View File
@@ -15,14 +15,12 @@ import 'package:spotiflac_android/providers/playback_provider.dart';
import 'package:spotiflac_android/widgets/download_service_picker.dart';
import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart';
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
class PlaylistScreen extends ConsumerStatefulWidget {
final String playlistName;
final String? coverUrl;
final List<Track> tracks;
final String? playlistId;
final String? recommendedService;
const PlaylistScreen({
super.key,
@@ -30,7 +28,6 @@ class PlaylistScreen extends ConsumerStatefulWidget {
this.coverUrl,
required this.tracks,
this.playlistId,
this.recommendedService,
});
@override
@@ -50,31 +47,6 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
String get _playlistName => _resolvedPlaylistName ?? widget.playlistName;
String? get _coverUrl => _resolvedCoverUrl ?? widget.coverUrl;
String? _recommendedDownloadService() {
final explicit = widget.recommendedService;
if (explicit != null && explicit.isNotEmpty) {
return explicit;
}
final playlistId = widget.playlistId;
if (playlistId != null) {
if (playlistId.startsWith('tidal:')) return 'tidal';
if (playlistId.startsWith('qobuz:')) return 'qobuz';
if (playlistId.startsWith('deezer:')) return 'deezer';
}
final source = _tracks.firstOrNull?.source;
if (source != null && source.isNotEmpty) {
return source;
}
final trackId = _tracks.firstOrNull?.id ?? '';
if (trackId.startsWith('tidal:')) return 'tidal';
if (trackId.startsWith('qobuz:')) return 'qobuz';
if (trackId.startsWith('deezer:')) return 'deezer';
return null;
}
@override
void initState() {
super.initState();
@@ -388,8 +360,8 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
if (_isLoading) {
return const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(16),
child: TrackListSkeleton(itemCount: 8),
padding: EdgeInsets.all(32),
child: Center(child: CircularProgressIndicator()),
),
);
}
@@ -439,12 +411,9 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
final track = _tracks[index];
return KeyedSubtree(
key: ValueKey(track.id),
child: StaggeredListItem(
index: index,
child: _PlaylistTrackItem(
track: track,
onDownload: () => _downloadTrack(context, track),
),
child: _PlaylistTrackItem(
track: track,
onDownload: () => _downloadTrack(context, track),
),
);
}, childCount: _tracks.length),
@@ -460,7 +429,6 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
trackName: track.name,
artistName: track.artistName,
coverUrl: track.coverUrl,
recommendedService: _recommendedDownloadService(),
onSelect: (quality, service) {
ref
.read(downloadQueueProvider.notifier)
@@ -578,7 +546,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
void _confirmDownloadAll(BuildContext context) {
if (_tracks.isEmpty) return;
showDialog<void>(
showDialog(
context: context,
builder: (dialogContext) {
final colorScheme = Theme.of(dialogContext).colorScheme;
@@ -648,6 +616,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
void _downloadTracks(BuildContext context, List<Track> tracks) {
if (tracks.isEmpty) return;
// Skip already-downloaded tracks
final historyState = ref.read(downloadHistoryProvider);
final settings = ref.read(settingsProvider);
final localLibState =
@@ -694,7 +663,6 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
context,
trackName: '${tracksToQueue.length} tracks',
artistName: _playlistName,
recommendedService: _recommendedDownloadService(),
onSelect: (quality, service) {
ref
.read(downloadQueueProvider.notifier)
@@ -757,6 +725,7 @@ class _PlaylistTrackItem extends ConsumerWidget {
}),
);
// Check local library for duplicate detection
final showLocalLibraryIndicator = ref.watch(
settingsProvider.select(
(s) => s.localLibraryEnabled && s.localLibraryShowDuplicates,
+1081 -941
View File
File diff suppressed because it is too large Load Diff
+33 -39
View File
@@ -8,7 +8,6 @@ import 'package:spotiflac_android/providers/track_provider.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
import 'package:spotiflac_android/utils/clickable_metadata.dart';
class SearchScreen extends ConsumerStatefulWidget {
@@ -52,9 +51,9 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
ref
.read(downloadQueueProvider.notifier)
.addToQueue(track, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))));
}
@override
@@ -96,20 +95,13 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
child: Text(error, style: TextStyle(color: colorScheme.error)),
),
Expanded(
child: AnimatedStateSwitcher(
child: isLoading && tracks.isEmpty
? const TrackListSkeleton(key: ValueKey('loading'))
: tracks.isEmpty
? _buildEmptyState(colorScheme)
: ListView.builder(
key: const ValueKey('results'),
itemCount: tracks.length,
itemBuilder: (context, index) => StaggeredListItem(
index: index,
child: _buildTrackTile(tracks[index], colorScheme),
),
),
),
child: tracks.isEmpty
? _buildEmptyState(colorScheme)
: ListView.builder(
itemCount: tracks.length,
itemBuilder: (context, index) =>
_buildTrackTile(tracks[index], colorScheme),
),
),
],
),
@@ -135,30 +127,32 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
}
Widget _buildTrackTile(Track track, ColorScheme colorScheme) {
final coverWidget = track.coverUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: track.coverUrl!,
return ListTile(
leading: track.coverUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: track.coverUrl!,
width: 48,
height: 48,
fit: BoxFit.cover,
memCacheWidth: 144,
memCacheHeight: 144,
cacheManager: CoverCacheManager.instance,
),
)
: Container(
width: 48,
height: 48,
fit: BoxFit.cover,
memCacheWidth: 144,
memCacheHeight: 144,
cacheManager: CoverCacheManager.instance,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
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),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -770,7 +770,7 @@ class _LanguageSelector extends StatelessWidget {
void _showLanguagePicker(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet<void>(
showModalBottomSheet(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surface,
+183 -15
View File
@@ -477,40 +477,122 @@ class _CryptoWalletItem extends StatelessWidget {
}
}
class _SupporterChip extends StatelessWidget {
int _cr(String v) {
int r = 0x1F;
for (final c in v.codeUnits) {
r = (r * 31 + c) & 0x7FFFFFFF;
}
return r;
}
// Highlighted supporters (hashes of names).
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 ColorScheme 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
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(
color: colorScheme.secondaryContainer,
color: effectiveChipColor,
borderRadius: BorderRadius.circular(20),
child: Padding(
child: Container(
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),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
CircleAvatar(
radius: 10,
backgroundColor: colorScheme.primary.withValues(alpha: 0.2),
child: Text(
name.isNotEmpty ? name[0].toUpperCase() : '?',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: colorScheme.primary,
),
),
backgroundColor: accentColor.withValues(alpha: 0.2),
child: isGold
? Icon(Icons.star_rounded, size: 12, color: accentColor)
: Text(
widget.name.isNotEmpty
? widget.name[0].toUpperCase()
: '?',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: accentColor,
),
),
),
const SizedBox(width: 8),
Text(
name,
widget.name,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: colorScheme.onSecondaryContainer,
fontWeight: FontWeight.w500,
color: isGold
? accentColor
: widget.colorScheme.onSecondaryContainer,
fontWeight: isGold ? FontWeight.w600 : FontWeight.w500,
),
),
],
@@ -518,6 +600,92 @@ class _SupporterChip extends StatelessWidget {
),
);
}
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 {
+111 -28
View File
@@ -465,6 +465,34 @@ 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,
),
],
),
),
@@ -510,7 +538,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
),
onTap: () => Navigator.push(
context,
MaterialPageRoute<void>(
MaterialPageRoute(
builder: (_) => const LyricsProviderPriorityPage(),
),
),
@@ -841,8 +869,6 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
return 'Albums/[Year] Album/';
case 'artist_album_singles':
return 'Artist/Album/ + Artist/Singles/';
case 'artist_album_flat':
return 'Artist/Album/ + Artist/song.flac';
default:
return 'Albums/Artist/Album Name/';
}
@@ -853,7 +879,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
WidgetRef ref,
String current,
) {
showModalBottomSheet<void>(
showModalBottomSheet(
context: context,
useRootNavigator: true,
builder: (context) => SafeArea(
@@ -932,20 +958,6 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
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);
},
),
],
),
),
@@ -1002,7 +1014,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
);
}
showModalBottomSheet<void>(
showModalBottomSheet(
context: context,
useRootNavigator: true,
isScrollControlled: true,
@@ -1220,7 +1232,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
final settings = ref.read(settingsProvider);
final isSafMode =
settings.storageMode == 'saf' && settings.downloadTreeUri.isNotEmpty;
showModalBottomSheet<void>(
showModalBottomSheet(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
@@ -1298,7 +1310,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
void _showIOSDirectoryOptions(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet<void>(
showModalBottomSheet(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
@@ -1493,7 +1505,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
String current,
) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet<void>(
showModalBottomSheet(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
@@ -1598,7 +1610,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
String current,
) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet<void>(
showModalBottomSheet(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
@@ -1677,6 +1689,68 @@ 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(
BuildContext context,
WidgetRef ref,
@@ -1685,7 +1759,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
final colorScheme = Theme.of(context).colorScheme;
final controller = TextEditingController(text: currentLanguage);
showModalBottomSheet<void>(
showModalBottomSheet(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
@@ -1771,7 +1845,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
String current,
) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet<void>(
showModalBottomSheet(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
@@ -1843,7 +1917,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
) {
final colorScheme = Theme.of(context).colorScheme;
final normalizedCurrent = current.trim().toUpperCase();
showModalBottomSheet<void>(
showModalBottomSheet(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
@@ -1911,7 +1985,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
String current,
) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet<void>(
showModalBottomSheet(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
@@ -2026,7 +2100,7 @@ class _ServiceSelector extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final extState = ref.watch(extensionProvider);
final builtInServiceIds = ['tidal', 'qobuz', 'deezer'];
final builtInServiceIds = ['tidal', 'qobuz', 'deezer', 'youtube'];
final extensionProviders = extState.extensions
.where((e) => e.enabled && e.hasDownloadProvider)
@@ -2062,6 +2136,15 @@ class _ServiceSelector extends ConsumerWidget {
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) ...[
@@ -61,7 +61,7 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
final hasError = extension.status == 'error';
return PopScope(
canPop: true,
canPop: true, // Always allow back gesture
child: Scaffold(
body: CustomScrollView(
slivers: [
@@ -832,9 +832,9 @@ class _SettingItemState extends State<_SettingItem> {
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarError(e.toString()))),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(context.l10n.snackbarError(e.toString()))));
}
} finally {
if (mounted) {
@@ -849,7 +849,7 @@ class _SettingItemState extends State<_SettingItem> {
);
final colorScheme = Theme.of(context).colorScheme;
showDialog<void>(
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(widget.setting.label),
+10 -11
View File
@@ -4,7 +4,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:file_picker/file_picker.dart';
import 'package:path_provider/path_provider.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/explore_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
@@ -62,7 +61,7 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
final topPadding = normalizedHeaderTopPadding(context);
return PopScope(
canPop: true,
canPop: true, // Always allow back gesture
child: Scaffold(
body: CustomScrollView(
slivers: [
@@ -213,7 +212,7 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
showDivider: index < extState.extensions.length - 1,
onTap: () => Navigator.push(
context,
MaterialPageRoute<void>(
MaterialPageRoute(
builder: (_) =>
ExtensionDetailPage(extensionId: ext.id),
),
@@ -470,9 +469,7 @@ class _DownloadPriorityItem extends ConsumerWidget {
onTap: hasDownloadExtensions
? () => Navigator.push(
context,
MaterialPageRoute<void>(
builder: (_) => const ProviderPriorityPage(),
),
MaterialPageRoute(builder: (_) => const ProviderPriorityPage()),
)
: null,
child: Padding(
@@ -537,7 +534,7 @@ class _MetadataPriorityItem extends ConsumerWidget {
onTap: hasMetadataExtensions
? () => Navigator.push(
context,
MaterialPageRoute<void>(
MaterialPageRoute(
builder: (_) => const MetadataProviderPriorityPage(),
),
)
@@ -603,12 +600,14 @@ class _SearchProviderSelector extends ConsumerWidget {
.where((e) => e.enabled && e.hasCustomSearch)
.toList();
// Always allow tapping: built-in providers are always available
final hasAnyProvider =
searchProviders.isNotEmpty || _builtInProviders.isNotEmpty;
String currentProviderName = context.l10n.extensionDefaultProvider;
if (settings.searchProvider != null &&
settings.searchProvider!.isNotEmpty) {
// Check built-in first
if (_builtInProviders.containsKey(settings.searchProvider)) {
currentProviderName = _builtInProviders[settings.searchProvider]!;
} else {
@@ -681,12 +680,12 @@ class _SearchProviderSelector extends ConsumerWidget {
void _showSearchProviderPicker(
BuildContext context,
WidgetRef ref,
AppSettings settings,
dynamic settings,
List<Extension> searchProviders,
) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet<void>(
showModalBottomSheet(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
@@ -862,12 +861,12 @@ class _HomeFeedProviderSelector extends ConsumerWidget {
void _showHomeFeedProviderPicker(
BuildContext context,
WidgetRef ref,
AppSettings settings,
dynamic settings,
List<Extension> homeFeedProviders,
) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet<void>(
showModalBottomSheet(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
@@ -23,15 +23,21 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
int _androidSdkVersion = 0;
bool _hasStoragePermission = false;
/// Convert SAF content URI to a readable display path
String _getDisplayPath(String 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 {
final uri = Uri.parse(path);
final treePath = uri.pathSegments.last;
final treePath =
uri.pathSegments.last; // e.g. "primary:Music" or "primary%3AMusic"
final decoded = Uri.decodeComponent(treePath);
if (decoded.startsWith('primary:')) {
return '/storage/emulated/0/${decoded.substring('primary:'.length)}';
}
// For SD card or other volumes, just show the decoded path
return decoded;
} catch (_) {
return path;
@@ -255,7 +261,7 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
void _showAutoScanPicker(BuildContext context, String current) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet<void>(
showModalBottomSheet(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
+2 -2
View File
@@ -92,7 +92,7 @@ class _LogScreenState extends State<LogScreen> {
}
void _clearLogs() {
showDialog<void>(
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.logClearLogsTitle),
@@ -136,7 +136,7 @@ class _LogScreenState extends State<LogScreen> {
final logs = _filteredLogs;
return PopScope(
canPop: true,
canPop: true, // Always allow back gesture
child: Scaffold(
body: CustomScrollView(
controller: _scrollController,
@@ -19,7 +19,7 @@ class OptionsSettingsPage extends ConsumerWidget {
final topPadding = normalizedHeaderTopPadding(context);
return PopScope(
canPop: true,
canPop: true, // Always allow back gesture
child: Scaffold(
body: CustomScrollView(
slivers: [
@@ -241,7 +241,7 @@ class OptionsSettingsPage extends ConsumerWidget {
WidgetRef ref,
ColorScheme colorScheme,
) {
showDialog<void>(
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.dialogClearHistoryTitle),
@@ -273,7 +273,7 @@ class OptionsSettingsPage extends ConsumerWidget {
BuildContext context,
WidgetRef ref,
) async {
showDialog<void>(
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => AlertDialog(
@@ -340,6 +340,12 @@ class _ProviderItem extends StatelessWidget {
icon: Icons.graphic_eq,
isBuiltIn: true,
);
case 'youtube':
return _ProviderInfo(
name: 'YouTube',
icon: Icons.play_circle_outline,
isBuiltIn: true,
);
default:
return _ProviderInfo(
name: provider,
+21 -2
View File
@@ -13,7 +13,6 @@ import 'package:spotiflac_android/screens/settings/donate_page.dart';
import 'package:spotiflac_android/screens/settings/log_screen.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
class SettingsTab extends ConsumerWidget {
const SettingsTab({super.key});
@@ -151,6 +150,26 @@ class SettingsTab extends ConsumerWidget {
void _navigateTo(BuildContext context, Widget page) {
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),
),
);
}
}
+10 -4
View File
@@ -124,7 +124,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
final shouldOpen = await _showAndroid11StorageDialog();
if (shouldOpen == true) {
await Permission.manageExternalStorage.request();
await Future<void>.delayed(const Duration(milliseconds: 500));
await Future.delayed(const Duration(milliseconds: 500));
manageStatus = await Permission.manageExternalStorage.status;
}
}
@@ -203,7 +203,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
}
Future<void> _showPermissionDeniedDialog(String permissionType) async {
await showDialog<void>(
await showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.setupPermissionRequired(permissionType)),
@@ -286,7 +286,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
Future<void> _showIOSDirectoryOptions() async {
final colorScheme = Theme.of(context).colorScheme;
await showModalBottomSheet<void>(
await showModalBottomSheet(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
@@ -441,9 +441,14 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
void _nextPage() {
bool canProceed = false;
// Step 0 is Welcome, always can proceed
if (_currentStep == 0) {
canProceed = true;
} 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);
}
@@ -465,8 +470,9 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
}
bool _isStepCompleted(int step) {
if (step == 0) return true;
if (step == 0) return true; // Welcome
// Adjust step index for logic because we added Welcome at 0
final logicStep = step - 1;
if (_androidSdkVersion >= 33) {
+28 -86
View File
@@ -4,7 +4,6 @@ import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/store_provider.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/utils/app_bar_layout.dart';
@@ -59,9 +58,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
final downloadingId = ref.watch(
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 filteredExtensions = StoreState(
extensions: extensions,
@@ -142,7 +139,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
prefixIcon: const Icon(Icons.search),
suffixIcon: value.text.isNotEmpty
? IconButton(
tooltip: 'Clear',
tooltip: 'Clear search',
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
@@ -154,37 +151,23 @@ class _StoreTabState extends ConsumerState<StoreTab> {
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(28),
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,
),
borderSide: BorderSide.none,
),
filled: true,
fillColor: colorScheme.surfaceContainerHighest,
fillColor:
Theme.of(context).brightness == Brightness.dark
? Color.alphaBlend(
Colors.white.withValues(alpha: 0.08),
colorScheme.surface,
)
: colorScheme.surfaceContainerHighest,
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 16,
horizontal: 16,
vertical: 12,
),
),
onChanged: (value) {
ref
.read(storeProvider.notifier)
.setSearchQuery(value);
},
onTapOutside: (_) {
FocusScope.of(context).unfocus();
ref.read(storeProvider.notifier).setSearchQuery(value);
},
);
},
@@ -248,8 +231,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
_CategoryChip(
label: context.l10n.storeFilterIntegration,
icon: Icons.link,
isSelected:
selectedCategory == StoreCategory.integration,
isSelected: selectedCategory == StoreCategory.integration,
onTap: () => ref
.read(storeProvider.notifier)
.setCategory(StoreCategory.integration),
@@ -260,11 +242,8 @@ class _StoreTabState extends ConsumerState<StoreTab> {
),
if (isLoading && extensions.isEmpty)
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(16),
child: TrackListSkeleton(itemCount: 6),
),
const SliverFillRemaining(
child: Center(child: CircularProgressIndicator()),
)
else if (error != null && extensions.isEmpty)
SliverFillRemaining(child: _buildErrorState(error, colorScheme))
@@ -330,9 +309,9 @@ class _StoreTabState extends ConsumerState<StoreTab> {
const SizedBox(height: 24),
Text(
context.l10n.storeAddRepoTitle,
style: Theme.of(
context,
).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold),
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
@@ -343,23 +322,16 @@ class _StoreTabState extends ConsumerState<StoreTab> {
labelText: context.l10n.storeRepoUrlLabel,
prefixIcon: const Icon(Icons.link),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(28),
borderSide: BorderSide(color: colorScheme.outlineVariant),
borderRadius: BorderRadius.circular(12),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(28),
borderSide: BorderSide(color: colorScheme.outlineVariant),
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: colorScheme.outline),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(28),
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: colorScheme.primary, width: 2),
),
filled: true,
fillColor: colorScheme.surfaceContainerHighest,
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 16,
),
),
keyboardType: TextInputType.url,
autocorrect: false,
@@ -375,11 +347,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
),
child: Row(
children: [
Icon(
Icons.error_outline,
size: 20,
color: colorScheme.onErrorContainer,
),
Icon(Icons.error_outline, size: 20, color: colorScheme.onErrorContainer),
const SizedBox(width: 8),
Expanded(
child: Text(
@@ -416,7 +384,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
void _showChangeRepoDialog(String currentUrl) {
final changeUrlController = TextEditingController(text: currentUrl);
showDialog<void>(
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.storeRepoDialogTitle),
@@ -448,31 +416,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
labelText: context.l10n.storeNewRepoUrlLabel,
prefixIcon: const Icon(Icons.link),
border: OutlineInputBorder(
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,
borderRadius: BorderRadius.circular(12),
),
),
keyboardType: TextInputType.url,
@@ -559,9 +503,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
),
const SizedBox(height: 16),
Text(
hasFilters
? context.l10n.storeEmptyNoResults
: context.l10n.storeEmptyNoExtensions,
hasFilters ? context.l10n.storeEmptyNoResults : context.l10n.storeEmptyNoExtensions,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -583,7 +525,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
void _showExtensionDetails(StoreExtension ext) {
Navigator.of(context).push(
MaterialPageRoute<void>(
MaterialPageRoute(
builder: (context) => ExtensionDetailsScreen(extension: ext),
),
);
+84 -42
View File
@@ -23,7 +23,6 @@ import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
import 'package:spotiflac_android/utils/mime_utils.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/widgets/audio_analysis_widget.dart';
final _log = AppLogger('TrackMetadata');
@@ -60,19 +59,19 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
bool _fileExists = false;
bool _hasCheckedFile = false;
int? _fileSize;
String? _lyrics;
String? _rawLyrics;
String? _lyrics; // Cleaned lyrics for display (no timestamps)
String? _rawLyrics; // Raw LRC with timestamps for embedding
bool _lyricsLoading = false;
String? _lyricsError;
String? _lyricsSource;
bool _showTitleInAppBar = false;
bool _lyricsEmbedded = false;
bool _isEmbedding = false;
bool _isEmbedding = false; // Track embed operation in progress
bool _isInstrumental = false;
bool _isConverting = false;
bool _isConverting = false; // Track convert operation in progress
bool _hasMetadataChanges = false;
bool _hasLoadedResolvedAudioMetadata = false;
Map<String, dynamic>? _editedMetadata;
Map<String, dynamic>? _editedMetadata; // Overrides after metadata edit
String? _embeddedCoverPreviewPath;
final ScrollController _scrollController = ScrollController();
static final RegExp _lrcTimestampPattern = RegExp(
@@ -308,10 +307,12 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
storedQuality: _quality,
);
// Fill in album name from file tags if stored value is empty
final needsAlbum =
resolvedAlbum != null &&
resolvedAlbum.isNotEmpty &&
(albumName.isEmpty);
// Fill in duration from file if stored value is missing/zero
final needsDuration =
resolvedDuration != null &&
resolvedDuration > 0 &&
@@ -518,8 +519,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
String get _filePath =>
_isLocalItem ? _localLibraryItem!.filePath : _downloadItem!.filePath;
String get _coverHeroTag =>
_isLocalItem ? 'cover_lib_$_itemId' : 'cover_$_itemId';
String? get _coverUrl =>
_isLocalItem ? null : normalizeRemoteHttpUrl(_downloadItem!.coverUrl);
String? get _localCoverPath =>
@@ -528,6 +527,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
String get _service => _isLocalItem ? 'local' : _downloadItem!.service;
DateTime get _addedAt {
if (_isLocalItem) {
// Use file modification time if available, otherwise fall back to scannedAt
final modTime = _localLibraryItem!.fileModTime;
if (modTime != null && modTime > 0) {
return DateTime.fromMillisecondsSinceEpoch(modTime);
@@ -577,6 +577,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
String get cleanFilePath {
var path = _filePath;
if (path.startsWith('EXISTS:')) path = path.substring(7);
// Strip CUE virtual path suffix for filesystem operations
if (isCueVirtualPath(path)) path = stripCueTrackSuffix(path);
return path;
}
@@ -769,11 +770,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
_buildLyricsCard(context, colorScheme),
if (_fileExists) ...[
const SizedBox(height: 16),
AudioAnalysisCard(filePath: _filePath),
],
const SizedBox(height: 24),
_buildActionButtons(context, ref, colorScheme, _fileExists),
@@ -794,42 +790,38 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
double expandedHeight,
bool showContent,
) {
final coverChild = _hasPath(_embeddedCoverPreviewPath)
? Image.file(
return Stack(
fit: StackFit.expand,
children: [
if (_hasPath(_embeddedCoverPreviewPath))
Image.file(
File(_embeddedCoverPreviewPath!),
fit: BoxFit.cover,
errorBuilder: (_, _, _) => Container(color: colorScheme.surface),
)
: _coverUrl != null
? CachedNetworkImage(
else if (_coverUrl != null)
CachedNetworkImage(
imageUrl: _coverUrl!,
fit: BoxFit.cover,
cacheManager: CoverCacheManager.instance,
placeholder: (_, _) => Container(color: colorScheme.surface),
errorWidget: (_, _, _) => Container(color: colorScheme.surface),
)
: _localCoverPath != null && _localCoverPath!.isNotEmpty
? Image.file(
else if (_localCoverPath != null && _localCoverPath!.isNotEmpty)
Image.file(
File(_localCoverPath!),
fit: BoxFit.cover,
errorBuilder: (_, _, _) => Container(color: colorScheme.surface),
)
: Container(
else
Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.music_note,
size: 80,
color: colorScheme.onSurfaceVariant,
),
);
return Stack(
fit: StackFit.expand,
children: [
Hero(
tag: _coverHeroTag,
child: Material(color: Colors.transparent, child: coverChild),
),
),
Positioned(
left: 0,
right: 0,
@@ -1622,6 +1614,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
),
),
),
// Show "Embed Lyrics" button if lyrics are from online (not already embedded)
if (!_lyricsEmbedded && _fileExists) ...[
const SizedBox(height: 16),
Center(
@@ -1669,6 +1662,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
try {
final durationMs = (duration ?? 0) * 1000;
// First, check if lyrics are embedded in the file
if (_fileExists) {
final embeddedResult =
await PlatformBridge.getLyricsLRCWithSource(
@@ -1702,11 +1696,12 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}
}
// No embedded lyrics, fetch from online
final result = await PlatformBridge.getLyricsLRCWithSource(
_spotifyId ?? '',
trackName,
artistName,
filePath: null,
filePath: null, // Don't check file again
durationMs: durationMs,
).timeout(const Duration(seconds: 20));
@@ -1732,9 +1727,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
final cleanLyrics = _cleanLrcForDisplay(lrcText);
setState(() {
_lyrics = cleanLyrics;
_rawLyrics = lrcText;
_rawLyrics = lrcText; // Keep raw LRC with timestamps for embedding
_lyricsSource = source.isNotEmpty ? source : null;
_lyricsEmbedded = false;
_lyricsEmbedded = false; // Lyrics from online, not embedded
_lyricsLoading = false;
});
}
@@ -1761,6 +1756,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
setState(() => _isEmbedding = true);
// Capture l10n strings before async gaps to avoid use_build_context_synchronously
final l10nFailedToWriteStorage = context.l10n.snackbarFailedToWriteStorage;
final l10nFailedToEmbedLyrics = context.l10n.snackbarFailedToEmbedLyrics;
final l10nUnsupportedFormat = context.l10n.snackbarUnsupportedAudioFormat;
@@ -1990,6 +1986,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
return;
}
// Write temp file to SAF tree
final treeUri = _downloadItem?.downloadTreeUri;
final relativeDir = _downloadItem?.safRelativeDir ?? '';
if (treeUri != null && treeUri.isNotEmpty) {
@@ -2036,6 +2033,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
return;
}
// Regular file path
final dir = _getFileDirectory();
final outputPath = '$dir${Platform.pathSeparator}$baseName.jpg';
@@ -2128,6 +2126,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
return;
}
// Write temp file to SAF tree
final treeUri = _downloadItem?.downloadTreeUri;
final relativeDir = _downloadItem?.safRelativeDir ?? '';
if (treeUri != null && treeUri.isNotEmpty) {
@@ -2135,7 +2134,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
treeUri: treeUri,
relativeDir: relativeDir,
fileName: '$baseName.lrc',
mimeType: 'application/octet-stream',
mimeType: 'text/plain',
srcPath: tempOutput,
);
try {
@@ -2183,6 +2182,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
return;
}
// Regular file path
final dir = _getFileDirectory();
final outputPath = '$dir${Platform.pathSeparator}$baseName.lrc';
@@ -2257,6 +2257,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
final result = await PlatformBridge.reEnrichFile(request);
final method = result['method'] as String?;
// Update local UI state with enriched metadata from online search
final enriched = result['enriched_metadata'] as Map<String, dynamic>?;
if (enriched != null && mounted) {
setState(() {
@@ -2343,6 +2344,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
);
}
// For SAF files, copy processed temp file back
if (ffmpegResult != null && tempPath != null && safUri != null) {
final ok = await PlatformBridge.writeTempToSaf(ffmpegResult, safUri);
if (!ok && mounted) {
@@ -2355,6 +2357,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
),
),
);
// Cleanup temp files
if (_hasPath(downloadedCoverPath)) {
try {
await File(downloadedCoverPath!).delete();
@@ -2372,6 +2375,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}
}
// Cleanup temp files
if (tempPath != null && tempPath.isNotEmpty) {
try {
await File(tempPath).delete();
@@ -2393,6 +2397,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
);
}
// Cleanup temp cover from Go backend
if (_hasPath(downloadedCoverPath)) {
try {
await File(downloadedCoverPath!).delete();
@@ -2457,6 +2462,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
for (final line in lines) {
var cleaned = line.trim();
// Skip metadata tags
if (_lrcMetadataPattern.hasMatch(cleaned) &&
!_lrcBackgroundLinePattern.hasMatch(cleaned)) {
continue;
@@ -2468,6 +2474,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
cleaned = bgMatch.group(1)?.trim() ?? '';
}
// Remove line timestamp, inline word-by-word timestamps, and speaker prefix.
cleaned = cleaned.replaceAll(_lrcTimestampPattern, '').trim();
cleaned = cleaned.replaceAll(_lrcInlineTimestampPattern, '');
cleaned = cleaned.replaceFirst(_lrcSpeakerPrefixPattern, '');
@@ -2533,7 +2540,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
WidgetRef ref,
ColorScheme colorScheme,
) {
showModalBottomSheet<void>(
showModalBottomSheet(
context: screenContext,
useRootNavigator: true,
shape: const RoundedRectangleBorder(
@@ -2678,9 +2685,11 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
/// Whether the current file is a CUE sheet (or CUE-referenced)
bool get _isCueFile {
// Check if the raw path has a CUE virtual path suffix
if (isCueVirtualPath(rawFilePath)) return true;
final lower = cleanFilePath.toLowerCase();
if (lower.endsWith('.cue')) return true;
// Check if local library item has cue+ format
if (_isLocalItem && _localLibraryItem != null) {
final format = _localLibraryItem!.format ?? '';
if (format.startsWith('cue+')) return true;
@@ -2806,6 +2815,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
final currentFormat = _currentFileFormat;
final isLosslessSource = currentFormat == 'FLAC' || currentFormat == 'M4A';
// Build available target formats based on source
final formats = <String>[];
if (currentFormat == 'FLAC') {
formats.addAll(['ALAC', 'MP3', 'Opus']);
@@ -2824,7 +2834,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
bool isLosslessTarget =
selectedFormat == 'ALAC' || selectedFormat == 'FLAC';
showModalBottomSheet<void>(
showModalBottomSheet(
context: context,
useRootNavigator: true,
shape: const RoundedRectangleBorder(
@@ -2896,6 +2906,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}).toList(),
),
// Only show bitrate for lossy targets
if (!isLosslessTarget) ...[
const SizedBox(height: 16),
Text(
@@ -2922,6 +2933,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
),
],
// Show lossless indicator
if (isLosslessTarget && isLosslessSource) ...[
const SizedBox(height: 16),
Row(
@@ -2979,12 +2991,14 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}
void _showCueSplitSheet(BuildContext context) async {
// Strip the #trackNN suffix from virtual CUE paths to get the real .cue path
var cuePath = cleanFilePath;
final trackSuffix = RegExp(r'#track\d+$');
if (trackSuffix.hasMatch(cuePath)) {
cuePath = cuePath.replaceFirst(trackSuffix, '');
}
// Show loading indicator
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarLoadingCueSheet)),
);
@@ -3023,7 +3037,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
if (!mounted) return;
showModalBottomSheet<void>(
showModalBottomSheet(
context: this.context,
useRootNavigator: true,
isScrollControlled: true,
@@ -3079,6 +3093,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
),
),
const SizedBox(height: 16),
// Track list preview (scrollable, max 200px)
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 200),
child: ListView.builder(
@@ -3186,7 +3201,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
required String date,
required List<CueSplitTrackInfo> tracks,
}) {
showDialog<void>(
showDialog(
context: context,
builder: (dialogContext) {
return AlertDialog(
@@ -3300,6 +3315,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
workingAudioPath = tempPath;
}
// Determine output directory
final String outputDir;
final treeUri = !_isLocalItem
? (_downloadItem?.downloadTreeUri ?? '')
@@ -3326,6 +3342,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
if (!mounted) return;
_showLongSnackBarMessage(_l10nCueSplitSplitting(1, tracks.length));
// Extract cover from audio file for embedding
String? coverPath;
try {
final tempDir = await getTemporaryDirectory();
@@ -3368,9 +3385,11 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
for (final path in finalOutputPaths) {
if (path.toLowerCase().endsWith('.flac')) {
try {
// Read existing metadata first
final metadata = await PlatformBridge.readFileMetadata(path);
if (metadata['error'] == null) {
final fields = <String, String>{'cover_path': coverPath};
// Preserve existing fields
for (final entry in metadata.entries) {
if (entry.key == 'error' || entry.value == null) continue;
final v = entry.value.toString().trim();
@@ -3396,6 +3415,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
finalOutputPaths = exportedUris;
}
// Cleanup cover temp
if (coverPath != null) {
try {
await File(coverPath).delete();
@@ -3417,6 +3437,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
_showSnackBarMessage(_l10nCueSplitFailed);
}
} finally {
// Cleanup SAF temp audio copy
if (safTempAudioPath != null) {
try {
await File(safTempAudioPath).delete();
@@ -3442,7 +3463,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
final isLossless =
targetFormat.toUpperCase() == 'ALAC' ||
targetFormat.toUpperCase() == 'FLAC';
showDialog<void>(
showDialog(
context: context,
builder: (dialogContext) {
return AlertDialog(
@@ -3535,6 +3556,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
String? safTempPath;
if (isSaf) {
// Copy SAF file to temp for processing
safTempPath = await PlatformBridge.copyContentUriToTemp(cleanFilePath);
if (safTempPath == null) {
if (mounted) {
@@ -3554,9 +3576,10 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
bitrate: bitrate,
metadata: metadata,
coverPath: coverPath,
deleteOriginal: !isSaf,
deleteOriginal: !isSaf, // Don't delete temp copy for SAF, we handle it
);
// Cleanup cover temp
if (coverPath != null) {
try {
await File(coverPath).delete();
@@ -3564,6 +3587,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}
if (newPath == null) {
// Cleanup SAF temp if needed
if (safTempPath != null) {
try {
await File(safTempPath).delete();
@@ -3625,7 +3649,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
newExt = '.flac';
mimeType = 'audio/flac';
break;
default:
default: // mp3
newExt = '.mp3';
mimeType = 'audio/mpeg';
break;
@@ -3665,6 +3689,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
_log.w('Converted SAF file created but failed deleting original URI');
}
// Update history with new SAF info
if (!_isLocalItem) {
await HistoryDatabase.instance.updateFilePath(
_downloadItem!.id,
@@ -3676,6 +3701,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
await ref.read(downloadHistoryProvider.notifier).reloadFromStorage();
}
// Cleanup temp files
try {
await File(newPath).delete();
} catch (_) {}
@@ -3685,6 +3711,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
} catch (_) {}
}
} else {
// Regular file: update history with new path
if (!_isLocalItem) {
await HistoryDatabase.instance.updateFilePath(
_downloadItem!.id,
@@ -3703,6 +3730,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
content: Text(context.l10n.trackConvertSuccess(targetFormat)),
),
);
// Pop and let the caller refresh
Navigator.pop(context, true);
}
} catch (e) {
@@ -3720,6 +3748,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
WidgetRef ref,
ColorScheme colorScheme,
) async {
// Read current metadata from file, fall back to item data on failure
Map<String, dynamic>? fileMetadata;
try {
final result = await PlatformBridge.readFileMetadata(cleanFilePath);
@@ -3730,6 +3759,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
debugPrint('readFileMetadata failed, using item data: $e');
}
// Build initial values map prefer file metadata, fall back to item data
String val(String key, String? fallback) {
final v = fileMetadata?[key]?.toString();
return (v != null && v.isNotEmpty) ? v : (fallback ?? '');
@@ -3775,6 +3805,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
ScaffoldMessenger.of(this.context).showSnackBar(
SnackBar(content: Text(this.context.l10n.snackbarMetadataSaved)),
);
// Re-read metadata from file to refresh the display
try {
final refreshed = await PlatformBridge.readFileMetadata(cleanFilePath);
setState(() => _editedMetadata = refreshed);
@@ -3792,7 +3823,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
WidgetRef ref,
ColorScheme colorScheme,
) {
showDialog<void>(
showDialog(
context: screenContext,
useRootNavigator: true,
builder: (dialogContext) => AlertDialog(
@@ -4019,8 +4050,10 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
String? _currentCoverTempDir;
bool _loadingCurrentCover = false;
// Auto-fill field selection which fields the user wants to fetch
final Set<String> _autoFillFields = {};
// All auto-fillable fields and their mapping
static const _fieldDefs = <String, String>{
'title': 'title',
'artist': 'artist',
@@ -4646,6 +4679,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
throw StateError('No metadata match resolved for auto-fill');
}
// Extract basic metadata from search result
final enriched = <String, String>{
'title': (selectedBest['name'] ?? '').toString(),
'artist': (selectedBest['artists'] ?? selectedBest['artist'] ?? '')
@@ -4723,6 +4757,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
if (!mounted) return;
// Fetch genre/label/copyright from Deezer extended metadata
if (needsExtended && deezerId != null) {
try {
final extended = await PlatformBridge.getDeezerExtendedMetadata(
@@ -4740,9 +4775,10 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
if (!mounted) return;
// Apply selected fields to controllers
var filledCount = 0;
for (final key in _autoFillFields) {
if (key == 'cover') continue;
if (key == 'cover') continue; // Handle cover separately below
final value = enriched[key];
if (value != null &&
value.isNotEmpty &&
@@ -4756,6 +4792,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
}
}
// Handle cover art download
if (_autoFillFields.contains('cover')) {
final coverUrl =
(selectedBest['cover_url'] ?? selectedBest['images'] ?? '')
@@ -5034,6 +5071,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
return;
}
// For SAF files, copy the processed temp file back
if (tempPath != null && safUri != null) {
final ok = await PlatformBridge.writeTempToSaf(ffmpegResult, safUri);
if (!ok && mounted) {
@@ -5146,6 +5184,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
),
_field('Genre', _genreCtrl),
_field('ISRC', _isrcCtrl),
// Advanced fields toggle
Padding(
padding: const EdgeInsets.only(top: 8, bottom: 4),
child: InkWell(
@@ -5243,6 +5282,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
),
),
const SizedBox(height: 8),
// Quick select buttons
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
@@ -5262,6 +5302,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
),
),
const SizedBox(height: 8),
// Field chips
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Wrap(
@@ -5298,6 +5339,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
),
),
const SizedBox(height: 10),
// Fetch button
Padding(
padding: const EdgeInsets.only(left: 12, right: 12, bottom: 12),
child: SizedBox(
+2 -2
View File
@@ -527,7 +527,7 @@ class _InteractiveDownloadExampleState
for (int i = 0; i <= 100; i += 5) {
if (!mounted) return;
await Future<void>.delayed(const Duration(milliseconds: 50));
await Future.delayed(const Duration(milliseconds: 50));
setState(() => _progress = i / 100);
}
@@ -536,7 +536,7 @@ class _InteractiveDownloadExampleState
_isCompleted = true;
});
await Future<void>.delayed(const Duration(seconds: 2));
await Future.delayed(const Duration(seconds: 2));
if (mounted) {
setState(() {
_isCompleted = false;

Some files were not shown because too many files have changed in this diff Show More