mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-03 19:27:57 +02:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f327cd1f6 | |||
| 4f2e677e8b | |||
| 79a69f8f70 | |||
| bf0f4bdf3e | |||
| 5e1cc3ecb5 | |||
| d4b37edc2f | |||
| 9483614bc7 | |||
| a73f2e1a13 | |||
| 091e3fadd9 | |||
| 5340ca7b16 | |||
| 85d3e58a26 | |||
| 1125c757fe | |||
| 66d714d368 | |||
| 49c2501fbc | |||
| e487817f21 | |||
| d8bbeb1e67 | |||
| 9693616645 | |||
| 0423e36d34 | |||
| c8d605fdee | |||
| 03fd734048 |
@@ -141,6 +141,11 @@ In AltStore/SideStore, go to **Browse > Sources**, tap **+**, and paste the link
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> If SpotiFLAC is useful to you, consider supporting development:
|
||||||
|
>
|
||||||
|
> [](https://ko-fi.com/zarzet)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Contributors
|
## Contributors
|
||||||
@@ -165,10 +170,5 @@ 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) |
|
| [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) | |
|
| [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:
|
|
||||||
>
|
|
||||||
> [](https://ko-fi.com/zarzet)
|
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> **Star the repo** to get notified about all new releases directly from GitHub.
|
> **Star the repo** to get notified about all new releases directly from GitHub.
|
||||||
|
|||||||
@@ -138,13 +138,12 @@ class DownloadService : Service() {
|
|||||||
private fun startForegroundService() {
|
private fun startForegroundService() {
|
||||||
isRunning = true
|
isRunning = true
|
||||||
|
|
||||||
// Acquire wake lock to prevent CPU sleep
|
|
||||||
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
|
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||||
wakeLock = powerManager.newWakeLock(
|
wakeLock = powerManager.newWakeLock(
|
||||||
PowerManager.PARTIAL_WAKE_LOCK,
|
PowerManager.PARTIAL_WAKE_LOCK,
|
||||||
WAKELOCK_TAG
|
WAKELOCK_TAG
|
||||||
).apply {
|
).apply {
|
||||||
acquire(60 * 60 * 1000L) // 1 hour max
|
acquire(60 * 60 * 1000L)
|
||||||
}
|
}
|
||||||
|
|
||||||
val notification = buildNotification(0, 0)
|
val notification = buildNotification(0, 0)
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
import org.json.JSONTokener
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileInputStream
|
import java.io.FileInputStream
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
@@ -129,39 +130,35 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
// Minimum API level we consider "safe" for Impeller (Android 10+)
|
|
||||||
private const val SAFE_API_FOR_IMPELLER = 29
|
private const val SAFE_API_FOR_IMPELLER = 29
|
||||||
|
|
||||||
// Known problematic GPU patterns (lowercase)
|
|
||||||
private val PROBLEMATIC_GPU_PATTERNS = listOf(
|
private val PROBLEMATIC_GPU_PATTERNS = listOf(
|
||||||
"adreno (tm) 3", // Adreno 300 series (305, 320, 330, etc.) - old Qualcomm
|
"adreno (tm) 3",
|
||||||
"adreno (tm) 4", // Adreno 400 series - some have issues
|
"adreno (tm) 4",
|
||||||
"mali-4", // Mali-400 series - old ARM GPUs
|
"mali-4",
|
||||||
"mali-t6", // Mali-T600 series
|
"mali-t6",
|
||||||
"mali-t7", // Mali-T700 series (some)
|
"mali-t7",
|
||||||
"powervr sgx", // PowerVR SGX series - old Imagination GPUs
|
"powervr sgx",
|
||||||
"powervr ge8320", // PowerVR GE8320 - known issues
|
"powervr ge8320",
|
||||||
"gc1000", // Vivante GC1000
|
"gc1000",
|
||||||
"gc2000", // Vivante GC2000
|
"gc2000",
|
||||||
)
|
)
|
||||||
|
|
||||||
// Known problematic chipsets/hardware (lowercase)
|
|
||||||
private val PROBLEMATIC_CHIPSETS = listOf(
|
private val PROBLEMATIC_CHIPSETS = listOf(
|
||||||
"mt6762", // MediaTek Helio P22 with PowerVR GE8320
|
"mt6762",
|
||||||
"mt6765", // MediaTek Helio P35 with PowerVR GE8320
|
"mt6765",
|
||||||
"mt8768", // MediaTek tablet chip
|
"mt8768",
|
||||||
"mp0873", // MediaTek variant
|
"mp0873",
|
||||||
"msm8974", // Snapdragon 800/801 with Adreno 330
|
"msm8974",
|
||||||
"msm8226", // Snapdragon 400 with Adreno 305
|
"msm8226",
|
||||||
"msm8926", // Snapdragon 400 with Adreno 305
|
"msm8926",
|
||||||
"apq8084", // Snapdragon 805 (some issues)
|
"apq8084",
|
||||||
)
|
)
|
||||||
|
|
||||||
// Known problematic device models (lowercase)
|
|
||||||
private val PROBLEMATIC_MODELS = listOf(
|
private val PROBLEMATIC_MODELS = listOf(
|
||||||
"sm-t220", // Samsung Tab A7 Lite
|
"sm-t220",
|
||||||
"sm-t225", // Samsung Tab A7 Lite LTE
|
"sm-t225",
|
||||||
"hammerhead", // Nexus 5 (Adreno 330)
|
"hammerhead",
|
||||||
)
|
)
|
||||||
/**
|
/**
|
||||||
* Check if device should use Skia instead of Impeller.
|
* Check if device should use Skia instead of Impeller.
|
||||||
@@ -173,7 +170,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
val model = Build.MODEL.lowercase(Locale.ROOT)
|
val model = Build.MODEL.lowercase(Locale.ROOT)
|
||||||
val device = Build.DEVICE.lowercase(Locale.ROOT)
|
val device = Build.DEVICE.lowercase(Locale.ROOT)
|
||||||
|
|
||||||
// 1. Check for explicitly problematic device models
|
|
||||||
for (problematicModel in PROBLEMATIC_MODELS) {
|
for (problematicModel in PROBLEMATIC_MODELS) {
|
||||||
if (model.contains(problematicModel) || device.contains(problematicModel)) {
|
if (model.contains(problematicModel) || device.contains(problematicModel)) {
|
||||||
android.util.Log.i("SpotiFLAC", "Matched problematic model: $problematicModel")
|
android.util.Log.i("SpotiFLAC", "Matched problematic model: $problematicModel")
|
||||||
@@ -181,7 +177,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Check for problematic chipsets
|
|
||||||
for (chipset in PROBLEMATIC_CHIPSETS) {
|
for (chipset in PROBLEMATIC_CHIPSETS) {
|
||||||
if (hardware.contains(chipset) || board.contains(chipset)) {
|
if (hardware.contains(chipset) || board.contains(chipset)) {
|
||||||
android.util.Log.i("SpotiFLAC", "Matched problematic chipset: $chipset")
|
android.util.Log.i("SpotiFLAC", "Matched problematic chipset: $chipset")
|
||||||
@@ -189,12 +184,9 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. For Android < 10 (API 29), be more aggressive about disabling Impeller
|
|
||||||
if (Build.VERSION.SDK_INT < SAFE_API_FOR_IMPELLER) {
|
if (Build.VERSION.SDK_INT < SAFE_API_FOR_IMPELLER) {
|
||||||
// For older Android, check GPU renderer if available
|
|
||||||
val gpuRenderer = getGpuRenderer().lowercase(Locale.ROOT)
|
val gpuRenderer = getGpuRenderer().lowercase(Locale.ROOT)
|
||||||
|
|
||||||
// Check for known problematic GPUs
|
|
||||||
for (pattern in PROBLEMATIC_GPU_PATTERNS) {
|
for (pattern in PROBLEMATIC_GPU_PATTERNS) {
|
||||||
if (gpuRenderer.contains(pattern)) {
|
if (gpuRenderer.contains(pattern)) {
|
||||||
android.util.Log.i("SpotiFLAC", "Matched problematic GPU on old Android: $pattern")
|
android.util.Log.i("SpotiFLAC", "Matched problematic GPU on old Android: $pattern")
|
||||||
@@ -202,14 +194,12 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// For very old Android (< 8.0), always use Skia as Vulkan support is spotty
|
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||||
android.util.Log.i("SpotiFLAC", "Android < 8.0, using Skia for safety")
|
android.util.Log.i("SpotiFLAC", "Android < 8.0, using Skia for safety")
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. For Android 10+, still check for known problematic GPUs
|
|
||||||
val gpuRenderer = getGpuRenderer().lowercase(Locale.ROOT)
|
val gpuRenderer = getGpuRenderer().lowercase(Locale.ROOT)
|
||||||
for (pattern in PROBLEMATIC_GPU_PATTERNS) {
|
for (pattern in PROBLEMATIC_GPU_PATTERNS) {
|
||||||
if (gpuRenderer.contains(pattern)) {
|
if (gpuRenderer.contains(pattern)) {
|
||||||
@@ -227,8 +217,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
*/
|
*/
|
||||||
private fun getGpuRenderer(): String {
|
private fun getGpuRenderer(): String {
|
||||||
return try {
|
return try {
|
||||||
// This might not work before GL context is created,
|
|
||||||
// but worth trying for additional detection
|
|
||||||
android.opengl.GLES20.glGetString(android.opengl.GLES20.GL_RENDERER) ?: ""
|
android.opengl.GLES20.glGetString(android.opengl.GLES20.GL_RENDERER) ?: ""
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
""
|
""
|
||||||
@@ -413,6 +401,38 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun parseJsonValue(value: Any?): Any? {
|
||||||
|
return when (value) {
|
||||||
|
null, JSONObject.NULL -> null
|
||||||
|
is JSONObject -> {
|
||||||
|
val map = LinkedHashMap<String, Any?>()
|
||||||
|
val keys = value.keys()
|
||||||
|
while (keys.hasNext()) {
|
||||||
|
val key = keys.next()
|
||||||
|
map[key] = parseJsonValue(value.opt(key))
|
||||||
|
}
|
||||||
|
map
|
||||||
|
}
|
||||||
|
is JSONArray -> {
|
||||||
|
val list = ArrayList<Any?>()
|
||||||
|
for (i in 0 until value.length()) {
|
||||||
|
list.add(parseJsonValue(value.opt(i)))
|
||||||
|
}
|
||||||
|
list
|
||||||
|
}
|
||||||
|
is Number, is Boolean, is String -> value
|
||||||
|
else -> value.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseJsonPayload(payload: String): Any {
|
||||||
|
return try {
|
||||||
|
parseJsonValue(JSONTokener(payload).nextValue()) ?: payload
|
||||||
|
} catch (_: Exception) {
|
||||||
|
payload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun startDownloadProgressStream(sink: EventChannel.EventSink) {
|
private fun startDownloadProgressStream(sink: EventChannel.EventSink) {
|
||||||
stopDownloadProgressStream()
|
stopDownloadProgressStream()
|
||||||
downloadProgressEventSink = sink
|
downloadProgressEventSink = sink
|
||||||
@@ -425,7 +445,7 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
if (payload != lastDownloadProgressPayload) {
|
if (payload != lastDownloadProgressPayload) {
|
||||||
lastDownloadProgressPayload = payload
|
lastDownloadProgressPayload = payload
|
||||||
sink.success(payload)
|
sink.success(parseJsonPayload(payload))
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
android.util.Log.w(
|
android.util.Log.w(
|
||||||
@@ -457,7 +477,7 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
if (payload != lastLibraryScanProgressPayload) {
|
if (payload != lastLibraryScanProgressPayload) {
|
||||||
lastLibraryScanProgressPayload = payload
|
lastLibraryScanProgressPayload = payload
|
||||||
sink.success(payload)
|
sink.success(parseJsonPayload(payload))
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
android.util.Log.w(
|
android.util.Log.w(
|
||||||
@@ -599,7 +619,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
* Resolve extension from a MediaStore URI by querying DISPLAY_NAME or MIME_TYPE.
|
* Resolve extension from a MediaStore URI by querying DISPLAY_NAME or MIME_TYPE.
|
||||||
*/
|
*/
|
||||||
private fun resolveMediaStoreExt(uri: Uri, fallbackExt: String?): String {
|
private fun resolveMediaStoreExt(uri: Uri, fallbackExt: String?): String {
|
||||||
// Try DISPLAY_NAME first
|
|
||||||
try {
|
try {
|
||||||
contentResolver.query(uri, arrayOf(android.provider.MediaStore.MediaColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
|
contentResolver.query(uri, arrayOf(android.provider.MediaStore.MediaColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
|
||||||
if (cursor.moveToFirst()) {
|
if (cursor.moveToFirst()) {
|
||||||
@@ -610,7 +629,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
} catch (_: Exception) {}
|
} catch (_: Exception) {}
|
||||||
|
|
||||||
// Try MIME_TYPE
|
|
||||||
try {
|
try {
|
||||||
val mime = contentResolver.getType(uri)
|
val mime = contentResolver.getType(uri)
|
||||||
val ext = extFromMimeType(mime)
|
val ext = extFromMimeType(mime)
|
||||||
@@ -836,8 +854,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
val mimeType = mimeTypeForExt(outputExt)
|
val mimeType = mimeTypeForExt(outputExt)
|
||||||
val fileName = buildSafFileName(req, outputExt)
|
val fileName = buildSafFileName(req, outputExt)
|
||||||
|
|
||||||
// Check for existing file WITHOUT creating the directory first.
|
|
||||||
// This prevents empty folders from being created for duplicate downloads.
|
|
||||||
val existingDir = findDocumentDir(treeUri, relativeDir)
|
val existingDir = findDocumentDir(treeUri, relativeDir)
|
||||||
if (existingDir != null) {
|
if (existingDir != null) {
|
||||||
val existing = existingDir.findFile(fileName)
|
val existing = existingDir.findFile(fileName)
|
||||||
@@ -852,7 +868,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only create the directory now that we know we need to download
|
|
||||||
val targetDir = ensureDocumentDir(treeUri, relativeDir)
|
val targetDir = ensureDocumentDir(treeUri, relativeDir)
|
||||||
?: return errorJson("Failed to access SAF directory")
|
?: return errorJson("Failed to access SAF directory")
|
||||||
|
|
||||||
@@ -875,7 +890,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
val respObj = JSONObject(response)
|
val respObj = JSONObject(response)
|
||||||
if (respObj.optBoolean("success", false)) {
|
if (respObj.optBoolean("success", false)) {
|
||||||
// Extension providers write to a local temp path instead of the SAF FD.
|
// Extension providers write to a local temp path instead of the SAF FD.
|
||||||
// Copy the local file into the SAF document so it is not empty.
|
|
||||||
val goFilePath = respObj.optString("file_path", "")
|
val goFilePath = respObj.optString("file_path", "")
|
||||||
if (goFilePath.isNotEmpty() &&
|
if (goFilePath.isNotEmpty() &&
|
||||||
!goFilePath.startsWith("content://") &&
|
!goFilePath.startsWith("content://") &&
|
||||||
@@ -924,15 +938,10 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
try {
|
try {
|
||||||
val docId = android.provider.DocumentsContract.getDocumentId(childUri)
|
val docId = android.provider.DocumentsContract.getDocumentId(childUri)
|
||||||
if (docId.isNullOrEmpty()) return null
|
if (docId.isNullOrEmpty()) return null
|
||||||
|
|
||||||
// Document IDs typically look like "primary:Music/Album/file.cue"
|
|
||||||
// Parent would be "primary:Music/Album"
|
|
||||||
val lastSlash = docId.lastIndexOf('/')
|
val lastSlash = docId.lastIndexOf('/')
|
||||||
if (lastSlash <= 0) return null
|
if (lastSlash <= 0) return null
|
||||||
|
|
||||||
val parentDocId = docId.substring(0, lastSlash)
|
val parentDocId = docId.substring(0, lastSlash)
|
||||||
|
|
||||||
// Build a tree document URI for the parent so it supports listing/findFile
|
|
||||||
val treeDocId = android.provider.DocumentsContract.getTreeDocumentId(childUri)
|
val treeDocId = android.provider.DocumentsContract.getTreeDocumentId(childUri)
|
||||||
if (treeDocId.isNullOrEmpty()) return null
|
if (treeDocId.isNullOrEmpty()) return null
|
||||||
|
|
||||||
@@ -957,21 +966,17 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
val lines = File(cueTempPath).readLines()
|
val lines = File(cueTempPath).readLines()
|
||||||
for (line in lines) {
|
for (line in lines) {
|
||||||
val trimmed = line.trim().let { l ->
|
val trimmed = line.trim().let { l ->
|
||||||
// Strip BOM
|
|
||||||
if (l.startsWith("\uFEFF")) l.removePrefix("\uFEFF").trim() else l
|
if (l.startsWith("\uFEFF")) l.removePrefix("\uFEFF").trim() else l
|
||||||
}
|
}
|
||||||
if (trimmed.uppercase(Locale.ROOT).startsWith("FILE ")) {
|
if (trimmed.uppercase(Locale.ROOT).startsWith("FILE ")) {
|
||||||
val rest = trimmed.substring(5).trim()
|
val rest = trimmed.substring(5).trim()
|
||||||
// Parse: "filename" TYPE or filename TYPE
|
|
||||||
val filename = if (rest.startsWith("\"")) {
|
val filename = if (rest.startsWith("\"")) {
|
||||||
val endQuote = rest.indexOf('"', 1)
|
val endQuote = rest.indexOf('"', 1)
|
||||||
if (endQuote > 0) rest.substring(1, endQuote) else rest
|
if (endQuote > 0) rest.substring(1, endQuote) else rest
|
||||||
} else {
|
} else {
|
||||||
// Last word is the type, everything else is the filename
|
|
||||||
val parts = rest.split("\\s+".toRegex())
|
val parts = rest.split("\\s+".toRegex())
|
||||||
if (parts.size >= 2) parts.dropLast(1).joinToString(" ") else rest
|
if (parts.size >= 2) parts.dropLast(1).joinToString(" ") else rest
|
||||||
}
|
}
|
||||||
// Return just the filename (strip any path separators)
|
|
||||||
return filename.substringAfterLast("/").substringAfterLast("\\")
|
return filename.substringAfterLast("/").substringAfterLast("\\")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1056,7 +1061,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
|
|
||||||
val supportedAudioExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg")
|
val supportedAudioExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg")
|
||||||
val audioFiles = mutableListOf<Pair<DocumentFile, String>>()
|
val audioFiles = mutableListOf<Pair<DocumentFile, String>>()
|
||||||
// CUE files: (cueDoc, parentDir) — we need the parent to find sibling audio
|
|
||||||
val cueFiles = mutableListOf<Pair<DocumentFile, DocumentFile>>()
|
val cueFiles = mutableListOf<Pair<DocumentFile, DocumentFile>>()
|
||||||
val visitedDirUris = mutableSetOf<String>()
|
val visitedDirUris = mutableSetOf<String>()
|
||||||
val safChildLookupCache = mutableMapOf<String, Map<String, DocumentFile>>()
|
val safChildLookupCache = mutableMapOf<String, Map<String, DocumentFile>>()
|
||||||
@@ -1141,7 +1145,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
var scanned = 0
|
var scanned = 0
|
||||||
var errors = traversalErrors
|
var errors = traversalErrors
|
||||||
|
|
||||||
// --- CUE first pass: parse CUE sheets, expand to tracks, track referenced audio ---
|
|
||||||
val cueReferencedAudioUris = mutableSetOf<String>()
|
val cueReferencedAudioUris = mutableSetOf<String>()
|
||||||
|
|
||||||
for ((cueDoc, parentDir) in cueFiles) {
|
for ((cueDoc, parentDir) in cueFiles) {
|
||||||
@@ -1180,10 +1183,8 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark this audio file so we skip it in the regular audio pass
|
|
||||||
cueReferencedAudioUris.add(audioDoc.uri.toString())
|
cueReferencedAudioUris.add(audioDoc.uri.toString())
|
||||||
|
|
||||||
// Copy audio to same temp dir so Go can resolve it
|
|
||||||
val tempDir = File(tempCuePath).parent ?: cacheDir.absolutePath
|
val tempDir = File(tempCuePath).parent ?: cacheDir.absolutePath
|
||||||
val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" }
|
val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" }
|
||||||
val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT)
|
val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT)
|
||||||
@@ -1197,7 +1198,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rename temp audio to its original name so Go can find it by name
|
|
||||||
val renamedAudio = File(tempDir, audioName)
|
val renamedAudio = File(tempDir, audioName)
|
||||||
val tempAudioFile = File(tempAudioPath)
|
val tempAudioFile = File(tempAudioPath)
|
||||||
if (renamedAudio.absolutePath != tempAudioFile.absolutePath) {
|
if (renamedAudio.absolutePath != tempAudioFile.absolutePath) {
|
||||||
@@ -1240,14 +1240,12 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Regular audio file pass: skip files referenced by CUE sheets ---
|
|
||||||
for ((doc, _) in audioFiles) {
|
for ((doc, _) in audioFiles) {
|
||||||
if (safScanCancel) {
|
if (safScanCancel) {
|
||||||
updateSafScanProgress { it.isComplete = true }
|
updateSafScanProgress { it.isComplete = true }
|
||||||
return "[]"
|
return "[]"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip audio files that are represented by CUE track entries
|
|
||||||
if (cueReferencedAudioUris.contains(doc.uri.toString())) {
|
if (cueReferencedAudioUris.contains(doc.uri.toString())) {
|
||||||
scanned++
|
scanned++
|
||||||
val pct = scanned.toDouble() / totalItems.toDouble() * 100.0
|
val pct = scanned.toDouble() / totalItems.toDouble() * 100.0
|
||||||
@@ -1326,7 +1324,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
return result.toString()
|
return result.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse existing files map: URI -> lastModified
|
|
||||||
val existingFiles = mutableMapOf<String, Long>()
|
val existingFiles = mutableMapOf<String, Long>()
|
||||||
try {
|
try {
|
||||||
val obj = JSONObject(existingFilesJson)
|
val obj = JSONObject(existingFilesJson)
|
||||||
@@ -1345,20 +1342,15 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val supportedAudioExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg")
|
val supportedAudioExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg")
|
||||||
val audioFiles = mutableListOf<Triple<DocumentFile, String, Long>>() // doc, path, lastModified
|
val audioFiles = mutableListOf<Triple<DocumentFile, String, Long>>()
|
||||||
// CUE files to scan: (cueDoc, parentDir, lastModified)
|
|
||||||
val cueFilesToScan = mutableListOf<Triple<DocumentFile, DocumentFile, Long>>()
|
val cueFilesToScan = mutableListOf<Triple<DocumentFile, DocumentFile, Long>>()
|
||||||
// Unchanged CUE files: (cueDoc, parentDir) — need to discover audio siblings for skip set
|
|
||||||
val unchangedCueFiles = mutableListOf<Pair<DocumentFile, DocumentFile>>()
|
val unchangedCueFiles = mutableListOf<Pair<DocumentFile, DocumentFile>>()
|
||||||
val currentUris = mutableSetOf<String>()
|
val currentUris = mutableSetOf<String>()
|
||||||
val visitedDirUris = mutableSetOf<String>()
|
val visitedDirUris = mutableSetOf<String>()
|
||||||
val safChildLookupCache = mutableMapOf<String, Map<String, DocumentFile>>()
|
val safChildLookupCache = mutableMapOf<String, Map<String, DocumentFile>>()
|
||||||
var traversalErrors = 0
|
var traversalErrors = 0
|
||||||
|
|
||||||
// Build a map of CUE base URIs -> existing virtual track URIs from the database.
|
val existingCueVirtualPaths = mutableMapOf<String, MutableList<String>>()
|
||||||
// Virtual paths look like "content://...album.cue#track01".
|
|
||||||
// We need this to preserve virtual paths for unchanged CUE files.
|
|
||||||
val existingCueVirtualPaths = mutableMapOf<String, MutableList<String>>() // cueUri -> [virtualPaths]
|
|
||||||
for (key in existingFiles.keys) {
|
for (key in existingFiles.keys) {
|
||||||
val hashIdx = key.indexOf("#track")
|
val hashIdx = key.indexOf("#track")
|
||||||
if (hashIdx > 0) {
|
if (hashIdx > 0) {
|
||||||
@@ -1367,7 +1359,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect all files with lastModified
|
|
||||||
val queue: ArrayDeque<Pair<DocumentFile, String>> = ArrayDeque()
|
val queue: ArrayDeque<Pair<DocumentFile, String>> = ArrayDeque()
|
||||||
queue.add(root to "")
|
queue.add(root to "")
|
||||||
|
|
||||||
@@ -1423,8 +1414,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
queue.add(child to childPath)
|
queue.add(child to childPath)
|
||||||
} else if (child.isFile) {
|
} else if (child.isFile) {
|
||||||
// Mark file as present first so it cannot be mis-classified as removed
|
|
||||||
// when provider-specific metadata calls (e.g., lastModified) fail.
|
|
||||||
val uriStr = child.uri.toString()
|
val uriStr = child.uri.toString()
|
||||||
currentUris.add(uriStr)
|
currentUris.add(uriStr)
|
||||||
|
|
||||||
@@ -1436,18 +1425,15 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
child.lastModified()
|
child.lastModified()
|
||||||
} catch (_: Exception) { 0L }
|
} catch (_: Exception) { 0L }
|
||||||
|
|
||||||
// Check if any virtual track from this CUE exists with matching modTime
|
|
||||||
val virtualPaths = existingCueVirtualPaths[uriStr]
|
val virtualPaths = existingCueVirtualPaths[uriStr]
|
||||||
val existingModified = virtualPaths?.firstOrNull()?.let { existingFiles[it] }
|
val existingModified = virtualPaths?.firstOrNull()?.let { existingFiles[it] }
|
||||||
|
|
||||||
if (existingModified != null && existingModified == lastModified) {
|
if (existingModified != null && existingModified == lastModified) {
|
||||||
// CUE is unchanged — mark virtual paths as current so they aren't removed
|
|
||||||
unchangedCueFiles.add(child to dir)
|
unchangedCueFiles.add(child to dir)
|
||||||
for (vp in virtualPaths) {
|
for (vp in virtualPaths) {
|
||||||
currentUris.add(vp)
|
currentUris.add(vp)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// CUE is new or modified — needs scanning
|
|
||||||
cueFilesToScan.add(Triple(child, dir, lastModified))
|
cueFilesToScan.add(Triple(child, dir, lastModified))
|
||||||
}
|
}
|
||||||
} else if (ext.isNotBlank() && supportedAudioExt.contains(".$ext")) {
|
} else if (ext.isNotBlank() && supportedAudioExt.contains(".$ext")) {
|
||||||
@@ -1458,7 +1444,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
existingModified ?: 0L
|
existingModified ?: 0L
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if file is new or modified
|
|
||||||
if (existingModified == null || existingModified != lastModified) {
|
if (existingModified == null || existingModified != lastModified) {
|
||||||
audioFiles.add(Triple(child, path, lastModified))
|
audioFiles.add(Triple(child, path, lastModified))
|
||||||
}
|
}
|
||||||
@@ -1475,7 +1460,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find removed files (in existing but not in current)
|
|
||||||
val removedUris = existingFiles.keys.filter { !currentUris.contains(it) }
|
val removedUris = existingFiles.keys.filter { !currentUris.contains(it) }
|
||||||
val totalFiles = currentUris.size
|
val totalFiles = currentUris.size
|
||||||
val filesToProcess = audioFiles.size + cueFilesToScan.size
|
val filesToProcess = audioFiles.size + cueFilesToScan.size
|
||||||
@@ -1503,7 +1487,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
var scanned = 0
|
var scanned = 0
|
||||||
var errors = traversalErrors
|
var errors = traversalErrors
|
||||||
|
|
||||||
// --- CUE first pass: parse new/modified CUE sheets ---
|
|
||||||
val cueReferencedAudioUris = mutableSetOf<String>()
|
val cueReferencedAudioUris = mutableSetOf<String>()
|
||||||
|
|
||||||
for ((cueDoc, parentDir, cueLastModified) in cueFilesToScan) {
|
for ((cueDoc, parentDir, cueLastModified) in cueFilesToScan) {
|
||||||
@@ -1524,7 +1507,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
var tempCuePath: String? = null
|
var tempCuePath: String? = null
|
||||||
var tempAudioPath: String? = null
|
var tempAudioPath: String? = null
|
||||||
try {
|
try {
|
||||||
// Copy CUE to temp
|
|
||||||
tempCuePath = copyUriToTemp(cueDoc.uri, ".cue")
|
tempCuePath = copyUriToTemp(cueDoc.uri, ".cue")
|
||||||
if (tempCuePath == null) {
|
if (tempCuePath == null) {
|
||||||
errors++
|
errors++
|
||||||
@@ -1533,10 +1515,8 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract the audio filename from the CUE sheet text
|
|
||||||
val audioFileName = extractCueAudioFileName(tempCuePath)
|
val audioFileName = extractCueAudioFileName(tempCuePath)
|
||||||
|
|
||||||
// Find the referenced audio file as a sibling in the same SAF directory
|
|
||||||
val audioDoc = resolveCueAudioSibling(
|
val audioDoc = resolveCueAudioSibling(
|
||||||
parentDir = parentDir,
|
parentDir = parentDir,
|
||||||
cueName = cueName,
|
cueName = cueName,
|
||||||
@@ -1551,10 +1531,8 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark this audio file so we skip it in the regular audio pass
|
|
||||||
cueReferencedAudioUris.add(audioDoc.uri.toString())
|
cueReferencedAudioUris.add(audioDoc.uri.toString())
|
||||||
|
|
||||||
// Copy audio to same temp dir so Go can resolve it
|
|
||||||
val tempDir = File(tempCuePath).parent ?: cacheDir.absolutePath
|
val tempDir = File(tempCuePath).parent ?: cacheDir.absolutePath
|
||||||
val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" }
|
val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" }
|
||||||
val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT)
|
val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT)
|
||||||
@@ -1568,7 +1546,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rename temp audio to its original name so Go can find it by name
|
|
||||||
val renamedAudio = File(tempDir, audioName)
|
val renamedAudio = File(tempDir, audioName)
|
||||||
val tempAudioFile = File(tempAudioPath)
|
val tempAudioFile = File(tempAudioPath)
|
||||||
if (renamedAudio.absolutePath != tempAudioFile.absolutePath) {
|
if (renamedAudio.absolutePath != tempAudioFile.absolutePath) {
|
||||||
@@ -1576,7 +1553,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
tempAudioPath = renamedAudio.absolutePath
|
tempAudioPath = renamedAudio.absolutePath
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call Go to produce library scan entries for each CUE track
|
|
||||||
val cueResultsJson = Gobackend.scanCueSheetForLibrary(
|
val cueResultsJson = Gobackend.scanCueSheetForLibrary(
|
||||||
tempCuePath,
|
tempCuePath,
|
||||||
tempDir,
|
tempDir,
|
||||||
@@ -1588,7 +1564,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
for (j in 0 until cueArray.length()) {
|
for (j in 0 until cueArray.length()) {
|
||||||
val trackObj = cueArray.getJSONObject(j)
|
val trackObj = cueArray.getJSONObject(j)
|
||||||
results.put(trackObj)
|
results.put(trackObj)
|
||||||
// Register each virtual path as current so deletion detection works
|
|
||||||
val virtualPath = trackObj.optString("filePath", "")
|
val virtualPath = trackObj.optString("filePath", "")
|
||||||
if (virtualPath.isNotBlank()) {
|
if (virtualPath.isNotBlank()) {
|
||||||
currentUris.add(virtualPath)
|
currentUris.add(virtualPath)
|
||||||
@@ -1621,9 +1596,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Discover audio siblings for unchanged CUE files so we skip them
|
|
||||||
// in the regular audio pass. Copy the .cue to temp (tiny file) to extract
|
|
||||||
// the audio filename, then find the sibling by name.
|
|
||||||
for ((cueDoc, parentDir) in unchangedCueFiles) {
|
for ((cueDoc, parentDir) in unchangedCueFiles) {
|
||||||
var tempCue: String? = null
|
var tempCue: String? = null
|
||||||
try {
|
try {
|
||||||
@@ -1648,7 +1620,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Regular audio file pass: skip files referenced by CUE sheets ---
|
|
||||||
for ((doc, _, lastModified) in audioFiles) {
|
for ((doc, _, lastModified) in audioFiles) {
|
||||||
if (safScanCancel) {
|
if (safScanCancel) {
|
||||||
updateSafScanProgress { it.isComplete = true }
|
updateSafScanProgress { it.isComplete = true }
|
||||||
@@ -1661,7 +1632,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
return result.toString()
|
return result.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip audio files that are represented by CUE track entries
|
|
||||||
if (cueReferencedAudioUris.contains(doc.uri.toString())) {
|
if (cueReferencedAudioUris.contains(doc.uri.toString())) {
|
||||||
scanned++
|
scanned++
|
||||||
val processed = skippedCount + scanned
|
val processed = skippedCount + scanned
|
||||||
@@ -1715,7 +1685,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recalculate removedUris now that CUE virtual paths have been registered
|
|
||||||
val finalRemovedUris = existingFiles.keys.filter { !currentUris.contains(it) }
|
val finalRemovedUris = existingFiles.keys.filter { !currentUris.contains(it) }
|
||||||
|
|
||||||
updateSafScanProgress {
|
updateSafScanProgress {
|
||||||
@@ -1893,7 +1862,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
// Update the intent so receive_sharing_intent can access the new data
|
|
||||||
setIntent(intent)
|
setIntent(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2000,13 +1968,13 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
val response = withContext(Dispatchers.IO) {
|
val response = withContext(Dispatchers.IO) {
|
||||||
Gobackend.getDownloadProgress()
|
Gobackend.getDownloadProgress()
|
||||||
}
|
}
|
||||||
result.success(response)
|
result.success(parseJsonPayload(response))
|
||||||
}
|
}
|
||||||
"getAllDownloadProgress" -> {
|
"getAllDownloadProgress" -> {
|
||||||
val response = withContext(Dispatchers.IO) {
|
val response = withContext(Dispatchers.IO) {
|
||||||
Gobackend.getAllDownloadProgress()
|
Gobackend.getAllDownloadProgress()
|
||||||
}
|
}
|
||||||
result.success(response)
|
result.success(parseJsonPayload(response))
|
||||||
}
|
}
|
||||||
"initItemProgress" -> {
|
"initItemProgress" -> {
|
||||||
val itemId = call.argument<String>("item_id") ?: ""
|
val itemId = call.argument<String>("item_id") ?: ""
|
||||||
@@ -2553,7 +2521,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
val tempPath = copyUriToTemp(uri)
|
val tempPath = copyUriToTemp(uri)
|
||||||
?: return@withContext """{"error":"Failed to copy SAF file to temp"}"""
|
?: return@withContext """{"error":"Failed to copy SAF file to temp"}"""
|
||||||
try {
|
try {
|
||||||
// Replace file_path with temp path for Go
|
|
||||||
reqObj.put("file_path", tempPath)
|
reqObj.put("file_path", tempPath)
|
||||||
val raw = Gobackend.reEnrichFile(reqObj.toString())
|
val raw = Gobackend.reEnrichFile(reqObj.toString())
|
||||||
val obj = JSONObject(raw)
|
val obj = JSONObject(raw)
|
||||||
@@ -2631,7 +2598,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
// Deezer API methods
|
|
||||||
"searchDeezerAll" -> {
|
"searchDeezerAll" -> {
|
||||||
val query = call.argument<String>("query") ?: ""
|
val query = call.argument<String>("query") ?: ""
|
||||||
val trackLimit = call.argument<Int>("track_limit") ?: 15
|
val trackLimit = call.argument<Int>("track_limit") ?: 15
|
||||||
@@ -2642,7 +2608,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
// Tidal search API
|
|
||||||
"searchTidalAll" -> {
|
"searchTidalAll" -> {
|
||||||
val query = call.argument<String>("query") ?: ""
|
val query = call.argument<String>("query") ?: ""
|
||||||
val trackLimit = call.argument<Int>("track_limit") ?: 15
|
val trackLimit = call.argument<Int>("track_limit") ?: 15
|
||||||
@@ -2653,7 +2618,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
// Qobuz search API
|
|
||||||
"searchQobuzAll" -> {
|
"searchQobuzAll" -> {
|
||||||
val query = call.argument<String>("query") ?: ""
|
val query = call.argument<String>("query") ?: ""
|
||||||
val trackLimit = call.argument<Int>("track_limit") ?: 15
|
val trackLimit = call.argument<Int>("track_limit") ?: 15
|
||||||
@@ -2783,7 +2747,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
// Log methods
|
|
||||||
"getLogs" -> {
|
"getLogs" -> {
|
||||||
val response = withContext(Dispatchers.IO) {
|
val response = withContext(Dispatchers.IO) {
|
||||||
Gobackend.getLogs()
|
Gobackend.getLogs()
|
||||||
@@ -2816,7 +2779,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
// Extension System methods
|
|
||||||
"initExtensionSystem" -> {
|
"initExtensionSystem" -> {
|
||||||
val extensionsDir = call.argument<String>("extensions_dir") ?: ""
|
val extensionsDir = call.argument<String>("extensions_dir") ?: ""
|
||||||
val dataDir = call.argument<String>("data_dir") ?: ""
|
val dataDir = call.argument<String>("data_dir") ?: ""
|
||||||
@@ -2961,7 +2923,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
// Extension Auth API methods
|
|
||||||
"getExtensionPendingAuth" -> {
|
"getExtensionPendingAuth" -> {
|
||||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||||
val response = withContext(Dispatchers.IO) {
|
val response = withContext(Dispatchers.IO) {
|
||||||
@@ -3011,7 +2972,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
// Extension FFmpeg API
|
|
||||||
"getPendingFFmpegCommand" -> {
|
"getPendingFFmpegCommand" -> {
|
||||||
val commandId = call.argument<String>("command_id") ?: ""
|
val commandId = call.argument<String>("command_id") ?: ""
|
||||||
val response = withContext(Dispatchers.IO) {
|
val response = withContext(Dispatchers.IO) {
|
||||||
@@ -3039,7 +2999,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
// Extension Custom Search API
|
|
||||||
"customSearchWithExtension" -> {
|
"customSearchWithExtension" -> {
|
||||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||||
val query = call.argument<String>("query") ?: ""
|
val query = call.argument<String>("query") ?: ""
|
||||||
@@ -3055,7 +3014,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
// Extension URL Handler API
|
|
||||||
"handleURLWithExtension" -> {
|
"handleURLWithExtension" -> {
|
||||||
val url = call.argument<String>("url") ?: ""
|
val url = call.argument<String>("url") ?: ""
|
||||||
val response = withContext(Dispatchers.IO) {
|
val response = withContext(Dispatchers.IO) {
|
||||||
@@ -3100,7 +3058,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
// Extension Post-Processing API
|
|
||||||
"runPostProcessing" -> {
|
"runPostProcessing" -> {
|
||||||
val filePath = call.argument<String>("file_path") ?: ""
|
val filePath = call.argument<String>("file_path") ?: ""
|
||||||
val metadataJson = call.argument<String>("metadata") ?: ""
|
val metadataJson = call.argument<String>("metadata") ?: ""
|
||||||
@@ -3144,7 +3101,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
// Extension Store
|
|
||||||
"initExtensionStore" -> {
|
"initExtensionStore" -> {
|
||||||
val cacheDir = call.argument<String>("cache_dir") ?: ""
|
val cacheDir = call.argument<String>("cache_dir") ?: ""
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
@@ -3206,7 +3162,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
// Extension Home Feed (Explore)
|
|
||||||
"getExtensionHomeFeed" -> {
|
"getExtensionHomeFeed" -> {
|
||||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||||
val response = withContext(Dispatchers.IO) {
|
val response = withContext(Dispatchers.IO) {
|
||||||
@@ -3221,7 +3176,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
// Local Library Scanning
|
|
||||||
"setLibraryCoverCacheDir" -> {
|
"setLibraryCoverCacheDir" -> {
|
||||||
val cacheDir = call.argument<String>("cache_dir") ?: ""
|
val cacheDir = call.argument<String>("cache_dir") ?: ""
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
@@ -3298,7 +3252,7 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
Gobackend.getLibraryScanProgressJSON()
|
Gobackend.getLibraryScanProgressJSON()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
result.success(response)
|
result.success(parseJsonPayload(response))
|
||||||
}
|
}
|
||||||
"cancelLibraryScan" -> {
|
"cancelLibraryScan" -> {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
@@ -3326,7 +3280,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
// CUE Sheet Parsing
|
|
||||||
"parseCueSheet" -> {
|
"parseCueSheet" -> {
|
||||||
val cuePath = call.argument<String>("cue_path") ?: ""
|
val cuePath = call.argument<String>("cue_path") ?: ""
|
||||||
val audioDir = call.argument<String>("audio_dir") ?: ""
|
val audioDir = call.argument<String>("audio_dir") ?: ""
|
||||||
@@ -3338,17 +3291,14 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
?: return@withContext """{"error":"Failed to copy CUE file to temp"}"""
|
?: return@withContext """{"error":"Failed to copy CUE file to temp"}"""
|
||||||
var tempAudioPath: String? = null
|
var tempAudioPath: String? = null
|
||||||
try {
|
try {
|
||||||
// Extract audio filename from CUE text
|
|
||||||
val audioFileName = extractCueAudioFileName(tempCuePath)
|
val audioFileName = extractCueAudioFileName(tempCuePath)
|
||||||
|
|
||||||
// Try to find the audio sibling in SAF
|
|
||||||
var audioDoc: DocumentFile? = null
|
var audioDoc: DocumentFile? = null
|
||||||
val parentDir = safParentDir(uri)
|
val parentDir = safParentDir(uri)
|
||||||
if (parentDir != null && !audioFileName.isNullOrBlank()) {
|
if (parentDir != null && !audioFileName.isNullOrBlank()) {
|
||||||
audioDoc = try { parentDir.findFile(audioFileName) } catch (_: Exception) { null }
|
audioDoc = try { parentDir.findFile(audioFileName) } catch (_: Exception) { null }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: try common extensions with the CUE base name
|
|
||||||
if (audioDoc == null && parentDir != null) {
|
if (audioDoc == null && parentDir != null) {
|
||||||
val cueName = try {
|
val cueName = try {
|
||||||
DocumentFile.fromSingleUri(this@MainActivity, uri)?.name ?: ""
|
DocumentFile.fromSingleUri(this@MainActivity, uri)?.name ?: ""
|
||||||
@@ -3367,7 +3317,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
|
|
||||||
val tempDir = File(tempCuePath).parent ?: cacheDir.absolutePath
|
val tempDir = File(tempCuePath).parent ?: cacheDir.absolutePath
|
||||||
if (audioDoc != null) {
|
if (audioDoc != null) {
|
||||||
// Copy audio to same temp dir with original name
|
|
||||||
val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" }
|
val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" }
|
||||||
val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT)
|
val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT)
|
||||||
val fallbackExt = if (audioExt.isNotBlank()) ".$audioExt" else null
|
val fallbackExt = if (audioExt.isNotBlank()) ".$audioExt" else null
|
||||||
@@ -3382,15 +3331,11 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse with audio in temp dir; Go will resolve there
|
|
||||||
val resultJson = Gobackend.parseCueSheet(tempCuePath, tempDir)
|
val resultJson = Gobackend.parseCueSheet(tempCuePath, tempDir)
|
||||||
|
|
||||||
// Replace the temp audio_path with the SAF content:// URI
|
|
||||||
// so Dart knows it's a SAF file and handles it accordingly
|
|
||||||
if (audioDoc != null) {
|
if (audioDoc != null) {
|
||||||
val resultObj = JSONObject(resultJson)
|
val resultObj = JSONObject(resultJson)
|
||||||
resultObj.put("audio_path", audioDoc.uri.toString())
|
resultObj.put("audio_path", audioDoc.uri.toString())
|
||||||
// Also pass the original CUE URI for reference
|
|
||||||
resultObj.put("cue_path", cuePath)
|
resultObj.put("cue_path", cuePath)
|
||||||
resultObj.toString()
|
resultObj.toString()
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -498,7 +498,13 @@ func extractUserTextFrame(data []byte) (string, string) {
|
|||||||
|
|
||||||
func isLyricsDescription(description string) bool {
|
func isLyricsDescription(description string) bool {
|
||||||
switch strings.ToLower(strings.TrimSpace(description)) {
|
switch strings.ToLower(strings.TrimSpace(description)) {
|
||||||
case "lyrics", "lyric", "unsyncedlyrics", "unsynced lyrics", "lrc":
|
case
|
||||||
|
"lyrics",
|
||||||
|
"lyric",
|
||||||
|
"unsyncedlyrics",
|
||||||
|
"unsynced lyrics",
|
||||||
|
"uslt",
|
||||||
|
"lrc":
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ffmpegCommand(args ...string) *exec.Cmd {
|
||||||
|
if ffmpegPath, err := exec.LookPath("ffmpeg"); err == nil {
|
||||||
|
return exec.Command(ffmpegPath, args...)
|
||||||
|
}
|
||||||
|
return exec.Command("ffmpeg", args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runFFmpegTestCommand(t *testing.T, args ...string) {
|
||||||
|
t.Helper()
|
||||||
|
cmd := ffmpegCommand(args...)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ffmpeg failed: %v\n%s", err, string(output))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractLyricsReadsMp3AfterCoverEmbed(t *testing.T) {
|
||||||
|
if _, err := exec.LookPath("ffmpeg"); err != nil {
|
||||||
|
t.Skip("ffmpeg not available")
|
||||||
|
}
|
||||||
|
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
sourceFlac := filepath.Join(tempDir, "source.flac")
|
||||||
|
baseMp3 := filepath.Join(tempDir, "base.mp3")
|
||||||
|
finalMp3 := filepath.Join(tempDir, "final.mp3")
|
||||||
|
coverPath := filepath.Join(tempDir, "cover.jpg")
|
||||||
|
lyrics := "[ti:Test Song]\n[ar:Test Artist]\n[00:00.00]Hello from embedded lyrics"
|
||||||
|
|
||||||
|
runFFmpegTestCommand(
|
||||||
|
t,
|
||||||
|
"-y",
|
||||||
|
"-f",
|
||||||
|
"lavfi",
|
||||||
|
"-i",
|
||||||
|
"sine=frequency=440:duration=1",
|
||||||
|
"-c:a",
|
||||||
|
"flac",
|
||||||
|
sourceFlac,
|
||||||
|
)
|
||||||
|
|
||||||
|
runFFmpegTestCommand(
|
||||||
|
t,
|
||||||
|
"-y",
|
||||||
|
"-f",
|
||||||
|
"lavfi",
|
||||||
|
"-i",
|
||||||
|
"color=c=red:s=32x32:d=1",
|
||||||
|
"-frames:v",
|
||||||
|
"1",
|
||||||
|
coverPath,
|
||||||
|
)
|
||||||
|
|
||||||
|
runFFmpegTestCommand(
|
||||||
|
t,
|
||||||
|
"-y",
|
||||||
|
"-i",
|
||||||
|
sourceFlac,
|
||||||
|
"-b:a",
|
||||||
|
"320k",
|
||||||
|
"-metadata",
|
||||||
|
"title=Test Song",
|
||||||
|
"-metadata",
|
||||||
|
"artist=Test Artist",
|
||||||
|
"-metadata",
|
||||||
|
"lyrics="+lyrics,
|
||||||
|
baseMp3,
|
||||||
|
)
|
||||||
|
|
||||||
|
runFFmpegTestCommand(
|
||||||
|
t,
|
||||||
|
"-y",
|
||||||
|
"-i",
|
||||||
|
baseMp3,
|
||||||
|
"-i",
|
||||||
|
coverPath,
|
||||||
|
"-map",
|
||||||
|
"0:a",
|
||||||
|
"-map_metadata",
|
||||||
|
"-1",
|
||||||
|
"-map",
|
||||||
|
"1:0",
|
||||||
|
"-c:v:0",
|
||||||
|
"copy",
|
||||||
|
"-id3v2_version",
|
||||||
|
"3",
|
||||||
|
"-metadata",
|
||||||
|
"title=Test Song",
|
||||||
|
"-metadata",
|
||||||
|
"artist=Test Artist",
|
||||||
|
"-metadata",
|
||||||
|
"lyrics="+lyrics,
|
||||||
|
"-metadata:s:v",
|
||||||
|
"title=Album cover",
|
||||||
|
"-metadata:s:v",
|
||||||
|
"comment=Cover (front)",
|
||||||
|
"-c:a",
|
||||||
|
"copy",
|
||||||
|
finalMp3,
|
||||||
|
)
|
||||||
|
|
||||||
|
meta, err := ReadID3Tags(finalMp3)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadID3Tags failed: %v", err)
|
||||||
|
}
|
||||||
|
if meta == nil {
|
||||||
|
t.Fatalf("ReadID3Tags returned nil metadata")
|
||||||
|
}
|
||||||
|
|
||||||
|
embeddedLyrics, err := ExtractLyrics(finalMp3)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ExtractLyrics failed: %v (metadata=%+v)", err, meta)
|
||||||
|
}
|
||||||
|
if !strings.Contains(embeddedLyrics, "Hello from embedded lyrics") {
|
||||||
|
t.Fatalf("embedded lyrics missing, got %q (metadata=%+v)", embeddedLyrics, meta)
|
||||||
|
}
|
||||||
|
if !strings.Contains(meta.Lyrics, "Hello from embedded lyrics") {
|
||||||
|
t.Fatalf("ReadID3Tags lyrics missing, got %+v", meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(finalMp3); err != nil {
|
||||||
|
t.Fatalf("expected final mp3 to exist: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
-4
@@ -17,6 +17,8 @@ const (
|
|||||||
// Deezer CDN supports these sizes: 56, 250, 500, 1000, 1400, 1800
|
// Deezer CDN supports these sizes: 56, 250, 500, 1000, 1400, 1800
|
||||||
var deezerSizeRegex = regexp.MustCompile(`/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$`)
|
var deezerSizeRegex = regexp.MustCompile(`/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$`)
|
||||||
|
|
||||||
|
var tidalSizeRegex = regexp.MustCompile(`/\d+x\d+\.jpg$`)
|
||||||
|
|
||||||
func convertSmallToMedium(imageURL string) string {
|
func convertSmallToMedium(imageURL string) string {
|
||||||
if strings.Contains(imageURL, spotifySize300) {
|
if strings.Contains(imageURL, spotifySize300) {
|
||||||
return strings.Replace(imageURL, spotifySize300, spotifySize640, 1)
|
return strings.Replace(imageURL, spotifySize300, spotifySize640, 1)
|
||||||
@@ -40,7 +42,6 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
|
|||||||
maxURL := upgradeToMaxQuality(downloadURL)
|
maxURL := upgradeToMaxQuality(downloadURL)
|
||||||
if maxURL != downloadURL {
|
if maxURL != downloadURL {
|
||||||
downloadURL = maxURL
|
downloadURL = maxURL
|
||||||
// Log already printed by upgradeToMaxQuality for Deezer
|
|
||||||
if strings.Contains(coverURL, "scdn.co") || strings.Contains(coverURL, "spotifycdn") {
|
if strings.Contains(coverURL, "scdn.co") || strings.Contains(coverURL, "spotifycdn") {
|
||||||
GoLog("[Cover] Spotify: upgraded to max resolution (~2000x2000)")
|
GoLog("[Cover] Spotify: upgraded to max resolution (~2000x2000)")
|
||||||
}
|
}
|
||||||
@@ -86,16 +87,22 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func upgradeToMaxQuality(coverURL string) string {
|
func upgradeToMaxQuality(coverURL string) string {
|
||||||
// Spotify CDN upgrade
|
|
||||||
if strings.Contains(coverURL, spotifySize640) {
|
if strings.Contains(coverURL, spotifySize640) {
|
||||||
return strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
|
return strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deezer CDN upgrade
|
|
||||||
if strings.Contains(coverURL, "cdn-images.dzcdn.net") {
|
if strings.Contains(coverURL, "cdn-images.dzcdn.net") {
|
||||||
return upgradeDeezerCover(coverURL)
|
return upgradeDeezerCover(coverURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if strings.Contains(coverURL, "resources.tidal.com") {
|
||||||
|
return upgradeTidalCover(coverURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(coverURL, "static.qobuz.com") {
|
||||||
|
return upgradeQobuzCover(coverURL)
|
||||||
|
}
|
||||||
|
|
||||||
return coverURL
|
return coverURL
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,12 +118,35 @@ func upgradeDeezerCover(coverURL string) string {
|
|||||||
return upgraded
|
return upgraded
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func upgradeTidalCover(coverURL string) string {
|
||||||
|
if !strings.Contains(coverURL, "resources.tidal.com") {
|
||||||
|
return coverURL
|
||||||
|
}
|
||||||
|
|
||||||
|
upgraded := tidalSizeRegex.ReplaceAllString(coverURL, "/origin.jpg")
|
||||||
|
if upgraded != coverURL {
|
||||||
|
GoLog("[Cover] Tidal: upgraded to origin resolution")
|
||||||
|
}
|
||||||
|
return upgraded
|
||||||
|
}
|
||||||
|
|
||||||
|
func upgradeQobuzCover(coverURL string) string {
|
||||||
|
if !strings.Contains(coverURL, "static.qobuz.com") {
|
||||||
|
return coverURL
|
||||||
|
}
|
||||||
|
|
||||||
|
upgraded := qobuzImageSizeRe.ReplaceAllString(coverURL, "_max.jpg")
|
||||||
|
if upgraded != coverURL {
|
||||||
|
GoLog("[Cover] Qobuz: upgraded to max resolution")
|
||||||
|
}
|
||||||
|
return upgraded
|
||||||
|
}
|
||||||
|
|
||||||
func GetCoverFromSpotify(imageURL string, maxQuality bool) string {
|
func GetCoverFromSpotify(imageURL string, maxQuality bool) string {
|
||||||
if imageURL == "" {
|
if imageURL == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always upgrade small to medium first
|
|
||||||
result := convertSmallToMedium(imageURL)
|
result := convertSmallToMedium(imageURL)
|
||||||
|
|
||||||
if maxQuality {
|
if maxQuality {
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import (
|
|||||||
|
|
||||||
// CueSheet represents a parsed .cue file
|
// CueSheet represents a parsed .cue file
|
||||||
type CueSheet struct {
|
type CueSheet struct {
|
||||||
// Album-level metadata
|
|
||||||
Performer string `json:"performer"`
|
Performer string `json:"performer"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
FileName string `json:"file_name"`
|
FileName string `json:"file_name"`
|
||||||
@@ -32,7 +31,6 @@ type CueTrack struct {
|
|||||||
Performer string `json:"performer"`
|
Performer string `json:"performer"`
|
||||||
ISRC string `json:"isrc,omitempty"`
|
ISRC string `json:"isrc,omitempty"`
|
||||||
Composer string `json:"composer,omitempty"`
|
Composer string `json:"composer,omitempty"`
|
||||||
// Index positions in seconds (fractional)
|
|
||||||
StartTime float64 `json:"start_time"` // INDEX 01 in seconds
|
StartTime float64 `json:"start_time"` // INDEX 01 in seconds
|
||||||
PreGap float64 `json:"pre_gap"` // INDEX 00 in seconds (or -1 if not present)
|
PreGap float64 `json:"pre_gap"` // INDEX 00 in seconds (or -1 if not present)
|
||||||
}
|
}
|
||||||
@@ -82,7 +80,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle BOM at start of file
|
|
||||||
if strings.HasPrefix(line, "\xef\xbb\xbf") {
|
if strings.HasPrefix(line, "\xef\xbb\xbf") {
|
||||||
line = strings.TrimPrefix(line, "\xef\xbb\xbf")
|
line = strings.TrimPrefix(line, "\xef\xbb\xbf")
|
||||||
line = strings.TrimSpace(line)
|
line = strings.TrimSpace(line)
|
||||||
@@ -90,7 +87,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
|
|||||||
|
|
||||||
upper := strings.ToUpper(line)
|
upper := strings.ToUpper(line)
|
||||||
|
|
||||||
// REM commands (album-level metadata)
|
|
||||||
if strings.HasPrefix(upper, "REM ") {
|
if strings.HasPrefix(upper, "REM ") {
|
||||||
matches := reRemCommand.FindStringSubmatch(line)
|
matches := reRemCommand.FindStringSubmatch(line)
|
||||||
if len(matches) == 3 {
|
if len(matches) == 3 {
|
||||||
@@ -136,9 +132,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
|
|||||||
|
|
||||||
if strings.HasPrefix(upper, "FILE ") {
|
if strings.HasPrefix(upper, "FILE ") {
|
||||||
rest := line[len("FILE "):]
|
rest := line[len("FILE "):]
|
||||||
// Extract filename and type
|
|
||||||
// Format: FILE "filename.flac" WAVE
|
|
||||||
// or: FILE filename.flac WAVE
|
|
||||||
fname, ftype := parseCueFileLine(rest)
|
fname, ftype := parseCueFileLine(rest)
|
||||||
sheet.FileName = fname
|
sheet.FileName = fname
|
||||||
sheet.FileType = ftype
|
sheet.FileType = ftype
|
||||||
@@ -146,7 +139,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if strings.HasPrefix(upper, "TRACK ") {
|
if strings.HasPrefix(upper, "TRACK ") {
|
||||||
// Save previous track
|
|
||||||
if currentTrack != nil {
|
if currentTrack != nil {
|
||||||
sheet.Tracks = append(sheet.Tracks, *currentTrack)
|
sheet.Tracks = append(sheet.Tracks, *currentTrack)
|
||||||
}
|
}
|
||||||
@@ -184,7 +176,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// SONGWRITER (used as composer sometimes)
|
|
||||||
if strings.HasPrefix(upper, "SONGWRITER ") {
|
if strings.HasPrefix(upper, "SONGWRITER ") {
|
||||||
value := unquoteCue(line[len("SONGWRITER "):])
|
value := unquoteCue(line[len("SONGWRITER "):])
|
||||||
if currentTrack != nil {
|
if currentTrack != nil {
|
||||||
@@ -196,7 +187,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't forget the last track
|
|
||||||
if currentTrack != nil {
|
if currentTrack != nil {
|
||||||
sheet.Tracks = append(sheet.Tracks, *currentTrack)
|
sheet.Tracks = append(sheet.Tracks, *currentTrack)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1181,7 +1181,7 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa
|
|||||||
|
|
||||||
for attempt := 0; attempt <= deezerMaxRetries; attempt++ {
|
for attempt := 0; attempt <= deezerMaxRetries; attempt++ {
|
||||||
if attempt > 0 {
|
if attempt > 0 {
|
||||||
delay := deezerRetryDelay * time.Duration(1<<(attempt-1)) // Exponential backoff
|
delay := deezerRetryDelay * time.Duration(1<<(attempt-1))
|
||||||
GoLog("[Deezer] Retry %d/%d after %v...\n", attempt, deezerMaxRetries, delay)
|
GoLog("[Deezer] Retry %d/%d after %v...\n", attempt, deezerMaxRetries, delay)
|
||||||
time.Sleep(delay)
|
time.Sleep(delay)
|
||||||
}
|
}
|
||||||
@@ -1194,7 +1194,6 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa
|
|||||||
lastErr = err
|
lastErr = err
|
||||||
errStr := err.Error()
|
errStr := err.Error()
|
||||||
|
|
||||||
// Check if error is retryable
|
|
||||||
isRetryable := strings.Contains(errStr, "timeout") ||
|
isRetryable := strings.Contains(errStr, "timeout") ||
|
||||||
strings.Contains(errStr, "connection reset") ||
|
strings.Contains(errStr, "connection reset") ||
|
||||||
strings.Contains(errStr, "connection refused") ||
|
strings.Contains(errStr, "connection refused") ||
|
||||||
|
|||||||
@@ -319,7 +319,6 @@ func (c *DeezerClient) GetMusicDLDownloadURL(deezerTrackURL string) (string, err
|
|||||||
return "", fmt.Errorf("MusicDL error: %s", errMsg)
|
return "", fmt.Errorf("MusicDL error: %s", errMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try various response fields for download URL
|
|
||||||
for _, key := range []string{"download_url", "url", "link"} {
|
for _, key := range []string{"download_url", "url", "link"} {
|
||||||
if urlVal, ok := raw[key].(string); ok && strings.TrimSpace(urlVal) != "" {
|
if urlVal, ok := raw[key].(string); ok && strings.TrimSpace(urlVal) != "" {
|
||||||
return strings.TrimSpace(urlVal), nil
|
return strings.TrimSpace(urlVal), nil
|
||||||
|
|||||||
+58
-126
@@ -48,7 +48,6 @@ func CheckAvailability(spotifyID, isrc string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SetSongLinkNetworkOptions is kept for backward compatibility.
|
// SetSongLinkNetworkOptions is kept for backward compatibility.
|
||||||
// It now applies global network compatibility options for all backend API requests.
|
|
||||||
func SetSongLinkNetworkOptions(allowHTTP, insecureTLS bool) {
|
func SetSongLinkNetworkOptions(allowHTTP, insecureTLS bool) {
|
||||||
SetNetworkCompatibilityOptions(allowHTTP, insecureTLS)
|
SetNetworkCompatibilityOptions(allowHTTP, insecureTLS)
|
||||||
}
|
}
|
||||||
@@ -407,24 +406,6 @@ func DownloadTrack(requestJSON string) (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
err = deezerErr
|
err = deezerErr
|
||||||
case "youtube":
|
|
||||||
youtubeResult, youtubeErr := downloadFromYouTube(req)
|
|
||||||
if youtubeErr == nil {
|
|
||||||
result = DownloadResult{
|
|
||||||
FilePath: youtubeResult.FilePath,
|
|
||||||
BitDepth: 0,
|
|
||||||
SampleRate: 0,
|
|
||||||
Title: youtubeResult.Title,
|
|
||||||
Artist: youtubeResult.Artist,
|
|
||||||
Album: youtubeResult.Album,
|
|
||||||
ReleaseDate: youtubeResult.ReleaseDate,
|
|
||||||
TrackNumber: youtubeResult.TrackNumber,
|
|
||||||
DiscNumber: youtubeResult.DiscNumber,
|
|
||||||
ISRC: youtubeResult.ISRC,
|
|
||||||
LyricsLRC: youtubeResult.LyricsLRC,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
err = youtubeErr
|
|
||||||
default:
|
default:
|
||||||
return errorResponse("Unknown service: " + req.Service)
|
return errorResponse("Unknown service: " + req.Service)
|
||||||
}
|
}
|
||||||
@@ -476,7 +457,7 @@ func DownloadByStrategy(requestJSON string) (string, error) {
|
|||||||
serviceNormalized := strings.ToLower(serviceRaw)
|
serviceNormalized := strings.ToLower(serviceRaw)
|
||||||
|
|
||||||
normalizedReq := req
|
normalizedReq := req
|
||||||
if serviceNormalized == "youtube" || isBuiltInProvider(serviceNormalized) {
|
if isBuiltInProvider(serviceNormalized) {
|
||||||
normalizedReq.Service = serviceNormalized
|
normalizedReq.Service = serviceNormalized
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -486,10 +467,6 @@ func DownloadByStrategy(requestJSON string) (string, error) {
|
|||||||
}
|
}
|
||||||
normalizedJSON := string(normalizedBytes)
|
normalizedJSON := string(normalizedBytes)
|
||||||
|
|
||||||
if serviceNormalized == "youtube" {
|
|
||||||
return DownloadFromYouTube(normalizedJSON)
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.UseExtensions {
|
if req.UseExtensions {
|
||||||
// Respect strict mode when auto fallback is disabled:
|
// Respect strict mode when auto fallback is disabled:
|
||||||
// for built-in providers, route directly to selected service only.
|
// for built-in providers, route directly to selected service only.
|
||||||
@@ -721,29 +698,57 @@ func ReadFileMetadata(filePath string) (string, error) {
|
|||||||
if isFlac {
|
if isFlac {
|
||||||
metadata, err := ReadMetadata(filePath)
|
metadata, err := ReadMetadata(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to read metadata: %w", err)
|
// File may have wrong extension (e.g. opus saved as .flac).
|
||||||
}
|
// Try Ogg/Opus parser as fallback before giving up.
|
||||||
result["title"] = metadata.Title
|
GoLog("[ReadFileMetadata] FLAC parse failed for %s, trying Ogg fallback: %v\n", filePath, err)
|
||||||
result["artist"] = metadata.Artist
|
oggMeta, oggErr := ReadOggVorbisComments(filePath)
|
||||||
result["album"] = metadata.Album
|
if oggErr == nil && oggMeta != nil {
|
||||||
result["album_artist"] = metadata.AlbumArtist
|
result["title"] = oggMeta.Title
|
||||||
result["date"] = metadata.Date
|
result["artist"] = oggMeta.Artist
|
||||||
result["track_number"] = metadata.TrackNumber
|
result["album"] = oggMeta.Album
|
||||||
result["disc_number"] = metadata.DiscNumber
|
result["album_artist"] = oggMeta.AlbumArtist
|
||||||
result["isrc"] = metadata.ISRC
|
result["date"] = oggMeta.Date
|
||||||
result["lyrics"] = metadata.Lyrics
|
if oggMeta.Date == "" {
|
||||||
result["genre"] = metadata.Genre
|
result["date"] = oggMeta.Year
|
||||||
result["label"] = metadata.Label
|
}
|
||||||
result["copyright"] = metadata.Copyright
|
result["track_number"] = oggMeta.TrackNumber
|
||||||
result["composer"] = metadata.Composer
|
result["disc_number"] = oggMeta.DiscNumber
|
||||||
result["comment"] = metadata.Comment
|
result["isrc"] = oggMeta.ISRC
|
||||||
|
result["lyrics"] = oggMeta.Lyrics
|
||||||
|
result["genre"] = oggMeta.Genre
|
||||||
|
result["composer"] = oggMeta.Composer
|
||||||
|
result["comment"] = oggMeta.Comment
|
||||||
|
quality, qualityErr := GetOggQuality(filePath)
|
||||||
|
if qualityErr == nil {
|
||||||
|
result["sample_rate"] = quality.SampleRate
|
||||||
|
result["duration"] = quality.Duration
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return "", fmt.Errorf("failed to read metadata: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result["title"] = metadata.Title
|
||||||
|
result["artist"] = metadata.Artist
|
||||||
|
result["album"] = metadata.Album
|
||||||
|
result["album_artist"] = metadata.AlbumArtist
|
||||||
|
result["date"] = metadata.Date
|
||||||
|
result["track_number"] = metadata.TrackNumber
|
||||||
|
result["disc_number"] = metadata.DiscNumber
|
||||||
|
result["isrc"] = metadata.ISRC
|
||||||
|
result["lyrics"] = metadata.Lyrics
|
||||||
|
result["genre"] = metadata.Genre
|
||||||
|
result["label"] = metadata.Label
|
||||||
|
result["copyright"] = metadata.Copyright
|
||||||
|
result["composer"] = metadata.Composer
|
||||||
|
result["comment"] = metadata.Comment
|
||||||
|
|
||||||
quality, qualityErr := GetAudioQuality(filePath)
|
quality, qualityErr := GetAudioQuality(filePath)
|
||||||
if qualityErr == nil {
|
if qualityErr == nil {
|
||||||
result["bit_depth"] = quality.BitDepth
|
result["bit_depth"] = quality.BitDepth
|
||||||
result["sample_rate"] = quality.SampleRate
|
result["sample_rate"] = quality.SampleRate
|
||||||
if quality.SampleRate > 0 && quality.TotalSamples > 0 {
|
if quality.SampleRate > 0 && quality.TotalSamples > 0 {
|
||||||
result["duration"] = int(quality.TotalSamples / int64(quality.SampleRate))
|
result["duration"] = int(quality.TotalSamples / int64(quality.SampleRate))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if isM4A {
|
} else if isM4A {
|
||||||
@@ -910,7 +915,6 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MP3/Opus: return metadata for Dart-side FFmpeg embedding
|
|
||||||
resp := map[string]any{
|
resp := map[string]any{
|
||||||
"success": true,
|
"success": true,
|
||||||
"method": "ffmpeg",
|
"method": "ffmpeg",
|
||||||
@@ -1670,62 +1674,6 @@ func errorResponse(msg string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func DownloadFromYouTube(requestJSON string) (string, error) {
|
|
||||||
var req DownloadRequest
|
|
||||||
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
|
||||||
return errorResponse("Invalid request: " + err.Error())
|
|
||||||
}
|
|
||||||
applySongLinkRegionFromRequest(&req)
|
|
||||||
defer closeOwnedOutputFD(req.OutputFD)
|
|
||||||
|
|
||||||
req.TrackName = strings.TrimSpace(req.TrackName)
|
|
||||||
req.ArtistName = strings.TrimSpace(req.ArtistName)
|
|
||||||
req.AlbumName = strings.TrimSpace(req.AlbumName)
|
|
||||||
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
|
|
||||||
req.OutputDir = strings.TrimSpace(req.OutputDir)
|
|
||||||
req.OutputPath = strings.TrimSpace(req.OutputPath)
|
|
||||||
req.OutputExt = strings.TrimSpace(req.OutputExt)
|
|
||||||
|
|
||||||
if req.OutputPath == "" && req.OutputFD <= 0 && req.OutputDir != "" {
|
|
||||||
AddAllowedDownloadDir(req.OutputDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
youtubeResult, err := downloadFromYouTube(req)
|
|
||||||
if err != nil {
|
|
||||||
return errorResponse(err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
resp := DownloadResponse{
|
|
||||||
Success: true,
|
|
||||||
Message: "Downloaded from YouTube",
|
|
||||||
FilePath: youtubeResult.FilePath,
|
|
||||||
Service: "youtube",
|
|
||||||
Title: youtubeResult.Title,
|
|
||||||
Artist: youtubeResult.Artist,
|
|
||||||
Album: youtubeResult.Album,
|
|
||||||
ReleaseDate: youtubeResult.ReleaseDate,
|
|
||||||
TrackNumber: youtubeResult.TrackNumber,
|
|
||||||
DiscNumber: youtubeResult.DiscNumber,
|
|
||||||
ISRC: youtubeResult.ISRC,
|
|
||||||
LyricsLRC: youtubeResult.LyricsLRC,
|
|
||||||
CoverURL: req.CoverURL,
|
|
||||||
Genre: req.Genre,
|
|
||||||
Label: req.Label,
|
|
||||||
Copyright: req.Copyright,
|
|
||||||
}
|
|
||||||
|
|
||||||
jsonBytes, _ := json.Marshal(resp)
|
|
||||||
return string(jsonBytes), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func IsYouTubeURLExport(urlStr string) bool {
|
|
||||||
return IsYouTubeURL(urlStr)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ExtractYouTubeVideoIDExport(urlStr string) (string, error) {
|
|
||||||
return ExtractYouTubeVideoID(urlStr)
|
|
||||||
}
|
|
||||||
|
|
||||||
func DownloadCoverToFile(coverURL string, outputPath string, maxQuality bool) error {
|
func DownloadCoverToFile(coverURL string, outputPath string, maxQuality bool) error {
|
||||||
if coverURL == "" {
|
if coverURL == "" {
|
||||||
return fmt.Errorf("no cover URL provided")
|
return fmt.Errorf("no cover URL provided")
|
||||||
@@ -1958,7 +1906,6 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log metadata summary before embedding
|
|
||||||
GoLog("[ReEnrich] Metadata to embed: title=%s, artist=%s, album=%s, albumArtist=%s\n",
|
GoLog("[ReEnrich] Metadata to embed: title=%s, artist=%s, album=%s, albumArtist=%s\n",
|
||||||
req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist)
|
req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist)
|
||||||
GoLog("[ReEnrich] track=%d, disc=%d, date=%s, isrc=%s, genre=%s, label=%s\n",
|
GoLog("[ReEnrich] track=%d, disc=%d, date=%s, isrc=%s, genre=%s, label=%s\n",
|
||||||
@@ -2041,7 +1988,6 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build enriched metadata response for Dart (includes online search results)
|
|
||||||
enrichedMeta := map[string]interface{}{
|
enrichedMeta := map[string]interface{}{
|
||||||
"track_name": req.TrackName,
|
"track_name": req.TrackName,
|
||||||
"artist_name": req.ArtistName,
|
"artist_name": req.ArtistName,
|
||||||
@@ -2187,12 +2133,6 @@ func LoadExtensionFromPath(filePath string) (string, error) {
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
settingsStore := GetExtensionSettingsStore()
|
|
||||||
settings := settingsStore.GetAll(ext.ID)
|
|
||||||
if len(settings) > 0 {
|
|
||||||
manager.InitializeExtension(ext.ID, settings)
|
|
||||||
}
|
|
||||||
|
|
||||||
result := map[string]interface{}{
|
result := map[string]interface{}{
|
||||||
"id": ext.ID,
|
"id": ext.ID,
|
||||||
"name": ext.Manifest.Name,
|
"name": ext.Manifest.Name,
|
||||||
@@ -2226,12 +2166,6 @@ func UpgradeExtensionFromPath(filePath string) (string, error) {
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
settingsStore := GetExtensionSettingsStore()
|
|
||||||
settings := settingsStore.GetAll(ext.ID)
|
|
||||||
if len(settings) > 0 {
|
|
||||||
manager.InitializeExtension(ext.ID, settings)
|
|
||||||
}
|
|
||||||
|
|
||||||
result := map[string]interface{}{
|
result := map[string]interface{}{
|
||||||
"id": ext.ID,
|
"id": ext.ID,
|
||||||
"display_name": ext.Manifest.DisplayName,
|
"display_name": ext.Manifest.DisplayName,
|
||||||
@@ -3226,11 +3160,7 @@ func GetStoreExtensionsJSON(forceRefresh bool) (string, error) {
|
|||||||
return "", fmt.Errorf("extension store not initialized")
|
return "", fmt.Errorf("extension store not initialized")
|
||||||
}
|
}
|
||||||
|
|
||||||
if forceRefresh {
|
extensions, err := store.getExtensionsWithStatus(forceRefresh)
|
||||||
store.FetchRegistry(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
extensions, err := store.GetExtensionsWithStatus()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -3324,12 +3254,14 @@ func callExtensionFunctionJSON(extensionID, functionName string, timeout time.Du
|
|||||||
if !ext.Enabled {
|
if !ext.Enabled {
|
||||||
return "", fmt.Errorf("extension '%s' is disabled", extensionID)
|
return "", fmt.Errorf("extension '%s' is disabled", extensionID)
|
||||||
}
|
}
|
||||||
|
vm, err := ext.lockReadyVM()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer ext.VMMu.Unlock()
|
||||||
|
|
||||||
// Goja runtime is not thread-safe; guard direct extension.*() calls with VMMu
|
// Goja runtime is not thread-safe; guard direct extension.*() calls with VMMu
|
||||||
// to avoid races with other provider calls (e.g. getAlbum/getPlaylist).
|
// to avoid races with other provider calls (e.g. getAlbum/getPlaylist).
|
||||||
ext.VMMu.Lock()
|
|
||||||
defer ext.VMMu.Unlock()
|
|
||||||
|
|
||||||
script := fmt.Sprintf(`
|
script := fmt.Sprintf(`
|
||||||
(function() {
|
(function() {
|
||||||
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
|
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
|
||||||
@@ -3339,7 +3271,7 @@ func callExtensionFunctionJSON(extensionID, functionName string, timeout time.Du
|
|||||||
})()
|
})()
|
||||||
`, functionName, functionName)
|
`, functionName, functionName)
|
||||||
|
|
||||||
result, err := RunWithTimeoutAndRecover(ext.VM, script, timeout)
|
result, err := RunWithTimeoutAndRecover(vm, script, timeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("%s failed: %w", functionName, err)
|
return "", fmt.Errorf("%s failed: %w", functionName, err)
|
||||||
}
|
}
|
||||||
|
|||||||
+241
-114
@@ -44,16 +44,76 @@ func compareVersions(v1, v2 string) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type LoadedExtension struct {
|
type LoadedExtension struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Manifest *ExtensionManifest `json:"manifest"`
|
Manifest *ExtensionManifest `json:"manifest"`
|
||||||
VM *goja.Runtime `json:"-"`
|
VM *goja.Runtime `json:"-"`
|
||||||
VMMu sync.Mutex `json:"-"`
|
VMMu sync.Mutex `json:"-"`
|
||||||
runtime *ExtensionRuntime
|
runtime *ExtensionRuntime
|
||||||
Enabled bool `json:"enabled"`
|
initialized bool
|
||||||
Error string `json:"error,omitempty"`
|
Enabled bool `json:"enabled"`
|
||||||
DataDir string `json:"data_dir"`
|
Error string `json:"error,omitempty"`
|
||||||
SourceDir string `json:"source_dir"`
|
DataDir string `json:"data_dir"`
|
||||||
IconPath string `json:"icon_path"`
|
SourceDir string `json:"source_dir"`
|
||||||
|
IconPath string `json:"icon_path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func getExtensionInitSettings(extensionID string) map[string]interface{} {
|
||||||
|
settings := GetExtensionSettingsStore().GetAll(extensionID)
|
||||||
|
if len(settings) == 0 {
|
||||||
|
return settings
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered := make(map[string]interface{}, len(settings))
|
||||||
|
for key, value := range settings {
|
||||||
|
if strings.HasPrefix(key, "_") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered[key] = value
|
||||||
|
}
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureRuntimeReadyLocked(ext *LoadedExtension, applyStoredSettings bool) error {
|
||||||
|
if ext.VM == nil || ext.runtime == nil {
|
||||||
|
if err := initializeVMLocked(ext); err != nil {
|
||||||
|
ext.Error = err.Error()
|
||||||
|
ext.Enabled = false
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if applyStoredSettings && !ext.initialized {
|
||||||
|
settings := getExtensionInitSettings(ext.ID)
|
||||||
|
if len(settings) > 0 {
|
||||||
|
if err := initializeExtensionWithSettingsLocked(ext, settings); err != nil {
|
||||||
|
teardownVMLocked(ext)
|
||||||
|
ext.Error = err.Error()
|
||||||
|
ext.Enabled = false
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ext.initialized = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ext.Error = ""
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ext *LoadedExtension) ensureRuntimeReady() error {
|
||||||
|
ext.VMMu.Lock()
|
||||||
|
defer ext.VMMu.Unlock()
|
||||||
|
|
||||||
|
return ensureRuntimeReadyLocked(ext, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ext *LoadedExtension) lockReadyVM() (*goja.Runtime, error) {
|
||||||
|
ext.VMMu.Lock()
|
||||||
|
if err := ensureRuntimeReadyLocked(ext, true); err != nil {
|
||||||
|
ext.VMMu.Unlock()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ext.VM, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExtensionManager struct {
|
type ExtensionManager struct {
|
||||||
@@ -220,10 +280,10 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
|||||||
SourceDir: extDir,
|
SourceDir: extDir,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := m.initializeVM(ext); err != nil {
|
if err := validateExtensionLoad(ext); err != nil {
|
||||||
ext.Error = err.Error()
|
ext.Error = err.Error()
|
||||||
ext.Enabled = false
|
ext.Enabled = false
|
||||||
GoLog("[Extension] Failed to initialize VM for %s: %v\n", manifest.Name, err)
|
GoLog("[Extension] Failed to validate extension %s: %v\n", manifest.Name, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
m.extensions[manifest.Name] = ext
|
m.extensions[manifest.Name] = ext
|
||||||
@@ -232,7 +292,10 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
|||||||
return ext, nil
|
return ext, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
|
func initializeVMLocked(ext *LoadedExtension) error {
|
||||||
|
ext.VM = nil
|
||||||
|
ext.runtime = nil
|
||||||
|
ext.initialized = false
|
||||||
vm := goja.New()
|
vm := goja.New()
|
||||||
ext.VM = vm
|
ext.VM = vm
|
||||||
|
|
||||||
@@ -279,6 +342,136 @@ func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
|
||||||
|
ext.VMMu.Lock()
|
||||||
|
defer ext.VMMu.Unlock()
|
||||||
|
return initializeVMLocked(ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
func initializeExtensionWithSettingsLocked(
|
||||||
|
ext *LoadedExtension,
|
||||||
|
settings map[string]interface{},
|
||||||
|
) error {
|
||||||
|
if ext.VM == nil {
|
||||||
|
return fmt.Errorf("Extension failed to load. Please reinstall the extension")
|
||||||
|
}
|
||||||
|
|
||||||
|
settingsJSON, err := json.Marshal(settings)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Failed to save settings")
|
||||||
|
}
|
||||||
|
|
||||||
|
script := fmt.Sprintf(`
|
||||||
|
(function() {
|
||||||
|
var settings = %s;
|
||||||
|
if (typeof extension !== 'undefined' && typeof extension.initialize === 'function') {
|
||||||
|
try {
|
||||||
|
extension.initialize(settings);
|
||||||
|
return { success: true };
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: e.toString() };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { success: true, message: 'no initialize function' };
|
||||||
|
})()
|
||||||
|
`, string(settingsJSON))
|
||||||
|
|
||||||
|
result, err := ext.VM.RunString(script)
|
||||||
|
if err != nil {
|
||||||
|
ext.Error = fmt.Sprintf("initialize failed: %v", err)
|
||||||
|
ext.Enabled = false
|
||||||
|
GoLog("[Extension] Initialize error for %s: %v\n", ext.ID, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if result != nil && !goja.IsUndefined(result) {
|
||||||
|
exported := result.Export()
|
||||||
|
if resultMap, ok := exported.(map[string]interface{}); ok {
|
||||||
|
if success, ok := resultMap["success"].(bool); ok && !success {
|
||||||
|
errMsg := "unknown error"
|
||||||
|
if e, ok := resultMap["error"].(string); ok {
|
||||||
|
errMsg = e
|
||||||
|
}
|
||||||
|
ext.Error = errMsg
|
||||||
|
ext.Enabled = false
|
||||||
|
GoLog("[Extension] Initialize failed for %s: %s\n", ext.ID, errMsg)
|
||||||
|
return fmt.Errorf("initialize failed: %s", errMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ext.initialized = true
|
||||||
|
GoLog("[Extension] Initialized %s\n", ext.ID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runCleanupLocked(ext *LoadedExtension) error {
|
||||||
|
if ext.VM != nil {
|
||||||
|
script := `
|
||||||
|
(function() {
|
||||||
|
if (typeof extension !== 'undefined' && typeof extension.cleanup === 'function') {
|
||||||
|
try {
|
||||||
|
extension.cleanup();
|
||||||
|
return { success: true };
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: e.toString() };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { success: true, message: 'no cleanup function' };
|
||||||
|
})()
|
||||||
|
`
|
||||||
|
|
||||||
|
result, err := ext.VM.RunString(script)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if result != nil && !goja.IsUndefined(result) {
|
||||||
|
exported := result.Export()
|
||||||
|
if resultMap, ok := exported.(map[string]interface{}); ok {
|
||||||
|
if success, ok := resultMap["success"].(bool); ok && !success {
|
||||||
|
errMsg := "unknown error"
|
||||||
|
if e, ok := resultMap["error"].(string); ok {
|
||||||
|
errMsg = e
|
||||||
|
}
|
||||||
|
return fmt.Errorf("cleanup failed: %s", errMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if result != nil && !goja.IsUndefined(result) && !goja.IsNull(result) {
|
||||||
|
GoLog("[Extension] Cleanup called for %s\n", ext.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func teardownVMLocked(ext *LoadedExtension) {
|
||||||
|
if err := runCleanupLocked(ext); err != nil {
|
||||||
|
GoLog("[Extension] Error calling cleanup for %s: %v\n", ext.ID, err)
|
||||||
|
}
|
||||||
|
if ext.runtime != nil {
|
||||||
|
if err := ext.runtime.flushStorageNow(); err != nil {
|
||||||
|
GoLog("[Extension] Failed to flush storage for %s: %v\n", ext.ID, err)
|
||||||
|
}
|
||||||
|
ext.runtime.closeStorageFlusher()
|
||||||
|
}
|
||||||
|
ext.runtime = nil
|
||||||
|
ext.VM = nil
|
||||||
|
ext.initialized = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateExtensionLoad(ext *LoadedExtension) error {
|
||||||
|
ext.VMMu.Lock()
|
||||||
|
defer ext.VMMu.Unlock()
|
||||||
|
|
||||||
|
if err := initializeVMLocked(ext); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
teardownVMLocked(ext)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) UnloadExtension(extensionID string) error {
|
func (m *ExtensionManager) UnloadExtension(extensionID string) error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
@@ -288,21 +481,9 @@ func (m *ExtensionManager) UnloadExtension(extensionID string) error {
|
|||||||
return fmt.Errorf("Extension not found")
|
return fmt.Errorf("Extension not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
if ext.VM != nil {
|
ext.VMMu.Lock()
|
||||||
cleanup, err := ext.VM.RunString("typeof extension !== 'undefined' && typeof extension.cleanup === 'function' ? extension.cleanup() : null")
|
teardownVMLocked(ext)
|
||||||
if err != nil {
|
ext.VMMu.Unlock()
|
||||||
GoLog("[Extension] Error calling cleanup for %s: %v\n", extensionID, err)
|
|
||||||
} else if cleanup != nil && !goja.IsUndefined(cleanup) && !goja.IsNull(cleanup) {
|
|
||||||
GoLog("[Extension] Cleanup called for %s\n", extensionID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ext.runtime != nil {
|
|
||||||
if err := ext.runtime.flushStorageNow(); err != nil {
|
|
||||||
GoLog("[Extension] Failed to flush storage for %s: %v\n", extensionID, err)
|
|
||||||
}
|
|
||||||
ext.runtime.closeStorageFlusher()
|
|
||||||
ext.runtime = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
delete(m.extensions, extensionID)
|
delete(m.extensions, extensionID)
|
||||||
GoLog("[Extension] Unloaded extension: %s\n", extensionID)
|
GoLog("[Extension] Unloaded extension: %s\n", extensionID)
|
||||||
@@ -341,7 +522,21 @@ func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool)
|
|||||||
return fmt.Errorf("Extension not found")
|
return fmt.Errorf("Extension not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
ext.Enabled = enabled
|
if enabled {
|
||||||
|
ext.Enabled = true
|
||||||
|
if err := ext.ensureRuntimeReady(); err != nil {
|
||||||
|
store := GetExtensionSettingsStore()
|
||||||
|
ext.Enabled = false
|
||||||
|
_ = store.Set(extensionID, "_enabled", false)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ext.Enabled = false
|
||||||
|
ext.Error = ""
|
||||||
|
ext.VMMu.Lock()
|
||||||
|
teardownVMLocked(ext)
|
||||||
|
ext.VMMu.Unlock()
|
||||||
|
}
|
||||||
GoLog("[Extension] %s %s\n", extensionID, map[bool]string{true: "enabled", false: "disabled"}[enabled])
|
GoLog("[Extension] %s %s\n", extensionID, map[bool]string{true: "enabled", false: "disabled"}[enabled])
|
||||||
|
|
||||||
store := GetExtensionSettingsStore()
|
store := GetExtensionSettingsStore()
|
||||||
@@ -436,10 +631,10 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := m.initializeVM(ext); err != nil {
|
if err := validateExtensionLoad(ext); err != nil {
|
||||||
ext.Error = err.Error()
|
ext.Error = err.Error()
|
||||||
ext.Enabled = false
|
ext.Enabled = false
|
||||||
GoLog("[Extension] Failed to initialize VM for %s: %v\n", manifest.Name, err)
|
GoLog("[Extension] Failed to validate extension %s: %v\n", manifest.Name, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
m.extensions[manifest.Name] = ext
|
m.extensions[manifest.Name] = ext
|
||||||
@@ -590,10 +785,14 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
|
|||||||
SourceDir: extDir,
|
SourceDir: extDir,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := m.initializeVM(ext); err != nil {
|
if wasEnabled {
|
||||||
|
if err := ext.ensureRuntimeReady(); err != nil {
|
||||||
|
GoLog("[Extension] Failed to initialize upgraded extension %s: %v\n", newManifest.Name, err)
|
||||||
|
}
|
||||||
|
} else if err := validateExtensionLoad(ext); err != nil {
|
||||||
ext.Error = err.Error()
|
ext.Error = err.Error()
|
||||||
ext.Enabled = false
|
ext.Enabled = false
|
||||||
GoLog("[Extension] Failed to initialize VM for %s: %v\n", newManifest.Name, err)
|
GoLog("[Extension] Failed to validate upgraded extension %s: %v\n", newManifest.Name, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
@@ -790,56 +989,13 @@ func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[
|
|||||||
return fmt.Errorf("Extension not found")
|
return fmt.Errorf("Extension not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
if ext.VM == nil {
|
ext.VMMu.Lock()
|
||||||
return fmt.Errorf("Extension failed to load. Please reinstall the extension")
|
defer ext.VMMu.Unlock()
|
||||||
}
|
|
||||||
|
|
||||||
settingsJSON, err := json.Marshal(settings)
|
if err := ensureRuntimeReadyLocked(ext, false); err != nil {
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("Failed to save settings")
|
|
||||||
}
|
|
||||||
|
|
||||||
script := fmt.Sprintf(`
|
|
||||||
(function() {
|
|
||||||
var settings = %s;
|
|
||||||
if (typeof extension !== 'undefined' && typeof extension.initialize === 'function') {
|
|
||||||
try {
|
|
||||||
extension.initialize(settings);
|
|
||||||
return { success: true };
|
|
||||||
} catch (e) {
|
|
||||||
return { success: false, error: e.toString() };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { success: true, message: 'no initialize function' };
|
|
||||||
})()
|
|
||||||
`, string(settingsJSON))
|
|
||||||
|
|
||||||
result, err := ext.VM.RunString(script)
|
|
||||||
if err != nil {
|
|
||||||
ext.Error = fmt.Sprintf("initialize failed: %v", err)
|
|
||||||
ext.Enabled = false
|
|
||||||
GoLog("[Extension] Initialize error for %s: %v\n", extensionID, err)
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
return initializeExtensionWithSettingsLocked(ext, settings)
|
||||||
if result != nil && !goja.IsUndefined(result) {
|
|
||||||
exported := result.Export()
|
|
||||||
if resultMap, ok := exported.(map[string]interface{}); ok {
|
|
||||||
if success, ok := resultMap["success"].(bool); ok && !success {
|
|
||||||
errMsg := "unknown error"
|
|
||||||
if e, ok := resultMap["error"].(string); ok {
|
|
||||||
errMsg = e
|
|
||||||
}
|
|
||||||
ext.Error = errMsg
|
|
||||||
ext.Enabled = false
|
|
||||||
GoLog("[Extension] Initialize failed for %s: %s\n", extensionID, errMsg)
|
|
||||||
return fmt.Errorf("initialize failed: %s", errMsg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
GoLog("[Extension] Initialized %s\n", extensionID)
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) CleanupExtension(extensionID string) error {
|
func (m *ExtensionManager) CleanupExtension(extensionID string) error {
|
||||||
@@ -854,41 +1010,12 @@ func (m *ExtensionManager) CleanupExtension(extensionID string) error {
|
|||||||
if ext.VM == nil {
|
if ext.VM == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
ext.VMMu.Lock()
|
||||||
script := `
|
defer ext.VMMu.Unlock()
|
||||||
(function() {
|
if err := runCleanupLocked(ext); err != nil {
|
||||||
if (typeof extension !== 'undefined' && typeof extension.cleanup === 'function') {
|
|
||||||
try {
|
|
||||||
extension.cleanup();
|
|
||||||
return { success: true };
|
|
||||||
} catch (e) {
|
|
||||||
return { success: false, error: e.toString() };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { success: true, message: 'no cleanup function' };
|
|
||||||
})()
|
|
||||||
`
|
|
||||||
|
|
||||||
result, err := ext.VM.RunString(script)
|
|
||||||
if err != nil {
|
|
||||||
GoLog("[Extension] Cleanup error for %s: %v\n", extensionID, err)
|
GoLog("[Extension] Cleanup error for %s: %v\n", extensionID, err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if result != nil && !goja.IsUndefined(result) {
|
|
||||||
exported := result.Export()
|
|
||||||
if resultMap, ok := exported.(map[string]interface{}); ok {
|
|
||||||
if success, ok := resultMap["success"].(bool); ok && !success {
|
|
||||||
errMsg := "unknown error"
|
|
||||||
if e, ok := resultMap["error"].(string); ok {
|
|
||||||
errMsg = e
|
|
||||||
}
|
|
||||||
GoLog("[Extension] Cleanup failed for %s: %s\n", extensionID, errMsg)
|
|
||||||
return fmt.Errorf("cleanup failed: %s", errMsg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
GoLog("[Extension] Cleaned up %s\n", extensionID)
|
GoLog("[Extension] Cleaned up %s\n", extensionID)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -917,8 +1044,8 @@ func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) (
|
|||||||
return nil, fmt.Errorf("extension not found: %s", extensionID)
|
return nil, fmt.Errorf("extension not found: %s", extensionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
if ext.VM == nil {
|
if err := ext.ensureRuntimeReady(); err != nil {
|
||||||
return nil, fmt.Errorf("extension VM not initialized")
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if !ext.Enabled {
|
if !ext.Enabled {
|
||||||
|
|||||||
@@ -125,6 +125,15 @@ func NewExtensionProviderWrapper(ext *LoadedExtension) *ExtensionProviderWrapper
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *ExtensionProviderWrapper) lockReadyVM() error {
|
||||||
|
vm, err := p.extension.lockReadyVM()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
p.vm = vm
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSearchResult, error) {
|
func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSearchResult, error) {
|
||||||
if !p.extension.Manifest.IsMetadataProvider() {
|
if !p.extension.Manifest.IsMetadataProvider() {
|
||||||
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
|
||||||
@@ -133,8 +142,9 @@ func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe
|
|||||||
if !p.extension.Enabled {
|
if !p.extension.Enabled {
|
||||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||||
}
|
}
|
||||||
|
if err := p.lockReadyVM(); err != nil {
|
||||||
p.extension.VMMu.Lock()
|
return nil, err
|
||||||
|
}
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
script := fmt.Sprintf(`
|
script := fmt.Sprintf(`
|
||||||
@@ -192,8 +202,9 @@ func (p *ExtensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata,
|
|||||||
if !p.extension.Enabled {
|
if !p.extension.Enabled {
|
||||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||||
}
|
}
|
||||||
|
if err := p.lockReadyVM(); err != nil {
|
||||||
p.extension.VMMu.Lock()
|
return nil, err
|
||||||
|
}
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
script := fmt.Sprintf(`
|
script := fmt.Sprintf(`
|
||||||
@@ -240,8 +251,9 @@ func (p *ExtensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata,
|
|||||||
if !p.extension.Enabled {
|
if !p.extension.Enabled {
|
||||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||||
}
|
}
|
||||||
|
if err := p.lockReadyVM(); err != nil {
|
||||||
p.extension.VMMu.Lock()
|
return nil, err
|
||||||
|
}
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
script := fmt.Sprintf(`
|
script := fmt.Sprintf(`
|
||||||
@@ -291,8 +303,9 @@ func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadat
|
|||||||
if !p.extension.Enabled {
|
if !p.extension.Enabled {
|
||||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||||
}
|
}
|
||||||
|
if err := p.lockReadyVM(); err != nil {
|
||||||
p.extension.VMMu.Lock()
|
return nil, err
|
||||||
|
}
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
script := fmt.Sprintf(`
|
script := fmt.Sprintf(`
|
||||||
@@ -345,8 +358,10 @@ func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTra
|
|||||||
if !p.extension.Enabled {
|
if !p.extension.Enabled {
|
||||||
return track, nil
|
return track, nil
|
||||||
}
|
}
|
||||||
|
if err := p.lockReadyVM(); err != nil {
|
||||||
p.extension.VMMu.Lock()
|
GoLog("[Extension] EnrichTrack init error for %s: %v\n", p.extension.ID, err)
|
||||||
|
return track, nil
|
||||||
|
}
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
trackJSON, err := json.Marshal(track)
|
trackJSON, err := json.Marshal(track)
|
||||||
@@ -405,8 +420,9 @@ func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName
|
|||||||
if !p.extension.Enabled {
|
if !p.extension.Enabled {
|
||||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||||
}
|
}
|
||||||
|
if err := p.lockReadyVM(); err != nil {
|
||||||
p.extension.VMMu.Lock()
|
return nil, err
|
||||||
|
}
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
script := fmt.Sprintf(`
|
script := fmt.Sprintf(`
|
||||||
@@ -452,8 +468,9 @@ func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*Ext
|
|||||||
if !p.extension.Enabled {
|
if !p.extension.Enabled {
|
||||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||||
}
|
}
|
||||||
|
if err := p.lockReadyVM(); err != nil {
|
||||||
p.extension.VMMu.Lock()
|
return nil, err
|
||||||
|
}
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
script := fmt.Sprintf(`
|
script := fmt.Sprintf(`
|
||||||
@@ -501,8 +518,13 @@ func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string,
|
|||||||
if !p.extension.Enabled {
|
if !p.extension.Enabled {
|
||||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||||
}
|
}
|
||||||
|
if err := p.lockReadyVM(); err != nil {
|
||||||
p.extension.VMMu.Lock()
|
return &ExtDownloadResult{
|
||||||
|
Success: false,
|
||||||
|
ErrorMessage: err.Error(),
|
||||||
|
ErrorType: "init_error",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
p.vm.Set("__onProgress", func(call goja.FunctionCall) goja.Value {
|
p.vm.Set("__onProgress", func(call goja.FunctionCall) goja.Value {
|
||||||
@@ -1626,8 +1648,9 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string
|
|||||||
if !p.extension.Enabled {
|
if !p.extension.Enabled {
|
||||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||||
}
|
}
|
||||||
|
if err := p.lockReadyVM(); err != nil {
|
||||||
p.extension.VMMu.Lock()
|
return nil, err
|
||||||
|
}
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
if options == nil {
|
if options == nil {
|
||||||
@@ -1707,8 +1730,9 @@ func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e
|
|||||||
if !p.extension.Enabled {
|
if !p.extension.Enabled {
|
||||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||||
}
|
}
|
||||||
|
if err := p.lockReadyVM(); err != nil {
|
||||||
p.extension.VMMu.Lock()
|
return nil, err
|
||||||
|
}
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
script := fmt.Sprintf(`
|
script := fmt.Sprintf(`
|
||||||
@@ -1792,8 +1816,9 @@ func (p *ExtensionProviderWrapper) MatchTrack(sourceTrack map[string]interface{}
|
|||||||
if !p.extension.Enabled {
|
if !p.extension.Enabled {
|
||||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||||
}
|
}
|
||||||
|
if err := p.lockReadyVM(); err != nil {
|
||||||
p.extension.VMMu.Lock()
|
return nil, err
|
||||||
|
}
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
sourceJSON, _ := json.Marshal(sourceTrack)
|
sourceJSON, _ := json.Marshal(sourceTrack)
|
||||||
@@ -1862,8 +1887,9 @@ func (p *ExtensionProviderWrapper) PostProcess(filePath string, metadata map[str
|
|||||||
if !p.extension.Enabled {
|
if !p.extension.Enabled {
|
||||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||||
}
|
}
|
||||||
|
if err := p.lockReadyVM(); err != nil {
|
||||||
p.extension.VMMu.Lock()
|
return &PostProcessResult{Success: false, Error: err.Error()}, nil
|
||||||
|
}
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
metadataJSON, _ := json.Marshal(metadata)
|
metadataJSON, _ := json.Marshal(metadata)
|
||||||
@@ -1924,8 +1950,9 @@ func (p *ExtensionProviderWrapper) PostProcessV2(input PostProcessInput, metadat
|
|||||||
if !p.extension.Enabled {
|
if !p.extension.Enabled {
|
||||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||||
}
|
}
|
||||||
|
if err := p.lockReadyVM(); err != nil {
|
||||||
p.extension.VMMu.Lock()
|
return &PostProcessResult{Success: false, Error: err.Error()}, nil
|
||||||
|
}
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
metadataJSON, _ := json.Marshal(metadata)
|
metadataJSON, _ := json.Marshal(metadata)
|
||||||
@@ -2182,8 +2209,9 @@ func (p *ExtensionProviderWrapper) FetchLyrics(trackName, artistName, albumName
|
|||||||
if !p.extension.Enabled {
|
if !p.extension.Enabled {
|
||||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||||
}
|
}
|
||||||
|
if err := p.lockReadyVM(); err != nil {
|
||||||
p.extension.VMMu.Lock()
|
return nil, err
|
||||||
|
}
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
// Use global variables to avoid JS injection issues with special characters in track/artist names
|
// Use global variables to avoid JS injection issues with special characters in track/artist names
|
||||||
|
|||||||
@@ -97,8 +97,8 @@ type StoreExtensionResponse struct {
|
|||||||
HasUpdate bool `json:"has_update"`
|
HasUpdate bool `json:"has_update"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *StoreExtension) ToResponse() StoreExtensionResponse {
|
func (e *StoreExtension) ToResponse() *StoreExtensionResponse {
|
||||||
return StoreExtensionResponse{
|
return &StoreExtensionResponse{
|
||||||
ID: e.ID,
|
ID: e.ID,
|
||||||
Name: e.Name,
|
Name: e.Name,
|
||||||
DisplayName: e.getDisplayName(),
|
DisplayName: e.getDisplayName(),
|
||||||
@@ -236,7 +236,6 @@ func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error
|
|||||||
s.cacheMu.Lock()
|
s.cacheMu.Lock()
|
||||||
defer s.cacheMu.Unlock()
|
defer s.cacheMu.Unlock()
|
||||||
|
|
||||||
// Check if a registry URL has been configured
|
|
||||||
if s.registryURL == "" {
|
if s.registryURL == "" {
|
||||||
return nil, fmt.Errorf("no registry URL configured. Please add a repository URL first")
|
return nil, fmt.Errorf("no registry URL configured. Please add a repository URL first")
|
||||||
}
|
}
|
||||||
@@ -289,8 +288,8 @@ func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error
|
|||||||
return ®istry, nil
|
return ®istry, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionResponse, error) {
|
func (s *ExtensionStore) getExtensionsWithStatus(forceRefresh bool) ([]*StoreExtensionResponse, error) {
|
||||||
registry, err := s.FetchRegistry(false)
|
registry, err := s.FetchRegistry(forceRefresh)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -304,22 +303,29 @@ func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionResponse, er
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
result := make([]StoreExtensionResponse, len(registry.Extensions))
|
LogDebug("ExtensionStore", "Building store response for %d registry extensions (%d installed)", len(registry.Extensions), len(installed))
|
||||||
for i, ext := range registry.Extensions {
|
|
||||||
resp := ext.ToResponse()
|
|
||||||
|
|
||||||
|
result := make([]*StoreExtensionResponse, 0, len(registry.Extensions))
|
||||||
|
for i := range registry.Extensions {
|
||||||
|
ext := ®istry.Extensions[i]
|
||||||
|
resp := ext.ToResponse()
|
||||||
if installedVersion, ok := installed[ext.ID]; ok {
|
if installedVersion, ok := installed[ext.ID]; ok {
|
||||||
resp.IsInstalled = true
|
resp.IsInstalled = true
|
||||||
resp.InstalledVersion = installedVersion
|
resp.InstalledVersion = installedVersion
|
||||||
resp.HasUpdate = compareVersions(ext.Version, installedVersion) > 0
|
resp.HasUpdate = compareVersions(ext.Version, installedVersion) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
result[i] = resp
|
result = append(result, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LogDebug("ExtensionStore", "Built store response payload for %d extensions", len(result))
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *ExtensionStore) GetExtensionsWithStatus() ([]*StoreExtensionResponse, error) {
|
||||||
|
return s.getExtensionsWithStatus(false)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string) error {
|
func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string) error {
|
||||||
registry, err := s.FetchRegistry(false)
|
registry, err := s.FetchRegistry(false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -389,7 +395,6 @@ func ResolveRegistryURL(input string) (string, error) {
|
|||||||
return input, nil
|
return input, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to match https://github.com/<owner>/<repo>[/...]
|
|
||||||
const ghPrefix = "https://github.com/"
|
const ghPrefix = "https://github.com/"
|
||||||
if !strings.HasPrefix(input, ghPrefix) {
|
if !strings.HasPrefix(input, ghPrefix) {
|
||||||
// Also accept http:// and upgrade silently.
|
// Also accept http:// and upgrade silently.
|
||||||
@@ -470,7 +475,7 @@ func (s *ExtensionStore) GetCategories() []string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ExtensionStore) SearchExtensions(query string, category string) ([]StoreExtensionResponse, error) {
|
func (s *ExtensionStore) SearchExtensions(query string, category string) ([]*StoreExtensionResponse, error) {
|
||||||
extensions, err := s.GetExtensionsWithStatus()
|
extensions, err := s.GetExtensionsWithStatus()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -480,7 +485,7 @@ func (s *ExtensionStore) SearchExtensions(query string, category string) ([]Stor
|
|||||||
return extensions, nil
|
return extensions, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var result []StoreExtensionResponse
|
result := make([]*StoreExtensionResponse, 0, len(extensions))
|
||||||
queryLower := toLower(query)
|
queryLower := toLower(query)
|
||||||
|
|
||||||
for _, ext := range extensions {
|
for _, ext := range extensions {
|
||||||
@@ -493,7 +498,6 @@ func (s *ExtensionStore) SearchExtensions(query string, category string) ([]Stor
|
|||||||
!containsIgnoreCase(ext.DisplayName, queryLower) &&
|
!containsIgnoreCase(ext.DisplayName, queryLower) &&
|
||||||
!containsIgnoreCase(ext.Description, queryLower) &&
|
!containsIgnoreCase(ext.Description, queryLower) &&
|
||||||
!containsIgnoreCase(ext.Author, queryLower) {
|
!containsIgnoreCase(ext.Author, queryLower) {
|
||||||
// Check tags
|
|
||||||
found := false
|
found := false
|
||||||
for _, tag := range ext.Tags {
|
for _, tag := range ext.Tags {
|
||||||
if containsIgnoreCase(tag, queryLower) {
|
if containsIgnoreCase(tag, queryLower) {
|
||||||
|
|||||||
+1
-1
@@ -12,6 +12,7 @@ require (
|
|||||||
github.com/refraction-networking/utls v1.8.2
|
github.com/refraction-networking/utls v1.8.2
|
||||||
golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864
|
golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864
|
||||||
golang.org/x/net v0.50.0
|
golang.org/x/net v0.50.0
|
||||||
|
golang.org/x/text v0.34.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -24,6 +25,5 @@ require (
|
|||||||
golang.org/x/mod v0.33.0 // indirect
|
golang.org/x/mod v0.33.0 // indirect
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.41.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
golang.org/x/text v0.34.0 // indirect
|
|
||||||
golang.org/x/tools v0.42.0 // indirect
|
golang.org/x/tools v0.42.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
|||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
|
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
|
||||||
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM=
|
|
||||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
|
||||||
github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5 h1:QckvTXtu55YMopmVeDrPQ/r+T6xjw8KMCmE3UgUldkw=
|
github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5 h1:QckvTXtu55YMopmVeDrPQ/r+T6xjw8KMCmE3UgUldkw=
|
||||||
github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
||||||
github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE=
|
github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE=
|
||||||
@@ -30,36 +28,20 @@ github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEv
|
|||||||
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
|
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
|
||||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
|
||||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3 h1:NiJtT7g4ncNFVjVZMAYNBrPSNhIjFYPj8UKA8MEw2A4=
|
|
||||||
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3/go.mod h1:wReH3Q1agKmmLapipWFnd4NSs8KPz3fK6mSEZjXLkrg=
|
|
||||||
golang.org/x/mobile v0.0.0-20260209203831-923679eb55af h1:VqXrZNyqFISxo0rNDFZQlRDRIp7RXSJDeh/LbrK+W1k=
|
|
||||||
golang.org/x/mobile v0.0.0-20260209203831-923679eb55af/go.mod h1:tbwefIr7RlQD1OpZ0KEZ9nux/uiihAOGdafgZfJkmII=
|
|
||||||
golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864 h1:cTVynMSsMYgbUrtia2HB1jrhdUwQNtQti91vUCyjMp4=
|
golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864 h1:cTVynMSsMYgbUrtia2HB1jrhdUwQNtQti91vUCyjMp4=
|
||||||
golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864/go.mod h1:4OGHIUSBiIqyFAQDaX1tpY0BVnO20DvNDeATBu8aeFQ=
|
golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864/go.mod h1:4OGHIUSBiIqyFAQDaX1tpY0BVnO20DvNDeATBu8aeFQ=
|
||||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
|
||||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
|
||||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
|
||||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
|
||||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
|
||||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
|
||||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
|
||||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
|
||||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
|
||||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
|
||||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||||
|
|||||||
@@ -300,14 +300,11 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for ISP blocking via HTTP status codes
|
|
||||||
// Some ISPs return 403 or 451 when blocking content
|
|
||||||
if resp.StatusCode == 403 || resp.StatusCode == 451 {
|
if resp.StatusCode == 403 || resp.StatusCode == 451 {
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, _ := io.ReadAll(resp.Body)
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
bodyStr := strings.ToLower(string(body))
|
bodyStr := strings.ToLower(string(body))
|
||||||
|
|
||||||
// Check if response looks like ISP blocking page
|
|
||||||
ispBlockingIndicators := []string{
|
ispBlockingIndicators := []string{
|
||||||
"blocked", "forbidden", "access denied", "not available in your",
|
"blocked", "forbidden", "access denied", "not available in your",
|
||||||
"restricted", "censored", "unavailable for legal", "blocked by",
|
"restricted", "censored", "unavailable for legal", "blocked by",
|
||||||
@@ -518,7 +515,6 @@ func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns true if ISP blocking was detected
|
|
||||||
func CheckAndLogISPBlocking(err error, requestURL string, tag string) bool {
|
func CheckAndLogISPBlocking(err error, requestURL string, tag string) bool {
|
||||||
ispErr := IsISPBlocking(err, requestURL)
|
ispErr := IsISPBlocking(err, requestURL)
|
||||||
if ispErr != nil {
|
if ispErr != nil {
|
||||||
@@ -553,7 +549,6 @@ func extractDomain(rawURL string) string {
|
|||||||
return "unknown"
|
return "unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
// If ISP blocking is detected, returns a more descriptive error
|
|
||||||
func WrapErrorWithISPCheck(err error, requestURL string, tag string) error {
|
func WrapErrorWithISPCheck(err error, requestURL string, tag string) error {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -112,7 +112,6 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
|
|||||||
|
|
||||||
resp, err := sharedClient.Do(req)
|
resp, err := sharedClient.Do(req)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
// Check for Cloudflare challenge page (403 with specific markers)
|
|
||||||
if resp.StatusCode == 403 || resp.StatusCode == 503 {
|
if resp.StatusCode == 403 || resp.StatusCode == 503 {
|
||||||
body, readErr := io.ReadAll(resp.Body)
|
body, readErr := io.ReadAll(resp.Body)
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
@@ -154,7 +153,6 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
|
|||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if error might be TLS-related (Cloudflare blocking)
|
|
||||||
errStr := strings.ToLower(err.Error())
|
errStr := strings.ToLower(err.Error())
|
||||||
tlsRelated := strings.Contains(errStr, "tls") ||
|
tlsRelated := strings.Contains(errStr, "tls") ||
|
||||||
strings.Contains(errStr, "handshake") ||
|
strings.Contains(errStr, "handshake") ||
|
||||||
|
|||||||
@@ -234,8 +234,6 @@ func ScanLibraryFolder(folderPath string) (string, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip audio files that are referenced by a .cue sheet
|
|
||||||
// (they will be represented by the cue sheet's track entries instead)
|
|
||||||
if cueReferencedAudioFiles[filePath] {
|
if cueReferencedAudioFiles[filePath] {
|
||||||
GoLog("[LibraryScan] Skipping %s (referenced by .cue sheet)\n", filepath.Base(filePath))
|
GoLog("[LibraryScan] Skipping %s (referenced by .cue sheet)\n", filepath.Base(filePath))
|
||||||
continue
|
continue
|
||||||
@@ -557,9 +555,6 @@ func ReadAudioMetadataWithDisplayName(filePath, displayNameHint string) (string,
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ScanLibraryFolderIncremental performs an incremental scan of the library folder
|
|
||||||
// existingFilesJSON is a JSON object mapping filePath -> modTime (unix millis)
|
|
||||||
// Only files that are new or have changed modification time will be scanned
|
|
||||||
func loadExistingFilesSnapshot(snapshotPath string) (map[string]int64, error) {
|
func loadExistingFilesSnapshot(snapshotPath string) (map[string]int64, error) {
|
||||||
existingFiles := make(map[string]int64)
|
existingFiles := make(map[string]int64)
|
||||||
if snapshotPath == "" {
|
if snapshotPath == "" {
|
||||||
@@ -637,7 +632,6 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
|
|||||||
libraryScanProgress.TotalFiles = totalFiles
|
libraryScanProgress.TotalFiles = totalFiles
|
||||||
libraryScanProgressMu.Unlock()
|
libraryScanProgressMu.Unlock()
|
||||||
|
|
||||||
// Find files to scan (new or modified)
|
|
||||||
var filesToScan []libraryAudioFileInfo
|
var filesToScan []libraryAudioFileInfo
|
||||||
skippedCount := 0
|
skippedCount := 0
|
||||||
existingCueTrackModTimes := make(map[string]int64)
|
existingCueTrackModTimes := make(map[string]int64)
|
||||||
@@ -653,10 +647,8 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
|
|||||||
for _, f := range currentFiles {
|
for _, f := range currentFiles {
|
||||||
existingModTime, exists := existingFiles[f.path]
|
existingModTime, exists := existingFiles[f.path]
|
||||||
if !exists {
|
if !exists {
|
||||||
// For .cue files, also check if any virtual path entries exist
|
|
||||||
if strings.ToLower(filepath.Ext(f.path)) == ".cue" {
|
if strings.ToLower(filepath.Ext(f.path)) == ".cue" {
|
||||||
if cueTrackModTime, hasCueTracks := existingCueTrackModTimes[f.path]; hasCueTracks {
|
if cueTrackModTime, hasCueTracks := existingCueTrackModTimes[f.path]; hasCueTracks {
|
||||||
// CUE file exists in DB via virtual paths; check if modTime changed
|
|
||||||
if f.modTime == cueTrackModTime {
|
if f.modTime == cueTrackModTime {
|
||||||
skippedCount++
|
skippedCount++
|
||||||
} else {
|
} else {
|
||||||
@@ -675,14 +667,11 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
|
|||||||
|
|
||||||
var deletedPaths []string
|
var deletedPaths []string
|
||||||
for existingPath := range existingFiles {
|
for existingPath := range existingFiles {
|
||||||
// For CUE virtual paths (e.g. "/path/album.cue#track01"),
|
|
||||||
// check if the base .cue file still exists on disk
|
|
||||||
if idx := strings.LastIndex(existingPath, "#track"); idx > 0 {
|
if idx := strings.LastIndex(existingPath, "#track"); idx > 0 {
|
||||||
baseCuePath := existingPath[:idx]
|
baseCuePath := existingPath[:idx]
|
||||||
if currentPathSet[baseCuePath] {
|
if currentPathSet[baseCuePath] {
|
||||||
continue // Base .cue file still exists, not deleted
|
continue
|
||||||
}
|
}
|
||||||
// Base CUE file is gone, mark virtual path as deleted
|
|
||||||
deletedPaths = append(deletedPaths, existingPath)
|
deletedPaths = append(deletedPaths, existingPath)
|
||||||
} else if !currentPathSet[existingPath] {
|
} else if !currentPathSet[existingPath] {
|
||||||
deletedPaths = append(deletedPaths, existingPath)
|
deletedPaths = append(deletedPaths, existingPath)
|
||||||
@@ -713,7 +702,6 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
|
|||||||
scanTime := time.Now().UTC().Format(time.RFC3339)
|
scanTime := time.Now().UTC().Format(time.RFC3339)
|
||||||
errorCount := 0
|
errorCount := 0
|
||||||
|
|
||||||
// Track audio files referenced by .cue sheets to avoid duplicates (incremental)
|
|
||||||
cueReferencedAudioFilesInc := make(map[string]bool)
|
cueReferencedAudioFilesInc := make(map[string]bool)
|
||||||
parsedCueFiles := make(map[string]scannedCueFileInfo)
|
parsedCueFiles := make(map[string]scannedCueFileInfo)
|
||||||
for _, f := range filesToScan {
|
for _, f := range filesToScan {
|
||||||
@@ -748,7 +736,6 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
|
|||||||
|
|
||||||
ext := strings.ToLower(filepath.Ext(f.path))
|
ext := strings.ToLower(filepath.Ext(f.path))
|
||||||
|
|
||||||
// Handle .cue files: produce multiple track results
|
|
||||||
if ext == ".cue" {
|
if ext == ".cue" {
|
||||||
var cueResults []LibraryScanResult
|
var cueResults []LibraryScanResult
|
||||||
cueInfo, ok := parsedCueFiles[f.path]
|
cueInfo, ok := parsedCueFiles[f.path]
|
||||||
@@ -773,7 +760,6 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip audio files referenced by .cue sheets
|
|
||||||
if cueReferencedAudioFilesInc[f.path] {
|
if cueReferencedAudioFilesInc[f.path] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,7 +83,6 @@ func SetLyricsProviderOrder(providers []string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate provider names
|
|
||||||
validNames := map[string]bool{
|
validNames := map[string]bool{
|
||||||
LyricsProviderSpotifyAPI: true,
|
LyricsProviderSpotifyAPI: true,
|
||||||
LyricsProviderLRCLIB: true,
|
LyricsProviderLRCLIB: true,
|
||||||
@@ -105,7 +104,6 @@ func SetLyricsProviderOrder(providers []string) {
|
|||||||
GoLog("[Lyrics] Provider order set to: %v\n", valid)
|
GoLog("[Lyrics] Provider order set to: %v\n", valid)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLyricsProviderOrder returns the current lyrics provider order.
|
|
||||||
func GetLyricsProviderOrder() []string {
|
func GetLyricsProviderOrder() []string {
|
||||||
lyricsProvidersMu.RLock()
|
lyricsProvidersMu.RLock()
|
||||||
defer lyricsProvidersMu.RUnlock()
|
defer lyricsProvidersMu.RUnlock()
|
||||||
@@ -119,7 +117,6 @@ func GetLyricsProviderOrder() []string {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAvailableLyricsProviders returns metadata about all available providers.
|
|
||||||
func GetAvailableLyricsProviders() []map[string]interface{} {
|
func GetAvailableLyricsProviders() []map[string]interface{} {
|
||||||
return []map[string]interface{}{
|
return []map[string]interface{}{
|
||||||
{"id": LyricsProviderSpotifyAPI, "name": "Spotify Lyrics API", "has_proxy_dependency": true, "description": "Spotify-sourced lyrics via Paxsenix"},
|
{"id": LyricsProviderSpotifyAPI, "name": "Spotify Lyrics API", "has_proxy_dependency": true, "description": "Spotify-sourced lyrics via Paxsenix"},
|
||||||
@@ -140,7 +137,6 @@ func normalizeLyricsFetchOptions(opts LyricsFetchOptions) LyricsFetchOptions {
|
|||||||
return opts
|
return opts
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetLyricsFetchOptions sets provider-specific lyric fetch behavior.
|
|
||||||
func SetLyricsFetchOptions(opts LyricsFetchOptions) {
|
func SetLyricsFetchOptions(opts LyricsFetchOptions) {
|
||||||
normalized := normalizeLyricsFetchOptions(opts)
|
normalized := normalizeLyricsFetchOptions(opts)
|
||||||
|
|
||||||
@@ -156,7 +152,6 @@ func SetLyricsFetchOptions(opts LyricsFetchOptions) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLyricsFetchOptions returns current provider-specific lyric fetch behavior.
|
|
||||||
func GetLyricsFetchOptions() LyricsFetchOptions {
|
func GetLyricsFetchOptions() LyricsFetchOptions {
|
||||||
lyricsFetchOptionsMu.RLock()
|
lyricsFetchOptionsMu.RLock()
|
||||||
defer lyricsFetchOptionsMu.RUnlock()
|
defer lyricsFetchOptionsMu.RUnlock()
|
||||||
@@ -667,7 +662,6 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
|
|||||||
|
|
||||||
GoLog("[Lyrics] Searching for: %s - %s (providers: %v)\n", artistName, trackName, providerOrder)
|
GoLog("[Lyrics] Searching for: %s - %s (providers: %v)\n", artistName, trackName, providerOrder)
|
||||||
|
|
||||||
// Cascade through all configured built-in providers
|
|
||||||
for _, providerName := range providerOrder {
|
for _, providerName := range providerOrder {
|
||||||
GoLog("[Lyrics] Trying provider: %s\n", providerName)
|
GoLog("[Lyrics] Trying provider: %s\n", providerName)
|
||||||
|
|
||||||
|
|||||||
+24
-5
@@ -262,26 +262,35 @@ func qobuzTrackDisplayTitle(track *QobuzTrack) string {
|
|||||||
return fmt.Sprintf("%s (%s)", title, version)
|
return fmt.Sprintf("%s (%s)", title, version)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var qobuzImageSizeRe = regexp.MustCompile(`_\d+\.jpg$`)
|
||||||
|
|
||||||
|
func qobuzUpscaleImageURL(url string) string {
|
||||||
|
if url == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return qobuzImageSizeRe.ReplaceAllString(url, "_max.jpg")
|
||||||
|
}
|
||||||
|
|
||||||
func qobuzTrackAlbumImage(track *QobuzTrack) string {
|
func qobuzTrackAlbumImage(track *QobuzTrack) string {
|
||||||
if track == nil {
|
if track == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return qobuzFirstNonEmpty(
|
return qobuzUpscaleImageURL(qobuzFirstNonEmpty(
|
||||||
track.Album.Image.Large,
|
track.Album.Image.Large,
|
||||||
track.Album.Image.Small,
|
track.Album.Image.Small,
|
||||||
track.Album.Image.Thumbnail,
|
track.Album.Image.Thumbnail,
|
||||||
)
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
func qobuzAlbumImage(album *qobuzAlbumDetails) string {
|
func qobuzAlbumImage(album *qobuzAlbumDetails) string {
|
||||||
if album == nil {
|
if album == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return qobuzFirstNonEmpty(
|
return qobuzUpscaleImageURL(qobuzFirstNonEmpty(
|
||||||
album.Image.Large,
|
album.Image.Large,
|
||||||
album.Image.Small,
|
album.Image.Small,
|
||||||
album.Image.Thumbnail,
|
album.Image.Thumbnail,
|
||||||
)
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
func qobuzTrackArtistID(track *QobuzTrack) string {
|
func qobuzTrackArtistID(track *QobuzTrack) string {
|
||||||
@@ -936,7 +945,17 @@ func (q *QobuzDownloader) GetAlbumMetadata(resourceID string) (*AlbumResponsePay
|
|||||||
|
|
||||||
tracks := make([]AlbumTrackMetadata, 0, len(album.Tracks.Items))
|
tracks := make([]AlbumTrackMetadata, 0, len(album.Tracks.Items))
|
||||||
for i := range album.Tracks.Items {
|
for i := range album.Tracks.Items {
|
||||||
tracks = append(tracks, qobuzTrackToAlbumTrackMetadata(&album.Tracks.Items[i]))
|
track := &album.Tracks.Items[i]
|
||||||
|
track.Album.ID = album.ID
|
||||||
|
track.Album.Title = album.Title
|
||||||
|
track.Album.ReleaseDate = album.ReleaseDateOriginal
|
||||||
|
track.Album.Image = qobuzImageSet{
|
||||||
|
Thumbnail: album.Image.Thumbnail,
|
||||||
|
Small: album.Image.Small,
|
||||||
|
Large: album.Image.Large,
|
||||||
|
}
|
||||||
|
track.Album.TracksCount = album.TracksCount
|
||||||
|
tracks = append(tracks, qobuzTrackToAlbumTrackMetadata(track))
|
||||||
}
|
}
|
||||||
|
|
||||||
return &AlbumResponsePayload{
|
return &AlbumResponsePayload{
|
||||||
|
|||||||
+5
-7
@@ -1015,13 +1015,11 @@ func (t *TidalDownloader) GetAlbumMetadata(resourceID string) (*AlbumResponsePay
|
|||||||
tracks := make([]AlbumTrackMetadata, 0, len(itemsModule.PagedList.Items))
|
tracks := make([]AlbumTrackMetadata, 0, len(itemsModule.PagedList.Items))
|
||||||
for _, item := range itemsModule.PagedList.Items {
|
for _, item := range itemsModule.PagedList.Items {
|
||||||
track := item.Item
|
track := item.Item
|
||||||
if track.Album.ID == 0 {
|
track.Album.ID = headerModule.Album.ID
|
||||||
track.Album.ID = headerModule.Album.ID
|
track.Album.Title = headerModule.Album.Title
|
||||||
track.Album.Title = headerModule.Album.Title
|
track.Album.Cover = headerModule.Album.Cover
|
||||||
track.Album.Cover = headerModule.Album.Cover
|
track.Album.ReleaseDate = headerModule.Album.ReleaseDate
|
||||||
track.Album.ReleaseDate = headerModule.Album.ReleaseDate
|
track.Album.URL = headerModule.Album.URL
|
||||||
track.Album.URL = headerModule.Album.URL
|
|
||||||
}
|
|
||||||
tracks = append(tracks, tidalTrackToAlbumTrackMetadata(&track))
|
tracks = append(tracks, tidalTrackToAlbumTrackMetadata(&track))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,11 +24,9 @@ func normalizeLooseTitle(title string) string {
|
|||||||
b.WriteRune(r)
|
b.WriteRune(r)
|
||||||
case unicode.IsSpace(r):
|
case unicode.IsSpace(r):
|
||||||
b.WriteByte(' ')
|
b.WriteByte(' ')
|
||||||
// Treat common separators as spaces.
|
|
||||||
case r == '/', r == '\\', r == '_', r == '-', r == '|', r == '.', r == '&', r == '+':
|
case r == '/', r == '\\', r == '_', r == '-', r == '|', r == '.', r == '&', r == '+':
|
||||||
b.WriteByte(' ')
|
b.WriteByte(' ')
|
||||||
default:
|
default:
|
||||||
// Drop other punctuation/symbols (including emoji) for loose matching.
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +57,6 @@ func normalizeLooseArtistName(name string) string {
|
|||||||
case r == '/', r == '\\', r == '_', r == '-', r == '|', r == '.', r == '&', r == '+':
|
case r == '/', r == '\\', r == '_', r == '-', r == '|', r == '.', r == '&', r == '+':
|
||||||
b.WriteByte(' ')
|
b.WriteByte(' ')
|
||||||
default:
|
default:
|
||||||
// Drop remaining punctuation/symbols for loose artist matching.
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,13 +99,11 @@ func normalizeSymbolOnlyTitle(title string) string {
|
|||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Shared Track Verification ====================
|
|
||||||
|
|
||||||
// resolvedTrackInfo holds the metadata fetched from a provider for verification.
|
// resolvedTrackInfo holds the metadata fetched from a provider for verification.
|
||||||
type resolvedTrackInfo struct {
|
type resolvedTrackInfo struct {
|
||||||
Title string
|
Title string
|
||||||
ArtistName string
|
ArtistName string
|
||||||
Duration int // seconds
|
Duration int
|
||||||
}
|
}
|
||||||
|
|
||||||
// trackMatchesRequest checks whether a resolved track from a provider matches
|
// trackMatchesRequest checks whether a resolved track from a provider matches
|
||||||
|
|||||||
@@ -1,750 +0,0 @@
|
|||||||
package gobackend
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
)
|
|
||||||
|
|
||||||
type YouTubeDownloader struct {
|
|
||||||
client *http.Client
|
|
||||||
apiURL string
|
|
||||||
mu sync.Mutex
|
|
||||||
}
|
|
||||||
|
|
||||||
const spotubeBaseURL = "https://spotubedl.com"
|
|
||||||
|
|
||||||
var (
|
|
||||||
globalYouTubeDownloader *YouTubeDownloader
|
|
||||||
youtubeDownloaderOnce sync.Once
|
|
||||||
)
|
|
||||||
|
|
||||||
type YouTubeQuality string
|
|
||||||
|
|
||||||
const (
|
|
||||||
YouTubeQualityOpus320 YouTubeQuality = "opus_320"
|
|
||||||
YouTubeQualityOpus256 YouTubeQuality = "opus_256"
|
|
||||||
YouTubeQualityOpus128 YouTubeQuality = "opus_128"
|
|
||||||
YouTubeQualityMP3128 YouTubeQuality = "mp3_128"
|
|
||||||
YouTubeQualityMP3256 YouTubeQuality = "mp3_256"
|
|
||||||
YouTubeQualityMP3320 YouTubeQuality = "mp3_320"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
youtubeOpusSupportedBitrates = []int{128, 256, 320}
|
|
||||||
youtubeMp3SupportedBitrates = []int{128, 256, 320}
|
|
||||||
)
|
|
||||||
|
|
||||||
type CobaltRequest struct {
|
|
||||||
URL string `json:"url"`
|
|
||||||
AudioBitrate string `json:"audioBitrate,omitempty"`
|
|
||||||
AudioFormat string `json:"audioFormat,omitempty"`
|
|
||||||
DownloadMode string `json:"downloadMode,omitempty"`
|
|
||||||
FilenameStyle string `json:"filenameStyle,omitempty"`
|
|
||||||
DisableMetadata bool `json:"disableMetadata,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type CobaltResponse struct {
|
|
||||||
Status string `json:"status"`
|
|
||||||
URL string `json:"url,omitempty"`
|
|
||||||
Filename string `json:"filename,omitempty"`
|
|
||||||
Error *struct {
|
|
||||||
Code string `json:"code"`
|
|
||||||
Context *struct {
|
|
||||||
Service string `json:"service,omitempty"`
|
|
||||||
Limit int `json:"limit,omitempty"`
|
|
||||||
} `json:"context,omitempty"`
|
|
||||||
} `json:"error,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type YouTubeDownloadResult struct {
|
|
||||||
FilePath string
|
|
||||||
Title string
|
|
||||||
Artist string
|
|
||||||
Album string
|
|
||||||
ReleaseDate string
|
|
||||||
TrackNumber int
|
|
||||||
DiscNumber int
|
|
||||||
ISRC string
|
|
||||||
Format string // "opus" or "mp3"
|
|
||||||
Bitrate int
|
|
||||||
LyricsLRC string
|
|
||||||
CoverData []byte
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewYouTubeDownloader() *YouTubeDownloader {
|
|
||||||
youtubeDownloaderOnce.Do(func() {
|
|
||||||
globalYouTubeDownloader = &YouTubeDownloader{
|
|
||||||
client: NewHTTPClientWithTimeout(DownloadTimeout),
|
|
||||||
apiURL: "https://api.qwkuns.me",
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return globalYouTubeDownloader
|
|
||||||
}
|
|
||||||
|
|
||||||
func extractBitrateFromQuality(raw string, defaultBitrate int) int {
|
|
||||||
parts := strings.FieldsFunc(raw, func(r rune) bool {
|
|
||||||
return (r < '0' || r > '9')
|
|
||||||
})
|
|
||||||
for i := len(parts) - 1; i >= 0; i-- {
|
|
||||||
part := parts[i]
|
|
||||||
if part == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if parsed, err := strconv.Atoi(part); err == nil {
|
|
||||||
return parsed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return defaultBitrate
|
|
||||||
}
|
|
||||||
|
|
||||||
func nearestSupportedBitrate(value int, supported []int) int {
|
|
||||||
nearest := supported[0]
|
|
||||||
nearestDistance := absInt(value - nearest)
|
|
||||||
|
|
||||||
for _, option := range supported[1:] {
|
|
||||||
distance := absInt(value - option)
|
|
||||||
// On tie prefer higher quality.
|
|
||||||
if distance < nearestDistance || (distance == nearestDistance && option > nearest) {
|
|
||||||
nearest = option
|
|
||||||
nearestDistance = distance
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nearest
|
|
||||||
}
|
|
||||||
|
|
||||||
func absInt(value int) int {
|
|
||||||
if value < 0 {
|
|
||||||
return -value
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseYouTubeQualityInput(raw string) (format string, bitrate int, normalized YouTubeQuality) {
|
|
||||||
normalizedRaw := strings.ToLower(strings.TrimSpace(raw))
|
|
||||||
|
|
||||||
if strings.HasPrefix(normalizedRaw, "opus") {
|
|
||||||
parsed := extractBitrateFromQuality(normalizedRaw, 256)
|
|
||||||
finalBitrate := nearestSupportedBitrate(parsed, youtubeOpusSupportedBitrates)
|
|
||||||
return "opus", finalBitrate, YouTubeQuality(fmt.Sprintf("opus_%d", finalBitrate))
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.HasPrefix(normalizedRaw, "mp3") {
|
|
||||||
parsed := extractBitrateFromQuality(normalizedRaw, 320)
|
|
||||||
finalBitrate := nearestSupportedBitrate(parsed, youtubeMp3SupportedBitrates)
|
|
||||||
return "mp3", finalBitrate, YouTubeQuality(fmt.Sprintf("mp3_%d", finalBitrate))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Backward compatibility for legacy symbolic values.
|
|
||||||
switch normalizedRaw {
|
|
||||||
case "opus_256", "opus256", "opus":
|
|
||||||
return "opus", 256, YouTubeQualityOpus256
|
|
||||||
case "opus_320", "opus320":
|
|
||||||
return "opus", 320, YouTubeQualityOpus320
|
|
||||||
case "opus_128", "opus128":
|
|
||||||
return "opus", 128, YouTubeQualityOpus128
|
|
||||||
case "mp3_320", "mp3320", "mp3", "":
|
|
||||||
return "mp3", 320, YouTubeQualityMP3320
|
|
||||||
case "mp3_256", "mp3256":
|
|
||||||
return "mp3", 256, YouTubeQualityMP3256
|
|
||||||
case "mp3_128", "mp3128":
|
|
||||||
return "mp3", 128, YouTubeQualityMP3128
|
|
||||||
default:
|
|
||||||
return "mp3", 320, YouTubeQualityMP3320
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (y *YouTubeDownloader) SearchYouTube(trackName, artistName string) (string, error) {
|
|
||||||
query := fmt.Sprintf("%s %s", artistName, trackName)
|
|
||||||
searchQuery := url.QueryEscape(query)
|
|
||||||
|
|
||||||
GoLog("[YouTube] Search query: %s\n", query)
|
|
||||||
|
|
||||||
youtubeMusicURL := fmt.Sprintf("https://music.youtube.com/search?q=%s", searchQuery)
|
|
||||||
|
|
||||||
return youtubeMusicURL, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (y *YouTubeDownloader) GetDownloadURL(youtubeURL string, quality YouTubeQuality) (*CobaltResponse, error) {
|
|
||||||
y.mu.Lock()
|
|
||||||
defer y.mu.Unlock()
|
|
||||||
|
|
||||||
audioFormat, bitrate, _ := parseYouTubeQualityInput(string(quality))
|
|
||||||
audioBitrate := strconv.Itoa(bitrate)
|
|
||||||
|
|
||||||
// Try SpotubeDL first (primary)
|
|
||||||
var spotubeErr error
|
|
||||||
videoID, extractErr := ExtractYouTubeVideoID(youtubeURL)
|
|
||||||
if extractErr == nil {
|
|
||||||
GoLog("[YouTube] Requesting from SpotubeDL: videoID=%s (format: %s, bitrate: %s)\n",
|
|
||||||
videoID, audioFormat, audioBitrate)
|
|
||||||
|
|
||||||
resp, err := y.requestSpotubeDL(videoID, audioFormat, audioBitrate)
|
|
||||||
if err == nil {
|
|
||||||
return resp, nil
|
|
||||||
}
|
|
||||||
spotubeErr = err
|
|
||||||
GoLog("[YouTube] SpotubeDL failed: %v, trying Cobalt fallback...\n", err)
|
|
||||||
} else {
|
|
||||||
GoLog("[YouTube] Could not extract video ID: %v, skipping SpotubeDL\n", extractErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: direct Cobalt API (api.qwkuns.me)
|
|
||||||
cobaltURL := toYouTubeMusicURL(youtubeURL)
|
|
||||||
GoLog("[YouTube] Requesting from Cobalt API: %s (format: %s, bitrate: %s)\n",
|
|
||||||
cobaltURL, audioFormat, audioBitrate)
|
|
||||||
|
|
||||||
resp, err := y.requestCobaltDirect(cobaltURL, audioFormat, audioBitrate)
|
|
||||||
if err != nil {
|
|
||||||
if spotubeErr != nil {
|
|
||||||
return nil, fmt.Errorf("all download methods failed: spotubedl: %v, cobalt: %v", spotubeErr, err)
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("all download methods failed: spotubedl: extractErr=%v, cobalt: %v", extractErr, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return resp, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (y *YouTubeDownloader) requestCobaltDirect(videoURL, audioFormat, audioBitrate string) (*CobaltResponse, error) {
|
|
||||||
reqBody := CobaltRequest{
|
|
||||||
URL: videoURL,
|
|
||||||
AudioFormat: audioFormat,
|
|
||||||
AudioBitrate: audioBitrate,
|
|
||||||
DownloadMode: "audio",
|
|
||||||
FilenameStyle: "basic",
|
|
||||||
DisableMetadata: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
jsonData, err := json.Marshal(reqBody)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequest("POST", y.apiURL, strings.NewReader(string(jsonData)))
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("Accept", "application/json")
|
|
||||||
|
|
||||||
resp, err := DoRequestWithUserAgent(y.client, req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("cobalt API request failed: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
GoLog("[YouTube] Cobalt API response status: %d\n", resp.StatusCode)
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return nil, fmt.Errorf("cobalt API returned status %d: %s", resp.StatusCode, string(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
var cobaltResp CobaltResponse
|
|
||||||
if err := json.Unmarshal(body, &cobaltResp); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if cobaltResp.Status == "error" && cobaltResp.Error != nil {
|
|
||||||
return nil, fmt.Errorf("cobalt error: %s", cobaltResp.Error.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
if cobaltResp.Status != "tunnel" && cobaltResp.Status != "redirect" {
|
|
||||||
return nil, fmt.Errorf("unexpected cobalt status: %s", cobaltResp.Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
if cobaltResp.URL == "" {
|
|
||||||
return nil, fmt.Errorf("no download URL in response")
|
|
||||||
}
|
|
||||||
|
|
||||||
GoLog("[YouTube] Got download URL from Cobalt (status: %s)\n", cobaltResp.Status)
|
|
||||||
return &cobaltResp, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// requestSpotubeDL uses SpotubeDL as a Cobalt proxy (they handle auth to yt-dl.click instances).
|
|
||||||
// Engines v3/v2 are MP3-oriented outputs, so we only use them for MP3 requests.
|
|
||||||
func (y *YouTubeDownloader) requestSpotubeDL(videoID, audioFormat, audioBitrate string) (*CobaltResponse, error) {
|
|
||||||
engines := []string{"v1"}
|
|
||||||
if strings.EqualFold(audioFormat, "mp3") {
|
|
||||||
engines = append(engines, "v3", "v2")
|
|
||||||
}
|
|
||||||
var lastErr error
|
|
||||||
|
|
||||||
for _, engine := range engines {
|
|
||||||
resp, err := y.requestSpotubeDLEngine(videoID, audioFormat, audioBitrate, engine)
|
|
||||||
if err == nil {
|
|
||||||
return resp, nil
|
|
||||||
}
|
|
||||||
lastErr = err
|
|
||||||
GoLog("[YouTube] SpotubeDL (%s) failed: %v\n", engine, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if lastErr == nil {
|
|
||||||
lastErr = fmt.Errorf("no SpotubeDL engine available")
|
|
||||||
}
|
|
||||||
return nil, lastErr
|
|
||||||
}
|
|
||||||
|
|
||||||
func (y *YouTubeDownloader) requestSpotubeDLEngine(videoID, audioFormat, audioBitrate, engine string) (*CobaltResponse, error) {
|
|
||||||
apiURL := fmt.Sprintf("%s/api/download/%s?engine=%s&format=%s&quality=%s",
|
|
||||||
spotubeBaseURL, videoID, url.QueryEscape(engine), url.QueryEscape(audioFormat), url.QueryEscape(audioBitrate))
|
|
||||||
|
|
||||||
GoLog("[YouTube] Requesting from SpotubeDL (%s): %s\n", engine, apiURL)
|
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", apiURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Set("Accept", "application/json")
|
|
||||||
|
|
||||||
resp, err := DoRequestWithUserAgent(y.client, req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("spotubedl request failed: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
GoLog("[YouTube] SpotubeDL (%s) response status: %d\n", engine, resp.StatusCode)
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return nil, fmt.Errorf("spotubedl(%s) returned status %d: %s", engine, resp.StatusCode, string(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
var result struct {
|
|
||||||
URL string `json:"url"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
Error string `json:"error"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
Filename string `json:"filename"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(body, &result); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse spotubedl response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadURL := strings.TrimSpace(result.URL)
|
|
||||||
if downloadURL == "" {
|
|
||||||
if result.Error != "" {
|
|
||||||
return nil, fmt.Errorf("spotubedl(%s) error: %s", engine, result.Error)
|
|
||||||
}
|
|
||||||
if result.Message != "" {
|
|
||||||
return nil, fmt.Errorf("spotubedl(%s) message: %s", engine, result.Message)
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("no download URL from spotubedl(%s)", engine)
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.HasPrefix(downloadURL, "/") {
|
|
||||||
downloadURL = spotubeBaseURL + downloadURL
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.HasPrefix(downloadURL, "http://") && !strings.HasPrefix(downloadURL, "https://") {
|
|
||||||
return nil, fmt.Errorf("invalid download URL from spotubedl(%s): %s", engine, downloadURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
filename := strings.TrimSpace(result.Filename)
|
|
||||||
if filename == "" {
|
|
||||||
if parsedURL, parseErr := url.Parse(downloadURL); parseErr == nil {
|
|
||||||
if queryFilename := strings.TrimSpace(parsedURL.Query().Get("filename")); queryFilename != "" {
|
|
||||||
if decodedFilename, decodeErr := url.QueryUnescape(queryFilename); decodeErr == nil {
|
|
||||||
filename = decodedFilename
|
|
||||||
} else {
|
|
||||||
filename = queryFilename
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
GoLog("[YouTube] Got download URL from SpotubeDL (%s)\n", engine)
|
|
||||||
return &CobaltResponse{
|
|
||||||
Status: "tunnel",
|
|
||||||
URL: downloadURL,
|
|
||||||
Filename: filename,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (y *YouTubeDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error {
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
if itemID != "" {
|
|
||||||
StartItemProgress(itemID)
|
|
||||||
defer CompleteItemProgress(itemID)
|
|
||||||
ctx = initDownloadCancel(itemID)
|
|
||||||
defer clearDownloadCancel(itemID)
|
|
||||||
}
|
|
||||||
|
|
||||||
if isDownloadCancelled(itemID) {
|
|
||||||
return ErrDownloadCancelled
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := DoRequestWithUserAgent(y.client, req)
|
|
||||||
if err != nil {
|
|
||||||
if isDownloadCancelled(itemID) {
|
|
||||||
return ErrDownloadCancelled
|
|
||||||
}
|
|
||||||
return fmt.Errorf("download request failed: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
expectedSize := resp.ContentLength
|
|
||||||
if expectedSize > 0 && itemID != "" {
|
|
||||||
SetItemBytesTotal(itemID, expectedSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
out, err := openOutputForWrite(outputPath, outputFD)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create output file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
|
||||||
|
|
||||||
var written int64
|
|
||||||
if itemID != "" {
|
|
||||||
progressWriter := NewItemProgressWriter(bufWriter, itemID)
|
|
||||||
written, err = io.Copy(progressWriter, resp.Body)
|
|
||||||
} else {
|
|
||||||
written, err = io.Copy(bufWriter, resp.Body)
|
|
||||||
}
|
|
||||||
|
|
||||||
flushErr := bufWriter.Flush()
|
|
||||||
closeErr := out.Close()
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
cleanupOutputOnError(outputPath, outputFD)
|
|
||||||
if isDownloadCancelled(itemID) {
|
|
||||||
return ErrDownloadCancelled
|
|
||||||
}
|
|
||||||
return fmt.Errorf("download interrupted: %w", err)
|
|
||||||
}
|
|
||||||
if flushErr != nil {
|
|
||||||
cleanupOutputOnError(outputPath, outputFD)
|
|
||||||
return fmt.Errorf("failed to flush buffer: %w", flushErr)
|
|
||||||
}
|
|
||||||
if closeErr != nil {
|
|
||||||
cleanupOutputOnError(outputPath, outputFD)
|
|
||||||
return fmt.Errorf("failed to close file: %w", closeErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
if expectedSize > 0 && written != expectedSize {
|
|
||||||
cleanupOutputOnError(outputPath, outputFD)
|
|
||||||
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
|
||||||
}
|
|
||||||
|
|
||||||
GoLog("[YouTube] Download completed: %d bytes written\n", written)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func BuildYouTubeSearchURL(trackName, artistName string) string {
|
|
||||||
query := fmt.Sprintf("%s %s official audio", artistName, trackName)
|
|
||||||
return fmt.Sprintf("https://music.youtube.com/search?q=%s", url.QueryEscape(query))
|
|
||||||
}
|
|
||||||
|
|
||||||
func BuildYouTubeWatchURL(videoID string) string {
|
|
||||||
return fmt.Sprintf("https://music.youtube.com/watch?v=%s", videoID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func isYouTubeVideoID(s string) bool {
|
|
||||||
if len(s) != 11 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, c := range s {
|
|
||||||
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_') {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func IsYouTubeURL(urlStr string) bool {
|
|
||||||
lower := strings.ToLower(urlStr)
|
|
||||||
return strings.Contains(lower, "youtube.com") ||
|
|
||||||
strings.Contains(lower, "youtu.be") ||
|
|
||||||
strings.Contains(lower, "music.youtube.com")
|
|
||||||
}
|
|
||||||
|
|
||||||
// toYouTubeMusicURL converts any YouTube URL to music.youtube.com format.
|
|
||||||
// YouTube Music URLs bypass the login requirement that affects regular YouTube videos on Cobalt.
|
|
||||||
func toYouTubeMusicURL(rawURL string) string {
|
|
||||||
videoID, err := ExtractYouTubeVideoID(rawURL)
|
|
||||||
if err != nil {
|
|
||||||
return rawURL
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("https://music.youtube.com/watch?v=%s", videoID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ExtractYouTubeVideoID(urlStr string) (string, error) {
|
|
||||||
if strings.Contains(urlStr, "youtu.be/") {
|
|
||||||
parts := strings.Split(urlStr, "youtu.be/")
|
|
||||||
if len(parts) >= 2 {
|
|
||||||
videoID := strings.Split(parts[1], "?")[0]
|
|
||||||
videoID = strings.Split(videoID, "&")[0]
|
|
||||||
return strings.TrimSpace(videoID), nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
parsed, err := url.Parse(urlStr)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("invalid URL: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if v := parsed.Query().Get("v"); v != "" {
|
|
||||||
return v, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.Contains(parsed.Path, "/embed/") {
|
|
||||||
parts := strings.Split(parsed.Path, "/embed/")
|
|
||||||
if len(parts) >= 2 {
|
|
||||||
return strings.Split(parts[1], "/")[0], nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.Contains(parsed.Path, "/v/") {
|
|
||||||
parts := strings.Split(parsed.Path, "/v/")
|
|
||||||
if len(parts) >= 2 {
|
|
||||||
return strings.Split(parts[1], "/")[0], nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", fmt.Errorf("could not extract video ID from URL")
|
|
||||||
}
|
|
||||||
|
|
||||||
// searchYouTubeMusicViaExtension uses the YT Music extension's customSearch
|
|
||||||
// to find a track by artist + title. It filters for tracks only (not videos,
|
|
||||||
// albums, or playlists) and returns the YouTube Music watch URL for the first
|
|
||||||
// matching track, or "" if nothing was found.
|
|
||||||
func searchYouTubeMusicViaExtension(artistName, trackName string) string {
|
|
||||||
extManager := GetExtensionManager()
|
|
||||||
searchProviders := extManager.GetSearchProviders()
|
|
||||||
|
|
||||||
// Find the ytmusic-spotiflac extension
|
|
||||||
var ytProvider *ExtensionProviderWrapper
|
|
||||||
for _, p := range searchProviders {
|
|
||||||
if p.extension.ID == "ytmusic-spotiflac" {
|
|
||||||
ytProvider = p
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ytProvider == nil {
|
|
||||||
GoLog("[YouTube] YT Music extension not found or not enabled, skipping fallback\n")
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
query := strings.TrimSpace(artistName + " " + trackName)
|
|
||||||
if query == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
GoLog("[YouTube] Searching YT Music extension for: %s\n", query)
|
|
||||||
results, err := ytProvider.CustomSearch(query, map[string]interface{}{
|
|
||||||
"filter": "tracks",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
GoLog("[YouTube] YT Music extension search failed: %v\n", err)
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the first track result (item_type == "track" with a valid video ID)
|
|
||||||
for _, track := range results {
|
|
||||||
if track.ItemType != "" && track.ItemType != "track" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
videoID := strings.TrimSpace(track.ID)
|
|
||||||
if videoID == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if isYouTubeVideoID(videoID) {
|
|
||||||
return BuildYouTubeWatchURL(videoID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
GoLog("[YouTube] YT Music extension returned no matching tracks for: %s\n", query)
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) {
|
|
||||||
downloader := NewYouTubeDownloader()
|
|
||||||
|
|
||||||
format, bitrate, quality := parseYouTubeQualityInput(req.Quality)
|
|
||||||
|
|
||||||
// URL lookup priority: YouTube video ID > YT Music extension > SongLink (Spotify/Deezer/ISRC)
|
|
||||||
var youtubeURL string
|
|
||||||
var lookupErr error
|
|
||||||
|
|
||||||
// SpotifyID might actually be a YouTube video ID (from YT Music extension)
|
|
||||||
if req.SpotifyID != "" && isYouTubeVideoID(req.SpotifyID) {
|
|
||||||
youtubeURL = BuildYouTubeWatchURL(req.SpotifyID)
|
|
||||||
GoLog("[YouTube] SpotifyID appears to be YouTube video ID, using directly: %s\n", youtubeURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try YT Music extension search first (if installed) - more accurate, tracks only
|
|
||||||
if youtubeURL == "" && (req.TrackName != "" || req.ArtistName != "") {
|
|
||||||
youtubeURL = searchYouTubeMusicViaExtension(req.ArtistName, req.TrackName)
|
|
||||||
if youtubeURL != "" {
|
|
||||||
GoLog("[YouTube] Found YouTube URL via YT Music extension: %s\n", youtubeURL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: Try Spotify ID via SongLink
|
|
||||||
if youtubeURL == "" && req.SpotifyID != "" && !isYouTubeVideoID(req.SpotifyID) {
|
|
||||||
GoLog("[YouTube] Looking up YouTube URL via SongLink for Spotify ID: %s\n", req.SpotifyID)
|
|
||||||
songlink := NewSongLinkClient()
|
|
||||||
youtubeURL, lookupErr = songlink.GetYouTubeURLFromSpotify(req.SpotifyID)
|
|
||||||
if lookupErr != nil {
|
|
||||||
GoLog("[YouTube] SongLink Spotify lookup failed: %v\n", lookupErr)
|
|
||||||
} else {
|
|
||||||
GoLog("[YouTube] Found YouTube URL via SongLink (Spotify): %s\n", youtubeURL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: Try Deezer ID via SongLink
|
|
||||||
if youtubeURL == "" && req.DeezerID != "" {
|
|
||||||
GoLog("[YouTube] Looking up YouTube URL via SongLink for Deezer ID: %s\n", req.DeezerID)
|
|
||||||
songlink := NewSongLinkClient()
|
|
||||||
youtubeURL, lookupErr = songlink.GetYouTubeURLFromDeezer(req.DeezerID)
|
|
||||||
if lookupErr != nil {
|
|
||||||
GoLog("[YouTube] SongLink Deezer lookup failed: %v\n", lookupErr)
|
|
||||||
} else {
|
|
||||||
GoLog("[YouTube] Found YouTube URL via SongLink (Deezer): %s\n", youtubeURL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: Try ISRC via SongLink
|
|
||||||
if youtubeURL == "" && req.ISRC != "" {
|
|
||||||
GoLog("[YouTube] Looking up YouTube URL via SongLink for ISRC: %s\n", req.ISRC)
|
|
||||||
songlink := NewSongLinkClient()
|
|
||||||
availability, isrcErr := songlink.CheckTrackAvailability("", req.ISRC)
|
|
||||||
if isrcErr == nil && availability.YouTube && availability.YouTubeURL != "" {
|
|
||||||
youtubeURL = availability.YouTubeURL
|
|
||||||
GoLog("[YouTube] Found YouTube URL via SongLink (ISRC): %s\n", youtubeURL)
|
|
||||||
} else if isrcErr != nil {
|
|
||||||
GoLog("[YouTube] SongLink ISRC lookup failed: %v\n", isrcErr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cobalt requires direct video URLs, not search URLs
|
|
||||||
if youtubeURL == "" {
|
|
||||||
return YouTubeDownloadResult{}, fmt.Errorf("could not find YouTube URL for track: %s - %s (no Spotify/Deezer ID available or track not on YouTube)", req.ArtistName, req.TrackName)
|
|
||||||
}
|
|
||||||
|
|
||||||
GoLog("[YouTube] Requesting download from Cobalt for: %s\n", youtubeURL)
|
|
||||||
|
|
||||||
cobaltResp, err := downloader.GetDownloadURL(youtubeURL, quality)
|
|
||||||
if err != nil {
|
|
||||||
return YouTubeDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ext := ".mp3"
|
|
||||||
if format == "opus" {
|
|
||||||
ext = ".opus"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Some SpotubeDL engines may return a different output container than requested.
|
|
||||||
// Respect the provider-reported filename to avoid saving MP3 bytes with .opus extension.
|
|
||||||
if cobaltResp != nil && cobaltResp.Filename != "" {
|
|
||||||
lowerName := strings.ToLower(strings.TrimSpace(cobaltResp.Filename))
|
|
||||||
switch {
|
|
||||||
case strings.HasSuffix(lowerName, ".mp3"):
|
|
||||||
ext = ".mp3"
|
|
||||||
format = "mp3"
|
|
||||||
case strings.HasSuffix(lowerName, ".opus"), strings.HasSuffix(lowerName, ".ogg"):
|
|
||||||
ext = ".opus"
|
|
||||||
format = "opus"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]any{
|
|
||||||
"title": req.TrackName,
|
|
||||||
"artist": req.ArtistName,
|
|
||||||
"album": req.AlbumName,
|
|
||||||
"track": req.TrackNumber,
|
|
||||||
"year": extractYear(req.ReleaseDate),
|
|
||||||
"date": req.ReleaseDate,
|
|
||||||
"disc": req.DiscNumber,
|
|
||||||
})
|
|
||||||
filename = sanitizeFilename(filename) + ext
|
|
||||||
|
|
||||||
var outputPath string
|
|
||||||
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
|
|
||||||
if isSafOutput {
|
|
||||||
outputPath = strings.TrimSpace(req.OutputPath)
|
|
||||||
if outputPath == "" && isFDOutput(req.OutputFD) {
|
|
||||||
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
outputPath = req.OutputDir + "/" + filename
|
|
||||||
}
|
|
||||||
|
|
||||||
GoLog("[YouTube] Downloading to: %s\n", outputPath)
|
|
||||||
|
|
||||||
var parallelResult *ParallelDownloadResult
|
|
||||||
if req.EmbedLyrics || req.CoverURL != "" {
|
|
||||||
GoLog("[YouTube] Starting parallel fetch for cover and lyrics...\n")
|
|
||||||
parallelResult = FetchCoverAndLyricsParallel(
|
|
||||||
req.CoverURL,
|
|
||||||
req.EmbedMaxQualityCover,
|
|
||||||
req.SpotifyID,
|
|
||||||
req.TrackName,
|
|
||||||
req.ArtistName,
|
|
||||||
req.EmbedLyrics,
|
|
||||||
int64(req.DurationMS),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := downloader.DownloadFile(cobaltResp.URL, outputPath, req.OutputFD, req.ItemID); err != nil {
|
|
||||||
return YouTubeDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
lyricsLRC := ""
|
|
||||||
var coverData []byte
|
|
||||||
if parallelResult != nil {
|
|
||||||
if parallelResult.LyricsLRC != "" {
|
|
||||||
lyricsLRC = parallelResult.LyricsLRC
|
|
||||||
GoLog("[YouTube] Got lyrics from lrclib (%d lines)\n", len(parallelResult.LyricsData.Lines))
|
|
||||||
}
|
|
||||||
if parallelResult.CoverData != nil {
|
|
||||||
coverData = parallelResult.CoverData
|
|
||||||
GoLog("[YouTube] Got cover art (%d bytes)\n", len(coverData))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return YouTubeDownloadResult{
|
|
||||||
FilePath: outputPath,
|
|
||||||
Title: req.TrackName,
|
|
||||||
Artist: req.ArtistName,
|
|
||||||
Album: req.AlbumName,
|
|
||||||
ReleaseDate: req.ReleaseDate,
|
|
||||||
TrackNumber: req.TrackNumber,
|
|
||||||
DiscNumber: req.DiscNumber,
|
|
||||||
ISRC: req.ISRC,
|
|
||||||
Format: format,
|
|
||||||
Bitrate: bitrate,
|
|
||||||
LyricsLRC: lyricsLRC,
|
|
||||||
CoverData: coverData,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
package gobackend
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func TestParseYouTubeQualityInput_OpusNormalizesToSupportedBitrates(t *testing.T) {
|
|
||||||
format, bitrate, normalized := parseYouTubeQualityInput("opus_160")
|
|
||||||
if format != "opus" {
|
|
||||||
t.Fatalf("expected opus format, got %s", format)
|
|
||||||
}
|
|
||||||
if bitrate != 128 {
|
|
||||||
t.Fatalf("expected 128 bitrate, got %d", bitrate)
|
|
||||||
}
|
|
||||||
if normalized != YouTubeQualityOpus128 {
|
|
||||||
t.Fatalf("expected %s normalized, got %s", YouTubeQualityOpus128, normalized)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseYouTubeQualityInput_Mp3NormalizesToSupportedBitrates(t *testing.T) {
|
|
||||||
format, bitrate, normalized := parseYouTubeQualityInput("mp3_192")
|
|
||||||
if format != "mp3" {
|
|
||||||
t.Fatalf("expected mp3 format, got %s", format)
|
|
||||||
}
|
|
||||||
if bitrate != 256 {
|
|
||||||
t.Fatalf("expected 256 bitrate, got %d", bitrate)
|
|
||||||
}
|
|
||||||
if normalized != YouTubeQualityMP3256 {
|
|
||||||
t.Fatalf("expected %s normalized, got %s", YouTubeQualityMP3256, normalized)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseYouTubeQualityInput_PicksNearestSupportedBitrate(t *testing.T) {
|
|
||||||
_, opusBitrate, _ := parseYouTubeQualityInput("opus_999")
|
|
||||||
if opusBitrate != 320 {
|
|
||||||
t.Fatalf("expected opus normalization to 320, got %d", opusBitrate)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, mp3Bitrate, _ := parseYouTubeQualityInput("mp3_1")
|
|
||||||
if mp3Bitrate != 128 {
|
|
||||||
t.Fatalf("expected mp3 normalization to 128, got %d", mp3Bitrate)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseYouTubeQualityInput_Opus320(t *testing.T) {
|
|
||||||
format, bitrate, normalized := parseYouTubeQualityInput("opus_320")
|
|
||||||
if format != "opus" {
|
|
||||||
t.Fatalf("expected opus format, got %s", format)
|
|
||||||
}
|
|
||||||
if bitrate != 320 {
|
|
||||||
t.Fatalf("expected 320 bitrate, got %d", bitrate)
|
|
||||||
}
|
|
||||||
if normalized != YouTubeQualityOpus320 {
|
|
||||||
t.Fatalf("expected %s normalized, got %s", YouTubeQualityOpus320, normalized)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,14 +3,14 @@ import 'package:flutter/foundation.dart';
|
|||||||
/// App version and info constants
|
/// App version and info constants
|
||||||
/// Update version here only - all other files will reference this
|
/// Update version here only - all other files will reference this
|
||||||
class AppInfo {
|
class AppInfo {
|
||||||
static const String version = '3.9.0';
|
static const String version = '4.1.0';
|
||||||
static const String buildNumber = '115';
|
static const String buildNumber = '117';
|
||||||
static const String fullVersion = '$version+$buildNumber';
|
static const String fullVersion = '$version+$buildNumber';
|
||||||
|
|
||||||
/// Shows "Internal" in debug builds, actual version in release.
|
/// Shows "Internal" in debug builds, actual version in release.
|
||||||
static String get displayVersion => kDebugMode ? 'Internal' : version;
|
static String get displayVersion => kDebugMode ? 'Internal' : version;
|
||||||
|
|
||||||
static const String appName = 'SpotiFLAC';
|
static const String appName = 'SpotiFLAC Mobile';
|
||||||
static const String copyright = '© 2026 SpotiFLAC';
|
static const String copyright = '© 2026 SpotiFLAC';
|
||||||
|
|
||||||
static const String mobileAuthor = 'zarzet';
|
static const String mobileAuthor = 'zarzet';
|
||||||
|
|||||||
+234
-18
@@ -1432,6 +1432,66 @@ abstract class AppLocalizations {
|
|||||||
/// **'Playlists'**
|
/// **'Playlists'**
|
||||||
String get searchPlaylists;
|
String get searchPlaylists;
|
||||||
|
|
||||||
|
/// Bottom sheet title for search sort options
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Sort Results'**
|
||||||
|
String get searchSortTitle;
|
||||||
|
|
||||||
|
/// Sort option - default API order
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Default'**
|
||||||
|
String get searchSortDefault;
|
||||||
|
|
||||||
|
/// Sort option - title ascending
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Title (A-Z)'**
|
||||||
|
String get searchSortTitleAZ;
|
||||||
|
|
||||||
|
/// Sort option - title descending
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Title (Z-A)'**
|
||||||
|
String get searchSortTitleZA;
|
||||||
|
|
||||||
|
/// Sort option - artist ascending
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Artist (A-Z)'**
|
||||||
|
String get searchSortArtistAZ;
|
||||||
|
|
||||||
|
/// Sort option - artist descending
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Artist (Z-A)'**
|
||||||
|
String get searchSortArtistZA;
|
||||||
|
|
||||||
|
/// Sort option - shortest duration first
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Duration (Shortest)'**
|
||||||
|
String get searchSortDurationShort;
|
||||||
|
|
||||||
|
/// Sort option - longest duration first
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Duration (Longest)'**
|
||||||
|
String get searchSortDurationLong;
|
||||||
|
|
||||||
|
/// Sort option - oldest release first
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Release Date (Oldest)'**
|
||||||
|
String get searchSortDateOldest;
|
||||||
|
|
||||||
|
/// Sort option - newest release first
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Release Date (Newest)'**
|
||||||
|
String get searchSortDateNewest;
|
||||||
|
|
||||||
/// Tooltip - play button
|
/// Tooltip - play button
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -2662,24 +2722,6 @@ abstract class AppLocalizations {
|
|||||||
/// **'Actual quality depends on track availability from the service'**
|
/// **'Actual quality depends on track availability from the service'**
|
||||||
String get qualityNote;
|
String get qualityNote;
|
||||||
|
|
||||||
/// Note for YouTube service explaining lossy-only quality
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'YouTube provides lossy audio only. Not part of lossless fallback.'**
|
|
||||||
String get youtubeQualityNote;
|
|
||||||
|
|
||||||
/// Title for YouTube Opus bitrate setting
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'YouTube Opus Bitrate'**
|
|
||||||
String get youtubeOpusBitrateTitle;
|
|
||||||
|
|
||||||
/// Title for YouTube MP3 bitrate setting
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'YouTube MP3 Bitrate'**
|
|
||||||
String get youtubeMp3BitrateTitle;
|
|
||||||
|
|
||||||
/// Setting - show quality picker
|
/// Setting - show quality picker
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -2860,6 +2902,18 @@ abstract class AppLocalizations {
|
|||||||
/// **'Artist/Album/ and Artist/Singles/'**
|
/// **'Artist/Album/ and Artist/Singles/'**
|
||||||
String get albumFolderArtistAlbumSinglesSubtitle;
|
String get albumFolderArtistAlbumSinglesSubtitle;
|
||||||
|
|
||||||
|
/// Album folder option with singles directly in artist folder
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Artist / Album (Singles flat)'**
|
||||||
|
String get albumFolderArtistAlbumFlat;
|
||||||
|
|
||||||
|
/// Folder structure example for flat singles
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Artist/Album/ and Artist/song.flac'**
|
||||||
|
String get albumFolderArtistAlbumFlatSubtitle;
|
||||||
|
|
||||||
/// Button - delete selected tracks
|
/// Button - delete selected tracks
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -5084,6 +5138,168 @@ abstract class AppLocalizations {
|
|||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Empty only'**
|
/// **'Empty only'**
|
||||||
String get editMetadataSelectEmpty;
|
String get editMetadataSelectEmpty;
|
||||||
|
|
||||||
|
/// Header for active downloads section with count
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Downloading ({count})'**
|
||||||
|
String queueDownloadingCount(int count);
|
||||||
|
|
||||||
|
/// Header label for downloaded items section in library
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Downloaded'**
|
||||||
|
String get queueDownloadedHeader;
|
||||||
|
|
||||||
|
/// Shown while filter results are being computed
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Filtering...'**
|
||||||
|
String get queueFilteringIndicator;
|
||||||
|
|
||||||
|
/// Track count label with plural support
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{count, plural, =1{1 track} other{{count} tracks}}'**
|
||||||
|
String queueTrackCount(int count);
|
||||||
|
|
||||||
|
/// Album count label with plural support
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{count, plural, =1{1 album} other{{count} albums}}'**
|
||||||
|
String queueAlbumCount(int count);
|
||||||
|
|
||||||
|
/// Empty state title when no album downloads exist
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'No album downloads'**
|
||||||
|
String get queueEmptyAlbums;
|
||||||
|
|
||||||
|
/// Empty state subtitle for album downloads
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Download multiple tracks from an album to see them here'**
|
||||||
|
String get queueEmptyAlbumsSubtitle;
|
||||||
|
|
||||||
|
/// Empty state title when no single track downloads exist
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'No single downloads'**
|
||||||
|
String get queueEmptySingles;
|
||||||
|
|
||||||
|
/// Empty state subtitle for single track downloads
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Single track downloads will appear here'**
|
||||||
|
String get queueEmptySinglesSubtitle;
|
||||||
|
|
||||||
|
/// Empty state title when download history is empty
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'No download history'**
|
||||||
|
String get queueEmptyHistory;
|
||||||
|
|
||||||
|
/// Empty state subtitle for download history
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Downloaded tracks will appear here'**
|
||||||
|
String get queueEmptyHistorySubtitle;
|
||||||
|
|
||||||
|
/// Shown when all playlists are selected in selection mode
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'All playlists selected'**
|
||||||
|
String get selectionAllPlaylistsSelected;
|
||||||
|
|
||||||
|
/// Hint shown in playlist selection mode
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Tap playlists to select'**
|
||||||
|
String get selectionTapPlaylistsToSelect;
|
||||||
|
|
||||||
|
/// Hint shown when no playlists are selected for deletion
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Select playlists to delete'**
|
||||||
|
String get selectionSelectPlaylistsToDelete;
|
||||||
|
|
||||||
|
/// Title for audio analysis section
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Audio Quality Analysis'**
|
||||||
|
String get audioAnalysisTitle;
|
||||||
|
|
||||||
|
/// Description for audio analysis tap-to-analyze prompt
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Verify lossless quality with spectrum analysis'**
|
||||||
|
String get audioAnalysisDescription;
|
||||||
|
|
||||||
|
/// Loading text while analyzing audio
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Analyzing audio...'**
|
||||||
|
String get audioAnalysisAnalyzing;
|
||||||
|
|
||||||
|
/// Sample rate metric label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Sample Rate'**
|
||||||
|
String get audioAnalysisSampleRate;
|
||||||
|
|
||||||
|
/// Bit depth metric label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Bit Depth'**
|
||||||
|
String get audioAnalysisBitDepth;
|
||||||
|
|
||||||
|
/// Channels metric label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Channels'**
|
||||||
|
String get audioAnalysisChannels;
|
||||||
|
|
||||||
|
/// Duration metric label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Duration'**
|
||||||
|
String get audioAnalysisDuration;
|
||||||
|
|
||||||
|
/// Nyquist frequency metric label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Nyquist'**
|
||||||
|
String get audioAnalysisNyquist;
|
||||||
|
|
||||||
|
/// File size metric label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Size'**
|
||||||
|
String get audioAnalysisFileSize;
|
||||||
|
|
||||||
|
/// Dynamic range metric label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Dynamic Range'**
|
||||||
|
String get audioAnalysisDynamicRange;
|
||||||
|
|
||||||
|
/// Peak amplitude metric label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Peak'**
|
||||||
|
String get audioAnalysisPeak;
|
||||||
|
|
||||||
|
/// RMS level metric label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'RMS'**
|
||||||
|
String get audioAnalysisRms;
|
||||||
|
|
||||||
|
/// Total samples metric label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Samples'**
|
||||||
|
String get audioAnalysisSamples;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AppLocalizationsDelegate
|
class _AppLocalizationsDelegate
|
||||||
|
|||||||
@@ -772,6 +772,36 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get searchPlaylists => 'Playlisten';
|
String get searchPlaylists => 'Playlisten';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitle => 'Sort Results';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDefault => 'Default';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitleAZ => 'Title (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitleZA => 'Title (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortArtistAZ => 'Artist (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortArtistZA => 'Artist (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDurationShort => 'Duration (Shortest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDurationLong => 'Duration (Longest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDateOldest => 'Release Date (Oldest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDateNewest => 'Release Date (Newest)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tooltipPlay => 'Abspielen';
|
String get tooltipPlay => 'Abspielen';
|
||||||
|
|
||||||
@@ -1449,16 +1479,6 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get qualityNote =>
|
String get qualityNote =>
|
||||||
'Die eigentliche Qualität hängt von der Verfügbarkeit des Dienstes ab';
|
'Die eigentliche Qualität hängt von der Verfügbarkeit des Dienstes ab';
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeQualityNote =>
|
|
||||||
'YouTube bietet nur verlustbehaftete Audioqualität. Deswegen ist es kein Teil des verlustfreien Fallbacks.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Qualität vor Download fragen';
|
String get downloadAskBeforeDownload => 'Qualität vor Download fragen';
|
||||||
|
|
||||||
@@ -1558,6 +1578,13 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||||
'Künstler/Album/ und Künstler/Singles/';
|
'Künstler/Album/ und Künstler/Singles/';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlatSubtitle =>
|
||||||
|
'Artist/Album/ and Artist/song.flac';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadedAlbumDeleteSelected => 'Ausgewählte löschen';
|
String get downloadedAlbumDeleteSelected => 'Ausgewählte löschen';
|
||||||
|
|
||||||
@@ -2995,4 +3022,106 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get editMetadataSelectEmpty => 'Empty only';
|
String get editMetadataSelectEmpty => 'Empty only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueDownloadingCount(int count) {
|
||||||
|
return 'Downloading ($count)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueDownloadedHeader => 'Downloaded';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueFilteringIndicator => 'Filtering...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueTrackCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count tracks',
|
||||||
|
one: '1 track',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueAlbumCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count albums',
|
||||||
|
one: '1 album',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyAlbums => 'No album downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyAlbumsSubtitle =>
|
||||||
|
'Download multiple tracks from an album to see them here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptySingles => 'No single downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptySinglesSubtitle =>
|
||||||
|
'Single track downloads will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyHistory => 'No download history';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionAllPlaylistsSelected => 'All playlists selected';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisTitle => 'Audio Quality Analysis';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDescription =>
|
||||||
|
'Verify lossless quality with spectrum analysis';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisAnalyzing => 'Analyzing audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisSampleRate => 'Sample Rate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisBitDepth => 'Bit Depth';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisChannels => 'Channels';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDuration => 'Duration';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisNyquist => 'Nyquist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisFileSize => 'Size';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDynamicRange => 'Dynamic Range';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisPeak => 'Peak';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisRms => 'RMS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisSamples => 'Samples';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -759,6 +759,36 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get searchPlaylists => 'Playlists';
|
String get searchPlaylists => 'Playlists';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitle => 'Sort Results';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDefault => 'Default';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitleAZ => 'Title (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitleZA => 'Title (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortArtistAZ => 'Artist (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortArtistZA => 'Artist (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDurationShort => 'Duration (Shortest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDurationLong => 'Duration (Longest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDateOldest => 'Release Date (Oldest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDateNewest => 'Release Date (Newest)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tooltipPlay => 'Play';
|
String get tooltipPlay => 'Play';
|
||||||
|
|
||||||
@@ -1425,16 +1455,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get qualityNote =>
|
String get qualityNote =>
|
||||||
'Actual quality depends on track availability from the service';
|
'Actual quality depends on track availability from the service';
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeQualityNote =>
|
|
||||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||||
|
|
||||||
@@ -1532,6 +1552,13 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||||
'Artist/Album/ and Artist/Singles/';
|
'Artist/Album/ and Artist/Singles/';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlatSubtitle =>
|
||||||
|
'Artist/Album/ and Artist/song.flac';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||||
|
|
||||||
@@ -2963,4 +2990,106 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get editMetadataSelectEmpty => 'Empty only';
|
String get editMetadataSelectEmpty => 'Empty only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueDownloadingCount(int count) {
|
||||||
|
return 'Downloading ($count)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueDownloadedHeader => 'Downloaded';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueFilteringIndicator => 'Filtering...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueTrackCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count tracks',
|
||||||
|
one: '1 track',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueAlbumCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count albums',
|
||||||
|
one: '1 album',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyAlbums => 'No album downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyAlbumsSubtitle =>
|
||||||
|
'Download multiple tracks from an album to see them here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptySingles => 'No single downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptySinglesSubtitle =>
|
||||||
|
'Single track downloads will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyHistory => 'No download history';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionAllPlaylistsSelected => 'All playlists selected';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisTitle => 'Audio Quality Analysis';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDescription =>
|
||||||
|
'Verify lossless quality with spectrum analysis';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisAnalyzing => 'Analyzing audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisSampleRate => 'Sample Rate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisBitDepth => 'Bit Depth';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisChannels => 'Channels';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDuration => 'Duration';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisNyquist => 'Nyquist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisFileSize => 'Size';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDynamicRange => 'Dynamic Range';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisPeak => 'Peak';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisRms => 'RMS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisSamples => 'Samples';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -759,6 +759,36 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get searchPlaylists => 'Playlists';
|
String get searchPlaylists => 'Playlists';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitle => 'Sort Results';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDefault => 'Default';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitleAZ => 'Title (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitleZA => 'Title (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortArtistAZ => 'Artist (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortArtistZA => 'Artist (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDurationShort => 'Duration (Shortest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDurationLong => 'Duration (Longest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDateOldest => 'Release Date (Oldest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDateNewest => 'Release Date (Newest)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tooltipPlay => 'Play';
|
String get tooltipPlay => 'Play';
|
||||||
|
|
||||||
@@ -1425,16 +1455,6 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
String get qualityNote =>
|
String get qualityNote =>
|
||||||
'Actual quality depends on track availability from the service';
|
'Actual quality depends on track availability from the service';
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeQualityNote =>
|
|
||||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||||
|
|
||||||
@@ -1532,6 +1552,13 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||||
'Artist/Album/ and Artist/Singles/';
|
'Artist/Album/ and Artist/Singles/';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlatSubtitle =>
|
||||||
|
'Artist/Album/ and Artist/song.flac';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||||
|
|
||||||
@@ -2963,6 +2990,108 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get editMetadataSelectEmpty => 'Empty only';
|
String get editMetadataSelectEmpty => 'Empty only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueDownloadingCount(int count) {
|
||||||
|
return 'Downloading ($count)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueDownloadedHeader => 'Downloaded';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueFilteringIndicator => 'Filtering...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueTrackCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count tracks',
|
||||||
|
one: '1 track',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueAlbumCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count albums',
|
||||||
|
one: '1 album',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyAlbums => 'No album downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyAlbumsSubtitle =>
|
||||||
|
'Download multiple tracks from an album to see them here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptySingles => 'No single downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptySinglesSubtitle =>
|
||||||
|
'Single track downloads will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyHistory => 'No download history';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionAllPlaylistsSelected => 'All playlists selected';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisTitle => 'Audio Quality Analysis';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDescription =>
|
||||||
|
'Verify lossless quality with spectrum analysis';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisAnalyzing => 'Analyzing audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisSampleRate => 'Sample Rate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisBitDepth => 'Bit Depth';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisChannels => 'Channels';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDuration => 'Duration';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisNyquist => 'Nyquist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisFileSize => 'Size';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDynamicRange => 'Dynamic Range';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisPeak => 'Peak';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisRms => 'RMS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisSamples => 'Samples';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The translations for Spanish Castilian, as used in Spain (`es_ES`).
|
/// The translations for Spanish Castilian, as used in Spain (`es_ES`).
|
||||||
@@ -4334,16 +4463,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs {
|
|||||||
String get qualityNote =>
|
String get qualityNote =>
|
||||||
'La calidad real depende de la disponibilidad de la pista del servicio';
|
'La calidad real depende de la disponibilidad de la pista del servicio';
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeQualityNote =>
|
|
||||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Preguntar antes de descargar';
|
String get downloadAskBeforeDownload => 'Preguntar antes de descargar';
|
||||||
|
|
||||||
|
|||||||
@@ -761,6 +761,36 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get searchPlaylists => 'Playlists';
|
String get searchPlaylists => 'Playlists';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitle => 'Sort Results';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDefault => 'Default';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitleAZ => 'Title (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitleZA => 'Title (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortArtistAZ => 'Artist (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortArtistZA => 'Artist (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDurationShort => 'Duration (Shortest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDurationLong => 'Duration (Longest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDateOldest => 'Release Date (Oldest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDateNewest => 'Release Date (Newest)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tooltipPlay => 'Play';
|
String get tooltipPlay => 'Play';
|
||||||
|
|
||||||
@@ -1427,16 +1457,6 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String get qualityNote =>
|
String get qualityNote =>
|
||||||
'Actual quality depends on track availability from the service';
|
'Actual quality depends on track availability from the service';
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeQualityNote =>
|
|
||||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||||
|
|
||||||
@@ -1534,6 +1554,13 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||||
'Artist/Album/ and Artist/Singles/';
|
'Artist/Album/ and Artist/Singles/';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlatSubtitle =>
|
||||||
|
'Artist/Album/ and Artist/song.flac';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||||
|
|
||||||
@@ -2964,4 +2991,106 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get editMetadataSelectEmpty => 'Empty only';
|
String get editMetadataSelectEmpty => 'Empty only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueDownloadingCount(int count) {
|
||||||
|
return 'Downloading ($count)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueDownloadedHeader => 'Downloaded';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueFilteringIndicator => 'Filtering...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueTrackCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count tracks',
|
||||||
|
one: '1 track',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueAlbumCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count albums',
|
||||||
|
one: '1 album',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyAlbums => 'No album downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyAlbumsSubtitle =>
|
||||||
|
'Download multiple tracks from an album to see them here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptySingles => 'No single downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptySinglesSubtitle =>
|
||||||
|
'Single track downloads will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyHistory => 'No download history';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionAllPlaylistsSelected => 'All playlists selected';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisTitle => 'Audio Quality Analysis';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDescription =>
|
||||||
|
'Verify lossless quality with spectrum analysis';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisAnalyzing => 'Analyzing audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisSampleRate => 'Sample Rate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisBitDepth => 'Bit Depth';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisChannels => 'Channels';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDuration => 'Duration';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisNyquist => 'Nyquist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisFileSize => 'Size';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDynamicRange => 'Dynamic Range';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisPeak => 'Peak';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisRms => 'RMS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisSamples => 'Samples';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -759,6 +759,36 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get searchPlaylists => 'Playlists';
|
String get searchPlaylists => 'Playlists';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitle => 'Sort Results';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDefault => 'Default';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitleAZ => 'Title (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitleZA => 'Title (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortArtistAZ => 'Artist (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortArtistZA => 'Artist (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDurationShort => 'Duration (Shortest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDurationLong => 'Duration (Longest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDateOldest => 'Release Date (Oldest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDateNewest => 'Release Date (Newest)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tooltipPlay => 'Play';
|
String get tooltipPlay => 'Play';
|
||||||
|
|
||||||
@@ -1425,16 +1455,6 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
String get qualityNote =>
|
String get qualityNote =>
|
||||||
'Actual quality depends on track availability from the service';
|
'Actual quality depends on track availability from the service';
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeQualityNote =>
|
|
||||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||||
|
|
||||||
@@ -1532,6 +1552,13 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||||
'Artist/Album/ and Artist/Singles/';
|
'Artist/Album/ and Artist/Singles/';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlatSubtitle =>
|
||||||
|
'Artist/Album/ and Artist/song.flac';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||||
|
|
||||||
@@ -2962,4 +2989,106 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get editMetadataSelectEmpty => 'Empty only';
|
String get editMetadataSelectEmpty => 'Empty only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueDownloadingCount(int count) {
|
||||||
|
return 'Downloading ($count)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueDownloadedHeader => 'Downloaded';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueFilteringIndicator => 'Filtering...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueTrackCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count tracks',
|
||||||
|
one: '1 track',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueAlbumCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count albums',
|
||||||
|
one: '1 album',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyAlbums => 'No album downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyAlbumsSubtitle =>
|
||||||
|
'Download multiple tracks from an album to see them here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptySingles => 'No single downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptySinglesSubtitle =>
|
||||||
|
'Single track downloads will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyHistory => 'No download history';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionAllPlaylistsSelected => 'All playlists selected';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisTitle => 'Audio Quality Analysis';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDescription =>
|
||||||
|
'Verify lossless quality with spectrum analysis';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisAnalyzing => 'Analyzing audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisSampleRate => 'Sample Rate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisBitDepth => 'Bit Depth';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisChannels => 'Channels';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDuration => 'Duration';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisNyquist => 'Nyquist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisFileSize => 'Size';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDynamicRange => 'Dynamic Range';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisPeak => 'Peak';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisRms => 'RMS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisSamples => 'Samples';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -762,6 +762,36 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get searchPlaylists => 'Playlist';
|
String get searchPlaylists => 'Playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitle => 'Sort Results';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDefault => 'Default';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitleAZ => 'Title (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitleZA => 'Title (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortArtistAZ => 'Artist (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortArtistZA => 'Artist (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDurationShort => 'Duration (Shortest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDurationLong => 'Duration (Longest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDateOldest => 'Release Date (Oldest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDateNewest => 'Release Date (Newest)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tooltipPlay => 'Putar';
|
String get tooltipPlay => 'Putar';
|
||||||
|
|
||||||
@@ -1433,16 +1463,6 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String get qualityNote =>
|
String get qualityNote =>
|
||||||
'Kualitas sebenarnya tergantung ketersediaan lagu dari layanan';
|
'Kualitas sebenarnya tergantung ketersediaan lagu dari layanan';
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeQualityNote =>
|
|
||||||
'YouTube hanya menyediakan audio terkompresi (lossy). Bukan bagian dari fallback lossless.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeOpusBitrateTitle => 'Bitrate YouTube Opus';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeMp3BitrateTitle => 'Kecepatan Bit MP3 YouTube';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Tanya Sebelum Unduh';
|
String get downloadAskBeforeDownload => 'Tanya Sebelum Unduh';
|
||||||
|
|
||||||
@@ -1541,6 +1561,13 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||||
'Artis/Album/ dan Artis/Single/';
|
'Artis/Album/ dan Artis/Single/';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlatSubtitle =>
|
||||||
|
'Artist/Album/ and Artist/song.flac';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadedAlbumDeleteSelected => 'Hapus yang Dipilih';
|
String get downloadedAlbumDeleteSelected => 'Hapus yang Dipilih';
|
||||||
|
|
||||||
@@ -2972,4 +2999,106 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get editMetadataSelectEmpty => 'Empty only';
|
String get editMetadataSelectEmpty => 'Empty only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueDownloadingCount(int count) {
|
||||||
|
return 'Downloading ($count)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueDownloadedHeader => 'Downloaded';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueFilteringIndicator => 'Filtering...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueTrackCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count tracks',
|
||||||
|
one: '1 track',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueAlbumCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count albums',
|
||||||
|
one: '1 album',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyAlbums => 'No album downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyAlbumsSubtitle =>
|
||||||
|
'Download multiple tracks from an album to see them here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptySingles => 'No single downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptySinglesSubtitle =>
|
||||||
|
'Single track downloads will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyHistory => 'No download history';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionAllPlaylistsSelected => 'All playlists selected';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisTitle => 'Audio Quality Analysis';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDescription =>
|
||||||
|
'Verify lossless quality with spectrum analysis';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisAnalyzing => 'Analyzing audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisSampleRate => 'Sample Rate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisBitDepth => 'Bit Depth';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisChannels => 'Channels';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDuration => 'Duration';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisNyquist => 'Nyquist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisFileSize => 'Size';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDynamicRange => 'Dynamic Range';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisPeak => 'Peak';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisRms => 'RMS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisSamples => 'Samples';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -754,6 +754,36 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get searchPlaylists => 'プレイリスト';
|
String get searchPlaylists => 'プレイリスト';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitle => 'Sort Results';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDefault => 'Default';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitleAZ => 'Title (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitleZA => 'Title (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortArtistAZ => 'Artist (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortArtistZA => 'Artist (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDurationShort => 'Duration (Shortest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDurationLong => 'Duration (Longest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDateOldest => 'Release Date (Oldest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDateNewest => 'Release Date (Newest)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tooltipPlay => '再生';
|
String get tooltipPlay => '再生';
|
||||||
|
|
||||||
@@ -1414,16 +1444,6 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get qualityNote => '実際の品質はサービスからのトラックの可用性に依存します';
|
String get qualityNote => '実際の品質はサービスからのトラックの可用性に依存します';
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeQualityNote =>
|
|
||||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeOpusBitrateTitle => 'YouTube Opus のビットレート';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeMp3BitrateTitle => 'YouTube MP3 のビットレート';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadAskBeforeDownload => 'ダウンロード前に確認する';
|
String get downloadAskBeforeDownload => 'ダウンロード前に確認する';
|
||||||
|
|
||||||
@@ -1519,6 +1539,13 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||||
'Artist/Album/ and Artist/Singles/';
|
'Artist/Album/ and Artist/Singles/';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlatSubtitle =>
|
||||||
|
'Artist/Album/ and Artist/song.flac';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadedAlbumDeleteSelected => '選択済みを削除';
|
String get downloadedAlbumDeleteSelected => '選択済みを削除';
|
||||||
|
|
||||||
@@ -2949,4 +2976,106 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get editMetadataSelectEmpty => 'Empty only';
|
String get editMetadataSelectEmpty => 'Empty only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueDownloadingCount(int count) {
|
||||||
|
return 'Downloading ($count)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueDownloadedHeader => 'Downloaded';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueFilteringIndicator => 'Filtering...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueTrackCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count tracks',
|
||||||
|
one: '1 track',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueAlbumCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count albums',
|
||||||
|
one: '1 album',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyAlbums => 'No album downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyAlbumsSubtitle =>
|
||||||
|
'Download multiple tracks from an album to see them here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptySingles => 'No single downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptySinglesSubtitle =>
|
||||||
|
'Single track downloads will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyHistory => 'No download history';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionAllPlaylistsSelected => 'All playlists selected';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisTitle => 'Audio Quality Analysis';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDescription =>
|
||||||
|
'Verify lossless quality with spectrum analysis';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisAnalyzing => 'Analyzing audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisSampleRate => 'Sample Rate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisBitDepth => 'Bit Depth';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisChannels => 'Channels';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDuration => 'Duration';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisNyquist => 'Nyquist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisFileSize => 'Size';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDynamicRange => 'Dynamic Range';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisPeak => 'Peak';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisRms => 'RMS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisSamples => 'Samples';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -741,6 +741,36 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get searchPlaylists => '재생목록들';
|
String get searchPlaylists => '재생목록들';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitle => 'Sort Results';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDefault => 'Default';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitleAZ => 'Title (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitleZA => 'Title (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortArtistAZ => 'Artist (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortArtistZA => 'Artist (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDurationShort => 'Duration (Shortest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDurationLong => 'Duration (Longest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDateOldest => 'Release Date (Oldest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDateNewest => 'Release Date (Newest)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tooltipPlay => '재생';
|
String get tooltipPlay => '재생';
|
||||||
|
|
||||||
@@ -1405,16 +1435,6 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
String get qualityNote =>
|
String get qualityNote =>
|
||||||
'Actual quality depends on track availability from the service';
|
'Actual quality depends on track availability from the service';
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeQualityNote =>
|
|
||||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||||
|
|
||||||
@@ -1512,6 +1532,13 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||||
'Artist/Album/ and Artist/Singles/';
|
'Artist/Album/ and Artist/Singles/';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlatSubtitle =>
|
||||||
|
'Artist/Album/ and Artist/song.flac';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||||
|
|
||||||
@@ -2942,4 +2969,106 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get editMetadataSelectEmpty => 'Empty only';
|
String get editMetadataSelectEmpty => 'Empty only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueDownloadingCount(int count) {
|
||||||
|
return 'Downloading ($count)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueDownloadedHeader => 'Downloaded';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueFilteringIndicator => 'Filtering...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueTrackCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count tracks',
|
||||||
|
one: '1 track',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueAlbumCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count albums',
|
||||||
|
one: '1 album',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyAlbums => 'No album downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyAlbumsSubtitle =>
|
||||||
|
'Download multiple tracks from an album to see them here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptySingles => 'No single downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptySinglesSubtitle =>
|
||||||
|
'Single track downloads will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyHistory => 'No download history';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionAllPlaylistsSelected => 'All playlists selected';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisTitle => 'Audio Quality Analysis';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDescription =>
|
||||||
|
'Verify lossless quality with spectrum analysis';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisAnalyzing => 'Analyzing audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisSampleRate => 'Sample Rate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisBitDepth => 'Bit Depth';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisChannels => 'Channels';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDuration => 'Duration';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisNyquist => 'Nyquist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisFileSize => 'Size';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDynamicRange => 'Dynamic Range';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisPeak => 'Peak';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisRms => 'RMS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisSamples => 'Samples';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -759,6 +759,36 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get searchPlaylists => 'Playlists';
|
String get searchPlaylists => 'Playlists';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitle => 'Sort Results';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDefault => 'Default';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitleAZ => 'Title (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitleZA => 'Title (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortArtistAZ => 'Artist (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortArtistZA => 'Artist (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDurationShort => 'Duration (Shortest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDurationLong => 'Duration (Longest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDateOldest => 'Release Date (Oldest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDateNewest => 'Release Date (Newest)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tooltipPlay => 'Play';
|
String get tooltipPlay => 'Play';
|
||||||
|
|
||||||
@@ -1425,16 +1455,6 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
String get qualityNote =>
|
String get qualityNote =>
|
||||||
'Actual quality depends on track availability from the service';
|
'Actual quality depends on track availability from the service';
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeQualityNote =>
|
|
||||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||||
|
|
||||||
@@ -1532,6 +1552,13 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||||
'Artist/Album/ and Artist/Singles/';
|
'Artist/Album/ and Artist/Singles/';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlatSubtitle =>
|
||||||
|
'Artist/Album/ and Artist/song.flac';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||||
|
|
||||||
@@ -2962,4 +2989,106 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get editMetadataSelectEmpty => 'Empty only';
|
String get editMetadataSelectEmpty => 'Empty only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueDownloadingCount(int count) {
|
||||||
|
return 'Downloading ($count)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueDownloadedHeader => 'Downloaded';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueFilteringIndicator => 'Filtering...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueTrackCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count tracks',
|
||||||
|
one: '1 track',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueAlbumCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count albums',
|
||||||
|
one: '1 album',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyAlbums => 'No album downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyAlbumsSubtitle =>
|
||||||
|
'Download multiple tracks from an album to see them here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptySingles => 'No single downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptySinglesSubtitle =>
|
||||||
|
'Single track downloads will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyHistory => 'No download history';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionAllPlaylistsSelected => 'All playlists selected';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisTitle => 'Audio Quality Analysis';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDescription =>
|
||||||
|
'Verify lossless quality with spectrum analysis';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisAnalyzing => 'Analyzing audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisSampleRate => 'Sample Rate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisBitDepth => 'Bit Depth';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisChannels => 'Channels';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDuration => 'Duration';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisNyquist => 'Nyquist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisFileSize => 'Size';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDynamicRange => 'Dynamic Range';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisPeak => 'Peak';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisRms => 'RMS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisSamples => 'Samples';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -759,6 +759,36 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get searchPlaylists => 'Playlists';
|
String get searchPlaylists => 'Playlists';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitle => 'Sort Results';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDefault => 'Default';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitleAZ => 'Title (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitleZA => 'Title (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortArtistAZ => 'Artist (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortArtistZA => 'Artist (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDurationShort => 'Duration (Shortest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDurationLong => 'Duration (Longest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDateOldest => 'Release Date (Oldest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDateNewest => 'Release Date (Newest)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tooltipPlay => 'Play';
|
String get tooltipPlay => 'Play';
|
||||||
|
|
||||||
@@ -1425,16 +1455,6 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
String get qualityNote =>
|
String get qualityNote =>
|
||||||
'Actual quality depends on track availability from the service';
|
'Actual quality depends on track availability from the service';
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeQualityNote =>
|
|
||||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||||
|
|
||||||
@@ -1532,6 +1552,13 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||||
'Artist/Album/ and Artist/Singles/';
|
'Artist/Album/ and Artist/Singles/';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlatSubtitle =>
|
||||||
|
'Artist/Album/ and Artist/song.flac';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||||
|
|
||||||
@@ -2963,6 +2990,108 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get editMetadataSelectEmpty => 'Empty only';
|
String get editMetadataSelectEmpty => 'Empty only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueDownloadingCount(int count) {
|
||||||
|
return 'Downloading ($count)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueDownloadedHeader => 'Downloaded';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueFilteringIndicator => 'Filtering...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueTrackCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count tracks',
|
||||||
|
one: '1 track',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueAlbumCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count albums',
|
||||||
|
one: '1 album',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyAlbums => 'No album downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyAlbumsSubtitle =>
|
||||||
|
'Download multiple tracks from an album to see them here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptySingles => 'No single downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptySinglesSubtitle =>
|
||||||
|
'Single track downloads will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyHistory => 'No download history';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionAllPlaylistsSelected => 'All playlists selected';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisTitle => 'Audio Quality Analysis';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDescription =>
|
||||||
|
'Verify lossless quality with spectrum analysis';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisAnalyzing => 'Analyzing audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisSampleRate => 'Sample Rate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisBitDepth => 'Bit Depth';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisChannels => 'Channels';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDuration => 'Duration';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisNyquist => 'Nyquist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisFileSize => 'Size';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDynamicRange => 'Dynamic Range';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisPeak => 'Peak';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisRms => 'RMS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisSamples => 'Samples';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The translations for Portuguese, as used in Portugal (`pt_PT`).
|
/// The translations for Portuguese, as used in Portugal (`pt_PT`).
|
||||||
@@ -4331,16 +4460,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
|
|||||||
String get qualityNote =>
|
String get qualityNote =>
|
||||||
'A qualidade real depende da faixa que estiver disponível no serviço';
|
'A qualidade real depende da faixa que estiver disponível no serviço';
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeQualityNote =>
|
|
||||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Perguntar qualidade antes de baixar';
|
String get downloadAskBeforeDownload => 'Perguntar qualidade antes de baixar';
|
||||||
|
|
||||||
|
|||||||
@@ -773,6 +773,36 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get searchPlaylists => 'Плейлисты';
|
String get searchPlaylists => 'Плейлисты';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitle => 'Sort Results';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDefault => 'Default';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitleAZ => 'Title (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitleZA => 'Title (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortArtistAZ => 'Artist (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortArtistZA => 'Artist (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDurationShort => 'Duration (Shortest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDurationLong => 'Duration (Longest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDateOldest => 'Release Date (Oldest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDateNewest => 'Release Date (Newest)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tooltipPlay => 'Воспроизвести';
|
String get tooltipPlay => 'Воспроизвести';
|
||||||
|
|
||||||
@@ -1450,16 +1480,6 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
String get qualityNote =>
|
String get qualityNote =>
|
||||||
'Фактическое качество зависит от доступности треков в сервисе';
|
'Фактическое качество зависит от доступности треков в сервисе';
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeQualityNote =>
|
|
||||||
'YouTube обеспечивает только звук с потерями(Lossy).';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeOpusBitrateTitle => 'Битрейт YouTube Opus';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeMp3BitrateTitle => 'Битрейт YouTube MP3';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Спрашивать перед скачиванием';
|
String get downloadAskBeforeDownload => 'Спрашивать перед скачиванием';
|
||||||
|
|
||||||
@@ -1561,6 +1581,13 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||||
'Исполнитель/Альбом и Исполнитель/Сингл/';
|
'Исполнитель/Альбом и Исполнитель/Сингл/';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlatSubtitle =>
|
||||||
|
'Artist/Album/ and Artist/song.flac';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadedAlbumDeleteSelected => 'Удалить выбранные';
|
String get downloadedAlbumDeleteSelected => 'Удалить выбранные';
|
||||||
|
|
||||||
@@ -3022,4 +3049,106 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get editMetadataSelectEmpty => 'Empty only';
|
String get editMetadataSelectEmpty => 'Empty only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueDownloadingCount(int count) {
|
||||||
|
return 'Downloading ($count)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueDownloadedHeader => 'Downloaded';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueFilteringIndicator => 'Filtering...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueTrackCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count tracks',
|
||||||
|
one: '1 track',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueAlbumCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count albums',
|
||||||
|
one: '1 album',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyAlbums => 'No album downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyAlbumsSubtitle =>
|
||||||
|
'Download multiple tracks from an album to see them here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptySingles => 'No single downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptySinglesSubtitle =>
|
||||||
|
'Single track downloads will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyHistory => 'No download history';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionAllPlaylistsSelected => 'All playlists selected';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisTitle => 'Audio Quality Analysis';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDescription =>
|
||||||
|
'Verify lossless quality with spectrum analysis';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisAnalyzing => 'Analyzing audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisSampleRate => 'Sample Rate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisBitDepth => 'Bit Depth';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisChannels => 'Channels';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDuration => 'Duration';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisNyquist => 'Nyquist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisFileSize => 'Size';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDynamicRange => 'Dynamic Range';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisPeak => 'Peak';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisRms => 'RMS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisSamples => 'Samples';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -764,6 +764,36 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get searchPlaylists => 'Çalma Listeleri';
|
String get searchPlaylists => 'Çalma Listeleri';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitle => 'Sort Results';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDefault => 'Default';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitleAZ => 'Title (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitleZA => 'Title (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortArtistAZ => 'Artist (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortArtistZA => 'Artist (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDurationShort => 'Duration (Shortest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDurationLong => 'Duration (Longest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDateOldest => 'Release Date (Oldest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDateNewest => 'Release Date (Newest)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tooltipPlay => 'Oynat';
|
String get tooltipPlay => 'Oynat';
|
||||||
|
|
||||||
@@ -1431,16 +1461,6 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
String get qualityNote =>
|
String get qualityNote =>
|
||||||
'Actual quality depends on track availability from the service';
|
'Actual quality depends on track availability from the service';
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeQualityNote =>
|
|
||||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||||
|
|
||||||
@@ -1538,6 +1558,13 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||||
'Artist/Album/ and Artist/Singles/';
|
'Artist/Album/ and Artist/Singles/';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlatSubtitle =>
|
||||||
|
'Artist/Album/ and Artist/song.flac';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||||
|
|
||||||
@@ -2968,4 +2995,106 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get editMetadataSelectEmpty => 'Empty only';
|
String get editMetadataSelectEmpty => 'Empty only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueDownloadingCount(int count) {
|
||||||
|
return 'Downloading ($count)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueDownloadedHeader => 'Downloaded';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueFilteringIndicator => 'Filtering...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueTrackCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count tracks',
|
||||||
|
one: '1 track',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueAlbumCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count albums',
|
||||||
|
one: '1 album',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyAlbums => 'No album downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyAlbumsSubtitle =>
|
||||||
|
'Download multiple tracks from an album to see them here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptySingles => 'No single downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptySinglesSubtitle =>
|
||||||
|
'Single track downloads will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyHistory => 'No download history';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionAllPlaylistsSelected => 'All playlists selected';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisTitle => 'Audio Quality Analysis';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDescription =>
|
||||||
|
'Verify lossless quality with spectrum analysis';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisAnalyzing => 'Analyzing audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisSampleRate => 'Sample Rate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisBitDepth => 'Bit Depth';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisChannels => 'Channels';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDuration => 'Duration';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisNyquist => 'Nyquist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisFileSize => 'Size';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDynamicRange => 'Dynamic Range';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisPeak => 'Peak';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisRms => 'RMS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisSamples => 'Samples';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -759,6 +759,36 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get searchPlaylists => 'Playlists';
|
String get searchPlaylists => 'Playlists';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitle => 'Sort Results';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDefault => 'Default';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitleAZ => 'Title (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitleZA => 'Title (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortArtistAZ => 'Artist (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortArtistZA => 'Artist (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDurationShort => 'Duration (Shortest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDurationLong => 'Duration (Longest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDateOldest => 'Release Date (Oldest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDateNewest => 'Release Date (Newest)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tooltipPlay => 'Play';
|
String get tooltipPlay => 'Play';
|
||||||
|
|
||||||
@@ -1425,16 +1455,6 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
String get qualityNote =>
|
String get qualityNote =>
|
||||||
'Actual quality depends on track availability from the service';
|
'Actual quality depends on track availability from the service';
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeQualityNote =>
|
|
||||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||||
|
|
||||||
@@ -1532,6 +1552,13 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||||
'Artist/Album/ and Artist/Singles/';
|
'Artist/Album/ and Artist/Singles/';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlatSubtitle =>
|
||||||
|
'Artist/Album/ and Artist/song.flac';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||||
|
|
||||||
@@ -2963,6 +2990,108 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get editMetadataSelectEmpty => 'Empty only';
|
String get editMetadataSelectEmpty => 'Empty only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueDownloadingCount(int count) {
|
||||||
|
return 'Downloading ($count)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueDownloadedHeader => 'Downloaded';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueFilteringIndicator => 'Filtering...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueTrackCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count tracks',
|
||||||
|
one: '1 track',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueAlbumCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count albums',
|
||||||
|
one: '1 album',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyAlbums => 'No album downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyAlbumsSubtitle =>
|
||||||
|
'Download multiple tracks from an album to see them here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptySingles => 'No single downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptySinglesSubtitle =>
|
||||||
|
'Single track downloads will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyHistory => 'No download history';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionAllPlaylistsSelected => 'All playlists selected';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisTitle => 'Audio Quality Analysis';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDescription =>
|
||||||
|
'Verify lossless quality with spectrum analysis';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisAnalyzing => 'Analyzing audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisSampleRate => 'Sample Rate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisBitDepth => 'Bit Depth';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisChannels => 'Channels';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDuration => 'Duration';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisNyquist => 'Nyquist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisFileSize => 'Size';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDynamicRange => 'Dynamic Range';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisPeak => 'Peak';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisRms => 'RMS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisSamples => 'Samples';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The translations for Chinese, as used in China (`zh_CN`).
|
/// The translations for Chinese, as used in China (`zh_CN`).
|
||||||
@@ -4297,16 +4426,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh {
|
|||||||
String get qualityNote =>
|
String get qualityNote =>
|
||||||
'Actual quality depends on track availability from the service';
|
'Actual quality depends on track availability from the service';
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeQualityNote =>
|
|
||||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||||
|
|
||||||
@@ -6703,16 +6822,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
|
|||||||
String get qualityNote =>
|
String get qualityNote =>
|
||||||
'Actual quality depends on track availability from the service';
|
'Actual quality depends on track availability from the service';
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeQualityNote =>
|
|
||||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||||
|
|
||||||
|
|||||||
@@ -1773,18 +1773,6 @@
|
|||||||
"@qualityNote": {
|
"@qualityNote": {
|
||||||
"description": "Note about quality availability"
|
"description": "Note about quality availability"
|
||||||
},
|
},
|
||||||
"youtubeQualityNote": "YouTube bietet nur verlustbehaftete Audioqualität. Deswegen ist es kein Teil des verlustfreien Fallbacks.",
|
|
||||||
"@youtubeQualityNote": {
|
|
||||||
"description": "Note for YouTube service explaining lossy-only quality"
|
|
||||||
},
|
|
||||||
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
|
|
||||||
"@youtubeOpusBitrateTitle": {
|
|
||||||
"description": "Title for YouTube Opus bitrate setting"
|
|
||||||
},
|
|
||||||
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
|
|
||||||
"@youtubeMp3BitrateTitle": {
|
|
||||||
"description": "Title for YouTube MP3 bitrate setting"
|
|
||||||
},
|
|
||||||
"downloadAskBeforeDownload": "Qualität vor Download fragen",
|
"downloadAskBeforeDownload": "Qualität vor Download fragen",
|
||||||
"@downloadAskBeforeDownload": {
|
"@downloadAskBeforeDownload": {
|
||||||
"description": "Setting - show quality picker"
|
"description": "Setting - show quality picker"
|
||||||
|
|||||||
+172
-12
@@ -999,6 +999,46 @@
|
|||||||
"@searchPlaylists": {
|
"@searchPlaylists": {
|
||||||
"description": "Search result category - playlists"
|
"description": "Search result category - playlists"
|
||||||
},
|
},
|
||||||
|
"searchSortTitle": "Sort Results",
|
||||||
|
"@searchSortTitle": {
|
||||||
|
"description": "Bottom sheet title for search sort options"
|
||||||
|
},
|
||||||
|
"searchSortDefault": "Default",
|
||||||
|
"@searchSortDefault": {
|
||||||
|
"description": "Sort option - default API order"
|
||||||
|
},
|
||||||
|
"searchSortTitleAZ": "Title (A-Z)",
|
||||||
|
"@searchSortTitleAZ": {
|
||||||
|
"description": "Sort option - title ascending"
|
||||||
|
},
|
||||||
|
"searchSortTitleZA": "Title (Z-A)",
|
||||||
|
"@searchSortTitleZA": {
|
||||||
|
"description": "Sort option - title descending"
|
||||||
|
},
|
||||||
|
"searchSortArtistAZ": "Artist (A-Z)",
|
||||||
|
"@searchSortArtistAZ": {
|
||||||
|
"description": "Sort option - artist ascending"
|
||||||
|
},
|
||||||
|
"searchSortArtistZA": "Artist (Z-A)",
|
||||||
|
"@searchSortArtistZA": {
|
||||||
|
"description": "Sort option - artist descending"
|
||||||
|
},
|
||||||
|
"searchSortDurationShort": "Duration (Shortest)",
|
||||||
|
"@searchSortDurationShort": {
|
||||||
|
"description": "Sort option - shortest duration first"
|
||||||
|
},
|
||||||
|
"searchSortDurationLong": "Duration (Longest)",
|
||||||
|
"@searchSortDurationLong": {
|
||||||
|
"description": "Sort option - longest duration first"
|
||||||
|
},
|
||||||
|
"searchSortDateOldest": "Release Date (Oldest)",
|
||||||
|
"@searchSortDateOldest": {
|
||||||
|
"description": "Sort option - oldest release first"
|
||||||
|
},
|
||||||
|
"searchSortDateNewest": "Release Date (Newest)",
|
||||||
|
"@searchSortDateNewest": {
|
||||||
|
"description": "Sort option - newest release first"
|
||||||
|
},
|
||||||
"tooltipPlay": "Play",
|
"tooltipPlay": "Play",
|
||||||
"@tooltipPlay": {
|
"@tooltipPlay": {
|
||||||
"description": "Tooltip - play button"
|
"description": "Tooltip - play button"
|
||||||
@@ -1869,18 +1909,6 @@
|
|||||||
"@qualityNote": {
|
"@qualityNote": {
|
||||||
"description": "Note about quality availability"
|
"description": "Note about quality availability"
|
||||||
},
|
},
|
||||||
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
|
|
||||||
"@youtubeQualityNote": {
|
|
||||||
"description": "Note for YouTube service explaining lossy-only quality"
|
|
||||||
},
|
|
||||||
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
|
|
||||||
"@youtubeOpusBitrateTitle": {
|
|
||||||
"description": "Title for YouTube Opus bitrate setting"
|
|
||||||
},
|
|
||||||
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
|
|
||||||
"@youtubeMp3BitrateTitle": {
|
|
||||||
"description": "Title for YouTube MP3 bitrate setting"
|
|
||||||
},
|
|
||||||
"downloadAskBeforeDownload": "Ask Before Download",
|
"downloadAskBeforeDownload": "Ask Before Download",
|
||||||
"@downloadAskBeforeDownload": {
|
"@downloadAskBeforeDownload": {
|
||||||
"description": "Setting - show quality picker"
|
"description": "Setting - show quality picker"
|
||||||
@@ -2001,6 +2029,14 @@
|
|||||||
"@albumFolderArtistAlbumSinglesSubtitle": {
|
"@albumFolderArtistAlbumSinglesSubtitle": {
|
||||||
"description": "Folder structure example"
|
"description": "Folder structure example"
|
||||||
},
|
},
|
||||||
|
"albumFolderArtistAlbumFlat": "Artist / Album (Singles flat)",
|
||||||
|
"@albumFolderArtistAlbumFlat": {
|
||||||
|
"description": "Album folder option with singles directly in artist folder"
|
||||||
|
},
|
||||||
|
"albumFolderArtistAlbumFlatSubtitle": "Artist/Album/ and Artist/song.flac",
|
||||||
|
"@albumFolderArtistAlbumFlatSubtitle": {
|
||||||
|
"description": "Folder structure example for flat singles"
|
||||||
|
},
|
||||||
"downloadedAlbumDeleteSelected": "Delete Selected",
|
"downloadedAlbumDeleteSelected": "Delete Selected",
|
||||||
"@downloadedAlbumDeleteSelected": {
|
"@downloadedAlbumDeleteSelected": {
|
||||||
"description": "Button - delete selected tracks"
|
"description": "Button - delete selected tracks"
|
||||||
@@ -3903,5 +3939,129 @@
|
|||||||
"editMetadataSelectEmpty": "Empty only",
|
"editMetadataSelectEmpty": "Empty only",
|
||||||
"@editMetadataSelectEmpty": {
|
"@editMetadataSelectEmpty": {
|
||||||
"description": "Button to select only fields that are currently empty"
|
"description": "Button to select only fields that are currently empty"
|
||||||
|
},
|
||||||
|
|
||||||
|
"queueDownloadingCount": "Downloading ({count})",
|
||||||
|
"@queueDownloadingCount": {
|
||||||
|
"description": "Header for active downloads section with count",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"queueDownloadedHeader": "Downloaded",
|
||||||
|
"@queueDownloadedHeader": {
|
||||||
|
"description": "Header label for downloaded items section in library"
|
||||||
|
},
|
||||||
|
"queueFilteringIndicator": "Filtering...",
|
||||||
|
"@queueFilteringIndicator": {
|
||||||
|
"description": "Shown while filter results are being computed"
|
||||||
|
},
|
||||||
|
"queueTrackCount": "{count, plural, =1{1 track} other{{count} tracks}}",
|
||||||
|
"@queueTrackCount": {
|
||||||
|
"description": "Track count label with plural support",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"queueAlbumCount": "{count, plural, =1{1 album} other{{count} albums}}",
|
||||||
|
"@queueAlbumCount": {
|
||||||
|
"description": "Album count label with plural support",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"queueEmptyAlbums": "No album downloads",
|
||||||
|
"@queueEmptyAlbums": {
|
||||||
|
"description": "Empty state title when no album downloads exist"
|
||||||
|
},
|
||||||
|
"queueEmptyAlbumsSubtitle": "Download multiple tracks from an album to see them here",
|
||||||
|
"@queueEmptyAlbumsSubtitle": {
|
||||||
|
"description": "Empty state subtitle for album downloads"
|
||||||
|
},
|
||||||
|
"queueEmptySingles": "No single downloads",
|
||||||
|
"@queueEmptySingles": {
|
||||||
|
"description": "Empty state title when no single track downloads exist"
|
||||||
|
},
|
||||||
|
"queueEmptySinglesSubtitle": "Single track downloads will appear here",
|
||||||
|
"@queueEmptySinglesSubtitle": {
|
||||||
|
"description": "Empty state subtitle for single track downloads"
|
||||||
|
},
|
||||||
|
"queueEmptyHistory": "No download history",
|
||||||
|
"@queueEmptyHistory": {
|
||||||
|
"description": "Empty state title when download history is empty"
|
||||||
|
},
|
||||||
|
"queueEmptyHistorySubtitle": "Downloaded tracks will appear here",
|
||||||
|
"@queueEmptyHistorySubtitle": {
|
||||||
|
"description": "Empty state subtitle for download history"
|
||||||
|
},
|
||||||
|
"selectionAllPlaylistsSelected": "All playlists selected",
|
||||||
|
"@selectionAllPlaylistsSelected": {
|
||||||
|
"description": "Shown when all playlists are selected in selection mode"
|
||||||
|
},
|
||||||
|
"selectionTapPlaylistsToSelect": "Tap playlists to select",
|
||||||
|
"@selectionTapPlaylistsToSelect": {
|
||||||
|
"description": "Hint shown in playlist selection mode"
|
||||||
|
},
|
||||||
|
"selectionSelectPlaylistsToDelete": "Select playlists to delete",
|
||||||
|
"@selectionSelectPlaylistsToDelete": {
|
||||||
|
"description": "Hint shown when no playlists are selected for deletion"
|
||||||
|
},
|
||||||
|
"audioAnalysisTitle": "Audio Quality Analysis",
|
||||||
|
"@audioAnalysisTitle": {
|
||||||
|
"description": "Title for audio analysis section"
|
||||||
|
},
|
||||||
|
"audioAnalysisDescription": "Verify lossless quality with spectrum analysis",
|
||||||
|
"@audioAnalysisDescription": {
|
||||||
|
"description": "Description for audio analysis tap-to-analyze prompt"
|
||||||
|
},
|
||||||
|
"audioAnalysisAnalyzing": "Analyzing audio...",
|
||||||
|
"@audioAnalysisAnalyzing": {
|
||||||
|
"description": "Loading text while analyzing audio"
|
||||||
|
},
|
||||||
|
"audioAnalysisSampleRate": "Sample Rate",
|
||||||
|
"@audioAnalysisSampleRate": {
|
||||||
|
"description": "Sample rate metric label"
|
||||||
|
},
|
||||||
|
"audioAnalysisBitDepth": "Bit Depth",
|
||||||
|
"@audioAnalysisBitDepth": {
|
||||||
|
"description": "Bit depth metric label"
|
||||||
|
},
|
||||||
|
"audioAnalysisChannels": "Channels",
|
||||||
|
"@audioAnalysisChannels": {
|
||||||
|
"description": "Channels metric label"
|
||||||
|
},
|
||||||
|
"audioAnalysisDuration": "Duration",
|
||||||
|
"@audioAnalysisDuration": {
|
||||||
|
"description": "Duration metric label"
|
||||||
|
},
|
||||||
|
"audioAnalysisNyquist": "Nyquist",
|
||||||
|
"@audioAnalysisNyquist": {
|
||||||
|
"description": "Nyquist frequency metric label"
|
||||||
|
},
|
||||||
|
"audioAnalysisFileSize": "Size",
|
||||||
|
"@audioAnalysisFileSize": {
|
||||||
|
"description": "File size metric label"
|
||||||
|
},
|
||||||
|
"audioAnalysisDynamicRange": "Dynamic Range",
|
||||||
|
"@audioAnalysisDynamicRange": {
|
||||||
|
"description": "Dynamic range metric label"
|
||||||
|
},
|
||||||
|
"audioAnalysisPeak": "Peak",
|
||||||
|
"@audioAnalysisPeak": {
|
||||||
|
"description": "Peak amplitude metric label"
|
||||||
|
},
|
||||||
|
"audioAnalysisRms": "RMS",
|
||||||
|
"@audioAnalysisRms": {
|
||||||
|
"description": "RMS level metric label"
|
||||||
|
},
|
||||||
|
"audioAnalysisSamples": "Samples",
|
||||||
|
"@audioAnalysisSamples": {
|
||||||
|
"description": "Total samples metric label"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1773,18 +1773,6 @@
|
|||||||
"@qualityNote": {
|
"@qualityNote": {
|
||||||
"description": "Note about quality availability"
|
"description": "Note about quality availability"
|
||||||
},
|
},
|
||||||
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
|
|
||||||
"@youtubeQualityNote": {
|
|
||||||
"description": "Note for YouTube service explaining lossy-only quality"
|
|
||||||
},
|
|
||||||
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
|
|
||||||
"@youtubeOpusBitrateTitle": {
|
|
||||||
"description": "Title for YouTube Opus bitrate setting"
|
|
||||||
},
|
|
||||||
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
|
|
||||||
"@youtubeMp3BitrateTitle": {
|
|
||||||
"description": "Title for YouTube MP3 bitrate setting"
|
|
||||||
},
|
|
||||||
"downloadAskBeforeDownload": "Preguntar antes de descargar",
|
"downloadAskBeforeDownload": "Preguntar antes de descargar",
|
||||||
"@downloadAskBeforeDownload": {
|
"@downloadAskBeforeDownload": {
|
||||||
"description": "Setting - show quality picker"
|
"description": "Setting - show quality picker"
|
||||||
|
|||||||
@@ -1773,18 +1773,6 @@
|
|||||||
"@qualityNote": {
|
"@qualityNote": {
|
||||||
"description": "Note about quality availability"
|
"description": "Note about quality availability"
|
||||||
},
|
},
|
||||||
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
|
|
||||||
"@youtubeQualityNote": {
|
|
||||||
"description": "Note for YouTube service explaining lossy-only quality"
|
|
||||||
},
|
|
||||||
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
|
|
||||||
"@youtubeOpusBitrateTitle": {
|
|
||||||
"description": "Title for YouTube Opus bitrate setting"
|
|
||||||
},
|
|
||||||
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
|
|
||||||
"@youtubeMp3BitrateTitle": {
|
|
||||||
"description": "Title for YouTube MP3 bitrate setting"
|
|
||||||
},
|
|
||||||
"downloadAskBeforeDownload": "Ask Before Download",
|
"downloadAskBeforeDownload": "Ask Before Download",
|
||||||
"@downloadAskBeforeDownload": {
|
"@downloadAskBeforeDownload": {
|
||||||
"description": "Setting - show quality picker"
|
"description": "Setting - show quality picker"
|
||||||
|
|||||||
@@ -1773,18 +1773,6 @@
|
|||||||
"@qualityNote": {
|
"@qualityNote": {
|
||||||
"description": "Note about quality availability"
|
"description": "Note about quality availability"
|
||||||
},
|
},
|
||||||
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
|
|
||||||
"@youtubeQualityNote": {
|
|
||||||
"description": "Note for YouTube service explaining lossy-only quality"
|
|
||||||
},
|
|
||||||
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
|
|
||||||
"@youtubeOpusBitrateTitle": {
|
|
||||||
"description": "Title for YouTube Opus bitrate setting"
|
|
||||||
},
|
|
||||||
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
|
|
||||||
"@youtubeMp3BitrateTitle": {
|
|
||||||
"description": "Title for YouTube MP3 bitrate setting"
|
|
||||||
},
|
|
||||||
"downloadAskBeforeDownload": "Ask Before Download",
|
"downloadAskBeforeDownload": "Ask Before Download",
|
||||||
"@downloadAskBeforeDownload": {
|
"@downloadAskBeforeDownload": {
|
||||||
"description": "Setting - show quality picker"
|
"description": "Setting - show quality picker"
|
||||||
|
|||||||
@@ -1773,18 +1773,6 @@
|
|||||||
"@qualityNote": {
|
"@qualityNote": {
|
||||||
"description": "Note about quality availability"
|
"description": "Note about quality availability"
|
||||||
},
|
},
|
||||||
"youtubeQualityNote": "YouTube hanya menyediakan audio terkompresi (lossy). Bukan bagian dari fallback lossless.",
|
|
||||||
"@youtubeQualityNote": {
|
|
||||||
"description": "Note for YouTube service explaining lossy-only quality"
|
|
||||||
},
|
|
||||||
"youtubeOpusBitrateTitle": "Bitrate YouTube Opus",
|
|
||||||
"@youtubeOpusBitrateTitle": {
|
|
||||||
"description": "Title for YouTube Opus bitrate setting"
|
|
||||||
},
|
|
||||||
"youtubeMp3BitrateTitle": "Kecepatan Bit MP3 YouTube",
|
|
||||||
"@youtubeMp3BitrateTitle": {
|
|
||||||
"description": "Title for YouTube MP3 bitrate setting"
|
|
||||||
},
|
|
||||||
"downloadAskBeforeDownload": "Tanya Sebelum Unduh",
|
"downloadAskBeforeDownload": "Tanya Sebelum Unduh",
|
||||||
"@downloadAskBeforeDownload": {
|
"@downloadAskBeforeDownload": {
|
||||||
"description": "Setting - show quality picker"
|
"description": "Setting - show quality picker"
|
||||||
|
|||||||
@@ -1773,18 +1773,6 @@
|
|||||||
"@qualityNote": {
|
"@qualityNote": {
|
||||||
"description": "Note about quality availability"
|
"description": "Note about quality availability"
|
||||||
},
|
},
|
||||||
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
|
|
||||||
"@youtubeQualityNote": {
|
|
||||||
"description": "Note for YouTube service explaining lossy-only quality"
|
|
||||||
},
|
|
||||||
"youtubeOpusBitrateTitle": "YouTube Opus のビットレート",
|
|
||||||
"@youtubeOpusBitrateTitle": {
|
|
||||||
"description": "Title for YouTube Opus bitrate setting"
|
|
||||||
},
|
|
||||||
"youtubeMp3BitrateTitle": "YouTube MP3 のビットレート",
|
|
||||||
"@youtubeMp3BitrateTitle": {
|
|
||||||
"description": "Title for YouTube MP3 bitrate setting"
|
|
||||||
},
|
|
||||||
"downloadAskBeforeDownload": "ダウンロード前に確認する",
|
"downloadAskBeforeDownload": "ダウンロード前に確認する",
|
||||||
"@downloadAskBeforeDownload": {
|
"@downloadAskBeforeDownload": {
|
||||||
"description": "Setting - show quality picker"
|
"description": "Setting - show quality picker"
|
||||||
|
|||||||
@@ -1773,18 +1773,6 @@
|
|||||||
"@qualityNote": {
|
"@qualityNote": {
|
||||||
"description": "Note about quality availability"
|
"description": "Note about quality availability"
|
||||||
},
|
},
|
||||||
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
|
|
||||||
"@youtubeQualityNote": {
|
|
||||||
"description": "Note for YouTube service explaining lossy-only quality"
|
|
||||||
},
|
|
||||||
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
|
|
||||||
"@youtubeOpusBitrateTitle": {
|
|
||||||
"description": "Title for YouTube Opus bitrate setting"
|
|
||||||
},
|
|
||||||
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
|
|
||||||
"@youtubeMp3BitrateTitle": {
|
|
||||||
"description": "Title for YouTube MP3 bitrate setting"
|
|
||||||
},
|
|
||||||
"downloadAskBeforeDownload": "Ask Before Download",
|
"downloadAskBeforeDownload": "Ask Before Download",
|
||||||
"@downloadAskBeforeDownload": {
|
"@downloadAskBeforeDownload": {
|
||||||
"description": "Setting - show quality picker"
|
"description": "Setting - show quality picker"
|
||||||
|
|||||||
@@ -1773,18 +1773,6 @@
|
|||||||
"@qualityNote": {
|
"@qualityNote": {
|
||||||
"description": "Note about quality availability"
|
"description": "Note about quality availability"
|
||||||
},
|
},
|
||||||
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
|
|
||||||
"@youtubeQualityNote": {
|
|
||||||
"description": "Note for YouTube service explaining lossy-only quality"
|
|
||||||
},
|
|
||||||
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
|
|
||||||
"@youtubeOpusBitrateTitle": {
|
|
||||||
"description": "Title for YouTube Opus bitrate setting"
|
|
||||||
},
|
|
||||||
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
|
|
||||||
"@youtubeMp3BitrateTitle": {
|
|
||||||
"description": "Title for YouTube MP3 bitrate setting"
|
|
||||||
},
|
|
||||||
"downloadAskBeforeDownload": "Ask Before Download",
|
"downloadAskBeforeDownload": "Ask Before Download",
|
||||||
"@downloadAskBeforeDownload": {
|
"@downloadAskBeforeDownload": {
|
||||||
"description": "Setting - show quality picker"
|
"description": "Setting - show quality picker"
|
||||||
|
|||||||
@@ -1773,18 +1773,6 @@
|
|||||||
"@qualityNote": {
|
"@qualityNote": {
|
||||||
"description": "Note about quality availability"
|
"description": "Note about quality availability"
|
||||||
},
|
},
|
||||||
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
|
|
||||||
"@youtubeQualityNote": {
|
|
||||||
"description": "Note for YouTube service explaining lossy-only quality"
|
|
||||||
},
|
|
||||||
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
|
|
||||||
"@youtubeOpusBitrateTitle": {
|
|
||||||
"description": "Title for YouTube Opus bitrate setting"
|
|
||||||
},
|
|
||||||
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
|
|
||||||
"@youtubeMp3BitrateTitle": {
|
|
||||||
"description": "Title for YouTube MP3 bitrate setting"
|
|
||||||
},
|
|
||||||
"downloadAskBeforeDownload": "Perguntar qualidade antes de baixar",
|
"downloadAskBeforeDownload": "Perguntar qualidade antes de baixar",
|
||||||
"@downloadAskBeforeDownload": {
|
"@downloadAskBeforeDownload": {
|
||||||
"description": "Setting - show quality picker"
|
"description": "Setting - show quality picker"
|
||||||
|
|||||||
@@ -1773,18 +1773,6 @@
|
|||||||
"@qualityNote": {
|
"@qualityNote": {
|
||||||
"description": "Note about quality availability"
|
"description": "Note about quality availability"
|
||||||
},
|
},
|
||||||
"youtubeQualityNote": "YouTube обеспечивает только звук с потерями(Lossy).",
|
|
||||||
"@youtubeQualityNote": {
|
|
||||||
"description": "Note for YouTube service explaining lossy-only quality"
|
|
||||||
},
|
|
||||||
"youtubeOpusBitrateTitle": "Битрейт YouTube Opus",
|
|
||||||
"@youtubeOpusBitrateTitle": {
|
|
||||||
"description": "Title for YouTube Opus bitrate setting"
|
|
||||||
},
|
|
||||||
"youtubeMp3BitrateTitle": "Битрейт YouTube MP3",
|
|
||||||
"@youtubeMp3BitrateTitle": {
|
|
||||||
"description": "Title for YouTube MP3 bitrate setting"
|
|
||||||
},
|
|
||||||
"downloadAskBeforeDownload": "Спрашивать перед скачиванием",
|
"downloadAskBeforeDownload": "Спрашивать перед скачиванием",
|
||||||
"@downloadAskBeforeDownload": {
|
"@downloadAskBeforeDownload": {
|
||||||
"description": "Setting - show quality picker"
|
"description": "Setting - show quality picker"
|
||||||
|
|||||||
@@ -1773,18 +1773,6 @@
|
|||||||
"@qualityNote": {
|
"@qualityNote": {
|
||||||
"description": "Note about quality availability"
|
"description": "Note about quality availability"
|
||||||
},
|
},
|
||||||
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
|
|
||||||
"@youtubeQualityNote": {
|
|
||||||
"description": "Note for YouTube service explaining lossy-only quality"
|
|
||||||
},
|
|
||||||
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
|
|
||||||
"@youtubeOpusBitrateTitle": {
|
|
||||||
"description": "Title for YouTube Opus bitrate setting"
|
|
||||||
},
|
|
||||||
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
|
|
||||||
"@youtubeMp3BitrateTitle": {
|
|
||||||
"description": "Title for YouTube MP3 bitrate setting"
|
|
||||||
},
|
|
||||||
"downloadAskBeforeDownload": "Ask Before Download",
|
"downloadAskBeforeDownload": "Ask Before Download",
|
||||||
"@downloadAskBeforeDownload": {
|
"@downloadAskBeforeDownload": {
|
||||||
"description": "Setting - show quality picker"
|
"description": "Setting - show quality picker"
|
||||||
|
|||||||
@@ -1773,18 +1773,6 @@
|
|||||||
"@qualityNote": {
|
"@qualityNote": {
|
||||||
"description": "Note about quality availability"
|
"description": "Note about quality availability"
|
||||||
},
|
},
|
||||||
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
|
|
||||||
"@youtubeQualityNote": {
|
|
||||||
"description": "Note for YouTube service explaining lossy-only quality"
|
|
||||||
},
|
|
||||||
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
|
|
||||||
"@youtubeOpusBitrateTitle": {
|
|
||||||
"description": "Title for YouTube Opus bitrate setting"
|
|
||||||
},
|
|
||||||
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
|
|
||||||
"@youtubeMp3BitrateTitle": {
|
|
||||||
"description": "Title for YouTube MP3 bitrate setting"
|
|
||||||
},
|
|
||||||
"downloadAskBeforeDownload": "Ask Before Download",
|
"downloadAskBeforeDownload": "Ask Before Download",
|
||||||
"@downloadAskBeforeDownload": {
|
"@downloadAskBeforeDownload": {
|
||||||
"description": "Setting - show quality picker"
|
"description": "Setting - show quality picker"
|
||||||
|
|||||||
@@ -1773,18 +1773,6 @@
|
|||||||
"@qualityNote": {
|
"@qualityNote": {
|
||||||
"description": "Note about quality availability"
|
"description": "Note about quality availability"
|
||||||
},
|
},
|
||||||
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
|
|
||||||
"@youtubeQualityNote": {
|
|
||||||
"description": "Note for YouTube service explaining lossy-only quality"
|
|
||||||
},
|
|
||||||
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
|
|
||||||
"@youtubeOpusBitrateTitle": {
|
|
||||||
"description": "Title for YouTube Opus bitrate setting"
|
|
||||||
},
|
|
||||||
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
|
|
||||||
"@youtubeMp3BitrateTitle": {
|
|
||||||
"description": "Title for YouTube MP3 bitrate setting"
|
|
||||||
},
|
|
||||||
"downloadAskBeforeDownload": "Ask Before Download",
|
"downloadAskBeforeDownload": "Ask Before Download",
|
||||||
"@downloadAskBeforeDownload": {
|
"@downloadAskBeforeDownload": {
|
||||||
"description": "Setting - show quality picker"
|
"description": "Setting - show quality picker"
|
||||||
|
|||||||
@@ -192,11 +192,9 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization>
|
|||||||
if (settings.localLibraryPath.isEmpty) return;
|
if (settings.localLibraryPath.isEmpty) return;
|
||||||
if (settings.localLibraryAutoScan == 'off') return;
|
if (settings.localLibraryAutoScan == 'off') return;
|
||||||
|
|
||||||
// Don't start a scan if one is already running.
|
|
||||||
final libraryState = ref.read(localLibraryProvider);
|
final libraryState = ref.read(localLibraryProvider);
|
||||||
if (libraryState.isScanning) return;
|
if (libraryState.isScanning) return;
|
||||||
|
|
||||||
// Determine cooldown based on auto-scan mode.
|
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final lastScanned = readLocalLibraryLastScannedAt(prefs);
|
final lastScanned = readLocalLibraryLastScannedAt(prefs);
|
||||||
@@ -220,7 +218,6 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// All checks passed -- start an incremental scan.
|
|
||||||
final iosBookmark = settings.localLibraryBookmark;
|
final iosBookmark = settings.localLibraryBookmark;
|
||||||
ref
|
ref
|
||||||
.read(localLibraryProvider.notifier)
|
.read(localLibraryProvider.notifier)
|
||||||
|
|||||||
@@ -42,10 +42,6 @@ class AppSettings {
|
|||||||
final String lyricsMode;
|
final String lyricsMode;
|
||||||
final String
|
final String
|
||||||
tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320', 'opus_256', or 'opus_128'
|
tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320', 'opus_256', or 'opus_128'
|
||||||
final int
|
|
||||||
youtubeOpusBitrate; // YouTube Opus bitrate (supported: 128/256/320 kbps)
|
|
||||||
final int
|
|
||||||
youtubeMp3Bitrate; // YouTube MP3 bitrate (supported: 128/256/320 kbps)
|
|
||||||
final bool
|
final bool
|
||||||
useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE
|
useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE
|
||||||
final bool
|
final bool
|
||||||
@@ -121,8 +117,6 @@ class AppSettings {
|
|||||||
this.locale = 'system',
|
this.locale = 'system',
|
||||||
this.lyricsMode = 'embed',
|
this.lyricsMode = 'embed',
|
||||||
this.tidalHighFormat = 'mp3_320',
|
this.tidalHighFormat = 'mp3_320',
|
||||||
this.youtubeOpusBitrate = 256,
|
|
||||||
this.youtubeMp3Bitrate = 320,
|
|
||||||
this.useAllFilesAccess = false,
|
this.useAllFilesAccess = false,
|
||||||
this.autoExportFailedDownloads = false,
|
this.autoExportFailedDownloads = false,
|
||||||
this.downloadNetworkMode = 'any',
|
this.downloadNetworkMode = 'any',
|
||||||
@@ -189,8 +183,6 @@ class AppSettings {
|
|||||||
String? locale,
|
String? locale,
|
||||||
String? lyricsMode,
|
String? lyricsMode,
|
||||||
String? tidalHighFormat,
|
String? tidalHighFormat,
|
||||||
int? youtubeOpusBitrate,
|
|
||||||
int? youtubeMp3Bitrate,
|
|
||||||
bool? useAllFilesAccess,
|
bool? useAllFilesAccess,
|
||||||
bool? autoExportFailedDownloads,
|
bool? autoExportFailedDownloads,
|
||||||
String? downloadNetworkMode,
|
String? downloadNetworkMode,
|
||||||
@@ -257,8 +249,6 @@ class AppSettings {
|
|||||||
locale: locale ?? this.locale,
|
locale: locale ?? this.locale,
|
||||||
lyricsMode: lyricsMode ?? this.lyricsMode,
|
lyricsMode: lyricsMode ?? this.lyricsMode,
|
||||||
tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat,
|
tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat,
|
||||||
youtubeOpusBitrate: youtubeOpusBitrate ?? this.youtubeOpusBitrate,
|
|
||||||
youtubeMp3Bitrate: youtubeMp3Bitrate ?? this.youtubeMp3Bitrate,
|
|
||||||
useAllFilesAccess: useAllFilesAccess ?? this.useAllFilesAccess,
|
useAllFilesAccess: useAllFilesAccess ?? this.useAllFilesAccess,
|
||||||
autoExportFailedDownloads:
|
autoExportFailedDownloads:
|
||||||
autoExportFailedDownloads ?? this.autoExportFailedDownloads,
|
autoExportFailedDownloads ?? this.autoExportFailedDownloads,
|
||||||
|
|||||||
@@ -47,8 +47,6 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
|||||||
locale: json['locale'] as String? ?? 'system',
|
locale: json['locale'] as String? ?? 'system',
|
||||||
lyricsMode: json['lyricsMode'] as String? ?? 'embed',
|
lyricsMode: json['lyricsMode'] as String? ?? 'embed',
|
||||||
tidalHighFormat: json['tidalHighFormat'] as String? ?? 'mp3_320',
|
tidalHighFormat: json['tidalHighFormat'] as String? ?? 'mp3_320',
|
||||||
youtubeOpusBitrate: (json['youtubeOpusBitrate'] as num?)?.toInt() ?? 256,
|
|
||||||
youtubeMp3Bitrate: (json['youtubeMp3Bitrate'] as num?)?.toInt() ?? 320,
|
|
||||||
useAllFilesAccess: json['useAllFilesAccess'] as bool? ?? false,
|
useAllFilesAccess: json['useAllFilesAccess'] as bool? ?? false,
|
||||||
autoExportFailedDownloads:
|
autoExportFailedDownloads:
|
||||||
json['autoExportFailedDownloads'] as bool? ?? false,
|
json['autoExportFailedDownloads'] as bool? ?? false,
|
||||||
@@ -125,8 +123,6 @@ Map<String, dynamic> _$AppSettingsToJson(
|
|||||||
'locale': instance.locale,
|
'locale': instance.locale,
|
||||||
'lyricsMode': instance.lyricsMode,
|
'lyricsMode': instance.lyricsMode,
|
||||||
'tidalHighFormat': instance.tidalHighFormat,
|
'tidalHighFormat': instance.tidalHighFormat,
|
||||||
'youtubeOpusBitrate': instance.youtubeOpusBitrate,
|
|
||||||
'youtubeMp3Bitrate': instance.youtubeMp3Bitrate,
|
|
||||||
'useAllFilesAccess': instance.useAllFilesAccess,
|
'useAllFilesAccess': instance.useAllFilesAccess,
|
||||||
'autoExportFailedDownloads': instance.autoExportFailedDownloads,
|
'autoExportFailedDownloads': instance.autoExportFailedDownloads,
|
||||||
'downloadNetworkMode': instance.downloadNetworkMode,
|
'downloadNetworkMode': instance.downloadNetworkMode,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'dart:io';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:spotiflac_android/models/download_item.dart';
|
import 'package:spotiflac_android/models/download_item.dart';
|
||||||
import 'package:spotiflac_android/models/settings.dart';
|
import 'package:spotiflac_android/models/settings.dart';
|
||||||
import 'package:spotiflac_android/models/track.dart';
|
import 'package:spotiflac_android/models/track.dart';
|
||||||
@@ -262,8 +263,14 @@ class DownloadHistoryState {
|
|||||||
class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||||
static const int _safRepairBatchSize = 20;
|
static const int _safRepairBatchSize = 20;
|
||||||
static const int _safRepairMaxPerLaunch = 60;
|
static const int _safRepairMaxPerLaunch = 60;
|
||||||
|
static const int _orphanCleanupMaxPerLaunch = 80;
|
||||||
static const int _audioMetadataBackfillMaxPerLaunch = 24;
|
static const int _audioMetadataBackfillMaxPerLaunch = 24;
|
||||||
static const _startupMaintenanceDelay = Duration(seconds: 2);
|
static const _startupMaintenanceDelay = Duration(seconds: 4);
|
||||||
|
static const _startupMaintenanceStepGap = Duration(milliseconds: 250);
|
||||||
|
static const _startupSafRepairCursorKey =
|
||||||
|
'history_startup_saf_repair_cursor_v1';
|
||||||
|
static const _startupOrphanCursorKey = 'history_startup_orphan_cursor_v1';
|
||||||
|
static const _startupAudioCursorKey = 'history_startup_audio_cursor_v1';
|
||||||
final HistoryDatabase _db = HistoryDatabase.instance;
|
final HistoryDatabase _db = HistoryDatabase.instance;
|
||||||
bool _isLoaded = false;
|
bool _isLoaded = false;
|
||||||
bool _isSafRepairInProgress = false;
|
bool _isSafRepairInProgress = false;
|
||||||
@@ -320,20 +327,29 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
|||||||
unawaited(
|
unawaited(
|
||||||
Future<void>.delayed(_startupMaintenanceDelay, () async {
|
Future<void>.delayed(_startupMaintenanceDelay, () async {
|
||||||
try {
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
|
||||||
if (Platform.isAndroid) {
|
if (Platform.isAndroid) {
|
||||||
await _repairMissingSafEntries(
|
await _repairMissingSafEntries(
|
||||||
initialItems,
|
initialItems,
|
||||||
maxItems: _safRepairMaxPerLaunch,
|
maxItems: _safRepairMaxPerLaunch,
|
||||||
|
prefs: prefs,
|
||||||
);
|
);
|
||||||
|
await Future<void>.delayed(_startupMaintenanceStepGap);
|
||||||
}
|
}
|
||||||
|
|
||||||
await cleanupOrphanedDownloads();
|
await _cleanupOrphanedDownloadsIncremental(
|
||||||
|
maxItems: _orphanCleanupMaxPerLaunch,
|
||||||
|
prefs: prefs,
|
||||||
|
);
|
||||||
|
await Future<void>.delayed(_startupMaintenanceStepGap);
|
||||||
|
|
||||||
final currentItems = state.items;
|
final currentItems = state.items;
|
||||||
if (currentItems.isNotEmpty) {
|
if (currentItems.isNotEmpty) {
|
||||||
await _backfillAudioMetadata(
|
await _backfillAudioMetadata(
|
||||||
currentItems,
|
currentItems,
|
||||||
maxItems: _audioMetadataBackfillMaxPerLaunch,
|
maxItems: _audioMetadataBackfillMaxPerLaunch,
|
||||||
|
prefs: prefs,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e, stack) {
|
} catch (e, stack) {
|
||||||
@@ -344,6 +360,30 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int _readStartupCursor(SharedPreferences prefs, String key, int totalCount) {
|
||||||
|
if (totalCount <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
final cursor = prefs.getInt(key) ?? 0;
|
||||||
|
if (cursor < 0 || cursor >= totalCount) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return cursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _writeStartupCursor(
|
||||||
|
SharedPreferences prefs,
|
||||||
|
String key,
|
||||||
|
int nextCursor,
|
||||||
|
int totalCount,
|
||||||
|
) async {
|
||||||
|
if (totalCount <= 0 || nextCursor <= 0 || nextCursor >= totalCount) {
|
||||||
|
await prefs.remove(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await prefs.setInt(key, nextCursor);
|
||||||
|
}
|
||||||
|
|
||||||
String _fileNameFromUri(String uri) {
|
String _fileNameFromUri(String uri) {
|
||||||
try {
|
try {
|
||||||
final parsed = Uri.parse(uri);
|
final parsed = Uri.parse(uri);
|
||||||
@@ -357,6 +397,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
|||||||
Future<void> _repairMissingSafEntries(
|
Future<void> _repairMissingSafEntries(
|
||||||
List<DownloadHistoryItem> items, {
|
List<DownloadHistoryItem> items, {
|
||||||
required int maxItems,
|
required int maxItems,
|
||||||
|
required SharedPreferences prefs,
|
||||||
}) async {
|
}) async {
|
||||||
if (_isSafRepairInProgress || items.isEmpty) {
|
if (_isSafRepairInProgress || items.isEmpty) {
|
||||||
return;
|
return;
|
||||||
@@ -378,22 +419,40 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
candidateIndexes.add(i);
|
candidateIndexes.add(i);
|
||||||
if (candidateIndexes.length >= maxItems) break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (candidateIndexes.isEmpty) {
|
if (candidateIndexes.isEmpty) {
|
||||||
|
await prefs.remove(_startupSafRepairCursorKey);
|
||||||
|
_isSafRepairInProgress = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final startCursor = _readStartupCursor(
|
||||||
|
prefs,
|
||||||
|
_startupSafRepairCursorKey,
|
||||||
|
candidateIndexes.length,
|
||||||
|
);
|
||||||
|
final endCursor = (startCursor + maxItems).clamp(
|
||||||
|
0,
|
||||||
|
candidateIndexes.length,
|
||||||
|
);
|
||||||
|
final selectedIndexes = candidateIndexes.sublist(startCursor, endCursor);
|
||||||
|
|
||||||
|
if (selectedIndexes.isEmpty) {
|
||||||
|
await prefs.remove(_startupSafRepairCursorKey);
|
||||||
_isSafRepairInProgress = false;
|
_isSafRepairInProgress = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final updatedItems = [...items];
|
final updatedItems = [...items];
|
||||||
|
final persistedUpdates = <Map<String, dynamic>>[];
|
||||||
var changed = false;
|
var changed = false;
|
||||||
var repairedCount = 0;
|
var repairedCount = 0;
|
||||||
var verifiedCount = 0;
|
var verifiedCount = 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (var c = 0; c < candidateIndexes.length; c++) {
|
for (var c = 0; c < selectedIndexes.length; c++) {
|
||||||
final i = candidateIndexes[c];
|
final i = selectedIndexes[c];
|
||||||
final item = items[i];
|
final item = items[i];
|
||||||
final rawPath = item.filePath.trim();
|
final rawPath = item.filePath.trim();
|
||||||
final isDirectSafUri = rawPath.isNotEmpty && isContentUri(rawPath);
|
final isDirectSafUri = rawPath.isNotEmpty && isContentUri(rawPath);
|
||||||
@@ -408,7 +467,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
|||||||
updatedItems[i] = verified;
|
updatedItems[i] = verified;
|
||||||
changed = true;
|
changed = true;
|
||||||
verifiedCount++;
|
verifiedCount++;
|
||||||
await _db.upsert(verified.toJson());
|
persistedUpdates.add(verified.toJson());
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -445,7 +504,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
|||||||
updatedItems[i] = updated;
|
updatedItems[i] = updated;
|
||||||
changed = true;
|
changed = true;
|
||||||
repairedCount++;
|
repairedCount++;
|
||||||
await _db.upsert(updated.toJson());
|
persistedUpdates.add(updated.toJson());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_historyLog.w('Failed to repair SAF URI: $e');
|
_historyLog.w('Failed to repair SAF URI: $e');
|
||||||
}
|
}
|
||||||
@@ -456,11 +515,18 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (changed) {
|
if (changed) {
|
||||||
|
await _db.upsertBatch(persistedUpdates);
|
||||||
state = state.copyWith(items: updatedItems);
|
state = state.copyWith(items: updatedItems);
|
||||||
_historyLog.i(
|
_historyLog.i(
|
||||||
'SAF repair pass: verified=$verifiedCount, repaired=$repairedCount, checked=${candidateIndexes.length}',
|
'SAF repair pass: verified=$verifiedCount, repaired=$repairedCount, checked=${selectedIndexes.length}',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
await _writeStartupCursor(
|
||||||
|
prefs,
|
||||||
|
_startupSafRepairCursorKey,
|
||||||
|
endCursor,
|
||||||
|
candidateIndexes.length,
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
_isSafRepairInProgress = false;
|
_isSafRepairInProgress = false;
|
||||||
}
|
}
|
||||||
@@ -556,6 +622,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
|||||||
Future<void> _backfillAudioMetadata(
|
Future<void> _backfillAudioMetadata(
|
||||||
List<DownloadHistoryItem> items, {
|
List<DownloadHistoryItem> items, {
|
||||||
required int maxItems,
|
required int maxItems,
|
||||||
|
required SharedPreferences prefs,
|
||||||
}) async {
|
}) async {
|
||||||
if (_isAudioMetadataBackfillInProgress || items.isEmpty) {
|
if (_isAudioMetadataBackfillInProgress || items.isEmpty) {
|
||||||
return;
|
return;
|
||||||
@@ -563,15 +630,40 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
|||||||
_isAudioMetadataBackfillInProgress = true;
|
_isAudioMetadataBackfillInProgress = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
final candidateIndexes = <int>[];
|
||||||
|
for (var i = 0; i < items.length; i++) {
|
||||||
|
if (_shouldBackfillAudioMetadata(items[i])) {
|
||||||
|
candidateIndexes.add(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidateIndexes.isEmpty) {
|
||||||
|
await prefs.remove(_startupAudioCursorKey);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final startCursor = _readStartupCursor(
|
||||||
|
prefs,
|
||||||
|
_startupAudioCursorKey,
|
||||||
|
candidateIndexes.length,
|
||||||
|
);
|
||||||
|
final endCursor = (startCursor + maxItems).clamp(
|
||||||
|
0,
|
||||||
|
candidateIndexes.length,
|
||||||
|
);
|
||||||
|
final selectedIndexes = candidateIndexes.sublist(startCursor, endCursor);
|
||||||
|
|
||||||
|
if (selectedIndexes.isEmpty) {
|
||||||
|
await prefs.remove(_startupAudioCursorKey);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<DownloadHistoryItem>? updatedItems;
|
||||||
|
final persistedUpdates = <Map<String, dynamic>>[];
|
||||||
var refreshedCount = 0;
|
var refreshedCount = 0;
|
||||||
|
|
||||||
for (final item in items) {
|
for (final index in selectedIndexes) {
|
||||||
if (refreshedCount >= maxItems) {
|
final item = items[index];
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (!_shouldBackfillAudioMetadata(item)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
final probed = await _probeAudioMetadata(
|
final probed = await _probeAudioMetadata(
|
||||||
item.filePath,
|
item.filePath,
|
||||||
@@ -598,15 +690,29 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
await updateAudioMetadataForItem(
|
final updated = item.copyWith(
|
||||||
id: item.id,
|
|
||||||
quality: resolvedQuality,
|
quality: resolvedQuality,
|
||||||
bitDepth: resolvedBitDepth,
|
bitDepth: resolvedBitDepth,
|
||||||
sampleRate: resolvedSampleRate,
|
sampleRate: resolvedSampleRate,
|
||||||
);
|
);
|
||||||
|
updatedItems ??= [...items];
|
||||||
|
updatedItems[index] = updated;
|
||||||
|
persistedUpdates.add(updated.toJson());
|
||||||
refreshedCount++;
|
refreshedCount++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (persistedUpdates.isNotEmpty && updatedItems != null) {
|
||||||
|
await _db.upsertBatch(persistedUpdates);
|
||||||
|
state = state.copyWith(items: updatedItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _writeStartupCursor(
|
||||||
|
prefs,
|
||||||
|
_startupAudioCursorKey,
|
||||||
|
endCursor,
|
||||||
|
candidateIndexes.length,
|
||||||
|
);
|
||||||
|
|
||||||
if (refreshedCount > 0) {
|
if (refreshedCount > 0) {
|
||||||
_historyLog.i(
|
_historyLog.i(
|
||||||
'Audio metadata backfill refreshed $refreshedCount items',
|
'Audio metadata backfill refreshed $refreshedCount items',
|
||||||
@@ -768,9 +874,6 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
|||||||
await _db.upsert(updated.toJson());
|
await _db.upsert(updated.toJson());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove history entries where the file no longer exists on disk
|
|
||||||
/// Returns the number of orphaned entries removed
|
|
||||||
/// Audio file extensions that the app commonly produces or converts between.
|
|
||||||
static const _audioExtensions = [
|
static const _audioExtensions = [
|
||||||
'.flac',
|
'.flac',
|
||||||
'.m4a',
|
'.m4a',
|
||||||
@@ -781,11 +884,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
|||||||
'.aac',
|
'.aac',
|
||||||
];
|
];
|
||||||
|
|
||||||
/// When the original file is missing, check whether a sibling with a
|
|
||||||
/// different audio extension exists (e.g. the user converted .flac → .opus).
|
|
||||||
/// Returns the path of the first match found, or `null` if none exist.
|
|
||||||
Future<String?> _findConvertedSibling(String originalPath) async {
|
Future<String?> _findConvertedSibling(String originalPath) async {
|
||||||
// Strip the current extension to get the base path.
|
|
||||||
final dotIndex = originalPath.lastIndexOf('.');
|
final dotIndex = originalPath.lastIndexOf('.');
|
||||||
if (dotIndex < 0) return null;
|
if (dotIndex < 0) return null;
|
||||||
final basePath = originalPath.substring(0, dotIndex);
|
final basePath = originalPath.substring(0, dotIndex);
|
||||||
@@ -801,11 +900,16 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<int> cleanupOrphanedDownloads() async {
|
Future<
|
||||||
_historyLog.i('Starting orphaned downloads cleanup...');
|
({
|
||||||
|
List<String> orphanedIds,
|
||||||
final entries = await _db.getAllEntriesWithPaths();
|
Map<String, String> replacementPaths,
|
||||||
|
Map<String, String> pathById,
|
||||||
|
})
|
||||||
|
>
|
||||||
|
_inspectOrphanedEntries(List<Map<String, dynamic>> entries) async {
|
||||||
final orphanedIds = <String>[];
|
final orphanedIds = <String>[];
|
||||||
|
final replacementPaths = <String, String>{};
|
||||||
final pathById = <String, String>{};
|
final pathById = <String, String>{};
|
||||||
const checkChunkSize = 16;
|
const checkChunkSize = 16;
|
||||||
|
|
||||||
@@ -824,14 +928,12 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
|||||||
try {
|
try {
|
||||||
if (await fileExists(filePath)) return MapEntry(id, true);
|
if (await fileExists(filePath)) return MapEntry(id, true);
|
||||||
|
|
||||||
// Original file missing -- check for a converted sibling.
|
|
||||||
final sibling = await _findConvertedSibling(filePath);
|
final sibling = await _findConvertedSibling(filePath);
|
||||||
if (sibling != null) {
|
if (sibling != null) {
|
||||||
_historyLog.i(
|
_historyLog.i(
|
||||||
'Found converted sibling for $id: $filePath → $sibling',
|
'Found converted sibling for $id: $filePath -> $sibling',
|
||||||
);
|
);
|
||||||
// Update the stored path so future checks succeed immediately.
|
replacementPaths[id] = sibling;
|
||||||
await _db.updateFilePath(id, sibling);
|
|
||||||
pathById[id] = sibling;
|
pathById[id] = sibling;
|
||||||
return MapEntry(id, true);
|
return MapEntry(id, true);
|
||||||
}
|
}
|
||||||
@@ -853,21 +955,127 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (orphanedIds.isEmpty) {
|
return (
|
||||||
|
orphanedIds: orphanedIds,
|
||||||
|
replacementPaths: replacementPaths,
|
||||||
|
pathById: pathById,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _applyHistoryPathAndDeletionChanges({
|
||||||
|
required List<String> deletedIds,
|
||||||
|
required Map<String, String> replacementPaths,
|
||||||
|
}) {
|
||||||
|
if (deletedIds.isEmpty && replacementPaths.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final deletedSet = deletedIds.toSet();
|
||||||
|
final updatedItems = <DownloadHistoryItem>[];
|
||||||
|
for (final item in state.items) {
|
||||||
|
if (deletedSet.contains(item.id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final replacementPath = replacementPaths[item.id];
|
||||||
|
if (replacementPath != null && replacementPath != item.filePath) {
|
||||||
|
updatedItems.add(item.copyWith(filePath: replacementPath));
|
||||||
|
} else {
|
||||||
|
updatedItems.add(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state = state.copyWith(items: updatedItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> _cleanupOrphanedDownloadsIncremental({
|
||||||
|
required int maxItems,
|
||||||
|
required SharedPreferences prefs,
|
||||||
|
}) async {
|
||||||
|
final cursor = prefs.getInt(_startupOrphanCursorKey) ?? 0;
|
||||||
|
final safeCursor = cursor < 0 ? 0 : cursor;
|
||||||
|
final entries = await _db.getEntriesWithPathsPage(
|
||||||
|
limit: maxItems,
|
||||||
|
offset: safeCursor,
|
||||||
|
);
|
||||||
|
if (entries.isEmpty) {
|
||||||
|
await prefs.remove(_startupOrphanCursorKey);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = await _inspectOrphanedEntries(entries);
|
||||||
|
for (final replacement in result.replacementPaths.entries) {
|
||||||
|
await _db.updateFilePath(replacement.key, replacement.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
final deletedCount = result.orphanedIds.isEmpty
|
||||||
|
? 0
|
||||||
|
: await _db.deleteByIds(result.orphanedIds);
|
||||||
|
|
||||||
|
_applyHistoryPathAndDeletionChanges(
|
||||||
|
deletedIds: result.orphanedIds,
|
||||||
|
replacementPaths: result.replacementPaths,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (entries.length < maxItems) {
|
||||||
|
await prefs.remove(_startupOrphanCursorKey);
|
||||||
|
} else {
|
||||||
|
final nextCursor =
|
||||||
|
safeCursor + entries.length - result.orphanedIds.length;
|
||||||
|
await prefs.setInt(_startupOrphanCursorKey, nextCursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deletedCount > 0 || result.replacementPaths.isNotEmpty) {
|
||||||
|
_historyLog.i(
|
||||||
|
'Startup orphan cleanup pass: removed=$deletedCount, repaired=${result.replacementPaths.length}, checked=${entries.length}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return deletedCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> cleanupOrphanedDownloads() async {
|
||||||
|
_historyLog.i('Starting orphaned downloads cleanup...');
|
||||||
|
final orphanedIds = <String>[];
|
||||||
|
final replacementPaths = <String, String>{};
|
||||||
|
const pageSize = 256;
|
||||||
|
var offset = 0;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
final entries = await _db.getEntriesWithPathsPage(
|
||||||
|
limit: pageSize,
|
||||||
|
offset: offset,
|
||||||
|
);
|
||||||
|
if (entries.isEmpty) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = await _inspectOrphanedEntries(entries);
|
||||||
|
orphanedIds.addAll(result.orphanedIds);
|
||||||
|
replacementPaths.addAll(result.replacementPaths);
|
||||||
|
|
||||||
|
if (entries.length < pageSize) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
offset += entries.length - result.orphanedIds.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final replacement in replacementPaths.entries) {
|
||||||
|
await _db.updateFilePath(replacement.key, replacement.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orphanedIds.isEmpty && replacementPaths.isEmpty) {
|
||||||
_historyLog.i('No orphaned entries found');
|
_historyLog.i('No orphaned entries found');
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
final deletedCount = await _db.deleteByIds(orphanedIds);
|
final deletedCount = orphanedIds.isEmpty
|
||||||
|
? 0
|
||||||
final orphanedSet = orphanedIds.toSet();
|
: await _db.deleteByIds(orphanedIds);
|
||||||
state = state.copyWith(
|
_applyHistoryPathAndDeletionChanges(
|
||||||
items: state.items
|
deletedIds: orphanedIds,
|
||||||
.where((item) => !orphanedSet.contains(item.id))
|
replacementPaths: replacementPaths,
|
||||||
.toList(),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
_historyLog.i('Cleaned up $deletedCount orphaned entries');
|
_historyLog.i(
|
||||||
|
'Cleaned up $deletedCount orphaned entries and repaired ${replacementPaths.length} paths',
|
||||||
|
);
|
||||||
return deletedCount;
|
return deletedCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1700,6 +1908,20 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (albumFolderStructure == 'artist_album_flat') {
|
||||||
|
if (isSingle) {
|
||||||
|
final artistPath = '$baseDir${Platform.pathSeparator}$artistName';
|
||||||
|
await _ensureDirExists(artistPath, label: 'Artist folder');
|
||||||
|
return artistPath;
|
||||||
|
} else {
|
||||||
|
final albumName = _sanitizeFolderName(track.albumName);
|
||||||
|
final albumPath =
|
||||||
|
'$baseDir${Platform.pathSeparator}$artistName${Platform.pathSeparator}$albumName';
|
||||||
|
await _ensureDirExists(albumPath, label: 'Artist Album folder');
|
||||||
|
return albumPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isSingle) {
|
if (isSingle) {
|
||||||
final singlesPath = '$baseDir${Platform.pathSeparator}Singles';
|
final singlesPath = '$baseDir${Platform.pathSeparator}Singles';
|
||||||
await _ensureDirExists(singlesPath, label: 'Singles folder');
|
await _ensureDirExists(singlesPath, label: 'Singles folder');
|
||||||
@@ -1920,15 +2142,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _determineOutputExt(String quality, String service) {
|
String _determineOutputExt(String quality, String service) {
|
||||||
if (service.toLowerCase() == 'youtube') {
|
|
||||||
if (quality.toLowerCase().contains('mp3')) {
|
|
||||||
return '.mp3';
|
|
||||||
}
|
|
||||||
return '.opus';
|
|
||||||
}
|
|
||||||
if (service.toLowerCase() == 'tidal' && quality == 'HIGH') {
|
if (service.toLowerCase() == 'tidal' && quality == 'HIGH') {
|
||||||
return '.m4a';
|
return '.m4a';
|
||||||
}
|
}
|
||||||
|
final q = quality.toLowerCase();
|
||||||
|
if (q.startsWith('opus')) return '.opus';
|
||||||
|
if (q.startsWith('mp3')) return '.mp3';
|
||||||
return '.flac';
|
return '.flac';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2206,6 +2425,31 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
_requestNativeCancel(id);
|
_requestNativeCancel(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void dismissItem(String id) {
|
||||||
|
final item = _findItemById(id);
|
||||||
|
if (item == null) return;
|
||||||
|
|
||||||
|
final isActive =
|
||||||
|
item.status == DownloadStatus.queued ||
|
||||||
|
item.status == DownloadStatus.downloading ||
|
||||||
|
item.status == DownloadStatus.finalizing;
|
||||||
|
|
||||||
|
if (isActive) {
|
||||||
|
_pausePendingItemIds.remove(id);
|
||||||
|
_locallyCancelledItemIds.add(id);
|
||||||
|
_requestNativeCancel(id);
|
||||||
|
} else {
|
||||||
|
_locallyCancelledItemIds.remove(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
final items = state.items.where((entry) => entry.id != id).toList();
|
||||||
|
final currentDownload = state.currentDownload?.id == id
|
||||||
|
? null
|
||||||
|
: state.currentDownload;
|
||||||
|
state = state.copyWith(items: items, currentDownload: currentDownload);
|
||||||
|
_saveQueueToStorage();
|
||||||
|
}
|
||||||
|
|
||||||
void clearCompleted() {
|
void clearCompleted() {
|
||||||
final items = state.items
|
final items = state.items
|
||||||
.where(
|
.where(
|
||||||
@@ -2474,7 +2718,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
static final _deezerSizeRegex = RegExp(r'/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$');
|
static final _deezerSizeRegex = RegExp(r'/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$');
|
||||||
|
|
||||||
String _upgradeToMaxQualityCover(String coverUrl) {
|
String _upgradeToMaxQualityCover(String coverUrl) {
|
||||||
// Spotify CDN upgrade (hash-based size identifiers)
|
|
||||||
const spotifySize300 = 'ab67616d00001e02';
|
const spotifySize300 = 'ab67616d00001e02';
|
||||||
const spotifySize640 = 'ab67616d0000b273';
|
const spotifySize640 = 'ab67616d0000b273';
|
||||||
const spotifySizeMax = 'ab67616d000082c1';
|
const spotifySizeMax = 'ab67616d000082c1';
|
||||||
@@ -2487,7 +2730,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
result = result.replaceFirst(spotifySize640, spotifySizeMax);
|
result = result.replaceFirst(spotifySize640, spotifySizeMax);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deezer CDN upgrade (1000x1000 → 1800x1800)
|
|
||||||
if (result.contains('cdn-images.dzcdn.net')) {
|
if (result.contains('cdn-images.dzcdn.net')) {
|
||||||
final upgraded = result.replaceFirst(
|
final upgraded = result.replaceFirst(
|
||||||
_deezerSizeRegex,
|
_deezerSizeRegex,
|
||||||
@@ -3168,7 +3410,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
Future<void> _processQueue() async {
|
Future<void> _processQueue() async {
|
||||||
if (state.isProcessing) return;
|
if (state.isProcessing) return;
|
||||||
|
|
||||||
// Check network connectivity before starting
|
|
||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
updateSettings(settings);
|
updateSettings(settings);
|
||||||
final isSafMode = _isSafMode(settings);
|
final isSafMode = _isSafMode(settings);
|
||||||
@@ -3228,7 +3469,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
state = state.copyWith(outputDir: musicDir.path);
|
state = state.copyWith(outputDir: musicDir.path);
|
||||||
ref.read(settingsProvider.notifier).setDownloadDirectory(musicDir.path);
|
ref.read(settingsProvider.notifier).setDownloadDirectory(musicDir.path);
|
||||||
} else if (!isValidIosWritablePath(state.outputDir)) {
|
} else if (!isValidIosWritablePath(state.outputDir)) {
|
||||||
// Check for other invalid paths (like container root without Documents/)
|
|
||||||
_log.w(
|
_log.w(
|
||||||
'iOS: Invalid output path detected (container root?), falling back to app Documents folder',
|
'iOS: Invalid output path detected (container root?), falling back to app Documents folder',
|
||||||
);
|
);
|
||||||
@@ -3250,7 +3490,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
_log.d('Output directory: ${state.outputDir}');
|
_log.d('Output directory: ${state.outputDir}');
|
||||||
} else {
|
} else {
|
||||||
_log.d('Output directory: SAF (tree_uri=${settings.downloadTreeUri})');
|
_log.d('Output directory: SAF (tree_uri=${settings.downloadTreeUri})');
|
||||||
// Validate SAF permission is still accessible
|
|
||||||
try {
|
try {
|
||||||
final testResult = await PlatformBridge.createSafFileFromPath(
|
final testResult = await PlatformBridge.createSafFileFromPath(
|
||||||
treeUri: settings.downloadTreeUri,
|
treeUri: settings.downloadTreeUri,
|
||||||
@@ -3259,16 +3498,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
mimeType: 'application/octet-stream',
|
mimeType: 'application/octet-stream',
|
||||||
srcPath: '',
|
srcPath: '',
|
||||||
);
|
);
|
||||||
// If we got a result, permission is valid (file creation may fail but that's ok)
|
|
||||||
// If permission is revoked, this will throw
|
|
||||||
if (testResult != null) {
|
if (testResult != null) {
|
||||||
// Clean up test file
|
|
||||||
await PlatformBridge.safDelete(testResult);
|
await PlatformBridge.safDelete(testResult);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_log.e('SAF permission validation failed: $e');
|
_log.e('SAF permission validation failed: $e');
|
||||||
_log.w('SAF tree URI may be invalid or permission revoked');
|
_log.w('SAF tree URI may be invalid or permission revoked');
|
||||||
// Mark all queued items as failed
|
|
||||||
for (final item in state.items) {
|
for (final item in state.items) {
|
||||||
if (item.status == DownloadStatus.queued) {
|
if (item.status == DownloadStatus.queued) {
|
||||||
updateItemStatus(
|
updateItemStatus(
|
||||||
@@ -3402,8 +3637,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (activeDownloads.isNotEmpty) {
|
if (activeDownloads.isNotEmpty) {
|
||||||
// Re-check queue/settings periodically so concurrency changes
|
|
||||||
// (e.g. 1 -> 3) can take effect before any active item finishes.
|
|
||||||
await Future.any([
|
await Future.any([
|
||||||
Future.any(activeDownloads.values),
|
Future.any(activeDownloads.values),
|
||||||
Future.delayed(_queueSchedulingInterval),
|
Future.delayed(_queueSchedulingInterval),
|
||||||
@@ -3555,28 +3788,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
var quality = item.qualityOverride ?? state.audioQuality;
|
var quality = item.qualityOverride ?? state.audioQuality;
|
||||||
if (item.service.toLowerCase() == 'youtube') {
|
|
||||||
final normalized = quality.toLowerCase();
|
|
||||||
final isYoutubeQuality =
|
|
||||||
normalized.startsWith('mp3_') || normalized.startsWith('opus_');
|
|
||||||
if (!isYoutubeQuality) {
|
|
||||||
final mp3Bitrate = (() {
|
|
||||||
const supported = [128, 256, 320];
|
|
||||||
var nearest = supported.first;
|
|
||||||
var nearestDistance = (settings.youtubeMp3Bitrate - nearest).abs();
|
|
||||||
for (final option in supported.skip(1)) {
|
|
||||||
final distance = (settings.youtubeMp3Bitrate - option).abs();
|
|
||||||
if (distance < nearestDistance ||
|
|
||||||
(distance == nearestDistance && option > nearest)) {
|
|
||||||
nearest = option;
|
|
||||||
nearestDistance = distance;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nearest;
|
|
||||||
})();
|
|
||||||
quality = 'mp3_$mp3Bitrate';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
final isSafMode = _isSafMode(settings);
|
final isSafMode = _isSafMode(settings);
|
||||||
final relativeOutputDir = isSafMode
|
final relativeOutputDir = isSafMode
|
||||||
? await _buildRelativeOutputDir(
|
? await _buildRelativeOutputDir(
|
||||||
@@ -3684,13 +3895,101 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: Use SongLink to convert Spotify ID to Deezer ID
|
// For tidal:/qobuz: tracks without ISRC, resolve ISRC from provider
|
||||||
|
// API directly (faster than SongLink and avoids rate limits).
|
||||||
|
if (deezerTrackId == null &&
|
||||||
|
(trackToDownload.isrc == null ||
|
||||||
|
trackToDownload.isrc!.isEmpty ||
|
||||||
|
!_isValidISRC(trackToDownload.isrc!)) &&
|
||||||
|
(trackToDownload.id.startsWith('tidal:') ||
|
||||||
|
trackToDownload.id.startsWith('qobuz:'))) {
|
||||||
|
try {
|
||||||
|
final colonIdx = trackToDownload.id.indexOf(':');
|
||||||
|
final provider = trackToDownload.id.substring(0, colonIdx);
|
||||||
|
final providerTrackId = trackToDownload.id.substring(colonIdx + 1);
|
||||||
|
|
||||||
|
_log.d('No ISRC, fetching from $provider API: $providerTrackId');
|
||||||
|
final providerData = provider == 'tidal'
|
||||||
|
? await PlatformBridge.getTidalMetadata('track', providerTrackId)
|
||||||
|
: await PlatformBridge.getQobuzMetadata('track', providerTrackId);
|
||||||
|
|
||||||
|
final trackData = providerData['track'] as Map<String, dynamic>?;
|
||||||
|
if (trackData != null) {
|
||||||
|
final resolvedIsrc = normalizeOptionalString(
|
||||||
|
trackData['isrc'] as String?,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (resolvedIsrc != null && _isValidISRC(resolvedIsrc)) {
|
||||||
|
_log.d('Resolved ISRC from $provider: $resolvedIsrc');
|
||||||
|
|
||||||
|
final provReleaseDate = normalizeOptionalString(
|
||||||
|
trackData['release_date'] as String?,
|
||||||
|
);
|
||||||
|
final provTrackNum = trackData['track_number'] as int?;
|
||||||
|
final provDiscNum = trackData['disc_number'] as int?;
|
||||||
|
|
||||||
|
trackToDownload = Track(
|
||||||
|
id: trackToDownload.id,
|
||||||
|
name: trackToDownload.name,
|
||||||
|
artistName: trackToDownload.artistName,
|
||||||
|
albumName: trackToDownload.albumName,
|
||||||
|
albumArtist: trackToDownload.albumArtist,
|
||||||
|
artistId: trackToDownload.artistId,
|
||||||
|
albumId: trackToDownload.albumId,
|
||||||
|
coverUrl: normalizeCoverReference(trackToDownload.coverUrl),
|
||||||
|
duration: trackToDownload.duration,
|
||||||
|
isrc: resolvedIsrc,
|
||||||
|
trackNumber:
|
||||||
|
(trackToDownload.trackNumber != null &&
|
||||||
|
trackToDownload.trackNumber! > 0)
|
||||||
|
? trackToDownload.trackNumber
|
||||||
|
: provTrackNum,
|
||||||
|
discNumber:
|
||||||
|
(trackToDownload.discNumber != null &&
|
||||||
|
trackToDownload.discNumber! > 0)
|
||||||
|
? trackToDownload.discNumber
|
||||||
|
: provDiscNum,
|
||||||
|
releaseDate: trackToDownload.releaseDate ?? provReleaseDate,
|
||||||
|
deezerId: trackToDownload.deezerId,
|
||||||
|
availability: trackToDownload.availability,
|
||||||
|
albumType: trackToDownload.albumType,
|
||||||
|
totalTracks: trackToDownload.totalTracks,
|
||||||
|
source: trackToDownload.source,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final deezerResult = await PlatformBridge.searchDeezerByISRC(
|
||||||
|
resolvedIsrc,
|
||||||
|
);
|
||||||
|
if (deezerResult['success'] == true &&
|
||||||
|
deezerResult['track_id'] != null) {
|
||||||
|
deezerTrackId = deezerResult['track_id'].toString();
|
||||||
|
_log.d(
|
||||||
|
'Found Deezer track ID via $provider ISRC: $deezerTrackId',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_log.w('Failed to search Deezer by $provider ISRC: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_log.w('Failed to resolve ISRC from provider: $e');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldAbortWork('during provider ISRC resolution')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!selectedExtensionDownloadProvider &&
|
if (!selectedExtensionDownloadProvider &&
|
||||||
deezerTrackId == null &&
|
deezerTrackId == null &&
|
||||||
!shouldSkipExtensionSongLinkPrelookup &&
|
!shouldSkipExtensionSongLinkPrelookup &&
|
||||||
trackToDownload.id.isNotEmpty &&
|
trackToDownload.id.isNotEmpty &&
|
||||||
!trackToDownload.id.startsWith('deezer:') &&
|
!trackToDownload.id.startsWith('deezer:') &&
|
||||||
!trackToDownload.id.startsWith('extension:')) {
|
!trackToDownload.id.startsWith('extension:') &&
|
||||||
|
!trackToDownload.id.startsWith('tidal:') &&
|
||||||
|
!trackToDownload.id.startsWith('qobuz:')) {
|
||||||
try {
|
try {
|
||||||
String spotifyId = trackToDownload.id;
|
String spotifyId = trackToDownload.id;
|
||||||
if (spotifyId.startsWith('spotify:track:')) {
|
if (spotifyId.startsWith('spotify:track:')) {
|
||||||
@@ -3703,7 +4002,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
'track',
|
'track',
|
||||||
spotifyId,
|
spotifyId,
|
||||||
);
|
);
|
||||||
// Response is TrackResponse: {"track": {"spotify_id": "deezer:XXXXX", ...}}
|
|
||||||
final trackData = deezerData['track'];
|
final trackData = deezerData['track'];
|
||||||
if (trackData is Map<String, dynamic>) {
|
if (trackData is Map<String, dynamic>) {
|
||||||
final rawId = trackData['spotify_id'] as String?;
|
final rawId = trackData['spotify_id'] as String?;
|
||||||
@@ -3839,14 +4137,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
final relativeDir = useSaf ? outputDir : '';
|
final relativeDir = useSaf ? outputDir : '';
|
||||||
final fileName = useSaf ? (safFileName ?? '') : '';
|
final fileName = useSaf ? (safFileName ?? '') : '';
|
||||||
final outputExt = useSaf ? safOutputExt : '';
|
final outputExt = useSaf ? safOutputExt : '';
|
||||||
final isYouTube = item.service == 'youtube';
|
final shouldUseExtensions = useExtensions;
|
||||||
final shouldUseExtensions = !isYouTube && useExtensions;
|
final shouldUseFallback = state.autoFallback;
|
||||||
final shouldUseFallback = !isYouTube && state.autoFallback;
|
|
||||||
|
|
||||||
if (isYouTube) {
|
if (shouldUseExtensions) {
|
||||||
_log.d('Using YouTube/Cobalt provider for download');
|
|
||||||
_log.d('Quality: $quality (lossy only)');
|
|
||||||
} else if (shouldUseExtensions) {
|
|
||||||
_log.d('Using extension providers for download');
|
_log.d('Using extension providers for download');
|
||||||
_log.d(
|
_log.d(
|
||||||
'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}',
|
'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}',
|
||||||
@@ -4013,7 +4307,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
finalSafFileName = reportedFileName;
|
finalSafFileName = reportedFileName;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if file already existed (detected via ISRC match in Go backend)
|
|
||||||
final wasExisting = result['already_exists'] == true;
|
final wasExisting = result['already_exists'] == true;
|
||||||
if (wasExisting) {
|
if (wasExisting) {
|
||||||
_log.i('File already exists in library: $filePath');
|
_log.i('File already exists in library: $filePath');
|
||||||
@@ -4026,7 +4319,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
String actualQuality = quality;
|
String actualQuality = quality;
|
||||||
|
|
||||||
if (actualBitDepth != null && actualBitDepth > 0) {
|
if (actualBitDepth != null && actualBitDepth > 0) {
|
||||||
// Format: "24-bit/96kHz" or "16-bit/44.1kHz"
|
|
||||||
final sampleRateKHz = actualSampleRate != null && actualSampleRate > 0
|
final sampleRateKHz = actualSampleRate != null && actualSampleRate > 0
|
||||||
? (actualSampleRate / 1000).toStringAsFixed(
|
? (actualSampleRate / 1000).toStringAsFixed(
|
||||||
actualSampleRate % 1000 == 0 ? 0 : 1,
|
actualSampleRate % 1000 == 0 ? 0 : 1,
|
||||||
@@ -4182,7 +4474,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isM4aFile || shouldForceTidalSafM4aHandling) {
|
if (isM4aFile || shouldForceTidalSafM4aHandling) {
|
||||||
// At this point filePath is guaranteed non-null by the checks above.
|
|
||||||
final currentFilePath = filePath;
|
final currentFilePath = filePath;
|
||||||
|
|
||||||
if (isContentUriPath && effectiveSafMode) {
|
if (isContentUriPath && effectiveSafMode) {
|
||||||
@@ -4521,11 +4812,23 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
} else if (metadataEmbeddingEnabled &&
|
} else if (metadataEmbeddingEnabled &&
|
||||||
isContentUriPath &&
|
isContentUriPath &&
|
||||||
effectiveSafMode &&
|
effectiveSafMode &&
|
||||||
isFlacFile &&
|
!isM4aFile &&
|
||||||
!wasExisting) {
|
!wasExisting) {
|
||||||
final currentFilePath = filePath;
|
final currentFilePath = filePath;
|
||||||
|
final isOpusFile = filePath.endsWith('.opus');
|
||||||
|
final isMp3File = filePath.endsWith('.mp3');
|
||||||
|
final ext = isOpusFile
|
||||||
|
? '.opus'
|
||||||
|
: isMp3File
|
||||||
|
? '.mp3'
|
||||||
|
: '.flac';
|
||||||
|
final formatName = isOpusFile
|
||||||
|
? 'Opus'
|
||||||
|
: isMp3File
|
||||||
|
? 'MP3'
|
||||||
|
: 'FLAC';
|
||||||
_log.d(
|
_log.d(
|
||||||
'SAF FLAC detected, embedding metadata and cover via temp file...',
|
'SAF $formatName detected, embedding metadata and cover via temp file...',
|
||||||
);
|
);
|
||||||
final tempPath = await _copySafToTemp(currentFilePath);
|
final tempPath = await _copySafToTemp(currentFilePath);
|
||||||
if (tempPath != null) {
|
if (tempPath != null) {
|
||||||
@@ -4545,21 +4848,39 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
final backendLabel = result['label'] as String?;
|
final backendLabel = result['label'] as String?;
|
||||||
final backendCopyright = result['copyright'] as String?;
|
final backendCopyright = result['copyright'] as String?;
|
||||||
|
|
||||||
await _embedMetadataAndCover(
|
if (isMp3File) {
|
||||||
tempPath,
|
await _embedMetadataToMp3(
|
||||||
finalTrack,
|
tempPath,
|
||||||
genre: backendGenre ?? genre,
|
finalTrack,
|
||||||
label: backendLabel ?? label,
|
genre: backendGenre ?? genre,
|
||||||
copyright: backendCopyright,
|
label: backendLabel ?? label,
|
||||||
writeExternalLrc: false,
|
copyright: backendCopyright,
|
||||||
);
|
);
|
||||||
|
} else if (isOpusFile) {
|
||||||
|
await _embedMetadataToOpus(
|
||||||
|
tempPath,
|
||||||
|
finalTrack,
|
||||||
|
genre: backendGenre ?? genre,
|
||||||
|
label: backendLabel ?? label,
|
||||||
|
copyright: backendCopyright,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await _embedMetadataAndCover(
|
||||||
|
tempPath,
|
||||||
|
finalTrack,
|
||||||
|
genre: backendGenre ?? genre,
|
||||||
|
label: backendLabel ?? label,
|
||||||
|
copyright: backendCopyright,
|
||||||
|
writeExternalLrc: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
final newFileName = '${safBaseName ?? 'track'}.flac';
|
final newFileName = '${safBaseName ?? 'track'}$ext';
|
||||||
final newUri = await _writeTempToSaf(
|
final newUri = await _writeTempToSaf(
|
||||||
treeUri: settings.downloadTreeUri,
|
treeUri: settings.downloadTreeUri,
|
||||||
relativeDir: effectiveOutputDir,
|
relativeDir: effectiveOutputDir,
|
||||||
fileName: newFileName,
|
fileName: newFileName,
|
||||||
mimeType: _mimeTypeForExt('.flac'),
|
mimeType: _mimeTypeForExt(ext),
|
||||||
srcPath: tempPath,
|
srcPath: tempPath,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -4569,12 +4890,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
filePath = newUri;
|
filePath = newUri;
|
||||||
finalSafFileName = newFileName;
|
finalSafFileName = newFileName;
|
||||||
_log.d('SAF FLAC metadata embedding completed');
|
_log.d('SAF $formatName metadata embedding completed');
|
||||||
} else {
|
} else {
|
||||||
_log.w('Failed to write metadata-updated FLAC back to SAF');
|
_log.w(
|
||||||
|
'Failed to write metadata-updated $formatName back to SAF',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_log.w('SAF FLAC metadata embedding failed: $e');
|
_log.w('SAF $formatName metadata embedding failed: $e');
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
try {
|
||||||
await File(tempPath).delete();
|
await File(tempPath).delete();
|
||||||
@@ -4619,109 +4942,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// YouTube downloads: embed metadata to raw Opus/MP3 files from Cobalt
|
|
||||||
if (metadataEmbeddingEnabled &&
|
|
||||||
!wasExisting &&
|
|
||||||
item.service == 'youtube' &&
|
|
||||||
filePath != null) {
|
|
||||||
final isOpusFile = filePath.endsWith('.opus');
|
|
||||||
final isMp3File = filePath.endsWith('.mp3');
|
|
||||||
|
|
||||||
if (isOpusFile || isMp3File) {
|
|
||||||
_log.i(
|
|
||||||
'YouTube download: embedding metadata to ${isOpusFile ? 'Opus' : 'MP3'} file',
|
|
||||||
);
|
|
||||||
updateItemStatus(
|
|
||||||
item.id,
|
|
||||||
DownloadStatus.downloading,
|
|
||||||
progress: 0.95,
|
|
||||||
);
|
|
||||||
|
|
||||||
final finalTrack = _buildTrackForMetadataEmbedding(
|
|
||||||
trackToDownload,
|
|
||||||
result,
|
|
||||||
resolvedAlbumArtist,
|
|
||||||
);
|
|
||||||
final backendGenre = result['genre'] as String?;
|
|
||||||
final backendLabel = result['label'] as String?;
|
|
||||||
final backendCopyright = result['copyright'] as String?;
|
|
||||||
|
|
||||||
final isContentUriPath = isContentUri(filePath);
|
|
||||||
if (isContentUriPath && effectiveSafMode) {
|
|
||||||
final tempPath = await _copySafToTemp(filePath);
|
|
||||||
if (tempPath != null) {
|
|
||||||
try {
|
|
||||||
if (isMp3File) {
|
|
||||||
await _embedMetadataToMp3(
|
|
||||||
tempPath,
|
|
||||||
finalTrack,
|
|
||||||
genre: backendGenre ?? genre,
|
|
||||||
label: backendLabel ?? label,
|
|
||||||
copyright: backendCopyright,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
await _embedMetadataToOpus(
|
|
||||||
tempPath,
|
|
||||||
finalTrack,
|
|
||||||
genre: backendGenre ?? genre,
|
|
||||||
label: backendLabel ?? label,
|
|
||||||
copyright: backendCopyright,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
final ext = isMp3File ? '.mp3' : '.opus';
|
|
||||||
final newFileName = '${safBaseName ?? 'track'}$ext';
|
|
||||||
final newUri = await _writeTempToSaf(
|
|
||||||
treeUri: settings.downloadTreeUri,
|
|
||||||
relativeDir: effectiveOutputDir,
|
|
||||||
fileName: newFileName,
|
|
||||||
mimeType: _mimeTypeForExt(ext),
|
|
||||||
srcPath: tempPath,
|
|
||||||
);
|
|
||||||
if (newUri != null) {
|
|
||||||
if (newUri != filePath) {
|
|
||||||
await _deleteSafFile(filePath);
|
|
||||||
}
|
|
||||||
filePath = newUri;
|
|
||||||
finalSafFileName = newFileName;
|
|
||||||
_log.d('YouTube SAF metadata embedding completed');
|
|
||||||
} else {
|
|
||||||
_log.w('Failed to write metadata-updated file back to SAF');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
_log.w('YouTube SAF metadata embedding failed: $e');
|
|
||||||
} finally {
|
|
||||||
try {
|
|
||||||
await File(tempPath).delete();
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
if (isMp3File) {
|
|
||||||
await _embedMetadataToMp3(
|
|
||||||
filePath,
|
|
||||||
finalTrack,
|
|
||||||
genre: backendGenre ?? genre,
|
|
||||||
label: backendLabel ?? label,
|
|
||||||
copyright: backendCopyright,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
await _embedMetadataToOpus(
|
|
||||||
filePath,
|
|
||||||
finalTrack,
|
|
||||||
genre: backendGenre ?? genre,
|
|
||||||
label: backendLabel ?? label,
|
|
||||||
copyright: backendCopyright,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
_log.d('YouTube metadata embedding completed');
|
|
||||||
} catch (e) {
|
|
||||||
_log.w('YouTube metadata embedding failed: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final itemAfterDownload = _findItemById(item.id);
|
final itemAfterDownload = _findItemById(item.id);
|
||||||
if (itemAfterDownload == null ||
|
if (itemAfterDownload == null ||
|
||||||
_isLocallyCancelled(item.id, item: itemAfterDownload)) {
|
_isLocallyCancelled(item.id, item: itemAfterDownload)) {
|
||||||
@@ -4746,9 +4966,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// SAF downloads should end with content URI. If we still have a
|
|
||||||
// transient FD path, recover URI from SAF metadata to keep history
|
|
||||||
// dedup/exclusion stable.
|
|
||||||
if (effectiveSafMode &&
|
if (effectiveSafMode &&
|
||||||
filePath != null &&
|
filePath != null &&
|
||||||
filePath.isNotEmpty &&
|
filePath.isNotEmpty &&
|
||||||
@@ -5063,8 +5280,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
);
|
);
|
||||||
_failedInSession++;
|
_failedInSession++;
|
||||||
|
|
||||||
// Immediately cleanup connections after failure to prevent
|
|
||||||
// poisoned connection pool from affecting subsequent downloads
|
|
||||||
try {
|
try {
|
||||||
await PlatformBridge.cleanupConnections();
|
await PlatformBridge.cleanupConnections();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -5117,7 +5332,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
);
|
);
|
||||||
_failedInSession++;
|
_failedInSession++;
|
||||||
|
|
||||||
// Immediately cleanup connections after exception
|
|
||||||
try {
|
try {
|
||||||
await PlatformBridge.cleanupConnections();
|
await PlatformBridge.cleanupConnections();
|
||||||
} catch (cleanupErr) {
|
} catch (cleanupErr) {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ final _log = AppLogger('ExploreProvider');
|
|||||||
class ExploreItem {
|
class ExploreItem {
|
||||||
final String id;
|
final String id;
|
||||||
final String uri;
|
final String uri;
|
||||||
final String type; // track, album, playlist, artist, station
|
final String type;
|
||||||
final String name;
|
final String name;
|
||||||
final String artists;
|
final String artists;
|
||||||
final String? description;
|
final String? description;
|
||||||
@@ -168,7 +168,6 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
|||||||
return const ExploreState();
|
return const ExploreState();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Restore cached home feed from SharedPreferences immediately on startup
|
|
||||||
Future<void> _restoreFromCache() async {
|
Future<void> _restoreFromCache() async {
|
||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
@@ -199,7 +198,6 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Save home feed to SharedPreferences for instant restore on next launch
|
|
||||||
Future<void> _saveToCache(List<ExploreSection> sections) async {
|
Future<void> _saveToCache(List<ExploreSection> sections) async {
|
||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
@@ -212,11 +210,9 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch home feed from spotify-web extension
|
|
||||||
Future<void> fetchHomeFeed({bool forceRefresh = false}) async {
|
Future<void> fetchHomeFeed({bool forceRefresh = false}) async {
|
||||||
_log.i('fetchHomeFeed called, forceRefresh=$forceRefresh');
|
_log.i('fetchHomeFeed called, forceRefresh=$forceRefresh');
|
||||||
|
|
||||||
// If we have cached content and it's fresh enough, skip network fetch
|
|
||||||
if (!forceRefresh &&
|
if (!forceRefresh &&
|
||||||
state.hasContent &&
|
state.hasContent &&
|
||||||
state.lastFetched != null &&
|
state.lastFetched != null &&
|
||||||
@@ -230,7 +226,6 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only show loading spinner if we have no cached content to display
|
|
||||||
final showLoading = !state.hasContent;
|
final showLoading = !state.hasContent;
|
||||||
state = state.copyWith(isLoading: showLoading, error: null);
|
state = state.copyWith(isLoading: showLoading, error: null);
|
||||||
|
|
||||||
@@ -247,14 +242,12 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
|||||||
if (!extension.enabled || !extension.hasHomeFeed) {
|
if (!extension.enabled || !extension.hasHomeFeed) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// If user has a preference, use that
|
|
||||||
if (preferredId != null &&
|
if (preferredId != null &&
|
||||||
preferredId.isNotEmpty &&
|
preferredId.isNotEmpty &&
|
||||||
extension.id == preferredId) {
|
extension.id == preferredId) {
|
||||||
targetExt = extension;
|
targetExt = extension;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// Otherwise take the first available (fallback to spotify-web if found)
|
|
||||||
if (targetExt == null || extension.id == 'spotify-web') {
|
if (targetExt == null || extension.id == 'spotify-web') {
|
||||||
targetExt = extension;
|
targetExt = extension;
|
||||||
if (preferredId == null && extension.id == 'spotify-web') {
|
if (preferredId == null && extension.id == 'spotify-web') {
|
||||||
@@ -317,7 +310,6 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
|||||||
lastFetched: DateTime.now(),
|
lastFetched: DateTime.now(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Save to disk cache for instant restore on next app launch
|
|
||||||
_saveToCache(sections);
|
_saveToCache(sections);
|
||||||
} catch (e, stack) {
|
} catch (e, stack) {
|
||||||
_log.e('Error fetching home feed: $e', e, stack);
|
_log.e('Error fetching home feed: $e', e, stack);
|
||||||
|
|||||||
@@ -32,14 +32,12 @@ class Extension {
|
|||||||
final bool hasMetadataProvider;
|
final bool hasMetadataProvider;
|
||||||
final bool hasDownloadProvider;
|
final bool hasDownloadProvider;
|
||||||
final bool hasLyricsProvider;
|
final bool hasLyricsProvider;
|
||||||
final bool
|
final bool skipMetadataEnrichment;
|
||||||
skipMetadataEnrichment; // If true, use metadata from extension instead of enriching
|
|
||||||
final SearchBehavior? searchBehavior;
|
final SearchBehavior? searchBehavior;
|
||||||
final URLHandler? urlHandler;
|
final URLHandler? urlHandler;
|
||||||
final TrackMatching? trackMatching;
|
final TrackMatching? trackMatching;
|
||||||
final PostProcessing? postProcessing;
|
final PostProcessing? postProcessing;
|
||||||
final Map<String, dynamic>
|
final Map<String, dynamic> capabilities;
|
||||||
capabilities; // Extension capabilities (homeFeed, browseCategories, etc.)
|
|
||||||
|
|
||||||
const Extension({
|
const Extension({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -198,12 +196,10 @@ class SearchBehavior {
|
|||||||
final String? placeholder;
|
final String? placeholder;
|
||||||
final bool primary;
|
final bool primary;
|
||||||
final String? icon;
|
final String? icon;
|
||||||
final String?
|
final String? thumbnailRatio;
|
||||||
thumbnailRatio; // "square" (1:1), "wide" (16:9), "portrait" (2:3)
|
|
||||||
final int? thumbnailWidth;
|
final int? thumbnailWidth;
|
||||||
final int? thumbnailHeight;
|
final int? thumbnailHeight;
|
||||||
final List<SearchFilter>
|
final List<SearchFilter> filters;
|
||||||
filters; // Available search filters (e.g., track, album, artist, playlist)
|
|
||||||
|
|
||||||
const SearchBehavior({
|
const SearchBehavior({
|
||||||
required this.enabled,
|
required this.enabled,
|
||||||
@@ -239,11 +235,11 @@ class SearchBehavior {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch (thumbnailRatio) {
|
switch (thumbnailRatio) {
|
||||||
case 'wide': // 16:9 - YouTube style
|
case 'wide':
|
||||||
return (defaultSize * 16 / 9, defaultSize);
|
return (defaultSize * 16 / 9, defaultSize);
|
||||||
case 'portrait': // 2:3 - Poster style
|
case 'portrait':
|
||||||
return (defaultSize * 2 / 3, defaultSize);
|
return (defaultSize * 2 / 3, defaultSize);
|
||||||
case 'square': // 1:1 - Album art style
|
case 'square':
|
||||||
default:
|
default:
|
||||||
return (defaultSize, defaultSize);
|
return (defaultSize, defaultSize);
|
||||||
}
|
}
|
||||||
@@ -290,7 +286,6 @@ class PostProcessing {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// URL handler configuration for custom URL patterns
|
|
||||||
class URLHandler {
|
class URLHandler {
|
||||||
final bool enabled;
|
final bool enabled;
|
||||||
final List<String> patterns;
|
final List<String> patterns;
|
||||||
@@ -304,7 +299,6 @@ class URLHandler {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a URL matches any of the patterns
|
|
||||||
bool matchesURL(String url) {
|
bool matchesURL(String url) {
|
||||||
if (!enabled || patterns.isEmpty) return false;
|
if (!enabled || patterns.isEmpty) return false;
|
||||||
final lowerUrl = url.toLowerCase();
|
final lowerUrl = url.toLowerCase();
|
||||||
|
|||||||
@@ -666,7 +666,6 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
|
|||||||
final destPath = p.join(coversDir.path, '$playlistId$ext');
|
final destPath = p.join(coversDir.path, '$playlistId$ext');
|
||||||
if (playlist.coverImagePath == destPath) return;
|
if (playlist.coverImagePath == destPath) return;
|
||||||
|
|
||||||
// Copy image to persistent location
|
|
||||||
await File(sourceFilePath).copy(destPath);
|
await File(sourceFilePath).copy(destPath);
|
||||||
|
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
@@ -686,7 +685,6 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
|
|||||||
final playlist = state.playlistById(playlistId);
|
final playlist = state.playlistById(playlistId);
|
||||||
if (playlist == null || playlist.coverImagePath == null) return;
|
if (playlist == null || playlist.coverImagePath == null) return;
|
||||||
|
|
||||||
// Delete the file if it exists
|
|
||||||
final path = playlist.coverImagePath;
|
final path = playlist.coverImagePath;
|
||||||
if (path != null) {
|
if (path != null) {
|
||||||
final file = File(path);
|
final file = File(path);
|
||||||
|
|||||||
@@ -252,8 +252,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
|
|
||||||
_startProgressPolling();
|
_startProgressPolling();
|
||||||
|
|
||||||
// On iOS, start accessing the security-scoped bookmark so the Go backend
|
|
||||||
// can read files outside the app sandbox.
|
|
||||||
String? resolvedPath;
|
String? resolvedPath;
|
||||||
bool didStartSecurityAccess = false;
|
bool didStartSecurityAccess = false;
|
||||||
if (Platform.isIOS && iosBookmark != null && iosBookmark.isNotEmpty) {
|
if (Platform.isIOS && iosBookmark != null && iosBookmark.isNotEmpty) {
|
||||||
@@ -275,9 +273,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
try {
|
try {
|
||||||
final isSaf = effectiveFolderPath.startsWith('content://');
|
final isSaf = effectiveFolderPath.startsWith('content://');
|
||||||
|
|
||||||
// Get all file paths from download history to exclude them.
|
|
||||||
// Merge DB + in-memory state to avoid race when a fresh download has not
|
|
||||||
// been flushed to SQLite yet.
|
|
||||||
final downloadedPaths = await _historyDb.getAllFilePaths();
|
final downloadedPaths = await _historyDb.getAllFilePaths();
|
||||||
final inMemoryHistoryPaths = ref
|
final inMemoryHistoryPaths = ref
|
||||||
.read(downloadHistoryProvider)
|
.read(downloadHistoryProvider)
|
||||||
@@ -298,7 +293,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (forceFullScan) {
|
if (forceFullScan) {
|
||||||
// Full scan path - ignores existing data
|
|
||||||
final results = isSaf
|
final results = isSaf
|
||||||
? await PlatformBridge.scanSafTree(effectiveFolderPath)
|
? await PlatformBridge.scanSafTree(effectiveFolderPath)
|
||||||
: await PlatformBridge.scanLibraryFolder(effectiveFolderPath);
|
: await PlatformBridge.scanLibraryFolder(effectiveFolderPath);
|
||||||
@@ -324,16 +318,8 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
_log.i('Skipped $skippedDownloads files already in download history');
|
_log.i('Skipped $skippedDownloads files already in download history');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Full scan should replace library index entirely.
|
await _db.replaceAll(items.map((e) => e.toJson()).toList());
|
||||||
await _db.clearAll();
|
final persistedItems = [...items]..sort(_compareLibraryItems);
|
||||||
if (items.isNotEmpty) {
|
|
||||||
await _db.upsertBatch(items.map((e) => e.toJson()).toList());
|
|
||||||
}
|
|
||||||
final persistedItems =
|
|
||||||
(await _db.getAll())
|
|
||||||
.map(LocalLibraryItem.fromJson)
|
|
||||||
.toList(growable: false)
|
|
||||||
..sort(_compareLibraryItems);
|
|
||||||
|
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
try {
|
try {
|
||||||
@@ -364,7 +350,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
errorCount: state.scanErrorCount,
|
errorCount: state.scanErrorCount,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Incremental scan path - only scans new/modified files
|
|
||||||
final existingFiles = await _db.getFileModTimes();
|
final existingFiles = await _db.getFileModTimes();
|
||||||
_log.i(
|
_log.i(
|
||||||
'Incremental scan: ${existingFiles.length} existing files in database',
|
'Incremental scan: ${existingFiles.length} existing files in database',
|
||||||
@@ -423,7 +408,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// SAF returns 'files' and 'removedUris', non-SAF returns 'scanned' and 'deletedPaths'
|
|
||||||
final scannedList =
|
final scannedList =
|
||||||
(result['files'] as List<dynamic>?) ??
|
(result['files'] as List<dynamic>?) ??
|
||||||
(result['scanned'] as List<dynamic>?) ??
|
(result['scanned'] as List<dynamic>?) ??
|
||||||
@@ -444,10 +428,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
'$skippedCount skipped, ${deletedPaths.length} deleted, $totalFiles total',
|
'$skippedCount skipped, ${deletedPaths.length} deleted, $totalFiles total',
|
||||||
);
|
);
|
||||||
|
|
||||||
// Build the incremental merge base from SQLite, not the current
|
|
||||||
// provider state. Startup auto-scan can fire before `state.items` has
|
|
||||||
// finished loading, which would otherwise drop unchanged rows from the
|
|
||||||
// in-memory library until a manual full rescan.
|
|
||||||
final existingJson = await _db.getAll();
|
final existingJson = await _db.getAll();
|
||||||
final currentByPath = <String, LocalLibraryItem>{
|
final currentByPath = <String, LocalLibraryItem>{
|
||||||
for (final item in existingJson.map(LocalLibraryItem.fromJson))
|
for (final item in existingJson.map(LocalLibraryItem.fromJson))
|
||||||
@@ -468,7 +448,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upsert new/modified items (excluding downloaded files)
|
|
||||||
final updatedItems = <LocalLibraryItem>[];
|
final updatedItems = <LocalLibraryItem>[];
|
||||||
int skippedDownloads = existingDownloadedPaths.length;
|
int skippedDownloads = existingDownloadedPaths.length;
|
||||||
if (scannedList.isNotEmpty) {
|
if (scannedList.isNotEmpty) {
|
||||||
@@ -502,11 +481,8 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
_log.i('Deleted $deleteCount items from database');
|
_log.i('Deleted $deleteCount items from database');
|
||||||
}
|
}
|
||||||
|
|
||||||
final items =
|
final items = currentByPath.values.toList(growable: false)
|
||||||
(await _db.getAll())
|
..sort(_compareLibraryItems);
|
||||||
.map(LocalLibraryItem.fromJson)
|
|
||||||
.toList(growable: false)
|
|
||||||
..sort(_compareLibraryItems);
|
|
||||||
|
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -5,18 +5,16 @@ import 'package:spotiflac_android/services/app_state_database.dart';
|
|||||||
|
|
||||||
const _maxRecentItems = 20;
|
const _maxRecentItems = 20;
|
||||||
|
|
||||||
/// Types of items that can be accessed
|
|
||||||
enum RecentAccessType { artist, album, track, playlist }
|
enum RecentAccessType { artist, album, track, playlist }
|
||||||
|
|
||||||
/// Represents a recently accessed item
|
|
||||||
class RecentAccessItem {
|
class RecentAccessItem {
|
||||||
final String id;
|
final String id;
|
||||||
final String name;
|
final String name;
|
||||||
final String? subtitle; // Artist name for tracks/albums, null for artists
|
final String? subtitle;
|
||||||
final String? imageUrl;
|
final String? imageUrl;
|
||||||
final RecentAccessType type;
|
final RecentAccessType type;
|
||||||
final DateTime accessedAt;
|
final DateTime accessedAt;
|
||||||
final String? providerId; // Extension ID or 'deezer' for built-in
|
final String? providerId;
|
||||||
|
|
||||||
const RecentAccessItem({
|
const RecentAccessItem({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -53,7 +51,6 @@ class RecentAccessItem {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a unique key for deduplication
|
|
||||||
String get uniqueKey => '${type.name}:${providerId ?? 'default'}:$id';
|
String get uniqueKey => '${type.name}:${providerId ?? 'default'}:$id';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -67,7 +64,6 @@ class RecentAccessItem {
|
|||||||
int get hashCode => uniqueKey.hashCode;
|
int get hashCode => uniqueKey.hashCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// State for recent access history
|
|
||||||
class RecentAccessState {
|
class RecentAccessState {
|
||||||
final List<RecentAccessItem> items;
|
final List<RecentAccessItem> items;
|
||||||
final Set<String> hiddenDownloadIds;
|
final Set<String> hiddenDownloadIds;
|
||||||
@@ -92,7 +88,6 @@ class RecentAccessState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Provider for managing recent access history
|
|
||||||
class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
||||||
final AppStateDatabase _appStateDb = AppStateDatabase.instance;
|
final AppStateDatabase _appStateDb = AppStateDatabase.instance;
|
||||||
|
|
||||||
@@ -135,7 +130,6 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Record an access to an artist
|
|
||||||
void recordArtistAccess({
|
void recordArtistAccess({
|
||||||
required String id,
|
required String id,
|
||||||
required String name,
|
required String name,
|
||||||
@@ -154,7 +148,6 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Record an access to an album
|
|
||||||
void recordAlbumAccess({
|
void recordAlbumAccess({
|
||||||
required String id,
|
required String id,
|
||||||
required String name,
|
required String name,
|
||||||
@@ -175,7 +168,6 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Record an access to a track
|
|
||||||
void recordTrackAccess({
|
void recordTrackAccess({
|
||||||
required String id,
|
required String id,
|
||||||
required String name,
|
required String name,
|
||||||
@@ -196,7 +188,6 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Record an access to a playlist
|
|
||||||
void recordPlaylistAccess({
|
void recordPlaylistAccess({
|
||||||
required String id,
|
required String id,
|
||||||
required String name,
|
required String name,
|
||||||
@@ -242,7 +233,6 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove a specific item from history
|
|
||||||
void removeItem(RecentAccessItem item) {
|
void removeItem(RecentAccessItem item) {
|
||||||
final updatedItems = state.items
|
final updatedItems = state.items
|
||||||
.where((e) => e.uniqueKey != item.uniqueKey)
|
.where((e) => e.uniqueKey != item.uniqueKey)
|
||||||
@@ -251,25 +241,21 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
|||||||
unawaited(_appStateDb.deleteRecentAccessRow(item.uniqueKey));
|
unawaited(_appStateDb.deleteRecentAccessRow(item.uniqueKey));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Hide a download item from recents (without deleting the actual download)
|
|
||||||
void hideDownloadFromRecents(String downloadId) {
|
void hideDownloadFromRecents(String downloadId) {
|
||||||
final updatedHidden = {...state.hiddenDownloadIds, downloadId};
|
final updatedHidden = {...state.hiddenDownloadIds, downloadId};
|
||||||
state = state.copyWith(hiddenDownloadIds: updatedHidden);
|
state = state.copyWith(hiddenDownloadIds: updatedHidden);
|
||||||
unawaited(_appStateDb.addHiddenRecentDownloadId(downloadId));
|
unawaited(_appStateDb.addHiddenRecentDownloadId(downloadId));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a download is hidden from recents
|
|
||||||
bool isDownloadHidden(String downloadId) {
|
bool isDownloadHidden(String downloadId) {
|
||||||
return state.hiddenDownloadIds.contains(downloadId);
|
return state.hiddenDownloadIds.contains(downloadId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear all history
|
|
||||||
void clearHistory() {
|
void clearHistory() {
|
||||||
state = state.copyWith(items: []);
|
state = state.copyWith(items: []);
|
||||||
unawaited(_appStateDb.clearRecentAccessRows());
|
unawaited(_appStateDb.clearRecentAccessRows());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear hidden downloads (show all again)
|
|
||||||
void clearHiddenDownloads() {
|
void clearHiddenDownloads() {
|
||||||
state = state.copyWith(hiddenDownloadIds: {});
|
state = state.copyWith(hiddenDownloadIds: {});
|
||||||
unawaited(_appStateDb.clearHiddenRecentDownloadIds());
|
unawaited(_appStateDb.clearHiddenRecentDownloadIds());
|
||||||
|
|||||||
@@ -11,13 +11,11 @@ import 'package:spotiflac_android/utils/logger.dart';
|
|||||||
|
|
||||||
const _settingsKey = 'app_settings';
|
const _settingsKey = 'app_settings';
|
||||||
const _migrationVersionKey = 'settings_migration_version';
|
const _migrationVersionKey = 'settings_migration_version';
|
||||||
const _currentMigrationVersion = 6;
|
const _currentMigrationVersion = 7;
|
||||||
const _spotifyClientSecretKey = 'spotify_client_secret';
|
const _spotifyClientSecretKey = 'spotify_client_secret';
|
||||||
final _log = AppLogger('SettingsProvider');
|
final _log = AppLogger('SettingsProvider');
|
||||||
|
|
||||||
class SettingsNotifier extends Notifier<AppSettings> {
|
class SettingsNotifier extends Notifier<AppSettings> {
|
||||||
static const List<int> _youtubeOpusSupportedBitrates = [128, 256, 320];
|
|
||||||
static const List<int> _youtubeMp3SupportedBitrates = [128, 256, 320];
|
|
||||||
static final RegExp _isoRegionPattern = RegExp(r'^[A-Z]{2}$');
|
static final RegExp _isoRegionPattern = RegExp(r'^[A-Z]{2}$');
|
||||||
|
|
||||||
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
|
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
|
||||||
@@ -40,7 +38,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
|
|
||||||
await _runMigrations(prefs);
|
await _runMigrations(prefs);
|
||||||
await _normalizeIosDownloadDirectoryIfNeeded();
|
await _normalizeIosDownloadDirectoryIfNeeded();
|
||||||
await _normalizeYouTubeBitratesIfNeeded();
|
|
||||||
await _normalizeSongLinkRegionIfNeeded();
|
await _normalizeSongLinkRegionIfNeeded();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,6 +119,10 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
state = state.copyWith(lastSeenVersion: AppInfo.version);
|
state = state.copyWith(lastSeenVersion: AppInfo.version);
|
||||||
|
// Migration 7: YouTube is no longer a built-in service — reset to Tidal
|
||||||
|
if (state.defaultService == 'youtube') {
|
||||||
|
state = state.copyWith(defaultService: 'tidal');
|
||||||
|
}
|
||||||
await prefs.setInt(_migrationVersionKey, _currentMigrationVersion);
|
await prefs.setInt(_migrationVersionKey, _currentMigrationVersion);
|
||||||
await _saveSettings();
|
await _saveSettings();
|
||||||
}
|
}
|
||||||
@@ -153,49 +154,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
int _nearestSupportedBitrate(int value, List<int> supported) {
|
|
||||||
var nearest = supported.first;
|
|
||||||
var nearestDistance = (value - nearest).abs();
|
|
||||||
|
|
||||||
for (final option in supported.skip(1)) {
|
|
||||||
final distance = (value - option).abs();
|
|
||||||
// On tie, prefer higher quality bitrate.
|
|
||||||
if (distance < nearestDistance ||
|
|
||||||
(distance == nearestDistance && option > nearest)) {
|
|
||||||
nearest = option;
|
|
||||||
nearestDistance = distance;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nearest;
|
|
||||||
}
|
|
||||||
|
|
||||||
int _normalizeYouTubeOpusBitrate(int bitrate) {
|
|
||||||
return _nearestSupportedBitrate(bitrate, _youtubeOpusSupportedBitrates);
|
|
||||||
}
|
|
||||||
|
|
||||||
int _normalizeYouTubeMp3Bitrate(int bitrate) {
|
|
||||||
return _nearestSupportedBitrate(bitrate, _youtubeMp3SupportedBitrates);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _normalizeYouTubeBitratesIfNeeded() async {
|
|
||||||
final normalizedOpus = _normalizeYouTubeOpusBitrate(
|
|
||||||
state.youtubeOpusBitrate,
|
|
||||||
);
|
|
||||||
final normalizedMp3 = _normalizeYouTubeMp3Bitrate(state.youtubeMp3Bitrate);
|
|
||||||
|
|
||||||
if (normalizedOpus == state.youtubeOpusBitrate &&
|
|
||||||
normalizedMp3 == state.youtubeMp3Bitrate) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
state = state.copyWith(
|
|
||||||
youtubeOpusBitrate: normalizedOpus,
|
|
||||||
youtubeMp3Bitrate: normalizedMp3,
|
|
||||||
);
|
|
||||||
await _saveSettings();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _normalizeIosDownloadDirectoryIfNeeded() async {
|
Future<void> _normalizeIosDownloadDirectoryIfNeeded() async {
|
||||||
if (!Platform.isIOS) return;
|
if (!Platform.isIOS) return;
|
||||||
|
|
||||||
@@ -469,18 +427,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
_saveSettings();
|
_saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
void setYoutubeOpusBitrate(int bitrate) {
|
|
||||||
final normalized = _normalizeYouTubeOpusBitrate(bitrate);
|
|
||||||
state = state.copyWith(youtubeOpusBitrate: normalized);
|
|
||||||
_saveSettings();
|
|
||||||
}
|
|
||||||
|
|
||||||
void setYoutubeMp3Bitrate(int bitrate) {
|
|
||||||
final normalized = _normalizeYouTubeMp3Bitrate(bitrate);
|
|
||||||
state = state.copyWith(youtubeMp3Bitrate: normalized);
|
|
||||||
_saveSettings();
|
|
||||||
}
|
|
||||||
|
|
||||||
void setUseAllFilesAccess(bool enabled) {
|
void setUseAllFilesAccess(bool enabled) {
|
||||||
state = state.copyWith(useAllFilesAccess: enabled);
|
state = state.copyWith(useAllFilesAccess: enabled);
|
||||||
_saveSettings();
|
_saveSettings();
|
||||||
|
|||||||
@@ -26,14 +26,19 @@ int compareVersions(String v1, String v2) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class StoreCategory {
|
class StoreCategory {
|
||||||
|
|
||||||
static const String metadata = 'metadata';
|
static const String metadata = 'metadata';
|
||||||
static const String download = 'download';
|
static const String download = 'download';
|
||||||
static const String utility = 'utility';
|
static const String utility = 'utility';
|
||||||
static const String lyrics = 'lyrics';
|
static const String lyrics = 'lyrics';
|
||||||
static const String integration = 'integration';
|
static const String integration = 'integration';
|
||||||
|
|
||||||
static const List<String> all = [metadata, download, utility, lyrics, integration];
|
static const List<String> all = [
|
||||||
|
metadata,
|
||||||
|
download,
|
||||||
|
utility,
|
||||||
|
lyrics,
|
||||||
|
integration,
|
||||||
|
];
|
||||||
|
|
||||||
static String getDisplayName(String category) {
|
static String getDisplayName(String category) {
|
||||||
switch (category) {
|
switch (category) {
|
||||||
@@ -94,7 +99,8 @@ class StoreExtension {
|
|||||||
return StoreExtension(
|
return StoreExtension(
|
||||||
id: json['id'] as String? ?? '',
|
id: json['id'] as String? ?? '',
|
||||||
name: json['name'] as String? ?? '',
|
name: json['name'] as String? ?? '',
|
||||||
displayName: json['display_name'] as String? ?? json['name'] as String? ?? '',
|
displayName:
|
||||||
|
json['display_name'] as String? ?? json['name'] as String? ?? '',
|
||||||
version: json['version'] as String? ?? '0.0.0',
|
version: json['version'] as String? ?? '0.0.0',
|
||||||
author: json['author'] as String? ?? 'Unknown',
|
author: json['author'] as String? ?? 'Unknown',
|
||||||
description: json['description'] as String? ?? '',
|
description: json['description'] as String? ?? '',
|
||||||
@@ -117,7 +123,6 @@ class StoreExtension {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class StoreState {
|
class StoreState {
|
||||||
final List<StoreExtension> extensions;
|
final List<StoreExtension> extensions;
|
||||||
final String? selectedCategory;
|
final String? selectedCategory;
|
||||||
@@ -160,11 +165,15 @@ class StoreState {
|
|||||||
}) {
|
}) {
|
||||||
return StoreState(
|
return StoreState(
|
||||||
extensions: extensions ?? this.extensions,
|
extensions: extensions ?? this.extensions,
|
||||||
selectedCategory: clearCategory ? null : (selectedCategory ?? this.selectedCategory),
|
selectedCategory: clearCategory
|
||||||
|
? null
|
||||||
|
: (selectedCategory ?? this.selectedCategory),
|
||||||
searchQuery: searchQuery ?? this.searchQuery,
|
searchQuery: searchQuery ?? this.searchQuery,
|
||||||
isLoading: isLoading ?? this.isLoading,
|
isLoading: isLoading ?? this.isLoading,
|
||||||
isDownloading: isDownloading ?? this.isDownloading,
|
isDownloading: isDownloading ?? this.isDownloading,
|
||||||
downloadingId: clearDownloadingId ? null : (downloadingId ?? this.downloadingId),
|
downloadingId: clearDownloadingId
|
||||||
|
? null
|
||||||
|
: (downloadingId ?? this.downloadingId),
|
||||||
error: clearError ? null : (error ?? this.error),
|
error: clearError ? null : (error ?? this.error),
|
||||||
isInitialized: isInitialized ?? this.isInitialized,
|
isInitialized: isInitialized ?? this.isInitialized,
|
||||||
registryUrl: registryUrl ?? this.registryUrl,
|
registryUrl: registryUrl ?? this.registryUrl,
|
||||||
@@ -180,13 +189,16 @@ class StoreState {
|
|||||||
|
|
||||||
if (searchQuery.isNotEmpty) {
|
if (searchQuery.isNotEmpty) {
|
||||||
final query = searchQuery.toLowerCase();
|
final query = searchQuery.toLowerCase();
|
||||||
result = result.where((e) =>
|
result = result
|
||||||
e.name.toLowerCase().contains(query) ||
|
.where(
|
||||||
e.displayName.toLowerCase().contains(query) ||
|
(e) =>
|
||||||
e.description.toLowerCase().contains(query) ||
|
e.name.toLowerCase().contains(query) ||
|
||||||
e.author.toLowerCase().contains(query) ||
|
e.displayName.toLowerCase().contains(query) ||
|
||||||
e.tags.any((t) => t.toLowerCase().contains(query))
|
e.description.toLowerCase().contains(query) ||
|
||||||
).toList();
|
e.author.toLowerCase().contains(query) ||
|
||||||
|
e.tags.any((t) => t.toLowerCase().contains(query)),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
@@ -206,23 +218,28 @@ class StoreNotifier extends Notifier<StoreState> {
|
|||||||
Future<void> initialize(String cacheDir) async {
|
Future<void> initialize(String cacheDir) async {
|
||||||
if (state.isInitialized) return;
|
if (state.isInitialized) return;
|
||||||
|
|
||||||
state = state.copyWith(isLoading: true, clearError: true);
|
// Load saved registry URL early to avoid UI flash (empty → setup screen)
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final savedUrl = prefs.getString(_registryUrlPrefKey) ?? '';
|
||||||
|
|
||||||
|
state = state.copyWith(
|
||||||
|
isLoading: true,
|
||||||
|
clearError: true,
|
||||||
|
registryUrl: savedUrl,
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await PlatformBridge.initExtensionStore(cacheDir);
|
await PlatformBridge.initExtensionStore(cacheDir);
|
||||||
|
|
||||||
// Load saved registry URL from SharedPreferences
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
final savedUrl = prefs.getString(_registryUrlPrefKey) ?? '';
|
|
||||||
|
|
||||||
if (savedUrl.isNotEmpty) {
|
if (savedUrl.isNotEmpty) {
|
||||||
await PlatformBridge.setStoreRegistryUrl(savedUrl);
|
await PlatformBridge.setStoreRegistryUrl(savedUrl);
|
||||||
state = state.copyWith(registryUrl: savedUrl);
|
|
||||||
await refresh();
|
await refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
state = state.copyWith(isInitialized: true, isLoading: false);
|
state = state.copyWith(isInitialized: true, isLoading: false);
|
||||||
_log.i('Extension store initialized (registryUrl: ${savedUrl.isEmpty ? "not set" : savedUrl})');
|
_log.i(
|
||||||
|
'Extension store initialized (registryUrl: ${savedUrl.isEmpty ? "not set" : savedUrl})',
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_log.e('Failed to initialize store: $e');
|
_log.e('Failed to initialize store: $e');
|
||||||
state = state.copyWith(isLoading: false, error: e.toString());
|
state = state.copyWith(isLoading: false, error: e.toString());
|
||||||
@@ -247,13 +264,12 @@ class StoreNotifier extends Notifier<StoreState> {
|
|||||||
// Read back the resolved URL (may differ from input after normalisation).
|
// Read back the resolved URL (may differ from input after normalisation).
|
||||||
final resolvedUrl = await PlatformBridge.getStoreRegistryUrl();
|
final resolvedUrl = await PlatformBridge.getStoreRegistryUrl();
|
||||||
|
|
||||||
// Persist to SharedPreferences
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setString(_registryUrlPrefKey, resolvedUrl);
|
await prefs.setString(_registryUrlPrefKey, resolvedUrl);
|
||||||
|
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
registryUrl: resolvedUrl,
|
registryUrl: resolvedUrl,
|
||||||
extensions: const [], // Clear old extensions
|
extensions: const [],
|
||||||
);
|
);
|
||||||
|
|
||||||
_log.i('Registry URL set to: $resolvedUrl');
|
_log.i('Registry URL set to: $resolvedUrl');
|
||||||
@@ -292,7 +308,9 @@ class StoreNotifier extends Notifier<StoreState> {
|
|||||||
state = state.copyWith(isLoading: true, clearError: true);
|
state = state.copyWith(isLoading: true, clearError: true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final extensions = await PlatformBridge.getStoreExtensions(forceRefresh: forceRefresh);
|
final extensions = await PlatformBridge.getStoreExtensions(
|
||||||
|
forceRefresh: forceRefresh,
|
||||||
|
);
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
extensions: extensions.map((e) => StoreExtension.fromJson(e)).toList(),
|
extensions: extensions.map((e) => StoreExtension.fromJson(e)).toList(),
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -320,12 +338,23 @@ class StoreNotifier extends Notifier<StoreState> {
|
|||||||
state = state.copyWith(searchQuery: '', clearCategory: true);
|
state = state.copyWith(searchQuery: '', clearCategory: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> installExtension(String extensionId, String tempDir, String extensionsDir) async {
|
Future<bool> installExtension(
|
||||||
state = state.copyWith(isDownloading: true, downloadingId: extensionId, clearError: true);
|
String extensionId,
|
||||||
|
String tempDir,
|
||||||
|
String extensionsDir,
|
||||||
|
) async {
|
||||||
|
state = state.copyWith(
|
||||||
|
isDownloading: true,
|
||||||
|
downloadingId: extensionId,
|
||||||
|
clearError: true,
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
_log.i('Downloading extension: $extensionId');
|
_log.i('Downloading extension: $extensionId');
|
||||||
final downloadPath = await PlatformBridge.downloadStoreExtension(extensionId, tempDir);
|
final downloadPath = await PlatformBridge.downloadStoreExtension(
|
||||||
|
extensionId,
|
||||||
|
tempDir,
|
||||||
|
);
|
||||||
|
|
||||||
_log.i('Installing extension from: $downloadPath');
|
_log.i('Installing extension from: $downloadPath');
|
||||||
final extNotifier = ref.read(extensionProvider.notifier);
|
final extNotifier = ref.read(extensionProvider.notifier);
|
||||||
@@ -340,18 +369,28 @@ class StoreNotifier extends Notifier<StoreState> {
|
|||||||
return success;
|
return success;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_log.e('Failed to install extension: $e');
|
_log.e('Failed to install extension: $e');
|
||||||
state = state.copyWith(isDownloading: false, clearDownloadingId: true, error: e.toString());
|
state = state.copyWith(
|
||||||
|
isDownloading: false,
|
||||||
|
clearDownloadingId: true,
|
||||||
|
error: e.toString(),
|
||||||
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
Future<bool> updateExtension(String extensionId, String tempDir) async {
|
Future<bool> updateExtension(String extensionId, String tempDir) async {
|
||||||
state = state.copyWith(isDownloading: true, downloadingId: extensionId, clearError: true);
|
state = state.copyWith(
|
||||||
|
isDownloading: true,
|
||||||
|
downloadingId: extensionId,
|
||||||
|
clearError: true,
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
_log.i('Downloading update for: $extensionId');
|
_log.i('Downloading update for: $extensionId');
|
||||||
final downloadPath = await PlatformBridge.downloadStoreExtension(extensionId, tempDir);
|
final downloadPath = await PlatformBridge.downloadStoreExtension(
|
||||||
|
extensionId,
|
||||||
|
tempDir,
|
||||||
|
);
|
||||||
|
|
||||||
_log.i('Upgrading extension from: $downloadPath');
|
_log.i('Upgrading extension from: $downloadPath');
|
||||||
final extNotifier = ref.read(extensionProvider.notifier);
|
final extNotifier = ref.read(extensionProvider.notifier);
|
||||||
@@ -366,7 +405,11 @@ class StoreNotifier extends Notifier<StoreState> {
|
|||||||
return success;
|
return success;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_log.e('Failed to update extension: $e');
|
_log.e('Failed to update extension: $e');
|
||||||
state = state.copyWith(isDownloading: false, clearDownloadingId: true, error: e.toString());
|
state = state.copyWith(
|
||||||
|
isDownloading: false,
|
||||||
|
clearDownloadingId: true,
|
||||||
|
error: e.toString(),
|
||||||
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,6 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
|
|||||||
await _saveToStorage();
|
await _saveToStorage();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set custom seed color (used when dynamic color is disabled)
|
|
||||||
Future<void> setSeedColor(Color color) async {
|
Future<void> setSeedColor(Color color) async {
|
||||||
state = state.copyWith(seedColorValue: color.toARGB32());
|
state = state.copyWith(seedColorValue: color.toARGB32());
|
||||||
await _saveToStorage();
|
await _saveToStorage();
|
||||||
@@ -81,4 +80,3 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,21 +18,18 @@ class TrackState {
|
|||||||
final String? artistId;
|
final String? artistId;
|
||||||
final String? artistName;
|
final String? artistName;
|
||||||
final String? coverUrl;
|
final String? coverUrl;
|
||||||
final String? headerImageUrl; // Artist header image for background
|
final String? headerImageUrl;
|
||||||
final int? monthlyListeners;
|
final int? monthlyListeners;
|
||||||
final List<ArtistAlbum>? artistAlbums; // For artist page
|
final List<ArtistAlbum>? artistAlbums;
|
||||||
final List<Track>? artistTopTracks; // Artist's popular tracks
|
final List<Track>? artistTopTracks;
|
||||||
final List<SearchArtist>? searchArtists; // For search results
|
final List<SearchArtist>? searchArtists;
|
||||||
final List<SearchAlbum>? searchAlbums; // For search results (albums)
|
final List<SearchAlbum>? searchAlbums;
|
||||||
final List<SearchPlaylist>? searchPlaylists; // For search results (playlists)
|
final List<SearchPlaylist>? searchPlaylists;
|
||||||
final bool hasSearchText; // For back button handling
|
final bool hasSearchText;
|
||||||
final bool isShowingRecentAccess; // For recent access mode
|
final bool isShowingRecentAccess;
|
||||||
final String?
|
final String? searchExtensionId;
|
||||||
searchExtensionId; // Extension ID used for current search results
|
final String? selectedSearchFilter;
|
||||||
final String?
|
final String? searchSource;
|
||||||
selectedSearchFilter; // Currently selected search filter (e.g., "track", "album", "artist", "playlist")
|
|
||||||
final String?
|
|
||||||
searchSource; // Built-in search provider used for current results (e.g., "deezer", "tidal", "qobuz")
|
|
||||||
|
|
||||||
const TrackState({
|
const TrackState({
|
||||||
this.tracks = const [],
|
this.tracks = const [],
|
||||||
@@ -127,9 +124,9 @@ class ArtistAlbum {
|
|||||||
final String releaseDate;
|
final String releaseDate;
|
||||||
final int totalTracks;
|
final int totalTracks;
|
||||||
final String? coverUrl;
|
final String? coverUrl;
|
||||||
final String albumType; // album, single, compilation
|
final String albumType;
|
||||||
final String artists;
|
final String artists;
|
||||||
final String? providerId; // Extension ID if from extension
|
final String? providerId;
|
||||||
|
|
||||||
const ArtistAlbum({
|
const ArtistAlbum({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -204,7 +201,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
return const TrackState();
|
return const TrackState();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if request is still valid (not cancelled by newer request)
|
|
||||||
bool _isRequestValid(int requestId) => requestId == _currentRequestId;
|
bool _isRequestValid(int requestId) => requestId == _currentRequestId;
|
||||||
|
|
||||||
Future<void> fetchFromUrl(String url, {bool useDeezerFallback = true}) async {
|
Future<void> fetchFromUrl(String url, {bool useDeezerFallback = true}) async {
|
||||||
@@ -217,7 +213,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
if (extensionHandler != null) {
|
if (extensionHandler != null) {
|
||||||
_log.i('Found extension URL handler: $extensionHandler for URL: $url');
|
_log.i('Found extension URL handler: $extensionHandler for URL: $url');
|
||||||
|
|
||||||
// Retry logic for extension URL handlers (up to 3 attempts)
|
|
||||||
Map<String, dynamic>? result;
|
Map<String, dynamic>? result;
|
||||||
for (int attempt = 1; attempt <= 3; attempt++) {
|
for (int attempt = 1; attempt <= 3; attempt++) {
|
||||||
result = await PlatformBridge.handleURLWithExtension(url);
|
result = await PlatformBridge.handleURLWithExtension(url);
|
||||||
@@ -541,7 +536,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If URL doesn't match any known service, it's unrecognized
|
|
||||||
final isSpotifyUrl =
|
final isSpotifyUrl =
|
||||||
url.contains('open.spotify.com') ||
|
url.contains('open.spotify.com') ||
|
||||||
url.contains('spotify.link') ||
|
url.contains('spotify.link') ||
|
||||||
@@ -643,7 +637,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
}) async {
|
}) async {
|
||||||
final requestId = ++_currentRequestId;
|
final requestId = ++_currentRequestId;
|
||||||
|
|
||||||
// Preserve selected filter during loading
|
|
||||||
final currentFilter = filterOverride ?? state.selectedSearchFilter;
|
final currentFilter = filterOverride ?? state.selectedSearchFilter;
|
||||||
|
|
||||||
state = TrackState(
|
state = TrackState(
|
||||||
@@ -662,7 +655,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
final includeExtensions =
|
final includeExtensions =
|
||||||
settings.useExtensionProviders && hasActiveMetadataExtensions;
|
settings.useExtensionProviders && hasActiveMetadataExtensions;
|
||||||
|
|
||||||
// Determine the effective search provider
|
|
||||||
final effectiveProvider = builtInSearchProvider ?? 'deezer';
|
final effectiveProvider = builtInSearchProvider ?? 'deezer';
|
||||||
|
|
||||||
_log.i(
|
_log.i(
|
||||||
@@ -672,7 +664,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
Map<String, dynamic> results;
|
Map<String, dynamic> results;
|
||||||
List<Map<String, dynamic>> metadataTrackResults = [];
|
List<Map<String, dynamic>> metadataTrackResults = [];
|
||||||
|
|
||||||
// Only use metadata providers for Deezer search (default behavior)
|
|
||||||
if (effectiveProvider == 'deezer') {
|
if (effectiveProvider == 'deezer') {
|
||||||
try {
|
try {
|
||||||
_log.d('Calling metadata provider search API...');
|
_log.d('Calling metadata provider search API...');
|
||||||
@@ -692,7 +683,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call the appropriate search API
|
|
||||||
switch (effectiveProvider) {
|
switch (effectiveProvider) {
|
||||||
case 'tidal':
|
case 'tidal':
|
||||||
_log.d('Calling Tidal search API...');
|
_log.d('Calling Tidal search API...');
|
||||||
@@ -808,9 +798,8 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
hasSearchText: state.hasSearchText,
|
hasSearchText: state.hasSearchText,
|
||||||
isShowingRecentAccess: state.isShowingRecentAccess,
|
isShowingRecentAccess: state.isShowingRecentAccess,
|
||||||
selectedSearchFilter: currentFilter, // Preserve filter in results
|
selectedSearchFilter: currentFilter,
|
||||||
searchSource:
|
searchSource: effectiveProvider,
|
||||||
effectiveProvider, // Track which service was used for search
|
|
||||||
);
|
);
|
||||||
} catch (e, stackTrace) {
|
} catch (e, stackTrace) {
|
||||||
if (!_isRequestValid(requestId)) return;
|
if (!_isRequestValid(requestId)) return;
|
||||||
@@ -837,7 +826,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
hasSearchText: state.hasSearchText,
|
hasSearchText: state.hasSearchText,
|
||||||
isShowingRecentAccess: state.isShowingRecentAccess,
|
isShowingRecentAccess: state.isShowingRecentAccess,
|
||||||
selectedSearchFilter:
|
selectedSearchFilter:
|
||||||
state.selectedSearchFilter, // Preserve filter during loading
|
state.selectedSearchFilter,
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -876,9 +865,8 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
hasSearchText: state.hasSearchText,
|
hasSearchText: state.hasSearchText,
|
||||||
isShowingRecentAccess: state.isShowingRecentAccess,
|
isShowingRecentAccess: state.isShowingRecentAccess,
|
||||||
searchExtensionId: extensionId, // Store which extension was used
|
searchExtensionId: extensionId,
|
||||||
selectedSearchFilter:
|
selectedSearchFilter: state.selectedSearchFilter,
|
||||||
state.selectedSearchFilter, // Preserve selected filter
|
|
||||||
);
|
);
|
||||||
} catch (e, stackTrace) {
|
} catch (e, stackTrace) {
|
||||||
if (!_isRequestValid(requestId)) return;
|
if (!_isRequestValid(requestId)) return;
|
||||||
@@ -934,7 +922,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
tracks[index] = updatedTrack;
|
tracks[index] = updatedTrack;
|
||||||
state = state.copyWith(tracks: tracks);
|
state = state.copyWith(tracks: tracks);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Silently ignore update failures - track may have been removed
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -942,7 +929,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
state = const TrackState();
|
state = const TrackState();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set selected search filter for extension search
|
|
||||||
void setSearchFilter(String? filter) {
|
void setSearchFilter(String? filter) {
|
||||||
if (state.selectedSearchFilter == filter) return;
|
if (state.selectedSearchFilter == filter) return;
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
@@ -951,7 +937,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set search text state for back button handling
|
|
||||||
void setSearchText(bool hasText) {
|
void setSearchText(bool hasText) {
|
||||||
if (state.hasSearchText == hasText) {
|
if (state.hasSearchText == hasText) {
|
||||||
return;
|
return;
|
||||||
@@ -966,7 +951,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
state = state.copyWith(isShowingRecentAccess: showing);
|
state = state.copyWith(isShowingRecentAccess: showing);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set tracks from a collection (album/playlist) opened from search results
|
|
||||||
void setTracksFromCollection({
|
void setTracksFromCollection({
|
||||||
required List<Track> tracks,
|
required List<Track> tracks,
|
||||||
String? albumName,
|
String? albumName,
|
||||||
@@ -1127,7 +1111,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
'isrc': isrc,
|
'isrc': isrc,
|
||||||
'track_name': track.name,
|
'track_name': track.name,
|
||||||
'artist_name': track.artistName,
|
'artist_name': track.artistName,
|
||||||
'spotify_id': track.id, // Include Spotify ID for Amazon lookup
|
'spotify_id': track.id,
|
||||||
'service': 'tidal',
|
'service': 'tidal',
|
||||||
});
|
});
|
||||||
if (cacheRequests.length >= _maxPreWarmTracksPerRequest) {
|
if (cacheRequests.length >= _maxPreWarmTracksPerRequest) {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import 'package:spotiflac_android/utils/file_access.dart';
|
|||||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||||
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
|
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
|
||||||
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||||
|
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||||
import 'package:spotiflac_android/providers/library_collections_provider.dart';
|
import 'package:spotiflac_android/providers/library_collections_provider.dart';
|
||||||
import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart';
|
import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart';
|
||||||
import 'package:spotiflac_android/utils/clickable_metadata.dart';
|
import 'package:spotiflac_android/utils/clickable_metadata.dart';
|
||||||
@@ -241,6 +242,16 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String? _recommendedDownloadService() {
|
||||||
|
if (widget.extensionId != null && widget.extensionId!.isNotEmpty) {
|
||||||
|
return widget.extensionId;
|
||||||
|
}
|
||||||
|
if (widget.albumId.startsWith('tidal:')) return 'tidal';
|
||||||
|
if (widget.albumId.startsWith('qobuz:')) return 'qobuz';
|
||||||
|
if (widget.albumId.startsWith('deezer:')) return 'deezer';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
@@ -257,8 +268,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
if (_isLoading)
|
if (_isLoading)
|
||||||
const SliverToBoxAdapter(
|
const SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.all(32),
|
padding: EdgeInsets.all(16),
|
||||||
child: Center(child: CircularProgressIndicator()),
|
child: AlbumTrackListSkeleton(itemCount: 10),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (_error != null)
|
if (_error != null)
|
||||||
@@ -534,9 +545,12 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
final track = tracks[index];
|
final track = tracks[index];
|
||||||
return KeyedSubtree(
|
return KeyedSubtree(
|
||||||
key: ValueKey(track.id),
|
key: ValueKey(track.id),
|
||||||
child: _AlbumTrackItem(
|
child: StaggeredListItem(
|
||||||
track: track,
|
index: index,
|
||||||
onDownload: () => _downloadTrack(context, track),
|
child: _AlbumTrackItem(
|
||||||
|
track: track,
|
||||||
|
onDownload: () => _downloadTrack(context, track),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}, childCount: tracks.length),
|
}, childCount: tracks.length),
|
||||||
@@ -551,6 +565,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
trackName: track.name,
|
trackName: track.name,
|
||||||
artistName: track.artistName,
|
artistName: track.artistName,
|
||||||
coverUrl: track.coverUrl,
|
coverUrl: track.coverUrl,
|
||||||
|
recommendedService: _recommendedDownloadService(),
|
||||||
onSelect: (quality, service) {
|
onSelect: (quality, service) {
|
||||||
ref
|
ref
|
||||||
.read(downloadQueueProvider.notifier)
|
.read(downloadQueueProvider.notifier)
|
||||||
@@ -576,7 +591,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
final tracks = _tracks;
|
final tracks = _tracks;
|
||||||
if (tracks == null || tracks.isEmpty) return;
|
if (tracks == null || tracks.isEmpty) return;
|
||||||
|
|
||||||
// Skip already-downloaded tracks
|
|
||||||
final historyState = ref.read(downloadHistoryProvider);
|
final historyState = ref.read(downloadHistoryProvider);
|
||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
final localLibState =
|
final localLibState =
|
||||||
@@ -623,6 +637,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
context,
|
context,
|
||||||
trackName: '${tracksToQueue.length} tracks',
|
trackName: '${tracksToQueue.length} tracks',
|
||||||
artistName: widget.albumName,
|
artistName: widget.albumName,
|
||||||
|
recommendedService: _recommendedDownloadService(),
|
||||||
onSelect: (quality, service) {
|
onSelect: (quality, service) {
|
||||||
ref
|
ref
|
||||||
.read(downloadQueueProvider.notifier)
|
.read(downloadQueueProvider.notifier)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import 'package:spotiflac_android/screens/home_tab.dart'
|
|||||||
show ExtensionAlbumScreen;
|
show ExtensionAlbumScreen;
|
||||||
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||||
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
|
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
|
||||||
|
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||||
import 'package:spotiflac_android/utils/clickable_metadata.dart';
|
import 'package:spotiflac_android/utils/clickable_metadata.dart';
|
||||||
|
|
||||||
class _ArtistCache {
|
class _ArtistCache {
|
||||||
@@ -152,6 +153,16 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
return tileSize + 64 + ((textScale - 1) * 14);
|
return tileSize + 64 + ((textScale - 1) * 14);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String? _recommendedDownloadService() {
|
||||||
|
if (widget.extensionId != null && widget.extensionId!.isNotEmpty) {
|
||||||
|
return widget.extensionId;
|
||||||
|
}
|
||||||
|
if (widget.artistId.startsWith('tidal:')) return 'tidal';
|
||||||
|
if (widget.artistId.startsWith('qobuz:')) return 'qobuz';
|
||||||
|
if (widget.artistId.startsWith('deezer:')) return 'deezer';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -481,12 +492,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
hasDiscography: hasDiscography,
|
hasDiscography: hasDiscography,
|
||||||
),
|
),
|
||||||
if (_isLoadingDiscography)
|
if (_isLoadingDiscography)
|
||||||
const SliverToBoxAdapter(
|
const SliverToBoxAdapter(child: ArtistScreenSkeleton()),
|
||||||
child: Padding(
|
|
||||||
padding: EdgeInsets.all(32),
|
|
||||||
child: Center(child: CircularProgressIndicator()),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (_error != null)
|
if (_error != null)
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -889,6 +895,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
if (settings.askQualityBeforeDownload) {
|
if (settings.askQualityBeforeDownload) {
|
||||||
DownloadServicePicker.show(
|
DownloadServicePicker.show(
|
||||||
context,
|
context,
|
||||||
|
recommendedService: _recommendedDownloadService(),
|
||||||
onSelect: (quality, service) {
|
onSelect: (quality, service) {
|
||||||
_fetchAndQueueAlbums(albums, service, quality);
|
_fetchAndQueueAlbums(albums, service, quality);
|
||||||
},
|
},
|
||||||
@@ -948,7 +955,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
|
|
||||||
fetchedCount++;
|
fetchedCount++;
|
||||||
|
|
||||||
// Update progress dialog
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
_FetchingProgressDialog.updateProgress(
|
_FetchingProgressDialog.updateProgress(
|
||||||
context,
|
context,
|
||||||
@@ -979,7 +985,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check which tracks are already downloaded
|
|
||||||
final historyState = ref.read(downloadHistoryProvider);
|
final historyState = ref.read(downloadHistoryProvider);
|
||||||
final tracksToQueue = <Track>[];
|
final tracksToQueue = <Track>[];
|
||||||
int skippedCount = 0;
|
int skippedCount = 0;
|
||||||
@@ -1030,10 +1035,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
content: Text(message),
|
content: Text(message),
|
||||||
action: SnackBarAction(
|
action: SnackBarAction(
|
||||||
label: context.l10n.snackbarViewQueue,
|
label: context.l10n.snackbarViewQueue,
|
||||||
onPressed: () {
|
onPressed: () {},
|
||||||
// Navigate to queue tab (index 1)
|
|
||||||
// This will be handled by the navigation system
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -1154,6 +1156,8 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
imageUrl.isNotEmpty &&
|
imageUrl.isNotEmpty &&
|
||||||
Uri.tryParse(imageUrl)?.hasAuthority == true;
|
Uri.tryParse(imageUrl)?.hasAuthority == true;
|
||||||
|
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
String? listenersText;
|
String? listenersText;
|
||||||
final listeners = _monthlyListeners ?? widget.monthlyListeners;
|
final listeners = _monthlyListeners ?? widget.monthlyListeners;
|
||||||
if (listeners != null && listeners > 0) {
|
if (listeners != null && listeners > 0) {
|
||||||
@@ -1224,7 +1228,9 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
Colors.transparent,
|
Colors.transparent,
|
||||||
Colors.black.withValues(alpha: 0.3),
|
Colors.black.withValues(alpha: 0.3),
|
||||||
Colors.black.withValues(alpha: 0.7),
|
Colors.black.withValues(alpha: 0.7),
|
||||||
colorScheme.surface,
|
isDark
|
||||||
|
? colorScheme.surface
|
||||||
|
: Colors.black.withValues(alpha: 0.85),
|
||||||
],
|
],
|
||||||
stops: const [0.0, 0.5, 0.75, 1.0],
|
stops: const [0.0, 0.5, 0.75, 1.0],
|
||||||
),
|
),
|
||||||
@@ -1265,7 +1271,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
listenersText,
|
listenersText,
|
||||||
style: Theme.of(context).textTheme.bodyMedium
|
style: Theme.of(context).textTheme.bodyMedium
|
||||||
?.copyWith(
|
?.copyWith(
|
||||||
color: Colors.white.withValues(alpha: 0.8),
|
color: Colors.white,
|
||||||
shadows: [
|
shadows: [
|
||||||
Shadow(
|
Shadow(
|
||||||
offset: const Offset(0, 1),
|
offset: const Offset(0, 1),
|
||||||
@@ -1689,6 +1695,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
if (settings.askQualityBeforeDownload) {
|
if (settings.askQualityBeforeDownload) {
|
||||||
DownloadServicePicker.show(
|
DownloadServicePicker.show(
|
||||||
context,
|
context,
|
||||||
|
recommendedService: _recommendedDownloadService(),
|
||||||
onSelect: (quality, service) {
|
onSelect: (quality, service) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
enqueue(service, quality: quality);
|
enqueue(service, quality: quality);
|
||||||
@@ -1839,29 +1846,14 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
Positioned(
|
Positioned(
|
||||||
top: 8,
|
top: 8,
|
||||||
right: 8,
|
right: 8,
|
||||||
child: AnimatedContainer(
|
child: AnimatedSelectionCheckbox(
|
||||||
duration: const Duration(milliseconds: 200),
|
visible: true,
|
||||||
width: 28,
|
selected: isSelected,
|
||||||
height: 28,
|
colorScheme: colorScheme,
|
||||||
decoration: BoxDecoration(
|
size: 28,
|
||||||
color: isSelected
|
unselectedColor: colorScheme.surface.withValues(
|
||||||
? colorScheme.primary
|
alpha: 0.9,
|
||||||
: colorScheme.surface.withValues(alpha: 0.9),
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
border: Border.all(
|
|
||||||
color: isSelected
|
|
||||||
? colorScheme.primary
|
|
||||||
: colorScheme.outline,
|
|
||||||
width: 2,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
child: isSelected
|
|
||||||
? Icon(
|
|
||||||
Icons.check,
|
|
||||||
color: colorScheme.onPrimary,
|
|
||||||
size: 18,
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (showTypeBadge)
|
if (showTypeBadge)
|
||||||
@@ -2070,7 +2062,6 @@ class _FetchingProgressDialog extends StatefulWidget {
|
|||||||
required this.onCancel,
|
required this.onCancel,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Static method to update progress from outside
|
|
||||||
static void updateProgress(BuildContext context, int current, int total) {
|
static void updateProgress(BuildContext context, int current, int total) {
|
||||||
final state = context
|
final state = context
|
||||||
.findAncestorStateOfType<_FetchingProgressDialogState>();
|
.findAncestorStateOfType<_FetchingProgressDialogState>();
|
||||||
@@ -2143,7 +2134,6 @@ class _FetchingProgressDialogState extends State<_FetchingProgressDialog> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
// Progress bar
|
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
child: LinearProgressIndicator(
|
child: LinearProgressIndicator(
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import 'package:spotiflac_android/providers/playback_provider.dart';
|
|||||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
||||||
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
|
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
|
||||||
|
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||||
|
|
||||||
class DownloadedAlbumScreen extends ConsumerStatefulWidget {
|
class DownloadedAlbumScreen extends ConsumerStatefulWidget {
|
||||||
final String albumName;
|
final String albumName;
|
||||||
@@ -120,7 +121,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
|
|
||||||
final tracks =
|
final tracks =
|
||||||
allItems.where((item) {
|
allItems.where((item) {
|
||||||
// Use albumArtist if available and not empty, otherwise artistName
|
|
||||||
final itemArtist =
|
final itemArtist =
|
||||||
(item.albumArtist != null && item.albumArtist!.isNotEmpty)
|
(item.albumArtist != null && item.albumArtist!.isNotEmpty)
|
||||||
? item.albumArtist!
|
? item.albumArtist!
|
||||||
@@ -129,7 +129,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
'${item.albumName.toLowerCase()}|${itemArtist.toLowerCase()}';
|
'${item.albumName.toLowerCase()}|${itemArtist.toLowerCase()}';
|
||||||
return itemKey == _albumLookupKey;
|
return itemKey == _albumLookupKey;
|
||||||
}).toList()..sort((a, b) {
|
}).toList()..sort((a, b) {
|
||||||
// Sort by disc number first, then by track number
|
|
||||||
final aDisc = a.discNumber ?? 1;
|
final aDisc = a.discNumber ?? 1;
|
||||||
final bDisc = b.discNumber ?? 1;
|
final bDisc = b.discNumber ?? 1;
|
||||||
if (aDisc != bDisc) return aDisc.compareTo(bDisc);
|
if (aDisc != bDisc) return aDisc.compareTo(bDisc);
|
||||||
@@ -310,14 +309,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
final result = await navigator.push(
|
final result = await navigator.push(
|
||||||
PageRouteBuilder(
|
slidePageRoute(page: TrackMetadataScreen(item: item)),
|
||||||
transitionDuration: const Duration(milliseconds: 300),
|
|
||||||
reverseTransitionDuration: const Duration(milliseconds: 250),
|
|
||||||
pageBuilder: (context, animation, secondaryAnimation) =>
|
|
||||||
TrackMetadataScreen(item: item),
|
|
||||||
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
|
|
||||||
FadeTransition(opacity: animation, child: child),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath(
|
await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath(
|
||||||
item.filePath,
|
item.filePath,
|
||||||
@@ -693,7 +685,10 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
final track = tracks[index];
|
final track = tracks[index];
|
||||||
return KeyedSubtree(
|
return KeyedSubtree(
|
||||||
key: ValueKey(track.id),
|
key: ValueKey(track.id),
|
||||||
child: _buildTrackItem(context, colorScheme, track),
|
child: StaggeredListItem(
|
||||||
|
index: index,
|
||||||
|
child: _buildTrackItem(context, colorScheme, track),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}, childCount: tracks.length),
|
}, childCount: tracks.length),
|
||||||
);
|
);
|
||||||
@@ -701,6 +696,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
|
|
||||||
final discNumbers = _getSortedDiscNumbers(tracks);
|
final discNumbers = _getSortedDiscNumbers(tracks);
|
||||||
final List<Widget> children = [];
|
final List<Widget> children = [];
|
||||||
|
var revealIndex = 0;
|
||||||
|
|
||||||
for (final discNumber in discNumbers) {
|
for (final discNumber in discNumbers) {
|
||||||
final discTracks = discMap[discNumber];
|
final discTracks = discMap[discNumber];
|
||||||
@@ -712,7 +708,10 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
children.add(
|
children.add(
|
||||||
KeyedSubtree(
|
KeyedSubtree(
|
||||||
key: ValueKey(track.id),
|
key: ValueKey(track.id),
|
||||||
child: _buildTrackItem(context, colorScheme, track),
|
child: StaggeredListItem(
|
||||||
|
index: revealIndex++,
|
||||||
|
child: _buildTrackItem(context, colorScheme, track),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -796,28 +795,11 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
if (_isSelectionMode) ...[
|
if (_isSelectionMode) ...[
|
||||||
Container(
|
AnimatedSelectionCheckbox(
|
||||||
width: 24,
|
visible: true,
|
||||||
height: 24,
|
selected: isSelected,
|
||||||
decoration: BoxDecoration(
|
colorScheme: colorScheme,
|
||||||
color: isSelected
|
size: 24,
|
||||||
? colorScheme.primary
|
|
||||||
: Colors.transparent,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
border: Border.all(
|
|
||||||
color: isSelected
|
|
||||||
? colorScheme.primary
|
|
||||||
: colorScheme.outline,
|
|
||||||
width: 2,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: isSelected
|
|
||||||
? Icon(
|
|
||||||
Icons.check,
|
|
||||||
color: colorScheme.onPrimary,
|
|
||||||
size: 16,
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
],
|
],
|
||||||
@@ -1123,7 +1105,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
? 'Opus'
|
? 'Opus'
|
||||||
: null;
|
: null;
|
||||||
if (ext == null || ext == targetFormat) continue;
|
if (ext == null || ext == targetFormat) continue;
|
||||||
// Skip lossy sources when target is lossless (pointless re-encoding)
|
|
||||||
final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC';
|
final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC';
|
||||||
final isLosslessSource = ext == 'FLAC' || ext == 'M4A';
|
final isLosslessSource = ext == 'FLAC' || ext == 'M4A';
|
||||||
if (isLosslessTarget && !isLosslessSource) continue;
|
if (isLosslessTarget && !isLosslessSource) continue;
|
||||||
|
|||||||
+375
-106
@@ -28,6 +28,7 @@ import 'package:spotiflac_android/screens/playlist_screen.dart';
|
|||||||
import 'package:spotiflac_android/screens/downloaded_album_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/download_service_picker.dart';
|
||||||
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
|
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
|
||||||
|
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||||
import 'package:spotiflac_android/utils/clickable_metadata.dart';
|
import 'package:spotiflac_android/utils/clickable_metadata.dart';
|
||||||
|
|
||||||
class HomeTab extends ConsumerStatefulWidget {
|
class HomeTab extends ConsumerStatefulWidget {
|
||||||
@@ -83,6 +84,18 @@ class _SearchResultBuckets {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum _SearchSortOption {
|
||||||
|
defaultOrder,
|
||||||
|
titleAsc,
|
||||||
|
titleDesc,
|
||||||
|
artistAsc,
|
||||||
|
artistDesc,
|
||||||
|
durationAsc,
|
||||||
|
durationDesc,
|
||||||
|
dateAsc,
|
||||||
|
dateDesc,
|
||||||
|
}
|
||||||
|
|
||||||
const _homeHistoryPreviewLimit = 48;
|
const _homeHistoryPreviewLimit = 48;
|
||||||
|
|
||||||
class _HomeHistoryPreview {
|
class _HomeHistoryPreview {
|
||||||
@@ -244,6 +257,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
Map<String, (double, double)>? _thumbnailSizesCache;
|
Map<String, (double, double)>? _thumbnailSizesCache;
|
||||||
List<Track>? _searchBucketsSourceTracks;
|
List<Track>? _searchBucketsSourceTracks;
|
||||||
_SearchResultBuckets? _searchBucketsCache;
|
_SearchResultBuckets? _searchBucketsCache;
|
||||||
|
_SearchSortOption _searchSortOption = _SearchSortOption.defaultOrder;
|
||||||
|
|
||||||
double _responsiveScale({
|
double _responsiveScale({
|
||||||
required BuildContext context,
|
required BuildContext context,
|
||||||
@@ -280,13 +294,13 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
double _exploreCardSize(BuildContext context) {
|
double _exploreCardSize(BuildContext context) {
|
||||||
final scale = _responsiveScale(context: context, min: 0.82, max: 1.08);
|
final scale = _responsiveScale(context: context, min: 0.82, max: 1.08);
|
||||||
final textScale = _effectiveTextScale(context);
|
final textScale = _effectiveTextScale(context);
|
||||||
return 120 * scale * (1 + (textScale - 1) * 0.12);
|
return 145 * scale * (1 + (textScale - 1) * 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
double _exploreSectionHeight(BuildContext context) {
|
double _exploreSectionHeight(BuildContext context) {
|
||||||
final cardSize = _exploreCardSize(context);
|
final cardSize = _exploreCardSize(context);
|
||||||
final textScale = _effectiveTextScale(context);
|
final textScale = _effectiveTextScale(context);
|
||||||
return cardSize + 55 + ((textScale - 1) * 12);
|
return cardSize + 58 + ((textScale - 1) * 12);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -564,6 +578,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
'${searchProvider ?? 'default'}:$query:${selectedFilter ?? 'all'}';
|
'${searchProvider ?? 'default'}:$query:${selectedFilter ?? 'all'}';
|
||||||
if (_lastSearchQuery == searchKey) return;
|
if (_lastSearchQuery == searchKey) return;
|
||||||
_lastSearchQuery = searchKey;
|
_lastSearchQuery = searchKey;
|
||||||
|
_searchSortOption = _SearchSortOption.defaultOrder;
|
||||||
|
|
||||||
final isBuiltInProvider =
|
final isBuiltInProvider =
|
||||||
searchProvider != null &&
|
searchProvider != null &&
|
||||||
@@ -698,6 +713,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
playlistName: trackState.playlistName!,
|
playlistName: trackState.playlistName!,
|
||||||
coverUrl: trackState.coverUrl,
|
coverUrl: trackState.coverUrl,
|
||||||
tracks: trackState.tracks,
|
tracks: trackState.tracks,
|
||||||
|
recommendedService:
|
||||||
|
trackState.searchExtensionId ?? trackState.searchSource,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -1281,8 +1298,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
exploreLoading)
|
exploreLoading)
|
||||||
const SliverToBoxAdapter(
|
const SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.all(32),
|
padding: EdgeInsets.all(16),
|
||||||
child: Center(child: CircularProgressIndicator()),
|
child: TrackListSkeleton(itemCount: 5),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -1485,7 +1502,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
delegate: SliverChildBuilderDelegate((context, index) {
|
delegate: SliverChildBuilderDelegate((context, index) {
|
||||||
if (hasGreeting && index == 0) {
|
if (hasGreeting && index == 0) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||||
child: Text(
|
child: Text(
|
||||||
greeting,
|
greeting,
|
||||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||||
@@ -1500,7 +1517,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
return _buildExploreSection(sections[sectionIndex], colorScheme);
|
return _buildExploreSection(sections[sectionIndex], colorScheme);
|
||||||
}
|
}
|
||||||
|
|
||||||
return const SizedBox(height: 16);
|
return const SizedBox(height: 24);
|
||||||
}, childCount: totalCount),
|
}, childCount: totalCount),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
@@ -1516,7 +1533,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
|
padding: const EdgeInsets.fromLTRB(16, 20, 16, 12),
|
||||||
child: Text(
|
child: Text(
|
||||||
section.title,
|
section.title,
|
||||||
style: Theme.of(
|
style: Theme.of(
|
||||||
@@ -1532,7 +1549,11 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
itemCount: section.items.length,
|
itemCount: section.items.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final item = section.items[index];
|
final item = section.items[index];
|
||||||
return _buildExploreItem(item, colorScheme);
|
return StaggeredListItem(
|
||||||
|
index: index,
|
||||||
|
staggerDelay: const Duration(milliseconds: 50),
|
||||||
|
child: _buildExploreItem(item, colorScheme),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1579,7 +1600,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
children: [
|
children: [
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(
|
borderRadius: BorderRadius.circular(
|
||||||
isArtist ? cardSize / 2 : 8,
|
isArtist ? cardSize / 2 : 10,
|
||||||
),
|
),
|
||||||
child: item.coverUrl != null && item.coverUrl!.isNotEmpty
|
child: item.coverUrl != null && item.coverUrl!.isNotEmpty
|
||||||
? CachedNetworkImage(
|
? CachedNetworkImage(
|
||||||
@@ -1618,8 +1639,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
textAlign: isArtist ? TextAlign.center : TextAlign.start,
|
textAlign: isArtist ? TextAlign.center : TextAlign.start,
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w600,
|
||||||
color: colorScheme.onSurface,
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1632,7 +1653,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
fontSize: 11,
|
fontSize: 12,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -2122,7 +2143,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
if (item.providerId != null &&
|
if (item.providerId != null &&
|
||||||
item.providerId!.isNotEmpty &&
|
item.providerId!.isNotEmpty &&
|
||||||
item.providerId != 'deezer' &&
|
item.providerId != 'deezer' &&
|
||||||
item.providerId != 'spotify') {
|
item.providerId != 'spotify' &&
|
||||||
|
item.providerId != 'tidal' &&
|
||||||
|
item.providerId != 'qobuz') {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
@@ -2162,7 +2185,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
} else if (item.providerId != null &&
|
} else if (item.providerId != null &&
|
||||||
item.providerId!.isNotEmpty &&
|
item.providerId!.isNotEmpty &&
|
||||||
item.providerId != 'deezer' &&
|
item.providerId != 'deezer' &&
|
||||||
item.providerId != 'spotify') {
|
item.providerId != 'spotify' &&
|
||||||
|
item.providerId != 'tidal' &&
|
||||||
|
item.providerId != 'qobuz') {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
@@ -2210,7 +2235,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
if (item.providerId != null &&
|
if (item.providerId != null &&
|
||||||
item.providerId!.isNotEmpty &&
|
item.providerId!.isNotEmpty &&
|
||||||
item.providerId != 'deezer' &&
|
item.providerId != 'deezer' &&
|
||||||
item.providerId != 'spotify') {
|
item.providerId != 'spotify' &&
|
||||||
|
item.providerId != 'tidal' &&
|
||||||
|
item.providerId != 'qobuz') {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
@@ -2248,14 +2275,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
);
|
);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final result = await navigator.push(
|
final result = await navigator.push(
|
||||||
PageRouteBuilder(
|
slidePageRoute(page: TrackMetadataScreen(item: item)),
|
||||||
transitionDuration: const Duration(milliseconds: 300),
|
|
||||||
reverseTransitionDuration: const Duration(milliseconds: 250),
|
|
||||||
pageBuilder: (context, animation, secondaryAnimation) =>
|
|
||||||
TrackMetadataScreen(item: item),
|
|
||||||
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
|
|
||||||
FadeTransition(opacity: animation, child: child),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath(
|
await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath(
|
||||||
item.filePath,
|
item.filePath,
|
||||||
@@ -2393,6 +2413,168 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Search result sorting ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
String _sortOptionLabel(_SearchSortOption option) {
|
||||||
|
switch (option) {
|
||||||
|
case _SearchSortOption.defaultOrder:
|
||||||
|
return context.l10n.searchSortDefault;
|
||||||
|
case _SearchSortOption.titleAsc:
|
||||||
|
return context.l10n.searchSortTitleAZ;
|
||||||
|
case _SearchSortOption.titleDesc:
|
||||||
|
return context.l10n.searchSortTitleZA;
|
||||||
|
case _SearchSortOption.artistAsc:
|
||||||
|
return context.l10n.searchSortArtistAZ;
|
||||||
|
case _SearchSortOption.artistDesc:
|
||||||
|
return context.l10n.searchSortArtistZA;
|
||||||
|
case _SearchSortOption.durationAsc:
|
||||||
|
return context.l10n.searchSortDurationShort;
|
||||||
|
case _SearchSortOption.durationDesc:
|
||||||
|
return context.l10n.searchSortDurationLong;
|
||||||
|
case _SearchSortOption.dateAsc:
|
||||||
|
return context.l10n.searchSortDateOldest;
|
||||||
|
case _SearchSortOption.dateDesc:
|
||||||
|
return context.l10n.searchSortDateNewest;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showSortOptions(ColorScheme colorScheme) {
|
||||||
|
var tempSort = _searchSortOption;
|
||||||
|
showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
useRootNavigator: true,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: colorScheme.surfaceContainerLow,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||||
|
),
|
||||||
|
builder: (ctx) => StatefulBuilder(
|
||||||
|
builder: (ctx, setSheetState) {
|
||||||
|
return SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Center(
|
||||||
|
child: Container(
|
||||||
|
width: 32,
|
||||||
|
height: 4,
|
||||||
|
margin: const EdgeInsets.only(bottom: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.outlineVariant,
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
context.l10n.searchSortTitle,
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => setSheetState(
|
||||||
|
() => tempSort = _SearchSortOption.defaultOrder,
|
||||||
|
),
|
||||||
|
child: Text(context.l10n.libraryFilterReset),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: _SearchSortOption.values.map((option) {
|
||||||
|
return FilterChip(
|
||||||
|
label: Text(_sortOptionLabel(option)),
|
||||||
|
selected: tempSort == option,
|
||||||
|
showCheckmark: false,
|
||||||
|
onSelected: (_) =>
|
||||||
|
setSheetState(() => tempSort = option),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
if (_searchSortOption != tempSort) {
|
||||||
|
setState(() {
|
||||||
|
_searchSortOption = tempSort;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Text(context.l10n.libraryFilterApply),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<T> _applySortToList<T>(
|
||||||
|
List<T> items,
|
||||||
|
String Function(T) getName,
|
||||||
|
String Function(T) getArtist,
|
||||||
|
int Function(T) getDuration,
|
||||||
|
String? Function(T) getDate,
|
||||||
|
) {
|
||||||
|
if (_searchSortOption == _SearchSortOption.defaultOrder) return items;
|
||||||
|
final sorted = List<T>.of(items);
|
||||||
|
switch (_searchSortOption) {
|
||||||
|
case _SearchSortOption.defaultOrder:
|
||||||
|
break;
|
||||||
|
case _SearchSortOption.titleAsc:
|
||||||
|
sorted.sort(
|
||||||
|
(a, b) =>
|
||||||
|
getName(a).toLowerCase().compareTo(getName(b).toLowerCase()),
|
||||||
|
);
|
||||||
|
case _SearchSortOption.titleDesc:
|
||||||
|
sorted.sort(
|
||||||
|
(a, b) =>
|
||||||
|
getName(b).toLowerCase().compareTo(getName(a).toLowerCase()),
|
||||||
|
);
|
||||||
|
case _SearchSortOption.artistAsc:
|
||||||
|
sorted.sort(
|
||||||
|
(a, b) =>
|
||||||
|
getArtist(a).toLowerCase().compareTo(getArtist(b).toLowerCase()),
|
||||||
|
);
|
||||||
|
case _SearchSortOption.artistDesc:
|
||||||
|
sorted.sort(
|
||||||
|
(a, b) =>
|
||||||
|
getArtist(b).toLowerCase().compareTo(getArtist(a).toLowerCase()),
|
||||||
|
);
|
||||||
|
case _SearchSortOption.durationAsc:
|
||||||
|
sorted.sort((a, b) => getDuration(a).compareTo(getDuration(b)));
|
||||||
|
case _SearchSortOption.durationDesc:
|
||||||
|
sorted.sort((a, b) => getDuration(b).compareTo(getDuration(a)));
|
||||||
|
case _SearchSortOption.dateAsc:
|
||||||
|
sorted.sort((a, b) {
|
||||||
|
final da = getDate(a) ?? '';
|
||||||
|
final db = getDate(b) ?? '';
|
||||||
|
return da.compareTo(db);
|
||||||
|
});
|
||||||
|
case _SearchSortOption.dateDesc:
|
||||||
|
sorted.sort((a, b) {
|
||||||
|
final da = getDate(a) ?? '';
|
||||||
|
final db = getDate(b) ?? '';
|
||||||
|
return db.compareTo(da);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
|
||||||
List<Widget> _buildSearchResults({
|
List<Widget> _buildSearchResults({
|
||||||
required List<Track> tracks,
|
required List<Track> tracks,
|
||||||
required List<SearchArtist>? searchArtists,
|
required List<SearchArtist>? searchArtists,
|
||||||
@@ -2406,6 +2588,15 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
required bool showLocalLibraryIndicator,
|
required bool showLocalLibraryIndicator,
|
||||||
required Map<String, (double, double)> thumbnailSizesByExtensionId,
|
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) {
|
if (!hasResults) {
|
||||||
return [const SliverToBoxAdapter(child: SizedBox.shrink())];
|
return [const SliverToBoxAdapter(child: SizedBox.shrink())];
|
||||||
}
|
}
|
||||||
@@ -2417,6 +2608,59 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
final playlistItems = buckets.playlistItems;
|
final playlistItems = buckets.playlistItems;
|
||||||
final artistItems = buckets.artistItems;
|
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>[
|
final slivers = <Widget>[
|
||||||
if (error != null)
|
if (error != null)
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
@@ -2434,24 +2678,28 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
if (searchArtists != null && searchArtists.isNotEmpty) {
|
bool sortButtonShown = false;
|
||||||
|
|
||||||
|
if (sortedArtists != null && sortedArtists.isNotEmpty) {
|
||||||
slivers.addAll(
|
slivers.addAll(
|
||||||
_buildVirtualizedResultSection(
|
_buildVirtualizedResultSection(
|
||||||
title: context.l10n.searchArtists,
|
title: context.l10n.searchArtists,
|
||||||
itemCount: searchArtists.length,
|
itemCount: sortedArtists.length,
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
|
showSortButton: !sortButtonShown,
|
||||||
itemBuilder: (index, showDivider) => _SearchArtistItemWidget(
|
itemBuilder: (index, showDivider) => _SearchArtistItemWidget(
|
||||||
key: ValueKey('search-artist-${searchArtists[index].id}'),
|
key: ValueKey('search-artist-${sortedArtists[index].id}'),
|
||||||
artist: searchArtists[index],
|
artist: sortedArtists[index],
|
||||||
showDivider: showDivider,
|
showDivider: showDivider,
|
||||||
onTap: () => _navigateToArtist(
|
onTap: () => _navigateToArtist(
|
||||||
searchArtists[index].id,
|
sortedArtists[index].id,
|
||||||
searchArtists[index].name,
|
sortedArtists[index].name,
|
||||||
searchArtists[index].imageUrl,
|
sortedArtists[index].imageUrl,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
sortButtonShown = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (artistItems.isNotEmpty) {
|
if (artistItems.isNotEmpty) {
|
||||||
@@ -2460,6 +2708,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
title: context.l10n.searchArtists,
|
title: context.l10n.searchArtists,
|
||||||
itemCount: artistItems.length,
|
itemCount: artistItems.length,
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
|
showSortButton: !sortButtonShown,
|
||||||
itemBuilder: (index, showDivider) => _CollectionItemWidget(
|
itemBuilder: (index, showDivider) => _CollectionItemWidget(
|
||||||
key: ValueKey('artist-${artistItems[index].id}'),
|
key: ValueKey('artist-${artistItems[index].id}'),
|
||||||
item: artistItems[index],
|
item: artistItems[index],
|
||||||
@@ -2468,22 +2717,25 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
sortButtonShown = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (searchAlbums != null && searchAlbums.isNotEmpty) {
|
if (sortedAlbums != null && sortedAlbums.isNotEmpty) {
|
||||||
slivers.addAll(
|
slivers.addAll(
|
||||||
_buildVirtualizedResultSection(
|
_buildVirtualizedResultSection(
|
||||||
title: context.l10n.searchAlbums,
|
title: context.l10n.searchAlbums,
|
||||||
itemCount: searchAlbums.length,
|
itemCount: sortedAlbums.length,
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
|
showSortButton: !sortButtonShown,
|
||||||
itemBuilder: (index, showDivider) => _SearchAlbumItemWidget(
|
itemBuilder: (index, showDivider) => _SearchAlbumItemWidget(
|
||||||
key: ValueKey('search-album-${searchAlbums[index].id}'),
|
key: ValueKey('search-album-${sortedAlbums[index].id}'),
|
||||||
album: searchAlbums[index],
|
album: sortedAlbums[index],
|
||||||
showDivider: showDivider,
|
showDivider: showDivider,
|
||||||
onTap: () => _navigateToSearchAlbum(searchAlbums[index]),
|
onTap: () => _navigateToSearchAlbum(sortedAlbums[index]),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
sortButtonShown = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (albumItems.isNotEmpty) {
|
if (albumItems.isNotEmpty) {
|
||||||
@@ -2492,6 +2744,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
title: context.l10n.searchAlbums,
|
title: context.l10n.searchAlbums,
|
||||||
itemCount: albumItems.length,
|
itemCount: albumItems.length,
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
|
showSortButton: !sortButtonShown,
|
||||||
itemBuilder: (index, showDivider) => _CollectionItemWidget(
|
itemBuilder: (index, showDivider) => _CollectionItemWidget(
|
||||||
key: ValueKey('album-${albumItems[index].id}'),
|
key: ValueKey('album-${albumItems[index].id}'),
|
||||||
item: albumItems[index],
|
item: albumItems[index],
|
||||||
@@ -2500,22 +2753,25 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
sortButtonShown = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (searchPlaylists != null && searchPlaylists.isNotEmpty) {
|
if (sortedPlaylists != null && sortedPlaylists.isNotEmpty) {
|
||||||
slivers.addAll(
|
slivers.addAll(
|
||||||
_buildVirtualizedResultSection(
|
_buildVirtualizedResultSection(
|
||||||
title: context.l10n.searchPlaylists,
|
title: context.l10n.searchPlaylists,
|
||||||
itemCount: searchPlaylists.length,
|
itemCount: sortedPlaylists.length,
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
|
showSortButton: !sortButtonShown,
|
||||||
itemBuilder: (index, showDivider) => _SearchPlaylistItemWidget(
|
itemBuilder: (index, showDivider) => _SearchPlaylistItemWidget(
|
||||||
key: ValueKey('search-playlist-${searchPlaylists[index].id}'),
|
key: ValueKey('search-playlist-${sortedPlaylists[index].id}'),
|
||||||
playlist: searchPlaylists[index],
|
playlist: sortedPlaylists[index],
|
||||||
showDivider: showDivider,
|
showDivider: showDivider,
|
||||||
onTap: () => _navigateToSearchPlaylist(searchPlaylists[index]),
|
onTap: () => _navigateToSearchPlaylist(sortedPlaylists[index]),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
sortButtonShown = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (playlistItems.isNotEmpty) {
|
if (playlistItems.isNotEmpty) {
|
||||||
@@ -2524,6 +2780,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
title: context.l10n.searchPlaylists,
|
title: context.l10n.searchPlaylists,
|
||||||
itemCount: playlistItems.length,
|
itemCount: playlistItems.length,
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
|
showSortButton: !sortButtonShown,
|
||||||
itemBuilder: (index, showDivider) => _CollectionItemWidget(
|
itemBuilder: (index, showDivider) => _CollectionItemWidget(
|
||||||
key: ValueKey('playlist-${playlistItems[index].id}'),
|
key: ValueKey('playlist-${playlistItems[index].id}'),
|
||||||
item: playlistItems[index],
|
item: playlistItems[index],
|
||||||
@@ -2532,20 +2789,22 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
sortButtonShown = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (realTracks.isNotEmpty) {
|
if (sortedTracks.isNotEmpty) {
|
||||||
slivers.addAll(
|
slivers.addAll(
|
||||||
_buildVirtualizedResultSection(
|
_buildVirtualizedResultSection(
|
||||||
title: context.l10n.searchSongs,
|
title: context.l10n.searchSongs,
|
||||||
itemCount: realTracks.length,
|
itemCount: sortedTracks.length,
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
|
showSortButton: !sortButtonShown,
|
||||||
itemBuilder: (index, showDivider) => _TrackItemWithStatus(
|
itemBuilder: (index, showDivider) => _TrackItemWithStatus(
|
||||||
key: ValueKey(realTracks[index].id),
|
key: ValueKey(sortedTracks[index].id),
|
||||||
track: realTracks[index],
|
track: sortedTracks[index],
|
||||||
index: realTrackIndexes[index],
|
index: sortedTrackIndexes[index],
|
||||||
showDivider: showDivider,
|
showDivider: showDivider,
|
||||||
onDownload: () => _downloadTrack(realTrackIndexes[index]),
|
onDownload: () => _downloadTrack(sortedTrackIndexes[index]),
|
||||||
searchExtensionId: searchExtensionId,
|
searchExtensionId: searchExtensionId,
|
||||||
showLocalLibraryIndicator: showLocalLibraryIndicator,
|
showLocalLibraryIndicator: showLocalLibraryIndicator,
|
||||||
thumbnailSizesByExtensionId: thumbnailSizesByExtensionId,
|
thumbnailSizesByExtensionId: thumbnailSizesByExtensionId,
|
||||||
@@ -2563,6 +2822,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
required int itemCount,
|
required int itemCount,
|
||||||
required ColorScheme colorScheme,
|
required ColorScheme colorScheme,
|
||||||
required Widget Function(int index, bool showDivider) itemBuilder,
|
required Widget Function(int index, bool showDivider) itemBuilder,
|
||||||
|
bool showSortButton = false,
|
||||||
}) {
|
}) {
|
||||||
final sectionColor = Theme.of(context).brightness == Brightness.dark
|
final sectionColor = Theme.of(context).brightness == Brightness.dark
|
||||||
? Color.alphaBlend(
|
? Color.alphaBlend(
|
||||||
@@ -2574,12 +2834,47 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
return [
|
return [
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
padding: const EdgeInsets.fromLTRB(16, 8, 8, 8),
|
||||||
child: Text(
|
child: Row(
|
||||||
title,
|
children: [
|
||||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
Expanded(
|
||||||
color: colorScheme.onSurfaceVariant,
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -2587,19 +2882,22 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
delegate: SliverChildBuilderDelegate((context, index) {
|
delegate: SliverChildBuilderDelegate((context, index) {
|
||||||
final isFirst = index == 0;
|
final isFirst = index == 0;
|
||||||
final isLast = index == itemCount - 1;
|
final isLast = index == itemCount - 1;
|
||||||
return Container(
|
return StaggeredListItem(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
index: index,
|
||||||
decoration: BoxDecoration(
|
child: Container(
|
||||||
color: sectionColor,
|
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
borderRadius: BorderRadius.vertical(
|
decoration: BoxDecoration(
|
||||||
top: isFirst ? const Radius.circular(20) : Radius.zero,
|
color: sectionColor,
|
||||||
bottom: isLast ? const Radius.circular(20) : Radius.zero,
|
borderRadius: BorderRadius.vertical(
|
||||||
|
top: isFirst ? const Radius.circular(20) : Radius.zero,
|
||||||
|
bottom: isLast ? const Radius.circular(20) : Radius.zero,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: itemBuilder(index, !isLast),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
clipBehavior: Clip.antiAlias,
|
|
||||||
child: Material(
|
|
||||||
color: Colors.transparent,
|
|
||||||
child: itemBuilder(index, !isLast),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}, childCount: itemCount),
|
}, childCount: itemCount),
|
||||||
@@ -2793,7 +3091,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (searchProvider != null && searchProvider.isNotEmpty) {
|
if (searchProvider != null && searchProvider.isNotEmpty) {
|
||||||
// Check built-in providers first
|
|
||||||
if (searchProvider == 'tidal') {
|
if (searchProvider == 'tidal') {
|
||||||
return 'Search with Tidal...';
|
return 'Search with Tidal...';
|
||||||
}
|
}
|
||||||
@@ -2835,16 +3132,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
_triggerSearchWithFilter(null);
|
_triggerSearchWithFilter(null);
|
||||||
},
|
},
|
||||||
showCheckmark: false,
|
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) {
|
...filters.map((filter) {
|
||||||
@@ -2859,24 +3146,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
_triggerSearchWithFilter(filter.id);
|
_triggerSearchWithFilter(filter.id);
|
||||||
},
|
},
|
||||||
showCheckmark: false,
|
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
|
avatar: filter.icon != null
|
||||||
? Icon(
|
? Icon(_getFilterIcon(filter.icon!), size: 18)
|
||||||
_getFilterIcon(filter.icon!),
|
|
||||||
size: 18,
|
|
||||||
color: isSelected
|
|
||||||
? colorScheme.onPrimaryContainer
|
|
||||||
: colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -2913,7 +3184,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
if (text.isEmpty || text.length < _minLiveSearchChars) return;
|
if (text.isEmpty || text.length < _minLiveSearchChars) return;
|
||||||
if (text.startsWith('http') || text.startsWith('spotify:')) return;
|
if (text.startsWith('http') || text.startsWith('spotify:')) return;
|
||||||
|
|
||||||
// Reset last search query to force new search
|
|
||||||
_lastSearchQuery = null;
|
_lastSearchQuery = null;
|
||||||
_performSearch(text, filterOverride: filter);
|
_performSearch(text, filterOverride: filter);
|
||||||
}
|
}
|
||||||
@@ -2931,15 +3201,11 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
fillColor: colorScheme.surfaceContainerHighest,
|
fillColor: colorScheme.surfaceContainerHighest,
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(28),
|
borderRadius: BorderRadius.circular(28),
|
||||||
borderSide: BorderSide(
|
borderSide: BorderSide(color: colorScheme.outlineVariant),
|
||||||
color: colorScheme.outline.withValues(alpha: 0.5),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(28),
|
borderRadius: BorderRadius.circular(28),
|
||||||
borderSide: BorderSide(
|
borderSide: BorderSide(color: colorScheme.outlineVariant),
|
||||||
color: colorScheme.outline.withValues(alpha: 0.5),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(28),
|
borderRadius: BorderRadius.circular(28),
|
||||||
@@ -2987,6 +3253,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
onSubmitted: (_) => _onSearchSubmitted(),
|
onSubmitted: (_) => _onSearchSubmitted(),
|
||||||
|
onTapOutside: (_) {
|
||||||
|
FocusScope.of(context).unfocus();
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3035,7 +3304,6 @@ class _SearchProviderDropdown extends ConsumerWidget {
|
|||||||
.firstOrNull;
|
.firstOrNull;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if current provider is a built-in provider (tidal/qobuz)
|
|
||||||
const builtInProviders = {'tidal', 'qobuz'};
|
const builtInProviders = {'tidal', 'qobuz'};
|
||||||
final isBuiltInProvider =
|
final isBuiltInProvider =
|
||||||
currentProvider != null && builtInProviders.contains(currentProvider);
|
currentProvider != null && builtInProviders.contains(currentProvider);
|
||||||
@@ -3115,7 +3383,6 @@ class _SearchProviderDropdown extends ConsumerWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Built-in Tidal search option
|
|
||||||
PopupMenuItem<String>(
|
PopupMenuItem<String>(
|
||||||
value: 'tidal',
|
value: 'tidal',
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -3143,7 +3410,6 @@ class _SearchProviderDropdown extends ConsumerWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Built-in Qobuz search option
|
|
||||||
PopupMenuItem<String>(
|
PopupMenuItem<String>(
|
||||||
value: 'qobuz',
|
value: 'qobuz',
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -3966,7 +4232,6 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
|
|||||||
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
// Extract artist info from album response
|
|
||||||
final artistId = (result['artist_id'] ?? result['artistId'])?.toString();
|
final artistId = (result['artist_id'] ?? result['artistId'])?.toString();
|
||||||
final artistName = result['artists'] as String?;
|
final artistName = result['artists'] as String?;
|
||||||
|
|
||||||
@@ -4024,7 +4289,10 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
|
|||||||
if (_isLoading) {
|
if (_isLoading) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: Text(widget.albumName)),
|
appBar: AppBar(title: Text(widget.albumName)),
|
||||||
body: const Center(child: CircularProgressIndicator()),
|
body: const AlbumTrackListSkeleton(
|
||||||
|
itemCount: 10,
|
||||||
|
showCoverHeader: true,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4178,7 +4446,7 @@ class _ExtensionPlaylistScreenState
|
|||||||
if (_isLoading) {
|
if (_isLoading) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: Text(widget.playlistName)),
|
appBar: AppBar(title: Text(widget.playlistName)),
|
||||||
body: const Center(child: CircularProgressIndicator()),
|
body: const TrackListSkeleton(itemCount: 8, showCoverHeader: true),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4208,6 +4476,7 @@ class _ExtensionPlaylistScreenState
|
|||||||
playlistName: widget.playlistName,
|
playlistName: widget.playlistName,
|
||||||
coverUrl: widget.coverUrl,
|
coverUrl: widget.coverUrl,
|
||||||
tracks: _tracks!,
|
tracks: _tracks!,
|
||||||
|
recommendedService: widget.extensionId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4349,7 +4618,7 @@ class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen> {
|
|||||||
if (_isLoading) {
|
if (_isLoading) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: Text(widget.artistName)),
|
appBar: AppBar(title: Text(widget.artistName)),
|
||||||
body: const Center(child: CircularProgressIndicator()),
|
body: const ArtistScreenSkeleton(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import 'package:spotiflac_android/l10n/l10n.dart';
|
|||||||
import 'package:spotiflac_android/providers/library_collections_provider.dart';
|
import 'package:spotiflac_android/providers/library_collections_provider.dart';
|
||||||
import 'package:spotiflac_android/screens/library_tracks_folder_screen.dart';
|
import 'package:spotiflac_android/screens/library_tracks_folder_screen.dart';
|
||||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||||
|
import 'package:spotiflac_android/widgets/bottom_sheet_option_tile.dart';
|
||||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||||
|
|
||||||
class LibraryPlaylistsScreen extends ConsumerWidget {
|
class LibraryPlaylistsScreen extends ConsumerWidget {
|
||||||
@@ -210,7 +211,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
|
|||||||
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||||
),
|
),
|
||||||
|
|
||||||
_PlaylistOptionTile(
|
BottomSheetOptionTile(
|
||||||
icon: Icons.edit_outlined,
|
icon: Icons.edit_outlined,
|
||||||
title: context.l10n.collectionRenamePlaylist,
|
title: context.l10n.collectionRenamePlaylist,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
@@ -224,7 +225,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
_PlaylistOptionTile(
|
BottomSheetOptionTile(
|
||||||
icon: Icons.image_outlined,
|
icon: Icons.image_outlined,
|
||||||
title: context.l10n.collectionPlaylistChangeCover,
|
title: context.l10n.collectionPlaylistChangeCover,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
@@ -233,7 +234,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
_PlaylistOptionTile(
|
BottomSheetOptionTile(
|
||||||
icon: Icons.delete_outline,
|
icon: Icons.delete_outline,
|
||||||
iconColor: colorScheme.error,
|
iconColor: colorScheme.error,
|
||||||
title: context.l10n.collectionDeletePlaylist,
|
title: context.l10n.collectionDeletePlaylist,
|
||||||
@@ -543,40 +544,3 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Styled like _OptionTile in track_collection_quick_actions.dart
|
|
||||||
class _PlaylistOptionTile extends StatelessWidget {
|
|
||||||
final IconData icon;
|
|
||||||
final Color? iconColor;
|
|
||||||
final String title;
|
|
||||||
final VoidCallback onTap;
|
|
||||||
|
|
||||||
const _PlaylistOptionTile({
|
|
||||||
required this.icon,
|
|
||||||
this.iconColor,
|
|
||||||
required this.title,
|
|
||||||
required this.onTap,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
return ListTile(
|
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
|
|
||||||
leading: Container(
|
|
||||||
padding: const EdgeInsets.all(10),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colorScheme.primaryContainer,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
child: Icon(
|
|
||||||
icon,
|
|
||||||
color: iconColor ?? colorScheme.onPrimaryContainer,
|
|
||||||
size: 20,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)),
|
|
||||||
onTap: onTap,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
|
|||||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||||
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
||||||
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||||
|
import 'package:spotiflac_android/widgets/bottom_sheet_option_tile.dart';
|
||||||
import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart';
|
import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart';
|
||||||
|
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||||
|
|
||||||
class LibraryTracksFolderScreen extends ConsumerStatefulWidget {
|
class LibraryTracksFolderScreen extends ConsumerStatefulWidget {
|
||||||
final LibraryTracksFolderMode mode;
|
final LibraryTracksFolderMode mode;
|
||||||
@@ -272,7 +274,6 @@ class _LibraryTracksFolderScreenState
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stale selection cleanup
|
|
||||||
if (_isSelectionMode) {
|
if (_isSelectionMode) {
|
||||||
final validKeys = entries.map((e) => e.key).toSet();
|
final validKeys = entries.map((e) => e.key).toSet();
|
||||||
_selectedKeys.removeWhere((key) => !validKeys.contains(key));
|
_selectedKeys.removeWhere((key) => !validKeys.contains(key));
|
||||||
@@ -348,20 +349,23 @@ class _LibraryTracksFolderScreenState
|
|||||||
final isSelected = _selectedKeys.contains(entry.key);
|
final isSelected = _selectedKeys.contains(entry.key);
|
||||||
return KeyedSubtree(
|
return KeyedSubtree(
|
||||||
key: ValueKey(entry.key),
|
key: ValueKey(entry.key),
|
||||||
child: _CollectionTrackTile(
|
child: StaggeredListItem(
|
||||||
entry: entry,
|
index: index,
|
||||||
mode: widget.mode,
|
child: _CollectionTrackTile(
|
||||||
playlistId: widget.playlistId,
|
entry: entry,
|
||||||
localLibraryState: localState,
|
mode: widget.mode,
|
||||||
folderTracks: folderTracks,
|
playlistId: widget.playlistId,
|
||||||
isSelectionMode: _isSelectionMode,
|
localLibraryState: localState,
|
||||||
isSelected: isSelected,
|
folderTracks: folderTracks,
|
||||||
onTap: _isSelectionMode
|
isSelectionMode: _isSelectionMode,
|
||||||
? () => _toggleSelection(entry.key)
|
isSelected: isSelected,
|
||||||
: null,
|
onTap: _isSelectionMode
|
||||||
onLongPress: _isSelectionMode
|
? () => _toggleSelection(entry.key)
|
||||||
? null
|
: null,
|
||||||
: () => _enterSelectionMode(entry.key),
|
onLongPress: _isSelectionMode
|
||||||
|
? null
|
||||||
|
: () => _enterSelectionMode(entry.key),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}, childCount: entries.length),
|
}, childCount: entries.length),
|
||||||
@@ -372,7 +376,6 @@ class _LibraryTracksFolderScreenState
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
// Selection bottom bar
|
|
||||||
AnimatedPositioned(
|
AnimatedPositioned(
|
||||||
duration: const Duration(milliseconds: 250),
|
duration: const Duration(milliseconds: 250),
|
||||||
curve: Curves.easeOutCubic,
|
curve: Curves.easeOutCubic,
|
||||||
@@ -1081,14 +1084,19 @@ class _CollectionTrackTile extends ConsumerWidget {
|
|||||||
final track = entry.track;
|
final track = entry.track;
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final effectiveCoverUrl = _resolveCoverUrl(track);
|
final effectiveCoverUrl = _resolveCoverUrl(track);
|
||||||
final isInHistory = ref.watch(
|
|
||||||
|
// Fine-grained provider watches – only this tile rebuilds when its own
|
||||||
|
// history / local-library entry changes.
|
||||||
|
final historyItem = ref.watch(
|
||||||
downloadHistoryProvider.select((state) {
|
downloadHistoryProvider.select((state) {
|
||||||
if (state.isDownloaded(track.id)) return true;
|
final byId = state.getBySpotifyId(track.id);
|
||||||
|
if (byId != null) return byId;
|
||||||
final isrc = track.isrc?.trim();
|
final isrc = track.isrc?.trim();
|
||||||
if (isrc != null && isrc.isNotEmpty && state.getByIsrc(isrc) != null) {
|
if (isrc != null && isrc.isNotEmpty) {
|
||||||
return true;
|
final byIsrc = state.getByIsrc(isrc);
|
||||||
|
if (byIsrc != null) return byIsrc;
|
||||||
}
|
}
|
||||||
return state.findByTrackAndArtist(track.name, track.artistName) != null;
|
return state.findByTrackAndArtist(track.name, track.artistName);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
final showLocalLibraryIndicator = ref.watch(
|
final showLocalLibraryIndicator = ref.watch(
|
||||||
@@ -1096,17 +1104,26 @@ class _CollectionTrackTile extends ConsumerWidget {
|
|||||||
(s) => s.localLibraryEnabled && s.localLibraryShowDuplicates,
|
(s) => s.localLibraryEnabled && s.localLibraryShowDuplicates,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
final isInLocalLibrary = showLocalLibraryIndicator
|
final localItem = showLocalLibraryIndicator
|
||||||
? ref.watch(
|
? ref.watch(
|
||||||
localLibraryProvider.select(
|
localLibraryProvider.select((state) {
|
||||||
(state) => state.existsInLibrary(
|
final isrc = track.isrc?.trim();
|
||||||
isrc: track.isrc,
|
if (isrc != null && isrc.isNotEmpty) {
|
||||||
trackName: track.name,
|
final byIsrc = state.getByIsrc(isrc);
|
||||||
artistName: track.artistName,
|
if (byIsrc != null) return byIsrc;
|
||||||
),
|
}
|
||||||
),
|
return state.findByTrackAndArtist(track.name, track.artistName);
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
: false;
|
: null;
|
||||||
|
|
||||||
|
final isInHistory = historyItem != null;
|
||||||
|
final isInLocalLibrary = localItem != null;
|
||||||
|
final heroTag = historyItem != null
|
||||||
|
? 'cover_${historyItem.id}'
|
||||||
|
: localItem != null
|
||||||
|
? 'cover_lib_${localItem.id}'
|
||||||
|
: null;
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
@@ -1124,43 +1141,51 @@ class _CollectionTrackTile extends ConsumerWidget {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
if (isSelectionMode) ...[
|
if (isSelectionMode) ...[
|
||||||
Container(
|
AnimatedSelectionCheckbox(
|
||||||
width: 24,
|
visible: true,
|
||||||
height: 24,
|
selected: isSelected,
|
||||||
decoration: BoxDecoration(
|
colorScheme: colorScheme,
|
||||||
color: isSelected
|
size: 24,
|
||||||
? colorScheme.primary
|
|
||||||
: Colors.transparent,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
border: Border.all(
|
|
||||||
color: isSelected
|
|
||||||
? colorScheme.primary
|
|
||||||
: colorScheme.outline,
|
|
||||||
width: 2,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: isSelected
|
|
||||||
? Icon(
|
|
||||||
Icons.check,
|
|
||||||
color: colorScheme.onPrimary,
|
|
||||||
size: 16,
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
],
|
],
|
||||||
ClipRRect(
|
HeroMode(
|
||||||
borderRadius: BorderRadius.circular(8),
|
enabled: heroTag != null,
|
||||||
child: effectiveCoverUrl != null && effectiveCoverUrl.isNotEmpty
|
child: heroTag != null
|
||||||
? _buildTrackCover(context, effectiveCoverUrl, 52)
|
? Hero(
|
||||||
: Container(
|
tag: heroTag,
|
||||||
width: 52,
|
child: ClipRRect(
|
||||||
height: 52,
|
borderRadius: BorderRadius.circular(8),
|
||||||
color: colorScheme.surfaceContainerHighest,
|
child:
|
||||||
child: Icon(
|
effectiveCoverUrl != null &&
|
||||||
Icons.music_note,
|
effectiveCoverUrl.isNotEmpty
|
||||||
color: colorScheme.onSurfaceVariant,
|
? _buildTrackCover(context, effectiveCoverUrl, 52)
|
||||||
|
: Container(
|
||||||
|
width: 52,
|
||||||
|
height: 52,
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
child: Icon(
|
||||||
|
Icons.music_note,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
)
|
||||||
|
: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child:
|
||||||
|
effectiveCoverUrl != null &&
|
||||||
|
effectiveCoverUrl.isNotEmpty
|
||||||
|
? _buildTrackCover(context, effectiveCoverUrl, 52)
|
||||||
|
: Container(
|
||||||
|
width: 52,
|
||||||
|
height: 52,
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
child: Icon(
|
||||||
|
Icons.music_note,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -1390,9 +1415,8 @@ class _CollectionTrackTile extends ConsumerWidget {
|
|||||||
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Add to playlist (hidden in wishlist unless already downloaded)
|
|
||||||
if (showAddToPlaylist)
|
if (showAddToPlaylist)
|
||||||
_CollectionOptionTile(
|
BottomSheetOptionTile(
|
||||||
icon: Icons.playlist_add,
|
icon: Icons.playlist_add,
|
||||||
title: context.l10n.collectionAddToPlaylist,
|
title: context.l10n.collectionAddToPlaylist,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
@@ -1401,8 +1425,7 @@ class _CollectionTrackTile extends ConsumerWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
// Remove from folder / playlist
|
BottomSheetOptionTile(
|
||||||
_CollectionOptionTile(
|
|
||||||
icon: Icons.remove_circle_outline,
|
icon: Icons.remove_circle_outline,
|
||||||
iconColor: colorScheme.error,
|
iconColor: colorScheme.error,
|
||||||
title: mode == LibraryTracksFolderMode.playlist
|
title: mode == LibraryTracksFolderMode.playlist
|
||||||
@@ -1500,16 +1523,9 @@ class _CollectionTrackTile extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (historyItem != null) {
|
if (historyItem != null) {
|
||||||
await Navigator.of(context).push(
|
await Navigator.of(
|
||||||
PageRouteBuilder(
|
context,
|
||||||
transitionDuration: const Duration(milliseconds: 300),
|
).push(slidePageRoute(page: TrackMetadataScreen(item: historyItem)));
|
||||||
reverseTransitionDuration: const Duration(milliseconds: 250),
|
|
||||||
pageBuilder: (context, animation, secondaryAnimation) =>
|
|
||||||
TrackMetadataScreen(item: historyItem),
|
|
||||||
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
|
|
||||||
FadeTransition(opacity: animation, child: child),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1524,16 +1540,9 @@ class _CollectionTrackTile extends ConsumerWidget {
|
|||||||
localItem ??= localState.findByTrackAndArtist(track.name, track.artistName);
|
localItem ??= localState.findByTrackAndArtist(track.name, track.artistName);
|
||||||
|
|
||||||
if (localItem != null) {
|
if (localItem != null) {
|
||||||
await Navigator.of(context).push(
|
await Navigator.of(
|
||||||
PageRouteBuilder(
|
context,
|
||||||
transitionDuration: const Duration(milliseconds: 300),
|
).push(slidePageRoute(page: TrackMetadataScreen(localItem: localItem)));
|
||||||
reverseTransitionDuration: const Duration(milliseconds: 250),
|
|
||||||
pageBuilder: (context, animation, secondaryAnimation) =>
|
|
||||||
TrackMetadataScreen(localItem: localItem),
|
|
||||||
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
|
|
||||||
FadeTransition(opacity: animation, child: child),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1542,43 +1551,6 @@ class _CollectionTrackTile extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Styled like _OptionTile in track_collection_quick_actions.dart
|
|
||||||
class _CollectionOptionTile extends StatelessWidget {
|
|
||||||
final IconData icon;
|
|
||||||
final Color? iconColor;
|
|
||||||
final String title;
|
|
||||||
final VoidCallback onTap;
|
|
||||||
|
|
||||||
const _CollectionOptionTile({
|
|
||||||
required this.icon,
|
|
||||||
this.iconColor,
|
|
||||||
required this.title,
|
|
||||||
required this.onTap,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
return ListTile(
|
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
|
|
||||||
leading: Container(
|
|
||||||
padding: const EdgeInsets.all(10),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colorScheme.primaryContainer,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
child: Icon(
|
|
||||||
icon,
|
|
||||||
color: iconColor ?? colorScheme.onPrimaryContainer,
|
|
||||||
size: 20,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)),
|
|
||||||
onTap: onTap,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _SelectionActionButton extends StatelessWidget {
|
class _SelectionActionButton extends StatelessWidget {
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final String label;
|
final String label;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import 'package:spotiflac_android/services/local_track_redownload_service.dart';
|
|||||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||||
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/playback_provider.dart';
|
import 'package:spotiflac_android/providers/playback_provider.dart';
|
||||||
|
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||||
|
|
||||||
class LocalAlbumScreen extends ConsumerStatefulWidget {
|
class LocalAlbumScreen extends ConsumerStatefulWidget {
|
||||||
final String albumName;
|
final String albumName;
|
||||||
@@ -531,7 +532,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
if (tracks.isEmpty) return null;
|
if (tracks.isEmpty) return null;
|
||||||
final first = tracks.first;
|
final first = tracks.first;
|
||||||
|
|
||||||
// For lossy formats, use bitrate
|
|
||||||
if (first.bitrate != null && first.bitrate! > 0) {
|
if (first.bitrate != null && first.bitrate! > 0) {
|
||||||
final fmt = first.format?.toUpperCase() ?? '';
|
final fmt = first.format?.toUpperCase() ?? '';
|
||||||
final firstBitrate = first.bitrate;
|
final firstBitrate = first.bitrate;
|
||||||
@@ -543,7 +543,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
return '$fmt ${firstBitrate}kbps'.trim();
|
return '$fmt ${firstBitrate}kbps'.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
// For lossless formats, use bit depth / sample rate
|
|
||||||
if (first.bitDepth == null ||
|
if (first.bitDepth == null ||
|
||||||
first.bitDepth == 0 ||
|
first.bitDepth == 0 ||
|
||||||
first.sampleRate == null) {
|
first.sampleRate == null) {
|
||||||
@@ -630,7 +629,10 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
final track = discTracks[index];
|
final track = discTracks[index];
|
||||||
return KeyedSubtree(
|
return KeyedSubtree(
|
||||||
key: ValueKey(track.id),
|
key: ValueKey(track.id),
|
||||||
child: _buildTrackItem(context, colorScheme, track),
|
child: StaggeredListItem(
|
||||||
|
index: index,
|
||||||
|
child: _buildTrackItem(context, colorScheme, track),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}, childCount: discTracks.length),
|
}, childCount: discTracks.length),
|
||||||
),
|
),
|
||||||
@@ -669,28 +671,11 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
if (_isSelectionMode) ...[
|
if (_isSelectionMode) ...[
|
||||||
Container(
|
AnimatedSelectionCheckbox(
|
||||||
width: 24,
|
visible: true,
|
||||||
height: 24,
|
selected: isSelected,
|
||||||
decoration: BoxDecoration(
|
colorScheme: colorScheme,
|
||||||
color: isSelected
|
size: 24,
|
||||||
? colorScheme.primary
|
|
||||||
: Colors.transparent,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
border: Border.all(
|
|
||||||
color: isSelected
|
|
||||||
? colorScheme.primary
|
|
||||||
: colorScheme.outline,
|
|
||||||
width: 2,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: isSelected
|
|
||||||
? Icon(
|
|
||||||
Icons.check,
|
|
||||||
color: colorScheme.onPrimary,
|
|
||||||
size: 16,
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
],
|
],
|
||||||
@@ -1382,7 +1367,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (currentFormat == null || currentFormat == targetFormat) continue;
|
if (currentFormat == null || currentFormat == targetFormat) continue;
|
||||||
// Skip lossy sources when target is lossless (pointless re-encoding)
|
|
||||||
final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC';
|
final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC';
|
||||||
final isLosslessSource =
|
final isLosslessSource =
|
||||||
currentFormat == 'FLAC' || currentFormat == 'M4A';
|
currentFormat == 'FLAC' || currentFormat == 'M4A';
|
||||||
@@ -1503,7 +1487,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
bitrate: bitrate,
|
bitrate: bitrate,
|
||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
coverPath: coverPath,
|
coverPath: coverPath,
|
||||||
deleteOriginal: !isSaf, // Only delete original for regular files
|
deleteOriginal: !isSaf,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (coverPath != null) {
|
if (coverPath != null) {
|
||||||
@@ -1522,15 +1506,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isSaf) {
|
if (isSaf) {
|
||||||
// For SAF: derive the parent tree URI and relative dir from the content URI,
|
|
||||||
// then create new SAF file and delete old one
|
|
||||||
// Parse the SAF URI to get the tree document path:
|
|
||||||
// content://...tree/...document/.../oldName.flac
|
|
||||||
// We need tree URI and relative dir to create the new file
|
|
||||||
final uri = Uri.parse(item.filePath);
|
final uri = Uri.parse(item.filePath);
|
||||||
final pathSegments = uri.pathSegments;
|
final pathSegments = uri.pathSegments;
|
||||||
|
|
||||||
// Try to find 'tree' and 'document' segments
|
|
||||||
String? treeUri;
|
String? treeUri;
|
||||||
String relativeDir = '';
|
String relativeDir = '';
|
||||||
String oldFileName = '';
|
String oldFileName = '';
|
||||||
|
|||||||
+71
-37
@@ -20,6 +20,7 @@ import 'package:spotiflac_android/services/shell_navigation_service.dart';
|
|||||||
import 'package:spotiflac_android/services/share_intent_service.dart';
|
import 'package:spotiflac_android/services/share_intent_service.dart';
|
||||||
import 'package:spotiflac_android/services/update_checker.dart';
|
import 'package:spotiflac_android/services/update_checker.dart';
|
||||||
import 'package:spotiflac_android/widgets/update_dialog.dart';
|
import 'package:spotiflac_android/widgets/update_dialog.dart';
|
||||||
|
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||||
import 'package:spotiflac_android/utils/logger.dart';
|
import 'package:spotiflac_android/utils/logger.dart';
|
||||||
|
|
||||||
final _log = AppLogger('MainShell');
|
final _log = AppLogger('MainShell');
|
||||||
@@ -31,9 +32,11 @@ class MainShell extends ConsumerStatefulWidget {
|
|||||||
ConsumerState<MainShell> createState() => _MainShellState();
|
ConsumerState<MainShell> createState() => _MainShellState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MainShellState extends ConsumerState<MainShell> {
|
class _MainShellState extends ConsumerState<MainShell>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
int _currentIndex = 0;
|
int _currentIndex = 0;
|
||||||
late final PageController _pageController;
|
late final PageController _pageController;
|
||||||
|
late final AnimationController _tabJumpTransitionController;
|
||||||
bool _hasCheckedUpdate = false;
|
bool _hasCheckedUpdate = false;
|
||||||
StreamSubscription<String>? _shareSubscription;
|
StreamSubscription<String>? _shareSubscription;
|
||||||
DateTime? _lastBackPress;
|
DateTime? _lastBackPress;
|
||||||
@@ -48,6 +51,11 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_pageController = PageController(initialPage: _currentIndex);
|
_pageController = PageController(initialPage: _currentIndex);
|
||||||
|
_tabJumpTransitionController = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 180),
|
||||||
|
value: 1,
|
||||||
|
);
|
||||||
ShellNavigationService.syncState(
|
ShellNavigationService.syncState(
|
||||||
currentTabIndex: _currentIndex,
|
currentTabIndex: _currentIndex,
|
||||||
showStoreTab: false,
|
showStoreTab: false,
|
||||||
@@ -154,7 +162,6 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
if (!Platform.isAndroid) return;
|
if (!Platform.isAndroid) return;
|
||||||
|
|
||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
// Only show if user is still on legacy storage mode with a download dir set
|
|
||||||
if (settings.storageMode == 'saf') return;
|
if (settings.storageMode == 'saf') return;
|
||||||
if (settings.downloadDirectory.isEmpty) return;
|
if (settings.downloadDirectory.isEmpty) return;
|
||||||
|
|
||||||
@@ -229,6 +236,7 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
_shareSubscription?.cancel();
|
_shareSubscription?.cancel();
|
||||||
_pageController.dispose();
|
_pageController.dispose();
|
||||||
|
_tabJumpTransitionController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,7 +259,8 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (_currentIndex != index) {
|
if (_currentIndex != index) {
|
||||||
final shouldResetHome = index == 0;
|
final previousIndex = _currentIndex;
|
||||||
|
final isNonAdjacentJump = (previousIndex - index).abs() > 1;
|
||||||
HapticFeedback.selectionClick();
|
HapticFeedback.selectionClick();
|
||||||
setState(() => _currentIndex = index);
|
setState(() => _currentIndex = index);
|
||||||
final showStore = ref.read(
|
final showStore = ref.read(
|
||||||
@@ -262,19 +271,23 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
showStoreTab: showStore,
|
showStoreTab: showStore,
|
||||||
);
|
);
|
||||||
FocusManager.instance.primaryFocus?.unfocus();
|
FocusManager.instance.primaryFocus?.unfocus();
|
||||||
if (shouldResetHome) {
|
// Jump directly when skipping intermediate tabs to avoid
|
||||||
_resetHomeToMain();
|
// sliding through them. For those jumps, keep a short fade-in
|
||||||
|
// so the transition still feels intentional.
|
||||||
|
if (isNonAdjacentJump) {
|
||||||
|
_pageController.jumpToPage(index);
|
||||||
|
_tabJumpTransitionController.forward(from: 0);
|
||||||
|
} else {
|
||||||
|
_pageController.animateToPage(
|
||||||
|
index,
|
||||||
|
duration: const Duration(milliseconds: 250),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
_pageController.animateToPage(
|
|
||||||
index,
|
|
||||||
duration: const Duration(milliseconds: 250),
|
|
||||||
curve: Curves.easeOutCubic,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onPageChanged(int index) {
|
void _onPageChanged(int index) {
|
||||||
final previousIndex = _currentIndex;
|
|
||||||
if (_currentIndex != index) {
|
if (_currentIndex != index) {
|
||||||
setState(() => _currentIndex = index);
|
setState(() => _currentIndex = index);
|
||||||
final showStore = ref.read(
|
final showStore = ref.read(
|
||||||
@@ -285,9 +298,6 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
showStoreTab: showStore,
|
showStoreTab: showStore,
|
||||||
);
|
);
|
||||||
FocusManager.instance.primaryFocus?.unfocus();
|
FocusManager.instance.primaryFocus?.unfocus();
|
||||||
if (index == 0 && previousIndex != 0) {
|
|
||||||
_resetHomeToMain();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -451,32 +461,44 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
label: l10n.navHome,
|
label: l10n.navHome,
|
||||||
),
|
),
|
||||||
NavigationDestination(
|
NavigationDestination(
|
||||||
icon: Badge(
|
icon: AnimatedBadge(
|
||||||
isLabelVisible: queueState > 0,
|
count: queueState,
|
||||||
label: Text('$queueState'),
|
|
||||||
child: const Icon(Icons.library_music_outlined),
|
|
||||||
),
|
|
||||||
selectedIcon: SlidingIcon(
|
|
||||||
child: Badge(
|
child: Badge(
|
||||||
isLabelVisible: queueState > 0,
|
isLabelVisible: queueState > 0,
|
||||||
label: Text('$queueState'),
|
label: Text('$queueState'),
|
||||||
child: const Icon(Icons.library_music),
|
child: const Icon(Icons.library_music_outlined),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
selectedIcon: SlidingIcon(
|
||||||
|
child: AnimatedBadge(
|
||||||
|
count: queueState,
|
||||||
|
child: Badge(
|
||||||
|
isLabelVisible: queueState > 0,
|
||||||
|
label: Text('$queueState'),
|
||||||
|
child: const Icon(Icons.library_music),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
label: l10n.navLibrary,
|
label: l10n.navLibrary,
|
||||||
),
|
),
|
||||||
if (showStore)
|
if (showStore)
|
||||||
NavigationDestination(
|
NavigationDestination(
|
||||||
icon: Badge(
|
icon: AnimatedBadge(
|
||||||
isLabelVisible: storeUpdatesCount > 0,
|
count: storeUpdatesCount,
|
||||||
label: Text('$storeUpdatesCount'),
|
|
||||||
child: const Icon(Icons.store_outlined),
|
|
||||||
),
|
|
||||||
selectedIcon: SwingIcon(
|
|
||||||
child: Badge(
|
child: Badge(
|
||||||
isLabelVisible: storeUpdatesCount > 0,
|
isLabelVisible: storeUpdatesCount > 0,
|
||||||
label: Text('$storeUpdatesCount'),
|
label: Text('$storeUpdatesCount'),
|
||||||
child: const Icon(Icons.store),
|
child: const Icon(Icons.store_outlined),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
selectedIcon: SwingIcon(
|
||||||
|
child: AnimatedBadge(
|
||||||
|
count: storeUpdatesCount,
|
||||||
|
child: Badge(
|
||||||
|
isLabelVisible: storeUpdatesCount > 0,
|
||||||
|
label: Text('$storeUpdatesCount'),
|
||||||
|
child: const Icon(Icons.store),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
label: l10n.navStore,
|
label: l10n.navStore,
|
||||||
@@ -504,15 +526,27 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
body: PageView.builder(
|
body: AnimatedBuilder(
|
||||||
controller: _pageController,
|
animation: _tabJumpTransitionController,
|
||||||
itemCount: tabs.length,
|
child: PageView.builder(
|
||||||
onPageChanged: _onPageChanged,
|
controller: _pageController,
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
itemCount: tabs.length,
|
||||||
itemBuilder: (context, index) => _KeepAliveTabPage(
|
onPageChanged: _onPageChanged,
|
||||||
key: ValueKey('page-$index'),
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
child: tabs[index],
|
itemBuilder: (context, index) => _KeepAliveTabPage(
|
||||||
|
key: ValueKey('page-$index'),
|
||||||
|
child: tabs[index],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
builder: (context, child) {
|
||||||
|
final t = Curves.easeOutCubic.transform(
|
||||||
|
_tabJumpTransitionController.value,
|
||||||
|
);
|
||||||
|
return Opacity(
|
||||||
|
opacity: t,
|
||||||
|
child: Transform.scale(scale: 0.985 + (0.015 * t), child: child),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
bottomNavigationBar: NavigationBar(
|
bottomNavigationBar: NavigationBar(
|
||||||
selectedIndex: _currentIndex.clamp(0, maxIndex),
|
selectedIndex: _currentIndex.clamp(0, maxIndex),
|
||||||
@@ -707,7 +741,7 @@ class _SwingIconState extends State<SwingIcon>
|
|||||||
TweenSequenceItem(tween: Tween(begin: 0.15, end: -0.1), weight: 20),
|
TweenSequenceItem(tween: Tween(begin: 0.15, end: -0.1), weight: 20),
|
||||||
TweenSequenceItem(tween: Tween(begin: -0.1, end: 0.05), weight: 20),
|
TweenSequenceItem(tween: Tween(begin: -0.1, end: 0.05), weight: 20),
|
||||||
TweenSequenceItem(tween: Tween(begin: 0.05, end: 0.0), weight: 20),
|
TweenSequenceItem(tween: Tween(begin: 0.05, end: 0.0), weight: 20),
|
||||||
]).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut));
|
]).animate(_controller);
|
||||||
|
|
||||||
_controller.forward();
|
_controller.forward();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,12 +15,14 @@ import 'package:spotiflac_android/providers/playback_provider.dart';
|
|||||||
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||||
import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart';
|
import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart';
|
||||||
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
|
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
|
||||||
|
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||||
|
|
||||||
class PlaylistScreen extends ConsumerStatefulWidget {
|
class PlaylistScreen extends ConsumerStatefulWidget {
|
||||||
final String playlistName;
|
final String playlistName;
|
||||||
final String? coverUrl;
|
final String? coverUrl;
|
||||||
final List<Track> tracks;
|
final List<Track> tracks;
|
||||||
final String? playlistId;
|
final String? playlistId;
|
||||||
|
final String? recommendedService;
|
||||||
|
|
||||||
const PlaylistScreen({
|
const PlaylistScreen({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -28,6 +30,7 @@ class PlaylistScreen extends ConsumerStatefulWidget {
|
|||||||
this.coverUrl,
|
this.coverUrl,
|
||||||
required this.tracks,
|
required this.tracks,
|
||||||
this.playlistId,
|
this.playlistId,
|
||||||
|
this.recommendedService,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -47,6 +50,31 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||||||
String get _playlistName => _resolvedPlaylistName ?? widget.playlistName;
|
String get _playlistName => _resolvedPlaylistName ?? widget.playlistName;
|
||||||
String? get _coverUrl => _resolvedCoverUrl ?? widget.coverUrl;
|
String? get _coverUrl => _resolvedCoverUrl ?? widget.coverUrl;
|
||||||
|
|
||||||
|
String? _recommendedDownloadService() {
|
||||||
|
final explicit = widget.recommendedService;
|
||||||
|
if (explicit != null && explicit.isNotEmpty) {
|
||||||
|
return explicit;
|
||||||
|
}
|
||||||
|
|
||||||
|
final playlistId = widget.playlistId;
|
||||||
|
if (playlistId != null) {
|
||||||
|
if (playlistId.startsWith('tidal:')) return 'tidal';
|
||||||
|
if (playlistId.startsWith('qobuz:')) return 'qobuz';
|
||||||
|
if (playlistId.startsWith('deezer:')) return 'deezer';
|
||||||
|
}
|
||||||
|
|
||||||
|
final source = _tracks.firstOrNull?.source;
|
||||||
|
if (source != null && source.isNotEmpty) {
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
final trackId = _tracks.firstOrNull?.id ?? '';
|
||||||
|
if (trackId.startsWith('tidal:')) return 'tidal';
|
||||||
|
if (trackId.startsWith('qobuz:')) return 'qobuz';
|
||||||
|
if (trackId.startsWith('deezer:')) return 'deezer';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -360,8 +388,8 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||||||
if (_isLoading) {
|
if (_isLoading) {
|
||||||
return const SliverToBoxAdapter(
|
return const SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.all(32),
|
padding: EdgeInsets.all(16),
|
||||||
child: Center(child: CircularProgressIndicator()),
|
child: TrackListSkeleton(itemCount: 8),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -411,9 +439,12 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||||||
final track = _tracks[index];
|
final track = _tracks[index];
|
||||||
return KeyedSubtree(
|
return KeyedSubtree(
|
||||||
key: ValueKey(track.id),
|
key: ValueKey(track.id),
|
||||||
child: _PlaylistTrackItem(
|
child: StaggeredListItem(
|
||||||
track: track,
|
index: index,
|
||||||
onDownload: () => _downloadTrack(context, track),
|
child: _PlaylistTrackItem(
|
||||||
|
track: track,
|
||||||
|
onDownload: () => _downloadTrack(context, track),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}, childCount: _tracks.length),
|
}, childCount: _tracks.length),
|
||||||
@@ -429,6 +460,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||||||
trackName: track.name,
|
trackName: track.name,
|
||||||
artistName: track.artistName,
|
artistName: track.artistName,
|
||||||
coverUrl: track.coverUrl,
|
coverUrl: track.coverUrl,
|
||||||
|
recommendedService: _recommendedDownloadService(),
|
||||||
onSelect: (quality, service) {
|
onSelect: (quality, service) {
|
||||||
ref
|
ref
|
||||||
.read(downloadQueueProvider.notifier)
|
.read(downloadQueueProvider.notifier)
|
||||||
@@ -616,7 +648,6 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||||||
void _downloadTracks(BuildContext context, List<Track> tracks) {
|
void _downloadTracks(BuildContext context, List<Track> tracks) {
|
||||||
if (tracks.isEmpty) return;
|
if (tracks.isEmpty) return;
|
||||||
|
|
||||||
// Skip already-downloaded tracks
|
|
||||||
final historyState = ref.read(downloadHistoryProvider);
|
final historyState = ref.read(downloadHistoryProvider);
|
||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
final localLibState =
|
final localLibState =
|
||||||
@@ -663,6 +694,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||||||
context,
|
context,
|
||||||
trackName: '${tracksToQueue.length} tracks',
|
trackName: '${tracksToQueue.length} tracks',
|
||||||
artistName: _playlistName,
|
artistName: _playlistName,
|
||||||
|
recommendedService: _recommendedDownloadService(),
|
||||||
onSelect: (quality, service) {
|
onSelect: (quality, service) {
|
||||||
ref
|
ref
|
||||||
.read(downloadQueueProvider.notifier)
|
.read(downloadQueueProvider.notifier)
|
||||||
@@ -725,7 +757,6 @@ class _PlaylistTrackItem extends ConsumerWidget {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Check local library for duplicate detection
|
|
||||||
final showLocalLibraryIndicator = ref.watch(
|
final showLocalLibraryIndicator = ref.watch(
|
||||||
settingsProvider.select(
|
settingsProvider.select(
|
||||||
(s) => s.localLibraryEnabled && s.localLibraryShowDuplicates,
|
(s) => s.localLibraryEnabled && s.localLibraryShowDuplicates,
|
||||||
|
|||||||
+924
-1076
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@ import 'package:spotiflac_android/providers/track_provider.dart';
|
|||||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
|
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
|
||||||
|
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||||
import 'package:spotiflac_android/utils/clickable_metadata.dart';
|
import 'package:spotiflac_android/utils/clickable_metadata.dart';
|
||||||
|
|
||||||
class SearchScreen extends ConsumerStatefulWidget {
|
class SearchScreen extends ConsumerStatefulWidget {
|
||||||
@@ -51,9 +52,9 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||||||
ref
|
ref
|
||||||
.read(downloadQueueProvider.notifier)
|
.read(downloadQueueProvider.notifier)
|
||||||
.addToQueue(track, settings.defaultService);
|
.addToQueue(track, settings.defaultService);
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
context,
|
SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))),
|
||||||
).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))));
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -95,13 +96,20 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||||||
child: Text(error, style: TextStyle(color: colorScheme.error)),
|
child: Text(error, style: TextStyle(color: colorScheme.error)),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: tracks.isEmpty
|
child: AnimatedStateSwitcher(
|
||||||
? _buildEmptyState(colorScheme)
|
child: isLoading && tracks.isEmpty
|
||||||
: ListView.builder(
|
? const TrackListSkeleton(key: ValueKey('loading'))
|
||||||
itemCount: tracks.length,
|
: tracks.isEmpty
|
||||||
itemBuilder: (context, index) =>
|
? _buildEmptyState(colorScheme)
|
||||||
_buildTrackTile(tracks[index], colorScheme),
|
: ListView.builder(
|
||||||
),
|
key: const ValueKey('results'),
|
||||||
|
itemCount: tracks.length,
|
||||||
|
itemBuilder: (context, index) => StaggeredListItem(
|
||||||
|
index: index,
|
||||||
|
child: _buildTrackTile(tracks[index], colorScheme),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -127,32 +135,30 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildTrackTile(Track track, ColorScheme colorScheme) {
|
Widget _buildTrackTile(Track track, ColorScheme colorScheme) {
|
||||||
return ListTile(
|
final coverWidget = track.coverUrl != null
|
||||||
leading: track.coverUrl != null
|
? ClipRRect(
|
||||||
? ClipRRect(
|
borderRadius: BorderRadius.circular(8),
|
||||||
borderRadius: BorderRadius.circular(8),
|
child: CachedNetworkImage(
|
||||||
child: CachedNetworkImage(
|
imageUrl: track.coverUrl!,
|
||||||
imageUrl: track.coverUrl!,
|
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
memCacheWidth: 144,
|
|
||||||
memCacheHeight: 144,
|
|
||||||
cacheManager: CoverCacheManager.instance,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: Container(
|
|
||||||
width: 48,
|
width: 48,
|
||||||
height: 48,
|
height: 48,
|
||||||
decoration: BoxDecoration(
|
fit: BoxFit.cover,
|
||||||
color: colorScheme.surfaceContainerHighest,
|
memCacheWidth: 144,
|
||||||
borderRadius: BorderRadius.circular(8),
|
memCacheHeight: 144,
|
||||||
),
|
cacheManager: CoverCacheManager.instance,
|
||||||
child: Icon(
|
|
||||||
Icons.music_note,
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
)
|
||||||
|
: Container(
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
|
||||||
|
);
|
||||||
|
return ListTile(
|
||||||
|
leading: coverWidget,
|
||||||
title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis),
|
title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||||
subtitle: Column(
|
subtitle: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
|||||||
@@ -477,122 +477,40 @@ class _CryptoWalletItem extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
int _cr(String v) {
|
class _SupporterChip extends StatelessWidget {
|
||||||
int r = 0x1F;
|
|
||||||
for (final c in v.codeUnits) {
|
|
||||||
r = (r * 31 + c) & 0x7FFFFFFF;
|
|
||||||
}
|
|
||||||
return r;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Highlighted supporters (hashes of names).
|
|
||||||
const _cv = <int>{1211573191, 1003219236};
|
|
||||||
|
|
||||||
// Diamond tier supporters ($50+ donors).
|
|
||||||
const _dv = <int>{560908930};
|
|
||||||
|
|
||||||
enum _SupporterTier { normal, gold, diamond }
|
|
||||||
|
|
||||||
_SupporterTier _tierOf(String name) {
|
|
||||||
final h = _cr(name);
|
|
||||||
if (_dv.contains(h)) return _SupporterTier.diamond;
|
|
||||||
if (_cv.contains(h)) return _SupporterTier.gold;
|
|
||||||
return _SupporterTier.normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
class _SupporterChip extends StatefulWidget {
|
|
||||||
final String name;
|
final String name;
|
||||||
final ColorScheme colorScheme;
|
final ColorScheme colorScheme;
|
||||||
|
|
||||||
const _SupporterChip({required this.name, required this.colorScheme});
|
const _SupporterChip({required this.name, required this.colorScheme});
|
||||||
|
|
||||||
@override
|
|
||||||
State<_SupporterChip> createState() => _SupporterChipState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _SupporterChipState extends State<_SupporterChip>
|
|
||||||
with SingleTickerProviderStateMixin {
|
|
||||||
late final _SupporterTier _tier;
|
|
||||||
AnimationController? _shimmerController;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_tier = _tierOf(widget.name);
|
|
||||||
if (_tier == _SupporterTier.diamond) {
|
|
||||||
_shimmerController = AnimationController(
|
|
||||||
vsync: this,
|
|
||||||
duration: const Duration(milliseconds: 2400),
|
|
||||||
)..repeat();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_shimmerController?.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
|
||||||
|
|
||||||
if (_tier == _SupporterTier.diamond) {
|
|
||||||
return _buildDiamondChip(isDark);
|
|
||||||
}
|
|
||||||
|
|
||||||
final isGold = _tier == _SupporterTier.gold;
|
|
||||||
const goldChipColor = Color(0xFFFFF8DC);
|
|
||||||
const goldAccentColor = Color(0xFFB8860B);
|
|
||||||
const goldDarkChipColor = Color(0xFF3A3000);
|
|
||||||
|
|
||||||
final chipColor = isGold
|
|
||||||
? goldChipColor
|
|
||||||
: widget.colorScheme.secondaryContainer;
|
|
||||||
final accentColor = isGold ? goldAccentColor : widget.colorScheme.primary;
|
|
||||||
final effectiveChipColor = isGold && isDark ? goldDarkChipColor : chipColor;
|
|
||||||
|
|
||||||
return Material(
|
return Material(
|
||||||
color: effectiveChipColor,
|
color: colorScheme.secondaryContainer,
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
child: Container(
|
child: Padding(
|
||||||
decoration: isGold
|
|
||||||
? BoxDecoration(
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
border: Border.all(
|
|
||||||
color: accentColor.withValues(alpha: 0.4),
|
|
||||||
width: 1,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
CircleAvatar(
|
CircleAvatar(
|
||||||
radius: 10,
|
radius: 10,
|
||||||
backgroundColor: accentColor.withValues(alpha: 0.2),
|
backgroundColor: colorScheme.primary.withValues(alpha: 0.2),
|
||||||
child: isGold
|
child: Text(
|
||||||
? Icon(Icons.star_rounded, size: 12, color: accentColor)
|
name.isNotEmpty ? name[0].toUpperCase() : '?',
|
||||||
: Text(
|
style: TextStyle(
|
||||||
widget.name.isNotEmpty
|
fontSize: 10,
|
||||||
? widget.name[0].toUpperCase()
|
fontWeight: FontWeight.bold,
|
||||||
: '?',
|
color: colorScheme.primary,
|
||||||
style: TextStyle(
|
),
|
||||||
fontSize: 10,
|
),
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: accentColor,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
widget.name,
|
name,
|
||||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||||
color: isGold
|
color: colorScheme.onSecondaryContainer,
|
||||||
? accentColor
|
fontWeight: FontWeight.w500,
|
||||||
: widget.colorScheme.onSecondaryContainer,
|
|
||||||
fontWeight: isGold ? FontWeight.w600 : FontWeight.w500,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -600,92 +518,6 @@ class _SupporterChipState extends State<_SupporterChip>
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildDiamondChip(bool isDark) {
|
|
||||||
const diamondLight = Color(0xFFE8F4FD);
|
|
||||||
const diamondDark = Color(0xFF0D2B3E);
|
|
||||||
const diamondAccent = Color(0xFF4FC3F7);
|
|
||||||
const diamondHighlight = Color(0xFFB3E5FC);
|
|
||||||
|
|
||||||
final chipBg = isDark ? diamondDark : diamondLight;
|
|
||||||
|
|
||||||
return AnimatedBuilder(
|
|
||||||
animation: _shimmerController!,
|
|
||||||
builder: (context, child) {
|
|
||||||
final t = _shimmerController!.value;
|
|
||||||
return Material(
|
|
||||||
color: Colors.transparent,
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
child: Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
gradient: LinearGradient(
|
|
||||||
begin: Alignment(-2.0 + 4.0 * t, 0.0),
|
|
||||||
end: Alignment(-1.0 + 4.0 * t, 0.0),
|
|
||||||
colors: [
|
|
||||||
chipBg,
|
|
||||||
isDark
|
|
||||||
? diamondAccent.withValues(alpha: 0.18)
|
|
||||||
: diamondHighlight.withValues(alpha: 0.7),
|
|
||||||
chipBg,
|
|
||||||
],
|
|
||||||
stops: const [0.0, 0.5, 1.0],
|
|
||||||
),
|
|
||||||
border: Border.all(
|
|
||||||
color: diamondAccent.withValues(
|
|
||||||
alpha: 0.5 + 0.3 * (0.5 - (t - 0.5).abs()),
|
|
||||||
),
|
|
||||||
width: 1.2,
|
|
||||||
),
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: diamondAccent.withValues(
|
|
||||||
alpha: 0.15 + 0.1 * (0.5 - (t - 0.5).abs()),
|
|
||||||
),
|
|
||||||
blurRadius: 8,
|
|
||||||
spreadRadius: 0,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 20,
|
|
||||||
height: 20,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
gradient: LinearGradient(
|
|
||||||
begin: Alignment.topLeft,
|
|
||||||
end: Alignment.bottomRight,
|
|
||||||
colors: [
|
|
||||||
diamondAccent.withValues(alpha: 0.3),
|
|
||||||
diamondAccent.withValues(alpha: 0.15),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: const Icon(
|
|
||||||
Icons.diamond_rounded,
|
|
||||||
size: 12,
|
|
||||||
color: diamondAccent,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(
|
|
||||||
widget.name,
|
|
||||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
|
||||||
color: isDark ? diamondHighlight : diamondAccent,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class _NoticeLine extends StatelessWidget {
|
class _NoticeLine extends StatelessWidget {
|
||||||
|
|||||||
@@ -465,34 +465,6 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
SettingsItem(
|
|
||||||
title: context.l10n.youtubeOpusBitrateTitle,
|
|
||||||
subtitle:
|
|
||||||
'${settings.youtubeOpusBitrate}kbps (128/256/320)',
|
|
||||||
onTap: () => _showYoutubeBitratePicker(
|
|
||||||
context: context,
|
|
||||||
title: context.l10n.youtubeOpusBitrateTitle,
|
|
||||||
currentValue: settings.youtubeOpusBitrate,
|
|
||||||
options: const [128, 256, 320],
|
|
||||||
onSave: (value) => ref
|
|
||||||
.read(settingsProvider.notifier)
|
|
||||||
.setYoutubeOpusBitrate(value),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SettingsItem(
|
|
||||||
title: context.l10n.youtubeMp3BitrateTitle,
|
|
||||||
subtitle: '${settings.youtubeMp3Bitrate}kbps (128/256/320)',
|
|
||||||
onTap: () => _showYoutubeBitratePicker(
|
|
||||||
context: context,
|
|
||||||
title: context.l10n.youtubeMp3BitrateTitle,
|
|
||||||
currentValue: settings.youtubeMp3Bitrate,
|
|
||||||
options: const [128, 256, 320],
|
|
||||||
onSave: (value) => ref
|
|
||||||
.read(settingsProvider.notifier)
|
|
||||||
.setYoutubeMp3Bitrate(value),
|
|
||||||
),
|
|
||||||
showDivider: false,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -869,6 +841,8 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
|||||||
return 'Albums/[Year] Album/';
|
return 'Albums/[Year] Album/';
|
||||||
case 'artist_album_singles':
|
case 'artist_album_singles':
|
||||||
return 'Artist/Album/ + Artist/Singles/';
|
return 'Artist/Album/ + Artist/Singles/';
|
||||||
|
case 'artist_album_flat':
|
||||||
|
return 'Artist/Album/ + Artist/song.flac';
|
||||||
default:
|
default:
|
||||||
return 'Albums/Artist/Album Name/';
|
return 'Albums/Artist/Album Name/';
|
||||||
}
|
}
|
||||||
@@ -958,6 +932,20 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
|||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.person_outline_outlined),
|
||||||
|
title: Text(context.l10n.albumFolderArtistAlbumFlat),
|
||||||
|
subtitle: Text(context.l10n.albumFolderArtistAlbumFlatSubtitle),
|
||||||
|
trailing: current == 'artist_album_flat'
|
||||||
|
? const Icon(Icons.check)
|
||||||
|
: null,
|
||||||
|
onTap: () {
|
||||||
|
ref
|
||||||
|
.read(settingsProvider.notifier)
|
||||||
|
.setAlbumFolderStructure('artist_album_flat');
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1689,68 +1677,6 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showYoutubeBitratePicker({
|
|
||||||
required BuildContext context,
|
|
||||||
required String title,
|
|
||||||
required int currentValue,
|
|
||||||
required List<int> options,
|
|
||||||
required void Function(int value) onSave,
|
|
||||||
}) {
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
|
|
||||||
showModalBottomSheet(
|
|
||||||
context: context,
|
|
||||||
useRootNavigator: true,
|
|
||||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
|
||||||
shape: const RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
|
||||||
),
|
|
||||||
builder: (sheetContext) => SafeArea(
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Container(
|
|
||||||
width: 40,
|
|
||||||
height: 4,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
|
|
||||||
borderRadius: BorderRadius.circular(2),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(24, 12, 24, 8),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
title,
|
|
||||||
style: Theme.of(sheetContext).textTheme.titleMedium
|
|
||||||
?.copyWith(fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
for (final bitrate in options)
|
|
||||||
ListTile(
|
|
||||||
title: Text('$bitrate kbps'),
|
|
||||||
trailing: bitrate == currentValue
|
|
||||||
? Icon(Icons.check, color: colorScheme.primary)
|
|
||||||
: null,
|
|
||||||
onTap: () {
|
|
||||||
onSave(bitrate);
|
|
||||||
Navigator.pop(sheetContext);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showMusixmatchLanguagePicker(
|
void _showMusixmatchLanguagePicker(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
WidgetRef ref,
|
WidgetRef ref,
|
||||||
@@ -2100,7 +2026,7 @@ class _ServiceSelector extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final extState = ref.watch(extensionProvider);
|
final extState = ref.watch(extensionProvider);
|
||||||
final builtInServiceIds = ['tidal', 'qobuz', 'deezer', 'youtube'];
|
final builtInServiceIds = ['tidal', 'qobuz', 'deezer'];
|
||||||
|
|
||||||
final extensionProviders = extState.extensions
|
final extensionProviders = extState.extensions
|
||||||
.where((e) => e.enabled && e.hasDownloadProvider)
|
.where((e) => e.enabled && e.hasDownloadProvider)
|
||||||
@@ -2136,15 +2062,6 @@ class _ServiceSelector extends ConsumerWidget {
|
|||||||
onTap: () => onChanged('qobuz'),
|
onTap: () => onChanged('qobuz'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
|
||||||
child: _ServiceChip(
|
|
||||||
icon: Icons.smart_display,
|
|
||||||
label: 'YouTube',
|
|
||||||
isSelected: effectiveService == 'youtube',
|
|
||||||
onTap: () => onChanged('youtube'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
if (extensionProviders.isNotEmpty) ...[
|
if (extensionProviders.isNotEmpty) ...[
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
|
|||||||
final hasError = extension.status == 'error';
|
final hasError = extension.status == 'error';
|
||||||
|
|
||||||
return PopScope(
|
return PopScope(
|
||||||
canPop: true, // Always allow back gesture
|
canPop: true,
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
body: CustomScrollView(
|
body: CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
|||||||
final topPadding = normalizedHeaderTopPadding(context);
|
final topPadding = normalizedHeaderTopPadding(context);
|
||||||
|
|
||||||
return PopScope(
|
return PopScope(
|
||||||
canPop: true, // Always allow back gesture
|
canPop: true,
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
body: CustomScrollView(
|
body: CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
@@ -600,14 +600,12 @@ class _SearchProviderSelector extends ConsumerWidget {
|
|||||||
.where((e) => e.enabled && e.hasCustomSearch)
|
.where((e) => e.enabled && e.hasCustomSearch)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
// Always allow tapping: built-in providers are always available
|
|
||||||
final hasAnyProvider =
|
final hasAnyProvider =
|
||||||
searchProviders.isNotEmpty || _builtInProviders.isNotEmpty;
|
searchProviders.isNotEmpty || _builtInProviders.isNotEmpty;
|
||||||
|
|
||||||
String currentProviderName = context.l10n.extensionDefaultProvider;
|
String currentProviderName = context.l10n.extensionDefaultProvider;
|
||||||
if (settings.searchProvider != null &&
|
if (settings.searchProvider != null &&
|
||||||
settings.searchProvider!.isNotEmpty) {
|
settings.searchProvider!.isNotEmpty) {
|
||||||
// Check built-in first
|
|
||||||
if (_builtInProviders.containsKey(settings.searchProvider)) {
|
if (_builtInProviders.containsKey(settings.searchProvider)) {
|
||||||
currentProviderName = _builtInProviders[settings.searchProvider]!;
|
currentProviderName = _builtInProviders[settings.searchProvider]!;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -23,21 +23,15 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
|||||||
int _androidSdkVersion = 0;
|
int _androidSdkVersion = 0;
|
||||||
bool _hasStoragePermission = false;
|
bool _hasStoragePermission = false;
|
||||||
|
|
||||||
/// Convert SAF content URI to a readable display path
|
|
||||||
String _getDisplayPath(String path) {
|
String _getDisplayPath(String path) {
|
||||||
if (!path.startsWith('content://')) return path;
|
if (!path.startsWith('content://')) return path;
|
||||||
// Extract the path portion from SAF tree URI
|
|
||||||
// e.g. content://com.android.externalstorage.documents/tree/primary%3AMusic
|
|
||||||
// -> /storage/emulated/0/Music
|
|
||||||
try {
|
try {
|
||||||
final uri = Uri.parse(path);
|
final uri = Uri.parse(path);
|
||||||
final treePath =
|
final treePath = uri.pathSegments.last;
|
||||||
uri.pathSegments.last; // e.g. "primary:Music" or "primary%3AMusic"
|
|
||||||
final decoded = Uri.decodeComponent(treePath);
|
final decoded = Uri.decodeComponent(treePath);
|
||||||
if (decoded.startsWith('primary:')) {
|
if (decoded.startsWith('primary:')) {
|
||||||
return '/storage/emulated/0/${decoded.substring('primary:'.length)}';
|
return '/storage/emulated/0/${decoded.substring('primary:'.length)}';
|
||||||
}
|
}
|
||||||
// For SD card or other volumes, just show the decoded path
|
|
||||||
return decoded;
|
return decoded;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return path;
|
return path;
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ class _LogScreenState extends State<LogScreen> {
|
|||||||
final logs = _filteredLogs;
|
final logs = _filteredLogs;
|
||||||
|
|
||||||
return PopScope(
|
return PopScope(
|
||||||
canPop: true, // Always allow back gesture
|
canPop: true,
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
body: CustomScrollView(
|
body: CustomScrollView(
|
||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
final topPadding = normalizedHeaderTopPadding(context);
|
final topPadding = normalizedHeaderTopPadding(context);
|
||||||
|
|
||||||
return PopScope(
|
return PopScope(
|
||||||
canPop: true, // Always allow back gesture
|
canPop: true,
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
body: CustomScrollView(
|
body: CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
|
|||||||
@@ -340,12 +340,6 @@ class _ProviderItem extends StatelessWidget {
|
|||||||
icon: Icons.graphic_eq,
|
icon: Icons.graphic_eq,
|
||||||
isBuiltIn: true,
|
isBuiltIn: true,
|
||||||
);
|
);
|
||||||
case 'youtube':
|
|
||||||
return _ProviderInfo(
|
|
||||||
name: 'YouTube',
|
|
||||||
icon: Icons.play_circle_outline,
|
|
||||||
isBuiltIn: true,
|
|
||||||
);
|
|
||||||
default:
|
default:
|
||||||
return _ProviderInfo(
|
return _ProviderInfo(
|
||||||
name: provider,
|
name: provider,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import 'package:spotiflac_android/screens/settings/donate_page.dart';
|
|||||||
import 'package:spotiflac_android/screens/settings/log_screen.dart';
|
import 'package:spotiflac_android/screens/settings/log_screen.dart';
|
||||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||||
|
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||||
|
|
||||||
class SettingsTab extends ConsumerWidget {
|
class SettingsTab extends ConsumerWidget {
|
||||||
const SettingsTab({super.key});
|
const SettingsTab({super.key});
|
||||||
@@ -150,26 +151,6 @@ class SettingsTab extends ConsumerWidget {
|
|||||||
|
|
||||||
void _navigateTo(BuildContext context, Widget page) {
|
void _navigateTo(BuildContext context, Widget page) {
|
||||||
FocusManager.instance.primaryFocus?.unfocus();
|
FocusManager.instance.primaryFocus?.unfocus();
|
||||||
|
Navigator.of(context).push(slidePageRoute(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),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -441,14 +441,9 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
|
|
||||||
void _nextPage() {
|
void _nextPage() {
|
||||||
bool canProceed = false;
|
bool canProceed = false;
|
||||||
// Step 0 is Welcome, always can proceed
|
|
||||||
if (_currentStep == 0) {
|
if (_currentStep == 0) {
|
||||||
canProceed = true;
|
canProceed = true;
|
||||||
} else {
|
} else {
|
||||||
// Logic for other steps (offset by 1 because of welcome step)
|
|
||||||
// Step 1: Storage
|
|
||||||
// Step 2: Notification (if android 13+) OR Directory
|
|
||||||
// etc.
|
|
||||||
canProceed = _isStepCompleted(_currentStep);
|
canProceed = _isStepCompleted(_currentStep);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -470,9 +465,8 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool _isStepCompleted(int step) {
|
bool _isStepCompleted(int step) {
|
||||||
if (step == 0) return true; // Welcome
|
if (step == 0) return true;
|
||||||
|
|
||||||
// Adjust step index for logic because we added Welcome at 0
|
|
||||||
final logicStep = step - 1;
|
final logicStep = step - 1;
|
||||||
|
|
||||||
if (_androidSdkVersion >= 33) {
|
if (_androidSdkVersion >= 33) {
|
||||||
|
|||||||
+84
-26
@@ -4,6 +4,7 @@ import 'package:path_provider/path_provider.dart';
|
|||||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||||
import 'package:spotiflac_android/providers/store_provider.dart';
|
import 'package:spotiflac_android/providers/store_provider.dart';
|
||||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||||
|
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||||
import 'package:spotiflac_android/screens/store/extension_details_screen.dart';
|
import 'package:spotiflac_android/screens/store/extension_details_screen.dart';
|
||||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||||
|
|
||||||
@@ -58,7 +59,9 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
|||||||
final downloadingId = ref.watch(
|
final downloadingId = ref.watch(
|
||||||
storeProvider.select((s) => s.downloadingId),
|
storeProvider.select((s) => s.downloadingId),
|
||||||
);
|
);
|
||||||
final hasRegistryUrl = ref.watch(storeProvider.select((s) => s.hasRegistryUrl));
|
final hasRegistryUrl = ref.watch(
|
||||||
|
storeProvider.select((s) => s.hasRegistryUrl),
|
||||||
|
);
|
||||||
final registryUrl = ref.watch(storeProvider.select((s) => s.registryUrl));
|
final registryUrl = ref.watch(storeProvider.select((s) => s.registryUrl));
|
||||||
final filteredExtensions = StoreState(
|
final filteredExtensions = StoreState(
|
||||||
extensions: extensions,
|
extensions: extensions,
|
||||||
@@ -139,7 +142,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
|||||||
prefixIcon: const Icon(Icons.search),
|
prefixIcon: const Icon(Icons.search),
|
||||||
suffixIcon: value.text.isNotEmpty
|
suffixIcon: value.text.isNotEmpty
|
||||||
? IconButton(
|
? IconButton(
|
||||||
tooltip: 'Clear search',
|
tooltip: 'Clear',
|
||||||
icon: const Icon(Icons.clear),
|
icon: const Icon(Icons.clear),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
_searchController.clear();
|
_searchController.clear();
|
||||||
@@ -151,23 +154,37 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
|||||||
: null,
|
: null,
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(28),
|
borderRadius: BorderRadius.circular(28),
|
||||||
borderSide: BorderSide.none,
|
borderSide: BorderSide(
|
||||||
|
color: colorScheme.outlineVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
borderSide: BorderSide(
|
||||||
|
color: colorScheme.outlineVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
borderSide: BorderSide(
|
||||||
|
color: colorScheme.primary,
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor:
|
fillColor: colorScheme.surfaceContainerHighest,
|
||||||
Theme.of(context).brightness == Brightness.dark
|
|
||||||
? Color.alphaBlend(
|
|
||||||
Colors.white.withValues(alpha: 0.08),
|
|
||||||
colorScheme.surface,
|
|
||||||
)
|
|
||||||
: colorScheme.surfaceContainerHighest,
|
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
horizontal: 16,
|
horizontal: 20,
|
||||||
vertical: 12,
|
vertical: 16,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
ref.read(storeProvider.notifier).setSearchQuery(value);
|
ref
|
||||||
|
.read(storeProvider.notifier)
|
||||||
|
.setSearchQuery(value);
|
||||||
|
},
|
||||||
|
onTapOutside: (_) {
|
||||||
|
FocusScope.of(context).unfocus();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -231,7 +248,8 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
|||||||
_CategoryChip(
|
_CategoryChip(
|
||||||
label: context.l10n.storeFilterIntegration,
|
label: context.l10n.storeFilterIntegration,
|
||||||
icon: Icons.link,
|
icon: Icons.link,
|
||||||
isSelected: selectedCategory == StoreCategory.integration,
|
isSelected:
|
||||||
|
selectedCategory == StoreCategory.integration,
|
||||||
onTap: () => ref
|
onTap: () => ref
|
||||||
.read(storeProvider.notifier)
|
.read(storeProvider.notifier)
|
||||||
.setCategory(StoreCategory.integration),
|
.setCategory(StoreCategory.integration),
|
||||||
@@ -242,8 +260,11 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
|||||||
),
|
),
|
||||||
|
|
||||||
if (isLoading && extensions.isEmpty)
|
if (isLoading && extensions.isEmpty)
|
||||||
const SliverFillRemaining(
|
const SliverToBoxAdapter(
|
||||||
child: Center(child: CircularProgressIndicator()),
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(16),
|
||||||
|
child: TrackListSkeleton(itemCount: 6),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
else if (error != null && extensions.isEmpty)
|
else if (error != null && extensions.isEmpty)
|
||||||
SliverFillRemaining(child: _buildErrorState(error, colorScheme))
|
SliverFillRemaining(child: _buildErrorState(error, colorScheme))
|
||||||
@@ -309,9 +330,9 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
|||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
Text(
|
Text(
|
||||||
context.l10n.storeAddRepoTitle,
|
context.l10n.storeAddRepoTitle,
|
||||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
style: Theme.of(
|
||||||
fontWeight: FontWeight.bold,
|
context,
|
||||||
),
|
).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
@@ -322,16 +343,23 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
|||||||
labelText: context.l10n.storeRepoUrlLabel,
|
labelText: context.l10n.storeRepoUrlLabel,
|
||||||
prefixIcon: const Icon(Icons.link),
|
prefixIcon: const Icon(Icons.link),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
borderSide: BorderSide(color: colorScheme.outlineVariant),
|
||||||
),
|
),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(28),
|
||||||
borderSide: BorderSide(color: colorScheme.outline),
|
borderSide: BorderSide(color: colorScheme.outlineVariant),
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(28),
|
||||||
borderSide: BorderSide(color: colorScheme.primary, width: 2),
|
borderSide: BorderSide(color: colorScheme.primary, width: 2),
|
||||||
),
|
),
|
||||||
|
filled: true,
|
||||||
|
fillColor: colorScheme.surfaceContainerHighest,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 20,
|
||||||
|
vertical: 16,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
keyboardType: TextInputType.url,
|
keyboardType: TextInputType.url,
|
||||||
autocorrect: false,
|
autocorrect: false,
|
||||||
@@ -347,7 +375,11 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
|||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.error_outline, size: 20, color: colorScheme.onErrorContainer),
|
Icon(
|
||||||
|
Icons.error_outline,
|
||||||
|
size: 20,
|
||||||
|
color: colorScheme.onErrorContainer,
|
||||||
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -416,7 +448,31 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
|||||||
labelText: context.l10n.storeNewRepoUrlLabel,
|
labelText: context.l10n.storeNewRepoUrlLabel,
|
||||||
prefixIcon: const Icon(Icons.link),
|
prefixIcon: const Icon(Icons.link),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
borderSide: BorderSide(
|
||||||
|
color: Theme.of(context).colorScheme.outlineVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
borderSide: BorderSide(
|
||||||
|
color: Theme.of(context).colorScheme.outlineVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
borderSide: BorderSide(
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
filled: true,
|
||||||
|
fillColor: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.surfaceContainerHighest,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 20,
|
||||||
|
vertical: 16,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
keyboardType: TextInputType.url,
|
keyboardType: TextInputType.url,
|
||||||
@@ -503,7 +559,9 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
hasFilters ? context.l10n.storeEmptyNoResults : context.l10n.storeEmptyNoExtensions,
|
hasFilters
|
||||||
|
? context.l10n.storeEmptyNoResults
|
||||||
|
: context.l10n.storeEmptyNoExtensions,
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import 'package:spotiflac_android/utils/logger.dart';
|
|||||||
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
|
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
|
||||||
import 'package:spotiflac_android/utils/mime_utils.dart';
|
import 'package:spotiflac_android/utils/mime_utils.dart';
|
||||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||||
|
import 'package:spotiflac_android/widgets/audio_analysis_widget.dart';
|
||||||
|
|
||||||
final _log = AppLogger('TrackMetadata');
|
final _log = AppLogger('TrackMetadata');
|
||||||
|
|
||||||
@@ -59,19 +60,19 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
bool _fileExists = false;
|
bool _fileExists = false;
|
||||||
bool _hasCheckedFile = false;
|
bool _hasCheckedFile = false;
|
||||||
int? _fileSize;
|
int? _fileSize;
|
||||||
String? _lyrics; // Cleaned lyrics for display (no timestamps)
|
String? _lyrics;
|
||||||
String? _rawLyrics; // Raw LRC with timestamps for embedding
|
String? _rawLyrics;
|
||||||
bool _lyricsLoading = false;
|
bool _lyricsLoading = false;
|
||||||
String? _lyricsError;
|
String? _lyricsError;
|
||||||
String? _lyricsSource;
|
String? _lyricsSource;
|
||||||
bool _showTitleInAppBar = false;
|
bool _showTitleInAppBar = false;
|
||||||
bool _lyricsEmbedded = false;
|
bool _lyricsEmbedded = false;
|
||||||
bool _isEmbedding = false; // Track embed operation in progress
|
bool _isEmbedding = false;
|
||||||
bool _isInstrumental = false;
|
bool _isInstrumental = false;
|
||||||
bool _isConverting = false; // Track convert operation in progress
|
bool _isConverting = false;
|
||||||
bool _hasMetadataChanges = false;
|
bool _hasMetadataChanges = false;
|
||||||
bool _hasLoadedResolvedAudioMetadata = false;
|
bool _hasLoadedResolvedAudioMetadata = false;
|
||||||
Map<String, dynamic>? _editedMetadata; // Overrides after metadata edit
|
Map<String, dynamic>? _editedMetadata;
|
||||||
String? _embeddedCoverPreviewPath;
|
String? _embeddedCoverPreviewPath;
|
||||||
final ScrollController _scrollController = ScrollController();
|
final ScrollController _scrollController = ScrollController();
|
||||||
static final RegExp _lrcTimestampPattern = RegExp(
|
static final RegExp _lrcTimestampPattern = RegExp(
|
||||||
@@ -307,12 +308,10 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
storedQuality: _quality,
|
storedQuality: _quality,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Fill in album name from file tags if stored value is empty
|
|
||||||
final needsAlbum =
|
final needsAlbum =
|
||||||
resolvedAlbum != null &&
|
resolvedAlbum != null &&
|
||||||
resolvedAlbum.isNotEmpty &&
|
resolvedAlbum.isNotEmpty &&
|
||||||
(albumName.isEmpty);
|
(albumName.isEmpty);
|
||||||
// Fill in duration from file if stored value is missing/zero
|
|
||||||
final needsDuration =
|
final needsDuration =
|
||||||
resolvedDuration != null &&
|
resolvedDuration != null &&
|
||||||
resolvedDuration > 0 &&
|
resolvedDuration > 0 &&
|
||||||
@@ -519,6 +518,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
|
|
||||||
String get _filePath =>
|
String get _filePath =>
|
||||||
_isLocalItem ? _localLibraryItem!.filePath : _downloadItem!.filePath;
|
_isLocalItem ? _localLibraryItem!.filePath : _downloadItem!.filePath;
|
||||||
|
String get _coverHeroTag =>
|
||||||
|
_isLocalItem ? 'cover_lib_$_itemId' : 'cover_$_itemId';
|
||||||
String? get _coverUrl =>
|
String? get _coverUrl =>
|
||||||
_isLocalItem ? null : normalizeRemoteHttpUrl(_downloadItem!.coverUrl);
|
_isLocalItem ? null : normalizeRemoteHttpUrl(_downloadItem!.coverUrl);
|
||||||
String? get _localCoverPath =>
|
String? get _localCoverPath =>
|
||||||
@@ -527,7 +528,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
String get _service => _isLocalItem ? 'local' : _downloadItem!.service;
|
String get _service => _isLocalItem ? 'local' : _downloadItem!.service;
|
||||||
DateTime get _addedAt {
|
DateTime get _addedAt {
|
||||||
if (_isLocalItem) {
|
if (_isLocalItem) {
|
||||||
// Use file modification time if available, otherwise fall back to scannedAt
|
|
||||||
final modTime = _localLibraryItem!.fileModTime;
|
final modTime = _localLibraryItem!.fileModTime;
|
||||||
if (modTime != null && modTime > 0) {
|
if (modTime != null && modTime > 0) {
|
||||||
return DateTime.fromMillisecondsSinceEpoch(modTime);
|
return DateTime.fromMillisecondsSinceEpoch(modTime);
|
||||||
@@ -577,7 +577,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
String get cleanFilePath {
|
String get cleanFilePath {
|
||||||
var path = _filePath;
|
var path = _filePath;
|
||||||
if (path.startsWith('EXISTS:')) path = path.substring(7);
|
if (path.startsWith('EXISTS:')) path = path.substring(7);
|
||||||
// Strip CUE virtual path suffix for filesystem operations
|
|
||||||
if (isCueVirtualPath(path)) path = stripCueTrackSuffix(path);
|
if (isCueVirtualPath(path)) path = stripCueTrackSuffix(path);
|
||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
@@ -770,6 +769,11 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
|
|
||||||
_buildLyricsCard(context, colorScheme),
|
_buildLyricsCard(context, colorScheme),
|
||||||
|
|
||||||
|
if (_fileExists) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
AudioAnalysisCard(filePath: _filePath),
|
||||||
|
],
|
||||||
|
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
_buildActionButtons(context, ref, colorScheme, _fileExists),
|
_buildActionButtons(context, ref, colorScheme, _fileExists),
|
||||||
@@ -790,38 +794,42 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
double expandedHeight,
|
double expandedHeight,
|
||||||
bool showContent,
|
bool showContent,
|
||||||
) {
|
) {
|
||||||
return Stack(
|
final coverChild = _hasPath(_embeddedCoverPreviewPath)
|
||||||
fit: StackFit.expand,
|
? Image.file(
|
||||||
children: [
|
|
||||||
if (_hasPath(_embeddedCoverPreviewPath))
|
|
||||||
Image.file(
|
|
||||||
File(_embeddedCoverPreviewPath!),
|
File(_embeddedCoverPreviewPath!),
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
errorBuilder: (_, _, _) => Container(color: colorScheme.surface),
|
errorBuilder: (_, _, _) => Container(color: colorScheme.surface),
|
||||||
)
|
)
|
||||||
else if (_coverUrl != null)
|
: _coverUrl != null
|
||||||
CachedNetworkImage(
|
? CachedNetworkImage(
|
||||||
imageUrl: _coverUrl!,
|
imageUrl: _coverUrl!,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
cacheManager: CoverCacheManager.instance,
|
cacheManager: CoverCacheManager.instance,
|
||||||
placeholder: (_, _) => Container(color: colorScheme.surface),
|
placeholder: (_, _) => Container(color: colorScheme.surface),
|
||||||
errorWidget: (_, _, _) => Container(color: colorScheme.surface),
|
errorWidget: (_, _, _) => Container(color: colorScheme.surface),
|
||||||
)
|
)
|
||||||
else if (_localCoverPath != null && _localCoverPath!.isNotEmpty)
|
: _localCoverPath != null && _localCoverPath!.isNotEmpty
|
||||||
Image.file(
|
? Image.file(
|
||||||
File(_localCoverPath!),
|
File(_localCoverPath!),
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
errorBuilder: (_, _, _) => Container(color: colorScheme.surface),
|
errorBuilder: (_, _, _) => Container(color: colorScheme.surface),
|
||||||
)
|
)
|
||||||
else
|
: Container(
|
||||||
Container(
|
|
||||||
color: colorScheme.surfaceContainerHighest,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
child: Icon(
|
child: Icon(
|
||||||
Icons.music_note,
|
Icons.music_note,
|
||||||
size: 80,
|
size: 80,
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
|
|
||||||
|
return Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
Hero(
|
||||||
|
tag: _coverHeroTag,
|
||||||
|
child: Material(color: Colors.transparent, child: coverChild),
|
||||||
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
@@ -1614,7 +1622,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Show "Embed Lyrics" button if lyrics are from online (not already embedded)
|
|
||||||
if (!_lyricsEmbedded && _fileExists) ...[
|
if (!_lyricsEmbedded && _fileExists) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Center(
|
Center(
|
||||||
@@ -1662,7 +1669,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
try {
|
try {
|
||||||
final durationMs = (duration ?? 0) * 1000;
|
final durationMs = (duration ?? 0) * 1000;
|
||||||
|
|
||||||
// First, check if lyrics are embedded in the file
|
|
||||||
if (_fileExists) {
|
if (_fileExists) {
|
||||||
final embeddedResult =
|
final embeddedResult =
|
||||||
await PlatformBridge.getLyricsLRCWithSource(
|
await PlatformBridge.getLyricsLRCWithSource(
|
||||||
@@ -1696,12 +1702,11 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// No embedded lyrics, fetch from online
|
|
||||||
final result = await PlatformBridge.getLyricsLRCWithSource(
|
final result = await PlatformBridge.getLyricsLRCWithSource(
|
||||||
_spotifyId ?? '',
|
_spotifyId ?? '',
|
||||||
trackName,
|
trackName,
|
||||||
artistName,
|
artistName,
|
||||||
filePath: null, // Don't check file again
|
filePath: null,
|
||||||
durationMs: durationMs,
|
durationMs: durationMs,
|
||||||
).timeout(const Duration(seconds: 20));
|
).timeout(const Duration(seconds: 20));
|
||||||
|
|
||||||
@@ -1727,9 +1732,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
final cleanLyrics = _cleanLrcForDisplay(lrcText);
|
final cleanLyrics = _cleanLrcForDisplay(lrcText);
|
||||||
setState(() {
|
setState(() {
|
||||||
_lyrics = cleanLyrics;
|
_lyrics = cleanLyrics;
|
||||||
_rawLyrics = lrcText; // Keep raw LRC with timestamps for embedding
|
_rawLyrics = lrcText;
|
||||||
_lyricsSource = source.isNotEmpty ? source : null;
|
_lyricsSource = source.isNotEmpty ? source : null;
|
||||||
_lyricsEmbedded = false; // Lyrics from online, not embedded
|
_lyricsEmbedded = false;
|
||||||
_lyricsLoading = false;
|
_lyricsLoading = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1756,7 +1761,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
|
|
||||||
setState(() => _isEmbedding = true);
|
setState(() => _isEmbedding = true);
|
||||||
|
|
||||||
// Capture l10n strings before async gaps to avoid use_build_context_synchronously
|
|
||||||
final l10nFailedToWriteStorage = context.l10n.snackbarFailedToWriteStorage;
|
final l10nFailedToWriteStorage = context.l10n.snackbarFailedToWriteStorage;
|
||||||
final l10nFailedToEmbedLyrics = context.l10n.snackbarFailedToEmbedLyrics;
|
final l10nFailedToEmbedLyrics = context.l10n.snackbarFailedToEmbedLyrics;
|
||||||
final l10nUnsupportedFormat = context.l10n.snackbarUnsupportedAudioFormat;
|
final l10nUnsupportedFormat = context.l10n.snackbarUnsupportedAudioFormat;
|
||||||
@@ -1986,7 +1990,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write temp file to SAF tree
|
|
||||||
final treeUri = _downloadItem?.downloadTreeUri;
|
final treeUri = _downloadItem?.downloadTreeUri;
|
||||||
final relativeDir = _downloadItem?.safRelativeDir ?? '';
|
final relativeDir = _downloadItem?.safRelativeDir ?? '';
|
||||||
if (treeUri != null && treeUri.isNotEmpty) {
|
if (treeUri != null && treeUri.isNotEmpty) {
|
||||||
@@ -2033,7 +2036,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regular file path
|
|
||||||
final dir = _getFileDirectory();
|
final dir = _getFileDirectory();
|
||||||
final outputPath = '$dir${Platform.pathSeparator}$baseName.jpg';
|
final outputPath = '$dir${Platform.pathSeparator}$baseName.jpg';
|
||||||
|
|
||||||
@@ -2126,7 +2128,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write temp file to SAF tree
|
|
||||||
final treeUri = _downloadItem?.downloadTreeUri;
|
final treeUri = _downloadItem?.downloadTreeUri;
|
||||||
final relativeDir = _downloadItem?.safRelativeDir ?? '';
|
final relativeDir = _downloadItem?.safRelativeDir ?? '';
|
||||||
if (treeUri != null && treeUri.isNotEmpty) {
|
if (treeUri != null && treeUri.isNotEmpty) {
|
||||||
@@ -2182,7 +2183,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regular file path
|
|
||||||
final dir = _getFileDirectory();
|
final dir = _getFileDirectory();
|
||||||
final outputPath = '$dir${Platform.pathSeparator}$baseName.lrc';
|
final outputPath = '$dir${Platform.pathSeparator}$baseName.lrc';
|
||||||
|
|
||||||
@@ -2257,7 +2257,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
final result = await PlatformBridge.reEnrichFile(request);
|
final result = await PlatformBridge.reEnrichFile(request);
|
||||||
final method = result['method'] as String?;
|
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>?;
|
final enriched = result['enriched_metadata'] as Map<String, dynamic>?;
|
||||||
if (enriched != null && mounted) {
|
if (enriched != null && mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -2344,7 +2343,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// For SAF files, copy processed temp file back
|
|
||||||
if (ffmpegResult != null && tempPath != null && safUri != null) {
|
if (ffmpegResult != null && tempPath != null && safUri != null) {
|
||||||
final ok = await PlatformBridge.writeTempToSaf(ffmpegResult, safUri);
|
final ok = await PlatformBridge.writeTempToSaf(ffmpegResult, safUri);
|
||||||
if (!ok && mounted) {
|
if (!ok && mounted) {
|
||||||
@@ -2357,7 +2355,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
// Cleanup temp files
|
|
||||||
if (_hasPath(downloadedCoverPath)) {
|
if (_hasPath(downloadedCoverPath)) {
|
||||||
try {
|
try {
|
||||||
await File(downloadedCoverPath!).delete();
|
await File(downloadedCoverPath!).delete();
|
||||||
@@ -2375,7 +2372,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup temp files
|
|
||||||
if (tempPath != null && tempPath.isNotEmpty) {
|
if (tempPath != null && tempPath.isNotEmpty) {
|
||||||
try {
|
try {
|
||||||
await File(tempPath).delete();
|
await File(tempPath).delete();
|
||||||
@@ -2397,7 +2393,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup temp cover from Go backend
|
|
||||||
if (_hasPath(downloadedCoverPath)) {
|
if (_hasPath(downloadedCoverPath)) {
|
||||||
try {
|
try {
|
||||||
await File(downloadedCoverPath!).delete();
|
await File(downloadedCoverPath!).delete();
|
||||||
@@ -2462,7 +2457,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
for (final line in lines) {
|
for (final line in lines) {
|
||||||
var cleaned = line.trim();
|
var cleaned = line.trim();
|
||||||
|
|
||||||
// Skip metadata tags
|
|
||||||
if (_lrcMetadataPattern.hasMatch(cleaned) &&
|
if (_lrcMetadataPattern.hasMatch(cleaned) &&
|
||||||
!_lrcBackgroundLinePattern.hasMatch(cleaned)) {
|
!_lrcBackgroundLinePattern.hasMatch(cleaned)) {
|
||||||
continue;
|
continue;
|
||||||
@@ -2474,7 +2468,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
cleaned = bgMatch.group(1)?.trim() ?? '';
|
cleaned = bgMatch.group(1)?.trim() ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove line timestamp, inline word-by-word timestamps, and speaker prefix.
|
|
||||||
cleaned = cleaned.replaceAll(_lrcTimestampPattern, '').trim();
|
cleaned = cleaned.replaceAll(_lrcTimestampPattern, '').trim();
|
||||||
cleaned = cleaned.replaceAll(_lrcInlineTimestampPattern, '');
|
cleaned = cleaned.replaceAll(_lrcInlineTimestampPattern, '');
|
||||||
cleaned = cleaned.replaceFirst(_lrcSpeakerPrefixPattern, '');
|
cleaned = cleaned.replaceFirst(_lrcSpeakerPrefixPattern, '');
|
||||||
@@ -2685,11 +2678,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
|
|
||||||
/// Whether the current file is a CUE sheet (or CUE-referenced)
|
/// Whether the current file is a CUE sheet (or CUE-referenced)
|
||||||
bool get _isCueFile {
|
bool get _isCueFile {
|
||||||
// Check if the raw path has a CUE virtual path suffix
|
|
||||||
if (isCueVirtualPath(rawFilePath)) return true;
|
if (isCueVirtualPath(rawFilePath)) return true;
|
||||||
final lower = cleanFilePath.toLowerCase();
|
final lower = cleanFilePath.toLowerCase();
|
||||||
if (lower.endsWith('.cue')) return true;
|
if (lower.endsWith('.cue')) return true;
|
||||||
// Check if local library item has cue+ format
|
|
||||||
if (_isLocalItem && _localLibraryItem != null) {
|
if (_isLocalItem && _localLibraryItem != null) {
|
||||||
final format = _localLibraryItem!.format ?? '';
|
final format = _localLibraryItem!.format ?? '';
|
||||||
if (format.startsWith('cue+')) return true;
|
if (format.startsWith('cue+')) return true;
|
||||||
@@ -2815,7 +2806,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
final currentFormat = _currentFileFormat;
|
final currentFormat = _currentFileFormat;
|
||||||
final isLosslessSource = currentFormat == 'FLAC' || currentFormat == 'M4A';
|
final isLosslessSource = currentFormat == 'FLAC' || currentFormat == 'M4A';
|
||||||
|
|
||||||
// Build available target formats based on source
|
|
||||||
final formats = <String>[];
|
final formats = <String>[];
|
||||||
if (currentFormat == 'FLAC') {
|
if (currentFormat == 'FLAC') {
|
||||||
formats.addAll(['ALAC', 'MP3', 'Opus']);
|
formats.addAll(['ALAC', 'MP3', 'Opus']);
|
||||||
@@ -2906,7 +2896,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
}).toList(),
|
}).toList(),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Only show bitrate for lossy targets
|
|
||||||
if (!isLosslessTarget) ...[
|
if (!isLosslessTarget) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
@@ -2933,7 +2922,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
||||||
// Show lossless indicator
|
|
||||||
if (isLosslessTarget && isLosslessSource) ...[
|
if (isLosslessTarget && isLosslessSource) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Row(
|
Row(
|
||||||
@@ -2991,14 +2979,12 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _showCueSplitSheet(BuildContext context) async {
|
void _showCueSplitSheet(BuildContext context) async {
|
||||||
// Strip the #trackNN suffix from virtual CUE paths to get the real .cue path
|
|
||||||
var cuePath = cleanFilePath;
|
var cuePath = cleanFilePath;
|
||||||
final trackSuffix = RegExp(r'#track\d+$');
|
final trackSuffix = RegExp(r'#track\d+$');
|
||||||
if (trackSuffix.hasMatch(cuePath)) {
|
if (trackSuffix.hasMatch(cuePath)) {
|
||||||
cuePath = cuePath.replaceFirst(trackSuffix, '');
|
cuePath = cuePath.replaceFirst(trackSuffix, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show loading indicator
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text(context.l10n.snackbarLoadingCueSheet)),
|
SnackBar(content: Text(context.l10n.snackbarLoadingCueSheet)),
|
||||||
);
|
);
|
||||||
@@ -3093,7 +3079,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
// Track list preview (scrollable, max 200px)
|
|
||||||
ConstrainedBox(
|
ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxHeight: 200),
|
constraints: const BoxConstraints(maxHeight: 200),
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
@@ -3315,7 +3300,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
workingAudioPath = tempPath;
|
workingAudioPath = tempPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine output directory
|
|
||||||
final String outputDir;
|
final String outputDir;
|
||||||
final treeUri = !_isLocalItem
|
final treeUri = !_isLocalItem
|
||||||
? (_downloadItem?.downloadTreeUri ?? '')
|
? (_downloadItem?.downloadTreeUri ?? '')
|
||||||
@@ -3342,7 +3326,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
_showLongSnackBarMessage(_l10nCueSplitSplitting(1, tracks.length));
|
_showLongSnackBarMessage(_l10nCueSplitSplitting(1, tracks.length));
|
||||||
|
|
||||||
// Extract cover from audio file for embedding
|
|
||||||
String? coverPath;
|
String? coverPath;
|
||||||
try {
|
try {
|
||||||
final tempDir = await getTemporaryDirectory();
|
final tempDir = await getTemporaryDirectory();
|
||||||
@@ -3385,11 +3368,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
for (final path in finalOutputPaths) {
|
for (final path in finalOutputPaths) {
|
||||||
if (path.toLowerCase().endsWith('.flac')) {
|
if (path.toLowerCase().endsWith('.flac')) {
|
||||||
try {
|
try {
|
||||||
// Read existing metadata first
|
|
||||||
final metadata = await PlatformBridge.readFileMetadata(path);
|
final metadata = await PlatformBridge.readFileMetadata(path);
|
||||||
if (metadata['error'] == null) {
|
if (metadata['error'] == null) {
|
||||||
final fields = <String, String>{'cover_path': coverPath};
|
final fields = <String, String>{'cover_path': coverPath};
|
||||||
// Preserve existing fields
|
|
||||||
for (final entry in metadata.entries) {
|
for (final entry in metadata.entries) {
|
||||||
if (entry.key == 'error' || entry.value == null) continue;
|
if (entry.key == 'error' || entry.value == null) continue;
|
||||||
final v = entry.value.toString().trim();
|
final v = entry.value.toString().trim();
|
||||||
@@ -3415,7 +3396,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
finalOutputPaths = exportedUris;
|
finalOutputPaths = exportedUris;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup cover temp
|
|
||||||
if (coverPath != null) {
|
if (coverPath != null) {
|
||||||
try {
|
try {
|
||||||
await File(coverPath).delete();
|
await File(coverPath).delete();
|
||||||
@@ -3437,7 +3417,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
_showSnackBarMessage(_l10nCueSplitFailed);
|
_showSnackBarMessage(_l10nCueSplitFailed);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
// Cleanup SAF temp audio copy
|
|
||||||
if (safTempAudioPath != null) {
|
if (safTempAudioPath != null) {
|
||||||
try {
|
try {
|
||||||
await File(safTempAudioPath).delete();
|
await File(safTempAudioPath).delete();
|
||||||
@@ -3556,7 +3535,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
String? safTempPath;
|
String? safTempPath;
|
||||||
|
|
||||||
if (isSaf) {
|
if (isSaf) {
|
||||||
// Copy SAF file to temp for processing
|
|
||||||
safTempPath = await PlatformBridge.copyContentUriToTemp(cleanFilePath);
|
safTempPath = await PlatformBridge.copyContentUriToTemp(cleanFilePath);
|
||||||
if (safTempPath == null) {
|
if (safTempPath == null) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -3576,10 +3554,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
bitrate: bitrate,
|
bitrate: bitrate,
|
||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
coverPath: coverPath,
|
coverPath: coverPath,
|
||||||
deleteOriginal: !isSaf, // Don't delete temp copy for SAF, we handle it
|
deleteOriginal: !isSaf,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Cleanup cover temp
|
|
||||||
if (coverPath != null) {
|
if (coverPath != null) {
|
||||||
try {
|
try {
|
||||||
await File(coverPath).delete();
|
await File(coverPath).delete();
|
||||||
@@ -3587,7 +3564,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (newPath == null) {
|
if (newPath == null) {
|
||||||
// Cleanup SAF temp if needed
|
|
||||||
if (safTempPath != null) {
|
if (safTempPath != null) {
|
||||||
try {
|
try {
|
||||||
await File(safTempPath).delete();
|
await File(safTempPath).delete();
|
||||||
@@ -3649,7 +3625,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
newExt = '.flac';
|
newExt = '.flac';
|
||||||
mimeType = 'audio/flac';
|
mimeType = 'audio/flac';
|
||||||
break;
|
break;
|
||||||
default: // mp3
|
default:
|
||||||
newExt = '.mp3';
|
newExt = '.mp3';
|
||||||
mimeType = 'audio/mpeg';
|
mimeType = 'audio/mpeg';
|
||||||
break;
|
break;
|
||||||
@@ -3689,7 +3665,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
_log.w('Converted SAF file created but failed deleting original URI');
|
_log.w('Converted SAF file created but failed deleting original URI');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update history with new SAF info
|
|
||||||
if (!_isLocalItem) {
|
if (!_isLocalItem) {
|
||||||
await HistoryDatabase.instance.updateFilePath(
|
await HistoryDatabase.instance.updateFilePath(
|
||||||
_downloadItem!.id,
|
_downloadItem!.id,
|
||||||
@@ -3701,7 +3676,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
await ref.read(downloadHistoryProvider.notifier).reloadFromStorage();
|
await ref.read(downloadHistoryProvider.notifier).reloadFromStorage();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup temp files
|
|
||||||
try {
|
try {
|
||||||
await File(newPath).delete();
|
await File(newPath).delete();
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
@@ -3711,7 +3685,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Regular file: update history with new path
|
|
||||||
if (!_isLocalItem) {
|
if (!_isLocalItem) {
|
||||||
await HistoryDatabase.instance.updateFilePath(
|
await HistoryDatabase.instance.updateFilePath(
|
||||||
_downloadItem!.id,
|
_downloadItem!.id,
|
||||||
@@ -3730,7 +3703,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
content: Text(context.l10n.trackConvertSuccess(targetFormat)),
|
content: Text(context.l10n.trackConvertSuccess(targetFormat)),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
// Pop and let the caller refresh
|
|
||||||
Navigator.pop(context, true);
|
Navigator.pop(context, true);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -3748,7 +3720,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
WidgetRef ref,
|
WidgetRef ref,
|
||||||
ColorScheme colorScheme,
|
ColorScheme colorScheme,
|
||||||
) async {
|
) async {
|
||||||
// Read current metadata from file, fall back to item data on failure
|
|
||||||
Map<String, dynamic>? fileMetadata;
|
Map<String, dynamic>? fileMetadata;
|
||||||
try {
|
try {
|
||||||
final result = await PlatformBridge.readFileMetadata(cleanFilePath);
|
final result = await PlatformBridge.readFileMetadata(cleanFilePath);
|
||||||
@@ -3759,7 +3730,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
debugPrint('readFileMetadata failed, using item data: $e');
|
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) {
|
String val(String key, String? fallback) {
|
||||||
final v = fileMetadata?[key]?.toString();
|
final v = fileMetadata?[key]?.toString();
|
||||||
return (v != null && v.isNotEmpty) ? v : (fallback ?? '');
|
return (v != null && v.isNotEmpty) ? v : (fallback ?? '');
|
||||||
@@ -3805,7 +3775,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
ScaffoldMessenger.of(this.context).showSnackBar(
|
ScaffoldMessenger.of(this.context).showSnackBar(
|
||||||
SnackBar(content: Text(this.context.l10n.snackbarMetadataSaved)),
|
SnackBar(content: Text(this.context.l10n.snackbarMetadataSaved)),
|
||||||
);
|
);
|
||||||
// Re-read metadata from file to refresh the display
|
|
||||||
try {
|
try {
|
||||||
final refreshed = await PlatformBridge.readFileMetadata(cleanFilePath);
|
final refreshed = await PlatformBridge.readFileMetadata(cleanFilePath);
|
||||||
setState(() => _editedMetadata = refreshed);
|
setState(() => _editedMetadata = refreshed);
|
||||||
@@ -4050,10 +4019,8 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
|||||||
String? _currentCoverTempDir;
|
String? _currentCoverTempDir;
|
||||||
bool _loadingCurrentCover = false;
|
bool _loadingCurrentCover = false;
|
||||||
|
|
||||||
// Auto-fill field selection — which fields the user wants to fetch
|
|
||||||
final Set<String> _autoFillFields = {};
|
final Set<String> _autoFillFields = {};
|
||||||
|
|
||||||
// All auto-fillable fields and their mapping
|
|
||||||
static const _fieldDefs = <String, String>{
|
static const _fieldDefs = <String, String>{
|
||||||
'title': 'title',
|
'title': 'title',
|
||||||
'artist': 'artist',
|
'artist': 'artist',
|
||||||
@@ -4679,7 +4646,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
|||||||
throw StateError('No metadata match resolved for auto-fill');
|
throw StateError('No metadata match resolved for auto-fill');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract basic metadata from search result
|
|
||||||
final enriched = <String, String>{
|
final enriched = <String, String>{
|
||||||
'title': (selectedBest['name'] ?? '').toString(),
|
'title': (selectedBest['name'] ?? '').toString(),
|
||||||
'artist': (selectedBest['artists'] ?? selectedBest['artist'] ?? '')
|
'artist': (selectedBest['artists'] ?? selectedBest['artist'] ?? '')
|
||||||
@@ -4757,7 +4723,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
|||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
// Fetch genre/label/copyright from Deezer extended metadata
|
|
||||||
if (needsExtended && deezerId != null) {
|
if (needsExtended && deezerId != null) {
|
||||||
try {
|
try {
|
||||||
final extended = await PlatformBridge.getDeezerExtendedMetadata(
|
final extended = await PlatformBridge.getDeezerExtendedMetadata(
|
||||||
@@ -4775,10 +4740,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
|||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
// Apply selected fields to controllers
|
|
||||||
var filledCount = 0;
|
var filledCount = 0;
|
||||||
for (final key in _autoFillFields) {
|
for (final key in _autoFillFields) {
|
||||||
if (key == 'cover') continue; // Handle cover separately below
|
if (key == 'cover') continue;
|
||||||
final value = enriched[key];
|
final value = enriched[key];
|
||||||
if (value != null &&
|
if (value != null &&
|
||||||
value.isNotEmpty &&
|
value.isNotEmpty &&
|
||||||
@@ -4792,7 +4756,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle cover art download
|
|
||||||
if (_autoFillFields.contains('cover')) {
|
if (_autoFillFields.contains('cover')) {
|
||||||
final coverUrl =
|
final coverUrl =
|
||||||
(selectedBest['cover_url'] ?? selectedBest['images'] ?? '')
|
(selectedBest['cover_url'] ?? selectedBest['images'] ?? '')
|
||||||
@@ -5071,7 +5034,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For SAF files, copy the processed temp file back
|
|
||||||
if (tempPath != null && safUri != null) {
|
if (tempPath != null && safUri != null) {
|
||||||
final ok = await PlatformBridge.writeTempToSaf(ffmpegResult, safUri);
|
final ok = await PlatformBridge.writeTempToSaf(ffmpegResult, safUri);
|
||||||
if (!ok && mounted) {
|
if (!ok && mounted) {
|
||||||
@@ -5184,7 +5146,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
|||||||
),
|
),
|
||||||
_field('Genre', _genreCtrl),
|
_field('Genre', _genreCtrl),
|
||||||
_field('ISRC', _isrcCtrl),
|
_field('ISRC', _isrcCtrl),
|
||||||
// Advanced fields toggle
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 8, bottom: 4),
|
padding: const EdgeInsets.only(top: 8, bottom: 4),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
@@ -5282,7 +5243,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
// Quick select buttons
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -5302,7 +5262,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
// Field chips
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
child: Wrap(
|
child: Wrap(
|
||||||
@@ -5339,7 +5298,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
// Fetch button
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(left: 12, right: 12, bottom: 12),
|
padding: const EdgeInsets.only(left: 12, right: 12, bottom: 12),
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
|
|||||||
@@ -198,8 +198,8 @@ class CsvImportService {
|
|||||||
artistName: artistName ?? 'Unknown Artist',
|
artistName: artistName ?? 'Unknown Artist',
|
||||||
albumName: albumName ?? 'Unknown Album',
|
albumName: albumName ?? 'Unknown Album',
|
||||||
isrc: isrc,
|
isrc: isrc,
|
||||||
duration: 0, // Will be updated by enrichment later
|
duration: 0,
|
||||||
coverUrl: null, // Will be fetched by enrichment
|
coverUrl: null,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1437,7 +1437,6 @@ class FFmpegService {
|
|||||||
final cmdBuffer = StringBuffer();
|
final cmdBuffer = StringBuffer();
|
||||||
cmdBuffer.write('-i "$inputPath" ');
|
cmdBuffer.write('-i "$inputPath" ');
|
||||||
|
|
||||||
// Cover art as second input for M4A attached picture
|
|
||||||
final hasCover =
|
final hasCover =
|
||||||
coverPath != null &&
|
coverPath != null &&
|
||||||
coverPath.trim().isNotEmpty &&
|
coverPath.trim().isNotEmpty &&
|
||||||
@@ -1455,7 +1454,6 @@ class FFmpegService {
|
|||||||
cmdBuffer.write('-c:a alac ');
|
cmdBuffer.write('-c:a alac ');
|
||||||
cmdBuffer.write('-map_metadata -1 ');
|
cmdBuffer.write('-map_metadata -1 ');
|
||||||
|
|
||||||
// Embed M4A metadata tags
|
|
||||||
final m4aTags = _convertToM4aTags(metadata);
|
final m4aTags = _convertToM4aTags(metadata);
|
||||||
for (final entry in m4aTags.entries) {
|
for (final entry in m4aTags.entries) {
|
||||||
final sanitized = entry.value.replaceAll('"', '\\"');
|
final sanitized = entry.value.replaceAll('"', '\\"');
|
||||||
@@ -1764,7 +1762,6 @@ class FFmpegService {
|
|||||||
|
|
||||||
final outputPaths = <String>[];
|
final outputPaths = <String>[];
|
||||||
final inputExt = audioPath.toLowerCase().split('.').last;
|
final inputExt = audioPath.toLowerCase().split('.').last;
|
||||||
// For lossless formats, keep as FLAC; for others, keep original format
|
|
||||||
final outputExt =
|
final outputExt =
|
||||||
(inputExt == 'flac' ||
|
(inputExt == 'flac' ||
|
||||||
inputExt == 'wav' ||
|
inputExt == 'wav' ||
|
||||||
@@ -1836,14 +1833,10 @@ class FFmpegService {
|
|||||||
final result = await _execute(command);
|
final result = await _execute(command);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
_log.e('CUE split failed for track ${track.number}: ${result.output}');
|
_log.e('CUE split failed for track ${track.number}: ${result.output}');
|
||||||
// Continue with remaining tracks instead of failing completely
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Embed cover art if available (for FLAC output)
|
|
||||||
if (coverPath != null && coverPath.isNotEmpty && outputExt == 'flac') {
|
if (coverPath != null && coverPath.isNotEmpty && outputExt == 'flac') {
|
||||||
// Use the Go backend for FLAC cover embedding via PlatformBridge
|
|
||||||
// (handled by the caller)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
outputPaths.add(outputPath);
|
outputPaths.add(outputPath);
|
||||||
|
|||||||
@@ -328,6 +328,20 @@ class HistoryDatabase {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> upsertBatch(List<Map<String, dynamic>> items) async {
|
||||||
|
if (items.isEmpty) return;
|
||||||
|
final db = await database;
|
||||||
|
final batch = db.batch();
|
||||||
|
for (final json in items) {
|
||||||
|
batch.insert(
|
||||||
|
'history',
|
||||||
|
_jsonToDbRow(json),
|
||||||
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await batch.commit(noResult: true);
|
||||||
|
}
|
||||||
|
|
||||||
/// Get all history items ordered by download date (newest first)
|
/// Get all history items ordered by download date (newest first)
|
||||||
Future<List<Map<String, dynamic>>> getAll({int? limit, int? offset}) async {
|
Future<List<Map<String, dynamic>>> getAll({int? limit, int? offset}) async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
@@ -532,6 +546,29 @@ class HistoryDatabase {
|
|||||||
return rows.map((r) => Map<String, dynamic>.from(r)).toList();
|
return rows.map((r) => Map<String, dynamic>.from(r)).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<List<Map<String, dynamic>>> getEntriesWithPathsPage({
|
||||||
|
required int limit,
|
||||||
|
int offset = 0,
|
||||||
|
}) async {
|
||||||
|
final db = await database;
|
||||||
|
final rows = await db.query(
|
||||||
|
'history',
|
||||||
|
columns: [
|
||||||
|
'id',
|
||||||
|
'file_path',
|
||||||
|
'storage_mode',
|
||||||
|
'download_tree_uri',
|
||||||
|
'saf_relative_dir',
|
||||||
|
'saf_file_name',
|
||||||
|
],
|
||||||
|
where: 'file_path IS NOT NULL AND file_path != ""',
|
||||||
|
orderBy: 'downloaded_at DESC, id DESC',
|
||||||
|
limit: limit,
|
||||||
|
offset: offset,
|
||||||
|
);
|
||||||
|
return rows.map((r) => Map<String, dynamic>.from(r)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
/// Delete multiple entries by IDs
|
/// Delete multiple entries by IDs
|
||||||
Future<int> deleteByIds(List<String> ids) async {
|
Future<int> deleteByIds(List<String> ids) async {
|
||||||
if (ids.isEmpty) return 0;
|
if (ids.isEmpty) return 0;
|
||||||
|
|||||||
@@ -255,20 +255,41 @@ class LibraryDatabase {
|
|||||||
Future<void> upsertBatch(List<Map<String, dynamic>> items) async {
|
Future<void> upsertBatch(List<Map<String, dynamic>> items) async {
|
||||||
if (items.isEmpty) return;
|
if (items.isEmpty) return;
|
||||||
final db = await database;
|
final db = await database;
|
||||||
final batch = db.batch();
|
await db.transaction((txn) async {
|
||||||
|
final batch = txn.batch();
|
||||||
for (final json in items) {
|
for (final json in items) {
|
||||||
batch.insert(
|
batch.insert(
|
||||||
'library',
|
'library',
|
||||||
_jsonToDbRow(json),
|
_jsonToDbRow(json),
|
||||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
await batch.commit(noResult: true);
|
||||||
await batch.commit(noResult: true);
|
});
|
||||||
_log.i('Batch inserted ${items.length} items');
|
_log.i('Batch inserted ${items.length} items');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> replaceAll(List<Map<String, dynamic>> items) async {
|
||||||
|
final db = await database;
|
||||||
|
await db.transaction((txn) async {
|
||||||
|
await txn.delete('library');
|
||||||
|
if (items.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final batch = txn.batch();
|
||||||
|
for (final json in items) {
|
||||||
|
batch.insert(
|
||||||
|
'library',
|
||||||
|
_jsonToDbRow(json),
|
||||||
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await batch.commit(noResult: true);
|
||||||
|
});
|
||||||
|
_log.i('Replaced library with ${items.length} items');
|
||||||
|
}
|
||||||
|
|
||||||
Future<List<Map<String, dynamic>>> getAll({int? limit, int? offset}) async {
|
Future<List<Map<String, dynamic>>> getAll({int? limit, int? offset}) async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
final rows = await db.query(
|
final rows = await db.query(
|
||||||
|
|||||||
@@ -83,24 +83,18 @@ class PlatformBridge {
|
|||||||
|
|
||||||
static Future<Map<String, dynamic>> getDownloadProgress() async {
|
static Future<Map<String, dynamic>> getDownloadProgress() async {
|
||||||
final result = await _channel.invokeMethod('getDownloadProgress');
|
final result = await _channel.invokeMethod('getDownloadProgress');
|
||||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
return _decodeMapResult(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<Map<String, dynamic>> getAllDownloadProgress() async {
|
static Future<Map<String, dynamic>> getAllDownloadProgress() async {
|
||||||
final result = await _channel.invokeMethod('getAllDownloadProgress');
|
final result = await _channel.invokeMethod('getAllDownloadProgress');
|
||||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
return _decodeMapResult(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Stream<Map<String, dynamic>> downloadProgressStream() {
|
static Stream<Map<String, dynamic>> downloadProgressStream() {
|
||||||
return _downloadProgressEvents.receiveBroadcastStream().map((event) {
|
return _downloadProgressEvents.receiveBroadcastStream().map(
|
||||||
if (event is String) {
|
_decodeMapResult,
|
||||||
return jsonDecode(event) as Map<String, dynamic>;
|
);
|
||||||
}
|
|
||||||
if (event is Map) {
|
|
||||||
return Map<String, dynamic>.from(event);
|
|
||||||
}
|
|
||||||
return const <String, dynamic>{};
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> exitApp() async {
|
static Future<void> exitApp() async {
|
||||||
@@ -1087,7 +1081,6 @@ class PlatformBridge {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the directory for caching extracted cover art
|
|
||||||
static Future<void> setLibraryCoverCacheDir(String cacheDir) async {
|
static Future<void> setLibraryCoverCacheDir(String cacheDir) async {
|
||||||
_log.i('setLibraryCoverCacheDir: $cacheDir');
|
_log.i('setLibraryCoverCacheDir: $cacheDir');
|
||||||
await _channel.invokeMethod('setLibraryCoverCacheDir', {
|
await _channel.invokeMethod('setLibraryCoverCacheDir', {
|
||||||
@@ -1095,8 +1088,6 @@ class PlatformBridge {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Scan a folder for audio files and read their metadata
|
|
||||||
/// Returns a list of track metadata
|
|
||||||
static Future<List<Map<String, dynamic>>> scanLibraryFolder(
|
static Future<List<Map<String, dynamic>>> scanLibraryFolder(
|
||||||
String folderPath,
|
String folderPath,
|
||||||
) async {
|
) async {
|
||||||
@@ -1108,10 +1099,6 @@ class PlatformBridge {
|
|||||||
return list.map((e) => e as Map<String, dynamic>).toList();
|
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Perform an incremental scan of the library folder
|
|
||||||
/// Only scans files that are new or have changed since last scan
|
|
||||||
/// [existingFiles] is a map of filePath -> modTime (unix millis)
|
|
||||||
/// Returns IncrementalScanResult with scanned items, deleted paths, and skip count
|
|
||||||
static Future<Map<String, dynamic>> scanLibraryFolderIncremental(
|
static Future<Map<String, dynamic>> scanLibraryFolderIncremental(
|
||||||
String folderPath,
|
String folderPath,
|
||||||
Map<String, int> existingFiles,
|
Map<String, int> existingFiles,
|
||||||
@@ -1146,8 +1133,6 @@ class PlatformBridge {
|
|||||||
return list.map((e) => e as Map<String, dynamic>).toList();
|
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Incremental SAF tree scan - only scans new or modified files
|
|
||||||
/// Returns a map with 'files' (new/changed) and 'removedUris' (deleted files)
|
|
||||||
static Future<Map<String, dynamic>> scanSafTreeIncremental(
|
static Future<Map<String, dynamic>> scanSafTreeIncremental(
|
||||||
String treeUri,
|
String treeUri,
|
||||||
Map<String, int> existingFiles,
|
Map<String, int> existingFiles,
|
||||||
@@ -1173,8 +1158,6 @@ class PlatformBridge {
|
|||||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get last-modified timestamps for a list of SAF file URIs.
|
|
||||||
/// Returns map uri -> modTime (unix millis), only for files that still exist.
|
|
||||||
static Future<Map<String, int>> getSafFileModTimes(List<String> uris) async {
|
static Future<Map<String, int>> getSafFileModTimes(List<String> uris) async {
|
||||||
final result = await _channel.invokeMethod('getSafFileModTimes', {
|
final result = await _channel.invokeMethod('getSafFileModTimes', {
|
||||||
'uris': jsonEncode(uris),
|
'uris': jsonEncode(uris),
|
||||||
@@ -1183,29 +1166,35 @@ class PlatformBridge {
|
|||||||
return map.map((key, value) => MapEntry(key, (value as num).toInt()));
|
return map.map((key, value) => MapEntry(key, (value as num).toInt()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get current library scan progress
|
|
||||||
static Future<Map<String, dynamic>> getLibraryScanProgress() async {
|
static Future<Map<String, dynamic>> getLibraryScanProgress() async {
|
||||||
final result = await _channel.invokeMethod('getLibraryScanProgress');
|
final result = await _channel.invokeMethod('getLibraryScanProgress');
|
||||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
return _decodeMapResult(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Stream<Map<String, dynamic>> libraryScanProgressStream() {
|
static Stream<Map<String, dynamic>> libraryScanProgressStream() {
|
||||||
return _libraryScanProgressEvents.receiveBroadcastStream().map((event) {
|
return _libraryScanProgressEvents.receiveBroadcastStream().map(
|
||||||
if (event is String) {
|
_decodeMapResult,
|
||||||
return jsonDecode(event) as Map<String, dynamic>;
|
);
|
||||||
}
|
|
||||||
if (event is Map) {
|
|
||||||
return Map<String, dynamic>.from(event);
|
|
||||||
}
|
|
||||||
return const <String, dynamic>{};
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cancel ongoing library scan
|
|
||||||
static Future<void> cancelLibraryScan() async {
|
static Future<void> cancelLibraryScan() async {
|
||||||
await _channel.invokeMethod('cancelLibraryScan');
|
await _channel.invokeMethod('cancelLibraryScan');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Map<String, dynamic> _decodeMapResult(dynamic result) {
|
||||||
|
if (result is Map) {
|
||||||
|
return Map<String, dynamic>.from(result);
|
||||||
|
}
|
||||||
|
if (result is String) {
|
||||||
|
if (result.isEmpty) return const <String, dynamic>{};
|
||||||
|
final decoded = jsonDecode(result);
|
||||||
|
if (decoded is Map) {
|
||||||
|
return Map<String, dynamic>.from(decoded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return const <String, dynamic>{};
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - iOS Security-Scoped Bookmark
|
// MARK: - iOS Security-Scoped Bookmark
|
||||||
|
|
||||||
/// Create a security-scoped bookmark from a filesystem path picked by
|
/// Create a security-scoped bookmark from a filesystem path picked by
|
||||||
@@ -1247,7 +1236,6 @@ class PlatformBridge {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read metadata from a single audio file
|
|
||||||
static Future<Map<String, dynamic>?> readAudioMetadata(
|
static Future<Map<String, dynamic>?> readAudioMetadata(
|
||||||
String filePath,
|
String filePath,
|
||||||
) async {
|
) async {
|
||||||
@@ -1367,10 +1355,6 @@ class PlatformBridge {
|
|||||||
await _channel.invokeMethod('clearStoreCache');
|
await _channel.invokeMethod('clearStoreCache');
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse a .cue file and return split information (track listing, timing, metadata).
|
|
||||||
/// Returns a map with: cue_path, audio_path, album, artist, genre, date, tracks[]
|
|
||||||
/// Each track has: number, title, artist, isrc, composer, start_sec, end_sec
|
|
||||||
/// [audioDir] optionally overrides the directory for audio file resolution (used for SAF).
|
|
||||||
static Future<Map<String, dynamic>> parseCueSheet(
|
static Future<Map<String, dynamic>> parseCueSheet(
|
||||||
String cuePath, {
|
String cuePath, {
|
||||||
String audioDir = '',
|
String audioDir = '',
|
||||||
|
|||||||
@@ -80,7 +80,6 @@ class ShareIntentService {
|
|||||||
bool isInitial = false,
|
bool isInitial = false,
|
||||||
}) {
|
}) {
|
||||||
for (final file in files) {
|
for (final file in files) {
|
||||||
// Check both path and message - apps may share URL in either field
|
|
||||||
final textsToCheck = [file.path, if (file.message != null) file.message!];
|
final textsToCheck = [file.path, if (file.message != null) file.message!];
|
||||||
|
|
||||||
for (final textToCheck in textsToCheck) {
|
for (final textToCheck in textsToCheck) {
|
||||||
@@ -100,13 +99,11 @@ class ShareIntentService {
|
|||||||
String? _extractMusicUrl(String text) {
|
String? _extractMusicUrl(String text) {
|
||||||
if (text.isEmpty) return null;
|
if (text.isEmpty) return null;
|
||||||
|
|
||||||
// Try Spotify URI first
|
|
||||||
final uriMatch = _spotifyUriPattern.firstMatch(text);
|
final uriMatch = _spotifyUriPattern.firstMatch(text);
|
||||||
if (uriMatch != null) {
|
if (uriMatch != null) {
|
||||||
return uriMatch.group(0);
|
return uriMatch.group(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try all URL patterns
|
|
||||||
final patterns = [
|
final patterns = [
|
||||||
_spotifyUrlPattern,
|
_spotifyUrlPattern,
|
||||||
_deezerUrlPattern,
|
_deezerUrlPattern,
|
||||||
|
|||||||
@@ -89,9 +89,7 @@ class AppTheme {
|
|||||||
|
|
||||||
static CardThemeData _cardTheme(ColorScheme scheme) => CardThemeData(
|
static CardThemeData _cardTheme(ColorScheme scheme) => CardThemeData(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
),
|
|
||||||
color: scheme.surfaceContainerLow,
|
color: scheme.surfaceContainerLow,
|
||||||
surfaceTintColor: scheme.surfaceTint,
|
surfaceTintColor: scheme.surfaceTint,
|
||||||
);
|
);
|
||||||
@@ -148,9 +146,7 @@ class AppTheme {
|
|||||||
static InputDecorationTheme _inputDecorationTheme(ColorScheme scheme) =>
|
static InputDecorationTheme _inputDecorationTheme(ColorScheme scheme) =>
|
||||||
InputDecorationTheme(
|
InputDecorationTheme(
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: scheme.surfaceContainerHighest.withValues(
|
fillColor: scheme.surfaceContainerHighest.withValues(alpha: 0.3),
|
||||||
alpha: 0.3,
|
|
||||||
),
|
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
borderSide: BorderSide.none,
|
borderSide: BorderSide.none,
|
||||||
@@ -175,9 +171,7 @@ class AppTheme {
|
|||||||
|
|
||||||
static ListTileThemeData _listTileTheme(ColorScheme scheme) =>
|
static ListTileThemeData _listTileTheme(ColorScheme scheme) =>
|
||||||
ListTileThemeData(
|
ListTileThemeData(
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
),
|
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -237,7 +231,7 @@ class AppTheme {
|
|||||||
);
|
);
|
||||||
|
|
||||||
static ChipThemeData _chipTheme(ColorScheme scheme) => ChipThemeData(
|
static ChipThemeData _chipTheme(ColorScheme scheme) => ChipThemeData(
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||||
backgroundColor: scheme.surfaceContainerLow,
|
backgroundColor: scheme.surfaceContainerLow,
|
||||||
selectedColor: scheme.secondaryContainer,
|
selectedColor: scheme.secondaryContainer,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ Future<void> navigateToArtist(
|
|||||||
|
|
||||||
final normalizedArtistId = _normalizeArtistId(artistId);
|
final normalizedArtistId = _normalizeArtistId(artistId);
|
||||||
|
|
||||||
// If we have a valid artist ID already, navigate directly
|
|
||||||
if (normalizedArtistId != null &&
|
if (normalizedArtistId != null &&
|
||||||
_canNavigateArtistDirectly(
|
_canNavigateArtistDirectly(
|
||||||
artistId: normalizedArtistId,
|
artistId: normalizedArtistId,
|
||||||
@@ -43,7 +42,6 @@ Future<void> navigateToArtist(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search Deezer to resolve the artist ID
|
|
||||||
_showLoadingSnackBar(context, 'Looking up artist...');
|
_showLoadingSnackBar(context, 'Looking up artist...');
|
||||||
try {
|
try {
|
||||||
final results = await PlatformBridge.searchDeezerAll(
|
final results = await PlatformBridge.searchDeezerAll(
|
||||||
@@ -60,7 +58,6 @@ Future<void> navigateToArtist(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find best match - prefer exact name match (case-insensitive)
|
|
||||||
Map<String, dynamic>? bestMatch;
|
Map<String, dynamic>? bestMatch;
|
||||||
final lowerName = artistName.toLowerCase().trim();
|
final lowerName = artistName.toLowerCase().trim();
|
||||||
for (final a in artistList) {
|
for (final a in artistList) {
|
||||||
@@ -113,7 +110,6 @@ Future<void> navigateToAlbum(
|
|||||||
}) async {
|
}) async {
|
||||||
if (albumName.isEmpty) return;
|
if (albumName.isEmpty) return;
|
||||||
|
|
||||||
// If we have a valid album ID already, navigate directly
|
|
||||||
if (albumId != null &&
|
if (albumId != null &&
|
||||||
albumId.isNotEmpty &&
|
albumId.isNotEmpty &&
|
||||||
albumId != 'unknown' &&
|
albumId != 'unknown' &&
|
||||||
@@ -128,16 +124,13 @@ Future<void> navigateToAlbum(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If it's extension-based content without an ID, can't search Deezer for it
|
|
||||||
if (extensionId != null) {
|
if (extensionId != null) {
|
||||||
_showUnavailable(context, 'Album');
|
_showUnavailable(context, 'Album');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search Deezer to resolve the album ID
|
|
||||||
_showLoadingSnackBar(context, 'Looking up album...');
|
_showLoadingSnackBar(context, 'Looking up album...');
|
||||||
try {
|
try {
|
||||||
// Build search query: "albumName artistName" for better accuracy
|
|
||||||
final query = artistName != null && artistName.isNotEmpty
|
final query = artistName != null && artistName.isNotEmpty
|
||||||
? '$albumName $artistName'
|
? '$albumName $artistName'
|
||||||
: albumName;
|
: albumName;
|
||||||
@@ -156,7 +149,6 @@ Future<void> navigateToAlbum(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find best match - prefer exact name match (case-insensitive)
|
|
||||||
Map<String, dynamic>? bestMatch;
|
Map<String, dynamic>? bestMatch;
|
||||||
final lowerName = albumName.toLowerCase().trim();
|
final lowerName = albumName.toLowerCase().trim();
|
||||||
for (final a in albumList) {
|
for (final a in albumList) {
|
||||||
@@ -225,11 +217,19 @@ void _pushAlbumScreen(
|
|||||||
String? coverUrl,
|
String? coverUrl,
|
||||||
String? extensionId,
|
String? extensionId,
|
||||||
}) {
|
}) {
|
||||||
|
// Built-in providers (tidal, qobuz, deezer) use AlbumScreen which
|
||||||
|
// detects the provider from the album ID prefix. Only true JS extensions
|
||||||
|
// should use ExtensionAlbumScreen.
|
||||||
|
const builtInProviders = {'tidal', 'qobuz', 'deezer'};
|
||||||
|
final isExtension =
|
||||||
|
extensionId != null && !builtInProviders.contains(extensionId);
|
||||||
|
final resolvedExtensionId = extensionId;
|
||||||
|
|
||||||
_pushViaPreferredNavigator(
|
_pushViaPreferredNavigator(
|
||||||
context,
|
context,
|
||||||
(context) => extensionId != null
|
(context) => isExtension && resolvedExtensionId != null
|
||||||
? ExtensionAlbumScreen(
|
? ExtensionAlbumScreen(
|
||||||
extensionId: extensionId,
|
extensionId: resolvedExtensionId,
|
||||||
albumId: albumId,
|
albumId: albumId,
|
||||||
albumName: albumName,
|
albumName: albumName,
|
||||||
coverUrl: coverUrl,
|
coverUrl: coverUrl,
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ const _audioExtensions = <String>[
|
|||||||
'.aac',
|
'.aac',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const _maxPathMatchKeyCacheSize = 6000;
|
||||||
|
final Map<String, Set<String>> _pathMatchKeyCache = <String, Set<String>>{};
|
||||||
|
|
||||||
/// Strips a trailing audio extension from [path] if present.
|
/// Strips a trailing audio extension from [path] if present.
|
||||||
/// Returns the path without extension, or `null` if no known audio extension
|
/// Returns the path without extension, or `null` if no known audio extension
|
||||||
/// was found.
|
/// was found.
|
||||||
@@ -41,6 +44,11 @@ Set<String> buildPathMatchKeys(String? filePath) {
|
|||||||
|
|
||||||
final cleaned = raw.startsWith('EXISTS:') ? raw.substring(7).trim() : raw;
|
final cleaned = raw.startsWith('EXISTS:') ? raw.substring(7).trim() : raw;
|
||||||
if (cleaned.isEmpty) return const {};
|
if (cleaned.isEmpty) return const {};
|
||||||
|
final cached = _pathMatchKeyCache.remove(cleaned);
|
||||||
|
if (cached != null) {
|
||||||
|
_pathMatchKeyCache[cleaned] = cached;
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
final keys = <String>{};
|
final keys = <String>{};
|
||||||
final visited = <String>{};
|
final visited = <String>{};
|
||||||
@@ -118,7 +126,12 @@ Set<String> buildPathMatchKeys(String? filePath) {
|
|||||||
}
|
}
|
||||||
keys.addAll(extensionStrippedKeys);
|
keys.addAll(extensionStrippedKeys);
|
||||||
|
|
||||||
return keys;
|
final result = Set<String>.unmodifiable(keys);
|
||||||
|
_pathMatchKeyCache[cleaned] = result;
|
||||||
|
while (_pathMatchKeyCache.length > _maxPathMatchKeyCacheSize) {
|
||||||
|
_pathMatchKeyCache.remove(_pathMatchKeyCache.keys.first);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
Iterable<String> _androidEquivalentPaths(String path) {
|
Iterable<String> _androidEquivalentPaths(String path) {
|
||||||
|
|||||||
@@ -0,0 +1,879 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// 1. Staggered List Item – fade + slide-up entrance with index-based delay
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Wraps a child in a staggered fade-in + slide-up animation.
|
||||||
|
///
|
||||||
|
/// [index] controls the stagger delay (each item delayed by [staggerDelay]).
|
||||||
|
/// Set [animate] to false to skip the animation (e.g. when scrolling back).
|
||||||
|
class StaggeredListItem extends StatelessWidget {
|
||||||
|
static const int _defaultMaxAnimatedItems = 10;
|
||||||
|
|
||||||
|
final int index;
|
||||||
|
final Widget child;
|
||||||
|
final Duration duration;
|
||||||
|
final Duration staggerDelay;
|
||||||
|
final bool animate;
|
||||||
|
final int maxAnimatedItems;
|
||||||
|
|
||||||
|
const StaggeredListItem({
|
||||||
|
super.key,
|
||||||
|
required this.index,
|
||||||
|
required this.child,
|
||||||
|
this.duration = const Duration(milliseconds: 250),
|
||||||
|
this.staggerDelay = const Duration(milliseconds: 40),
|
||||||
|
this.animate = true,
|
||||||
|
this.maxAnimatedItems = _defaultMaxAnimatedItems,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (!animate || index >= maxAnimatedItems) return child;
|
||||||
|
// Cap the delay so very long lists don't have absurd wait times.
|
||||||
|
final cappedIndex = index.clamp(0, maxAnimatedItems - 1);
|
||||||
|
final delay = staggerDelay * cappedIndex;
|
||||||
|
final totalDuration = duration + delay;
|
||||||
|
|
||||||
|
return TweenAnimationBuilder<double>(
|
||||||
|
key: ValueKey('stagger_$index'),
|
||||||
|
tween: Tween(begin: 0.0, end: 1.0),
|
||||||
|
duration: totalDuration,
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
builder: (context, value, child) {
|
||||||
|
// Compute the effective progress after the stagger delay.
|
||||||
|
final delayFraction = totalDuration.inMilliseconds > 0
|
||||||
|
? delay.inMilliseconds / totalDuration.inMilliseconds
|
||||||
|
: 0.0;
|
||||||
|
final progress = value <= delayFraction
|
||||||
|
? 0.0
|
||||||
|
: ((value - delayFraction) / (1.0 - delayFraction)).clamp(0.0, 1.0);
|
||||||
|
return Opacity(
|
||||||
|
opacity: progress,
|
||||||
|
child: Transform.translate(
|
||||||
|
offset: Offset(0, 12 * (1 - progress)),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// 2. Animated State Switcher – crossfade between loading / content / empty / error
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// A convenience wrapper around [AnimatedSwitcher] that crossfades between
|
||||||
|
/// different widget states (loading, content, empty, error).
|
||||||
|
///
|
||||||
|
/// Assign a unique [ValueKey] to each child so the switcher detects changes.
|
||||||
|
class AnimatedStateSwitcher extends StatelessWidget {
|
||||||
|
final Widget child;
|
||||||
|
final Duration duration;
|
||||||
|
|
||||||
|
const AnimatedStateSwitcher({
|
||||||
|
super.key,
|
||||||
|
required this.child,
|
||||||
|
this.duration = const Duration(milliseconds: 250),
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AnimatedSwitcher(
|
||||||
|
duration: duration,
|
||||||
|
switchInCurve: Curves.easeOut,
|
||||||
|
switchOutCurve: Curves.easeIn,
|
||||||
|
transitionBuilder: (child, animation) {
|
||||||
|
return FadeTransition(opacity: animation, child: child);
|
||||||
|
},
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// 3. Shared Page Route – consistent slide-from-right transition
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Creates a platform-aware material route.
|
||||||
|
///
|
||||||
|
/// This intentionally defers route transitions to Flutter's material route and
|
||||||
|
/// theme so Android predictive back and platform-default animations remain
|
||||||
|
/// intact.
|
||||||
|
Route<T> slidePageRoute<T>({required Widget page}) {
|
||||||
|
return MaterialPageRoute<T>(builder: (context) => page);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// 4. Shimmer / Skeleton Loading Widget
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// A shimmer effect widget that can wrap skeleton placeholders.
|
||||||
|
class ShimmerLoading extends StatefulWidget {
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
const ShimmerLoading({super.key, required this.child});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ShimmerLoading> createState() => _ShimmerLoadingState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ShimmerLoadingState extends State<ShimmerLoading>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late AnimationController _controller;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 1200),
|
||||||
|
)..repeat();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
|
final baseColor = isDark
|
||||||
|
? Color.alphaBlend(
|
||||||
|
Colors.white.withValues(alpha: 0.08),
|
||||||
|
colorScheme.surface,
|
||||||
|
)
|
||||||
|
: Color.alphaBlend(
|
||||||
|
Colors.black.withValues(alpha: 0.10),
|
||||||
|
colorScheme.surface,
|
||||||
|
);
|
||||||
|
final highlightColor = isDark
|
||||||
|
? Color.alphaBlend(
|
||||||
|
Colors.white.withValues(alpha: 0.14),
|
||||||
|
colorScheme.surface,
|
||||||
|
)
|
||||||
|
: Color.alphaBlend(
|
||||||
|
Colors.black.withValues(alpha: 0.01),
|
||||||
|
colorScheme.surface,
|
||||||
|
);
|
||||||
|
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: _controller,
|
||||||
|
builder: (context, child) {
|
||||||
|
return ShaderMask(
|
||||||
|
shaderCallback: (bounds) {
|
||||||
|
return LinearGradient(
|
||||||
|
begin: Alignment.centerLeft,
|
||||||
|
end: Alignment.centerRight,
|
||||||
|
colors: [baseColor, highlightColor, baseColor],
|
||||||
|
stops: [
|
||||||
|
(_controller.value - 0.3).clamp(0.0, 1.0),
|
||||||
|
_controller.value,
|
||||||
|
(_controller.value + 0.3).clamp(0.0, 1.0),
|
||||||
|
],
|
||||||
|
tileMode: TileMode.clamp,
|
||||||
|
).createShader(bounds);
|
||||||
|
},
|
||||||
|
blendMode: BlendMode.srcATop,
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: widget.child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A skeleton placeholder box used inside [ShimmerLoading].
|
||||||
|
class SkeletonBox extends StatelessWidget {
|
||||||
|
final double width;
|
||||||
|
final double height;
|
||||||
|
final double borderRadius;
|
||||||
|
|
||||||
|
const SkeletonBox({
|
||||||
|
super.key,
|
||||||
|
required this.width,
|
||||||
|
required this.height,
|
||||||
|
this.borderRadius = 8,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
final color = isDark
|
||||||
|
? Color.alphaBlend(
|
||||||
|
Colors.white.withValues(alpha: 0.08),
|
||||||
|
colorScheme.surface,
|
||||||
|
)
|
||||||
|
: Color.alphaBlend(
|
||||||
|
Colors.black.withValues(alpha: 0.06),
|
||||||
|
colorScheme.surface,
|
||||||
|
);
|
||||||
|
return Container(
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color,
|
||||||
|
borderRadius: BorderRadius.circular(borderRadius),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Track list skeleton – mimics a list of track items while loading.
|
||||||
|
class TrackListSkeleton extends StatelessWidget {
|
||||||
|
final int itemCount;
|
||||||
|
final bool showCoverHeader;
|
||||||
|
|
||||||
|
const TrackListSkeleton({
|
||||||
|
super.key,
|
||||||
|
this.itemCount = 8,
|
||||||
|
this.showCoverHeader = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final screenWidth = MediaQuery.of(context).size.width;
|
||||||
|
return ShimmerLoading(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
if (showCoverHeader) ...[
|
||||||
|
SkeletonBox(
|
||||||
|
width: screenWidth,
|
||||||
|
height: screenWidth * 0.75,
|
||||||
|
borderRadius: 0,
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 16),
|
||||||
|
child: SkeletonBox(width: 180, height: 20, borderRadius: 4),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8, bottom: 20),
|
||||||
|
child: SkeletonBox(width: 110, height: 14, borderRadius: 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
...List.generate(itemCount, (index) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const SkeletonBox(width: 48, height: 48),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SkeletonBox(
|
||||||
|
width: 140 + (index % 3) * 30,
|
||||||
|
height: 14,
|
||||||
|
borderRadius: 4,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
SkeletonBox(
|
||||||
|
width: 90 + (index % 2) * 20,
|
||||||
|
height: 12,
|
||||||
|
borderRadius: 4,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SkeletonBox(width: 24, height: 24, borderRadius: 12),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Grid skeleton – mimics a grid of album/playlist cards while loading.
|
||||||
|
|
||||||
|
/// Album track list skeleton – mimics the album screen track list layout
|
||||||
|
/// (track number + title + artist + trailing icon, no cover art thumbnail).
|
||||||
|
class AlbumTrackListSkeleton extends StatelessWidget {
|
||||||
|
final int itemCount;
|
||||||
|
final bool showCoverHeader;
|
||||||
|
|
||||||
|
const AlbumTrackListSkeleton({
|
||||||
|
super.key,
|
||||||
|
this.itemCount = 10,
|
||||||
|
this.showCoverHeader = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final screenWidth = MediaQuery.of(context).size.width;
|
||||||
|
return ShimmerLoading(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
if (showCoverHeader) ...[
|
||||||
|
SkeletonBox(
|
||||||
|
width: screenWidth,
|
||||||
|
height: screenWidth * 0.75,
|
||||||
|
borderRadius: 0,
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 16),
|
||||||
|
child: SkeletonBox(width: 180, height: 20, borderRadius: 4),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8, bottom: 20),
|
||||||
|
child: SkeletonBox(width: 110, height: 14, borderRadius: 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
...List.generate(itemCount, (index) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 6,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 32,
|
||||||
|
child: Center(
|
||||||
|
child: SkeletonBox(
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
borderRadius: 4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SkeletonBox(
|
||||||
|
width: 120 + (index % 4) * 35,
|
||||||
|
height: 14,
|
||||||
|
borderRadius: 4,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
SkeletonBox(
|
||||||
|
width: 70 + (index % 3) * 20,
|
||||||
|
height: 12,
|
||||||
|
borderRadius: 4,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SkeletonBox(width: 20, height: 20, borderRadius: 10),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class GridSkeleton extends StatelessWidget {
|
||||||
|
final int itemCount;
|
||||||
|
final int crossAxisCount;
|
||||||
|
|
||||||
|
const GridSkeleton({super.key, this.itemCount = 6, this.crossAxisCount = 2});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ShimmerLoading(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: GridView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: crossAxisCount,
|
||||||
|
mainAxisSpacing: 12,
|
||||||
|
crossAxisSpacing: 12,
|
||||||
|
childAspectRatio: 0.78,
|
||||||
|
),
|
||||||
|
itemCount: itemCount,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const AspectRatio(
|
||||||
|
aspectRatio: 1,
|
||||||
|
child: SkeletonBox(width: double.infinity, height: 0),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
SkeletonBox(
|
||||||
|
width: 80 + (index % 3) * 20,
|
||||||
|
height: 12,
|
||||||
|
borderRadius: 4,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
SkeletonBox(
|
||||||
|
width: 50 + (index % 2) * 15,
|
||||||
|
height: 10,
|
||||||
|
borderRadius: 4,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Artist screen skeleton – mimics the artist page content below the header:
|
||||||
|
/// an optional "Popular" section (rank + cover 48x48 + title + trailing) then
|
||||||
|
/// a horizontal-scroll album section.
|
||||||
|
class ArtistScreenSkeleton extends StatelessWidget {
|
||||||
|
final int popularCount;
|
||||||
|
final int albumCount;
|
||||||
|
|
||||||
|
const ArtistScreenSkeleton({
|
||||||
|
super.key,
|
||||||
|
this.popularCount = 5,
|
||||||
|
this.albumCount = 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final screenWidth = MediaQuery.of(context).size.width;
|
||||||
|
return ShimmerLoading(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SkeletonBox(
|
||||||
|
width: screenWidth,
|
||||||
|
height: screenWidth * 0.75,
|
||||||
|
borderRadius: 0,
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
||||||
|
child: SkeletonBox(width: 180, height: 24, borderRadius: 4),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 4, 16, 16),
|
||||||
|
child: SkeletonBox(width: 120, height: 14, borderRadius: 4),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
|
||||||
|
child: SkeletonBox(width: 90, height: 20, borderRadius: 4),
|
||||||
|
),
|
||||||
|
...List.generate(popularCount, (index) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 24,
|
||||||
|
child: Center(
|
||||||
|
child: SkeletonBox(
|
||||||
|
width: 12,
|
||||||
|
height: 14,
|
||||||
|
borderRadius: 4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
const SkeletonBox(width: 48, height: 48, borderRadius: 4),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SkeletonBox(
|
||||||
|
width: 110 + (index % 4) * 30,
|
||||||
|
height: 14,
|
||||||
|
borderRadius: 4,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
SkeletonBox(
|
||||||
|
width: 70 + (index % 3) * 15,
|
||||||
|
height: 11,
|
||||||
|
borderRadius: 4,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SkeletonBox(width: 20, height: 20, borderRadius: 10),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
|
||||||
|
child: SkeletonBox(width: 80, height: 20, borderRadius: 4),
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
height: 190,
|
||||||
|
child: ListView.builder(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
itemCount: albumCount,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const SkeletonBox(width: 140, height: 140),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
SkeletonBox(
|
||||||
|
width: 80 + (index % 3) * 20,
|
||||||
|
height: 12,
|
||||||
|
borderRadius: 4,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
SkeletonBox(
|
||||||
|
width: 50 + (index % 2) * 15,
|
||||||
|
height: 10,
|
||||||
|
borderRadius: 4,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Home search skeleton – mimics filter chips + sectioned results
|
||||||
|
/// (Artists section with rounded card items, Albums section, etc.)
|
||||||
|
class HomeSearchSkeleton extends StatelessWidget {
|
||||||
|
const HomeSearchSkeleton({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ShimmerLoading(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
SkeletonBox(width: 48, height: 32, borderRadius: 16),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
SkeletonBox(width: 64, height: 32, borderRadius: 16),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
SkeletonBox(width: 72, height: 32, borderRadius: 16),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
SkeletonBox(width: 60, height: 32, borderRadius: 16),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
SkeletonBox(width: 70, height: 32, borderRadius: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_sectionSkeleton(context, 70, 2),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_sectionSkeleton(context, 65, 4),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Widget _sectionSkeleton(
|
||||||
|
BuildContext context,
|
||||||
|
double headerWidth,
|
||||||
|
int itemCount,
|
||||||
|
) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
SkeletonBox(width: headerWidth, height: 18, borderRadius: 4),
|
||||||
|
const Spacer(),
|
||||||
|
const SkeletonBox(width: 50, height: 16, borderRadius: 4),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: List.generate(itemCount, (index) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 10,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const SkeletonBox(width: 48, height: 48, borderRadius: 24),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SkeletonBox(
|
||||||
|
width: 100 + (index % 3) * 40,
|
||||||
|
height: 14,
|
||||||
|
borderRadius: 4,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
SkeletonBox(
|
||||||
|
width: 60 + (index % 2) * 25,
|
||||||
|
height: 12,
|
||||||
|
borderRadius: 4,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SkeletonBox(width: 20, height: 20, borderRadius: 10),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// 5. Animated Selection Checkbox – scales in when entering selection mode
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// An animated selection indicator that scales in/out and crossfades the
|
||||||
|
/// checked/unchecked state.
|
||||||
|
class AnimatedSelectionCheckbox extends StatelessWidget {
|
||||||
|
final bool visible;
|
||||||
|
final bool selected;
|
||||||
|
final ColorScheme colorScheme;
|
||||||
|
final double size;
|
||||||
|
|
||||||
|
/// Background color when not selected. Defaults to `Colors.transparent`.
|
||||||
|
final Color? unselectedColor;
|
||||||
|
|
||||||
|
const AnimatedSelectionCheckbox({
|
||||||
|
super.key,
|
||||||
|
required this.visible,
|
||||||
|
required this.selected,
|
||||||
|
required this.colorScheme,
|
||||||
|
this.size = 20,
|
||||||
|
this.unselectedColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AnimatedScale(
|
||||||
|
scale: visible ? 1.0 : 0.0,
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
curve: Curves.easeOutBack,
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: selected
|
||||||
|
? colorScheme.primary
|
||||||
|
: unselectedColor ?? Colors.transparent,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(
|
||||||
|
color: selected ? colorScheme.primary : colorScheme.outline,
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: AnimatedSwitcher(
|
||||||
|
duration: const Duration(milliseconds: 150),
|
||||||
|
child: selected
|
||||||
|
? Icon(
|
||||||
|
Icons.check,
|
||||||
|
key: const ValueKey('checked'),
|
||||||
|
size: size - 6,
|
||||||
|
color: colorScheme.onPrimary,
|
||||||
|
)
|
||||||
|
: SizedBox(
|
||||||
|
key: const ValueKey('unchecked'),
|
||||||
|
width: size - 6,
|
||||||
|
height: size - 6,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// 6. Download Success Animation – green flash + checkmark
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// A widget that briefly flashes a success color behind its child and shows
|
||||||
|
/// an animated checkmark when [showSuccess] transitions to true.
|
||||||
|
class DownloadSuccessOverlay extends StatefulWidget {
|
||||||
|
final bool showSuccess;
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
const DownloadSuccessOverlay({
|
||||||
|
super.key,
|
||||||
|
required this.showSuccess,
|
||||||
|
required this.child,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<DownloadSuccessOverlay> createState() => _DownloadSuccessOverlayState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DownloadSuccessOverlayState extends State<DownloadSuccessOverlay>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late AnimationController _controller;
|
||||||
|
late Animation<double> _flashAnimation;
|
||||||
|
late bool _wasSuccess;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
// Initialise from the current widget value so items that are already
|
||||||
|
// completed when first built do not play the flash animation.
|
||||||
|
_wasSuccess = widget.showSuccess;
|
||||||
|
_controller = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 600),
|
||||||
|
);
|
||||||
|
_flashAnimation = TweenSequence<double>([
|
||||||
|
TweenSequenceItem(tween: Tween(begin: 0.0, end: 0.15), weight: 30),
|
||||||
|
TweenSequenceItem(tween: Tween(begin: 0.15, end: 0.0), weight: 70),
|
||||||
|
]).animate(_controller);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(DownloadSuccessOverlay oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (widget.showSuccess && !_wasSuccess) {
|
||||||
|
_controller.forward(from: 0);
|
||||||
|
}
|
||||||
|
_wasSuccess = widget.showSuccess;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: _controller,
|
||||||
|
builder: (context, child) {
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.green.withValues(alpha: _flashAnimation.value),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: widget.child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// 7. Badge Bump Animation – scales the badge when count changes
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Wraps a [Badge] child and plays a brief scale-bump whenever [count] changes.
|
||||||
|
class AnimatedBadge extends StatefulWidget {
|
||||||
|
final int count;
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
const AnimatedBadge({super.key, required this.count, required this.child});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AnimatedBadge> createState() => _AnimatedBadgeState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AnimatedBadgeState extends State<AnimatedBadge>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late AnimationController _controller;
|
||||||
|
late Animation<double> _scaleAnimation;
|
||||||
|
int _previousCount = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_previousCount = widget.count;
|
||||||
|
_controller = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
);
|
||||||
|
_scaleAnimation = TweenSequence<double>([
|
||||||
|
TweenSequenceItem(tween: Tween(begin: 1.0, end: 1.3), weight: 40),
|
||||||
|
TweenSequenceItem(tween: Tween(begin: 1.3, end: 1.0), weight: 60),
|
||||||
|
]).animate(_controller);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(AnimatedBadge oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (widget.count != _previousCount && widget.count > _previousCount) {
|
||||||
|
_controller.forward(from: 0);
|
||||||
|
}
|
||||||
|
_previousCount = widget.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ScaleTransition(scale: _scaleAnimation, child: widget.child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// 8. Animated Removal Item – fade + slide out when removed from a list
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Build a removal animation for [AnimatedList] items.
|
||||||
|
/// Use as the `builder` callback in [AnimatedListState.removeItem].
|
||||||
|
Widget buildRemovalAnimation(Widget child, Animation<double> animation) {
|
||||||
|
return SizeTransition(
|
||||||
|
sizeFactor: CurvedAnimation(parent: animation, curve: Curves.easeInOut),
|
||||||
|
child: FadeTransition(
|
||||||
|
opacity: CurvedAnimation(parent: animation, curve: Curves.easeIn),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user